loading
Generated 2025-10-01T03:08:06+00:00

All Files ( 94.54% covered at 623.38 hits/line )

105 files in total.
4874 relevant lines, 4608 lines covered and 266 lines missed. ( 94.54% )
1335 total branches, 1088 branches covered and 247 branches missed. ( 81.5% )
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 95.73 % 229 117 112 5 322.39 79.49 % 39 31 8
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 95.45 % 86 44 42 2 23.77 90.00 % 10 9 1
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 18.54 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 23.88 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 218.75 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 12.04 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 34.83 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 100.00 % 42 22 22 0 4.77 100.00 % 4 4 0
app/controllers/fan_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 100.00 % 51 24 24 0 3.75 100.00 % 2 2 0
app/controllers/inspections_controller.rb 95.60 % 557 273 261 12 65.66 85.56 % 90 77 13
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.68 % 429 152 150 2 27.49 88.57 % 35 31 4
app/controllers/search_controller.rb 100.00 % 7 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 100.00 % 103 48 48 0 108.85 83.33 % 6 5 1
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.56 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.33 79.59 % 49 39 10
app/errors/application_errors.rb 100.00 % 13 7 7 0 5.14 100.00 % 0 0 0
app/helpers/application_helper.rb 96.05 % 131 76 73 3 660.41 100.00 % 20 20 0
app/helpers/concerns/controller_context.rb 100.00 % 48 20 20 0 5.60 100.00 % 0 0 0
app/helpers/inspections_helper.rb 97.26 % 167 73 71 2 174.68 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 % 105 59 59 0 741.44 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 77 23 23 0 41.78 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.18 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 100.00 % 57 14 14 0 4.00 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 100.00 % 84 23 23 0 7.09 100.00 % 6 6 0
app/models/assessments/structure_assessment.rb 100.00 % 106 24 24 0 10.67 58.33 % 12 7 5
app/models/assessments/user_height_assessment.rb 96.30 % 96 27 26 1 4.11 50.00 % 2 1 1
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 13602.17 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 934.80 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 680.25 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 45 25 25 0 11654.68 83.33 % 6 5 1
app/models/concerns/form_configurable.rb 100.00 % 26 15 15 0 288.67 100.00 % 0 0 0
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 291.82 50.00 % 2 1 1
app/models/inspection.rb 98.39 % 569 249 245 4 1595.14 85.00 % 80 68 12
app/models/inspector_company.rb 96.55 % 144 58 56 2 78.91 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 129.93 100.00 % 0 0 0
app/models/unit.rb 89.58 % 214 96 86 10 36.74 75.00 % 32 24 8
app/models/user.rb 90.91 % 246 121 110 11 311.01 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 47 13 13 0 168.77 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 100.00 % 12 4 4 0 4.00 100.00 % 0 0 0
app/serializers/inspection_blueprint.rb 97.37 % 89 38 37 1 142.92 75.00 % 16 12 4
app/serializers/json_date_transformer.rb 92.31 % 31 13 12 1 726.69 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 69 30 30 0 11.90 75.00 % 16 12 4
app/services/concerns/s3_backup_operations.rb 100.00 % 68 40 40 0 11.03 90.00 % 10 9 1
app/services/concerns/s3_helpers.rb 100.00 % 20 10 10 0 20.10 100.00 % 4 4 0
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.62 93.75 % 16 15 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.71 88.00 % 25 22 3
app/services/ntfy_service.rb 93.10 % 53 29 27 2 5.31 66.67 % 6 4 2
app/services/pdf_cache_service.rb 98.96 % 210 96 95 1 66.17 83.87 % 31 26 5
app/services/pdf_generator_service.rb 90.22 % 210 92 83 9 42.11 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 25 15 15 0 2657.00 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1340.35 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 105 51 48 3 8049.06 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 193 84 80 4 1163.88 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 6.86 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.82 84.62 % 26 22 4
app/services/pdf_generator_service/header_generator.rb 92.21 % 149 77 71 6 22.65 83.33 % 18 15 3
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_processor.rb 89.66 % 118 58 52 6 11.91 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 127 63 63 0 9.38 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 92 41 41 0 6.66 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.67 % 306 158 148 10 20.42 85.71 % 56 48 8
app/services/pdf_generator_service/utilities.rb 100.00 % 63 31 31 0 27.84 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.81 75.00 % 4 3 1
app/services/rpii_verification_service.rb 87.50 % 169 80 70 10 4.75 94.44 % 18 17 1
app/services/s3_backup_service.rb 100.00 % 57 32 32 0 15.59 66.67 % 6 4 2
app/services/seed_data_service.rb 97.69 % 299 130 127 3 348.94 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 100.00 % 126 74 74 0 13.24 100.00 % 16 16 0
lib/i18n_usage_tracker.rb 95.95 % 147 74 71 3 4121.95 70.83 % 24 17 7
lib/rubocop/cop/custom/one_line_methods.rb 100.00 % 70 25 25 0 5.60 85.71 % 14 12 2
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 592.58 100.00 % 36 36 0
lib/test_data_helpers.rb 100.00 % 74 24 24 0 12.04 100.00 % 0 0 0

Controllers ( 94.83% covered at 52.42 hits/line )

29 files in total.
1509 relevant lines, 1431 lines covered and 78 lines missed. ( 94.83% )
389 total branches, 324 branches covered and 65 branches missed. ( 83.29% )
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 95.73 % 229 117 112 5 322.39 79.49 % 39 31 8
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 95.45 % 86 44 42 2 23.77 90.00 % 10 9 1
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 18.54 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 23.88 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 218.75 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 12.04 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 34.83 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 100.00 % 42 22 22 0 4.77 100.00 % 4 4 0
app/controllers/fan_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 100.00 % 51 24 24 0 3.75 100.00 % 2 2 0
app/controllers/inspections_controller.rb 95.60 % 557 273 261 12 65.66 85.56 % 90 77 13
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.68 % 429 152 150 2 27.49 88.57 % 35 31 4
app/controllers/search_controller.rb 100.00 % 7 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 100.00 % 103 48 48 0 108.85 83.33 % 6 5 1
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.56 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.33 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 ( 96.4% covered at 1879.6 hits/line )

23 files in total.
917 relevant lines, 884 lines covered and 33 lines missed. ( 96.4% )
221 total branches, 169 branches covered and 52 branches missed. ( 76.47% )
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.18 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 100.00 % 57 14 14 0 4.00 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 100.00 % 84 23 23 0 7.09 100.00 % 6 6 0
app/models/assessments/structure_assessment.rb 100.00 % 106 24 24 0 10.67 58.33 % 12 7 5
app/models/assessments/user_height_assessment.rb 96.30 % 96 27 26 1 4.11 50.00 % 2 1 1
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 13602.17 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 934.80 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 680.25 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 45 25 25 0 11654.68 83.33 % 6 5 1
app/models/concerns/form_configurable.rb 100.00 % 26 15 15 0 288.67 100.00 % 0 0 0
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 291.82 50.00 % 2 1 1
app/models/inspection.rb 98.39 % 569 249 245 4 1595.14 85.00 % 80 68 12
app/models/inspector_company.rb 96.55 % 144 58 56 2 78.91 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 129.93 100.00 % 0 0 0
app/models/unit.rb 89.58 % 214 96 86 10 36.74 75.00 % 32 24 8
app/models/user.rb 90.91 % 246 121 110 11 311.01 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 47 13 13 0 168.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 ( 98.12% covered at 405.32 hits/line )

7 files in total.
266 relevant lines, 261 lines covered and 5 lines missed. ( 98.12% )
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 660.41 100.00 % 20 20 0
app/helpers/concerns/controller_context.rb 100.00 % 48 20 20 0 5.60 100.00 % 0 0 0
app/helpers/inspections_helper.rb 97.26 % 167 73 71 2 174.68 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 % 105 59 59 0 741.44 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 77 23 23 0 41.78 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 ( 93.66% covered at 781.05 hits/line )

8 files in total.
473 relevant lines, 443 lines covered and 30 lines missed. ( 93.66% )
134 total branches, 115 branches covered and 19 branches missed. ( 85.82% )
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 100.00 % 126 74 74 0 13.24 100.00 % 16 16 0
lib/i18n_usage_tracker.rb 95.95 % 147 74 71 3 4121.95 70.83 % 24 17 7
lib/rubocop/cop/custom/one_line_methods.rb 100.00 % 70 25 25 0 5.60 85.71 % 14 12 2
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 592.58 100.00 % 36 36 0
lib/test_data_helpers.rb 100.00 % 74 24 24 0 12.04 100.00 % 0 0 0

Ungrouped ( 94.0% covered at 450.61 hits/line )

35 files in total.
1683 relevant lines, 1582 lines covered and 101 lines missed. ( 94.0% )
499 total branches, 404 branches covered and 95 branches missed. ( 80.96% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/errors/application_errors.rb 100.00 % 13 7 7 0 5.14 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 100.00 % 12 4 4 0 4.00 100.00 % 0 0 0
app/serializers/inspection_blueprint.rb 97.37 % 89 38 37 1 142.92 75.00 % 16 12 4
app/serializers/json_date_transformer.rb 92.31 % 31 13 12 1 726.69 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 69 30 30 0 11.90 75.00 % 16 12 4
app/services/concerns/s3_backup_operations.rb 100.00 % 68 40 40 0 11.03 90.00 % 10 9 1
app/services/concerns/s3_helpers.rb 100.00 % 20 10 10 0 20.10 100.00 % 4 4 0
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.62 93.75 % 16 15 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.71 88.00 % 25 22 3
app/services/ntfy_service.rb 93.10 % 53 29 27 2 5.31 66.67 % 6 4 2
app/services/pdf_cache_service.rb 98.96 % 210 96 95 1 66.17 83.87 % 31 26 5
app/services/pdf_generator_service.rb 90.22 % 210 92 83 9 42.11 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 25 15 15 0 2657.00 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1340.35 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 105 51 48 3 8049.06 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 193 84 80 4 1163.88 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 6.86 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.82 84.62 % 26 22 4
app/services/pdf_generator_service/header_generator.rb 92.21 % 149 77 71 6 22.65 83.33 % 18 15 3
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_processor.rb 89.66 % 118 58 52 6 11.91 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 127 63 63 0 9.38 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 92 41 41 0 6.66 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.67 % 306 158 148 10 20.42 85.71 % 56 48 8
app/services/pdf_generator_service/utilities.rb 100.00 % 63 31 31 0 27.84 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.81 75.00 % 4 3 1
app/services/rpii_verification_service.rb 87.50 % 169 80 70 10 4.75 94.44 % 18 17 1
app/services/s3_backup_service.rb 100.00 % 57 32 32 0 15.59 66.67 % 6 4 2
app/services/seed_data_service.rb 97.69 % 299 130 127 3 348.94 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

95.73% lines covered

79.49% branches covered

117 relevant lines. 112 lines covered and 5 lines missed.
39 total branches, 31 branches covered and 8 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. 2879 around_action :n_plus_one_detection, unless: -> { Rails.env.production? || skip_authentication? }
  12. 4 rescue_from StandardError do |exception|
  13. 19 then: 4 else: 15 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. 19 raise exception
  37. end
  38. 8 sig { returns(T::Boolean) }
  39. 4 def skip_authentication?
  40. 8325 false
  41. end
  42. # Class method version for use in rescue_from blocks
  43. 5 sig { params(table: Symbol, key: Symbol, args: T.untyped).returns(String) }
  44. 4 def self.app_i18n(table, key, **args)
  45. 31 I18n.t("application.#{table}.#{key}", **args)
  46. end
  47. # Instance method delegates to class method
  48. 5 sig { params(table: Symbol, key: Symbol, args: T.untyped).returns(String) }
  49. 4 def app_i18n(table, key, **args)
  50. 31 self.class.app_i18n(table, key, **args)
  51. end
  52. 8 sig { params(form: Symbol, key: T.any(Symbol, String), args: T.untyped).returns(String) }
  53. 4 def form_i18n(form, key, **args)
  54. 44 I18n.t("forms.#{form}.#{key}", **args)
  55. end
  56. 8 sig { void }
  57. 4 def require_login
  58. 1259 then: 1221 else: 38 return if logged_in?
  59. 38 flash[:alert] = form_i18n(:session_new, :"status.login_required")
  60. 38 redirect_to login_path
  61. end
  62. 8 sig { void }
  63. 4 def require_logged_out
  64. 1038 else: 5 then: 1033 return unless logged_in?
  65. 5 flash[:alert] = form_i18n(:session_new, :"status.already_logged_in")
  66. 5 redirect_to inspections_path
  67. end
  68. 8 sig { void }
  69. 4 def update_last_active_at
  70. 2850 else: 1502 then: 1348 return unless current_user.is_a?(User)
  71. 1502 current_user.update(last_active_at: Time.current)
  72. # Update UserSession last_active_at
  73. 1502 else: 1463 then: 39 return unless session[:session_token]
  74. 1463 then: 1463 else: 0 current_session&.touch_last_active
  75. end
  76. 8 sig { void }
  77. 4 def require_admin
  78. 208 then: 207 else: 1 then: 170 else: 38 return if current_user&.admin?
  79. 38 flash[:alert] = I18n.t("forms.session_new.status.admin_required")
  80. 38 redirect_to root_path
  81. end
  82. 8 sig { returns(T::Boolean) }
  83. 4 def admin_debug_enabled?
  84. 7224 Rails.env.development?
  85. end
  86. 5 sig { returns(T::Boolean) }
  87. 4 def seed_data_action?
  88. 4 seed_actions = %w[add_seeds delete_seeds]
  89. 4 controller_name == "users" && seed_actions.include?(action_name)
  90. end
  91. 5 sig { returns(T::Boolean) }
  92. 4 def impersonating?
  93. 2 session[:original_admin_id].present?
  94. end
  95. 5 sig { void }
  96. 4 def start_debug_timer
  97. 1 @debug_start_time = Time.current
  98. 1 @debug_sql_queries = []
  99. 1 then: 0 else: 1 ActiveSupport::Notifications.unsubscribe(@debug_subscription) if @debug_subscription
  100. 1 @debug_subscription = ActiveSupport::Notifications
  101. .subscribe("sql.active_record") do |_name, start, finish, _id, payload|
  102. 11 else: 0 then: 11 unless payload[:name] == "SCHEMA" || payload[:sql] =~ /^PRAGMA/
  103. 11 @debug_sql_queries << {
  104. sql: payload[:sql],
  105. 11 duration: ((finish - start) * 1000).round(2),
  106. name: payload[:name]
  107. }
  108. end
  109. end
  110. end
  111. # Make debug data available to views
  112. 4 helper_method :admin_debug_enabled?,
  113. :impersonating?,
  114. :debug_render_time,
  115. :debug_sql_queries
  116. 5 sig { returns(T.nilable(Float)) }
  117. 4 def debug_render_time
  118. 2 else: 1 then: 1 return unless @debug_start_time
  119. 1 ((Time.current - @debug_start_time) * 1000).round(2)
  120. end
  121. 8 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  122. 4 def debug_sql_queries
  123. 47 @debug_sql_queries || []
  124. end
  125. 8 sig { void }
  126. 4 def n_plus_one_detection
  127. 2867 Prosopite.scan
  128. 2867 yield
  129. ensure
  130. 2867 Prosopite.finish
  131. end
  132. 5 sig { returns(T::Boolean) }
  133. 4 def processing_image_upload?
  134. 5 case controller_name
  135. when: 2 when "users"
  136. 2 action_name == "update_settings" && params.dig(:user, :logo).present?
  137. when: 2 when "units"
  138. 2 %w[create update].include?(action_name) &&
  139. params.dig(:unit, :photo).present?
  140. else: 1 else
  141. 1 false
  142. end
  143. end
  144. 5 sig { void }
  145. 4 def cleanup_debug_subscription
  146. 1 else: 1 then: 0 return unless @debug_subscription
  147. 1 ActiveSupport::Notifications.unsubscribe(@debug_subscription)
  148. 1 @debug_subscription = nil
  149. end
  150. 5 sig { params(exception: StandardError).returns(T::Boolean) }
  151. 4 def should_notify_error?(exception)
  152. 9 then: 4 else: 5 if exception.is_a?(ActionController::InvalidAuthenticityToken)
  153. csrf_ignored_actions = [
  154. 4 %w[sessions create],
  155. %w[users create]
  156. ]
  157. 4 action = [controller_name, action_name]
  158. 4 then: 3 else: 1 return false if csrf_ignored_actions.include?(action)
  159. end
  160. 6 then: 0 else: 6 return false if exception.is_a?(ActionController::InvalidCrossOriginRequest) && !logged_in?
  161. 6 true
  162. end
  163. 8 sig { params(result: T.untyped, filename: String).void }
  164. 4 def handle_pdf_response(result, filename)
  165. 45 else: 0 case result.type
  166. when: 0 when :redirect
  167. Rails.logger.info "PDF response: Redirecting to S3 URL for #{filename}"
  168. redirect_to result.data, allow_other_host: true
  169. when: 0 when :stream
  170. Rails.logger.info "PDF response: Streaming #{filename} from S3 through Rails"
  171. expires_in 0, public: false
  172. send_data result.data.download,
  173. filename: filename,
  174. type: "application/pdf",
  175. disposition: "inline"
  176. when: 45 when :pdf_data
  177. 45 Rails.logger.info "PDF response: Sending generated PDF data for #{filename}"
  178. 45 send_data result.data,
  179. filename: filename,
  180. type: "application/pdf",
  181. disposition: "inline"
  182. end
  183. end
  184. 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

95.45% lines covered

90.0% branches covered

44 relevant lines. 42 lines covered and 2 lines missed.
10 total branches, 9 branches covered and 1 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. 153 image_fields.each do |field|
  26. 383 then: 357 else: 26 next if params_hash[field].blank?
  27. 26 uploaded_file = params_hash[field]
  28. 26 else: 25 then: 1 next unless uploaded_file.respond_to?(:read)
  29. begin
  30. 25 processed_io = process_image(uploaded_file)
  31. 18 then: 18 else: 0 params_hash[field] = processed_io if processed_io
  32. rescue ApplicationErrors::NotAnImageError,
  33. ApplicationErrors::ImageProcessingError => e
  34. 7 @image_processing_error = e
  35. 7 params_hash[field] = nil
  36. end
  37. end
  38. 153 params_hash
  39. end
  40. 7 sig { params(uploaded_file: T.untyped).returns(T.untyped) }
  41. 4 def process_image(uploaded_file)
  42. 29 validate_image!(uploaded_file)
  43. 23 processed_io = PhotoProcessingService.process_upload(uploaded_file)
  44. 21 else: 19 then: 2 raise ApplicationErrors::ImageProcessingError unless processed_io
  45. 19 processed_io
  46. rescue Vips::Error => e
  47. 1 Rails.logger.error "Image processing failed: #{e.message}"
  48. 1 error_message = I18n.t("errors.messages.image_processing_error",
  49. error: e.message)
  50. 1 raise ApplicationErrors::ImageProcessingError, error_message
  51. end
  52. 7 sig { params(uploaded_file: T.untyped).void }
  53. 4 def validate_image!(uploaded_file)
  54. 31 then: 24 else: 7 return if PhotoProcessingService.valid_image?(uploaded_file)
  55. 7 raise ApplicationErrors::NotAnImageError
  56. end
  57. 5 sig { params(exception: StandardError).void }
  58. 4 def handle_image_error(exception)
  59. 8 respond_to do |format|
  60. 8 format.html do
  61. 7 flash[:alert] = exception.message
  62. 7 redirect_back(fallback_location: root_path)
  63. end
  64. 8 format.turbo_stream do
  65. 1 flash.now[:alert] = exception.message
  66. # For turbo_stream, we just need to redirect back with the flash message
  67. # The application layout will handle rendering the flash
  68. 1 redirect_back(fallback_location: root_path, status: :see_other)
  69. end
  70. end
  71. end
  72. 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. 4 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. 291 then: 103 else: 188 return if request.format.pdf? || request.format.json? || request.format.png?
  29. # Rule 2: Always allow HTML access (show action decides the view)
  30. 188 then: 184 else: 4 return if request.format.html? && action_name == "show"
  31. # Rule 3: Always allow HEAD requests for federation
  32. 4 then: 4 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. 168 else: 145 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. 630 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. 630 session[:session_token] = user_session.session_token
  13. 630 create_user_session
  14. 630 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. 7 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. 7 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. 358 then: 346 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

100.0% lines covered

100.0% branches covered

22 relevant lines. 22 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 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. 5 capture_exception_for_sentry
  8. 5 respond_to do |format|
  9. 7 format.html { render status: :not_found }
  10. 5 format.json do
  11. 1 render json: {error: I18n.t("errors.not_found.title")},
  12. status: :not_found
  13. end
  14. 7 format.any { head :not_found }
  15. end
  16. end
  17. 4 def internal_server_error
  18. 5 capture_exception_for_sentry
  19. 5 respond_to do |format|
  20. 7 format.html { render status: :internal_server_error }
  21. 5 format.json do
  22. 1 render json: {error: I18n.t("errors.internal_server_error.title")},
  23. status: :internal_server_error
  24. end
  25. 7 format.any { head :internal_server_error }
  26. end
  27. end
  28. 4 private
  29. 4 def capture_exception_for_sentry
  30. 13 else: 2 then: 11 return unless Rails.env.production?
  31. 2 exception = request.env["action_dispatch.exception"]
  32. 2 then: 1 else: 1 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

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. 4 class GuidesController < ApplicationController
  2. 4 skip_before_action :require_login
  3. 4 def index
  4. 1 @guides = collect_guides
  5. end
  6. 4 def show
  7. 5 guide_path = params[:path]
  8. 5 metadata_file = guide_screenshots_root.join(guide_path, "metadata.json")
  9. 5 then: 3 if metadata_file.exist?
  10. 3 @guide_data = JSON.parse(metadata_file.read)
  11. 3 @guide_path = guide_path
  12. 3 @guide_title = humanize_guide_title(guide_path)
  13. else: 2 else
  14. 2 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. 10 Rails.public_path.join("guide_screenshots")
  20. end
  21. 4 def collect_guides
  22. 2 guides = []
  23. # Find all metadata.json files
  24. 2 Dir.glob(guide_screenshots_root.join("**", "metadata.json")).each do |metadata_path|
  25. 2 relative_path = Pathname.new(metadata_path).relative_path_from(guide_screenshots_root).dirname.to_s
  26. 2 metadata = JSON.parse(File.read(metadata_path))
  27. 2 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. 4 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. 7 path.split("/").last.gsub(/_spec$/, "").humanize
  40. end
  41. end

app/controllers/inspections_controller.rb

95.6% lines covered

85.56% branches covered

273 relevant lines. 261 lines covered and 12 lines missed.
90 total branches, 77 branches covered and 13 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. 426 all_inspections = filtered_inspections_query_without_order.to_a
  20. 426 partition_inspections(all_inspections)
  21. 426 @title = build_index_title
  22. 426 @has_any_inspections = all_inspections.any?
  23. 426 respond_to do |format|
  24. 426 format.html
  25. 426 format.csv do
  26. 10 log_inspection_event("exported", nil, "Exported #{@complete_inspections.count} inspections to CSV")
  27. 10 send_inspections_csv
  28. end
  29. end
  30. end
  31. 4 def show
  32. # Handle federation HEAD requests
  33. 156 then: 2 else: 154 return head :ok if request.head?
  34. 154 respond_to do |format|
  35. 253 format.html { render_show_html }
  36. 183 format.pdf { send_inspection_pdf }
  37. 161 format.png { send_inspection_qr_code }
  38. 154 format.json do
  39. 19 render json: InspectionBlueprint.render(@inspection)
  40. end
  41. end
  42. end
  43. 4 def create
  44. 21 unit_id = params[:unit_id] || params.dig(:inspection, :unit_id)
  45. 21 result = InspectionCreationService.new(
  46. current_user,
  47. unit_id: unit_id
  48. ).create
  49. 21 then: 19 if result[:success]
  50. 19 log_inspection_event("created", result[:inspection])
  51. 19 flash[:notice] = result[:message]
  52. 19 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. 186 validate_tab_parameter
  60. 186 set_previous_inspection
  61. end
  62. 4 def update
  63. 35 previous_attributes = @inspection.attributes.dup
  64. 35 params_to_update = inspection_params
  65. 35 then: 1 else: 34 if @image_processing_error
  66. 1 flash.now[:alert] = @image_processing_error.message
  67. 1 render :edit, status: :unprocessable_content
  68. 1 return
  69. end
  70. 34 then: 32 if @inspection.update(params_to_update)
  71. 32 changed_data = calculate_changes(
  72. previous_attributes,
  73. @inspection.attributes,
  74. inspection_params.keys
  75. )
  76. 32 log_inspection_event("updated", @inspection, nil, changed_data)
  77. 32 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. 4 @events = Event.for_resource(@inspection).recent.includes(:user)
  165. 4 @title = I18n.t("inspections.titles.log", inspection: @inspection.id)
  166. end
  167. 4 def inspection_params
  168. 106 base_params = build_base_params
  169. 106 add_assessment_params(base_params)
  170. 106 process_image_params(base_params, :photo_1, :photo_2, :photo_3)
  171. end
  172. 4 private
  173. 4 def check_assessments_enabled
  174. 918 else: 917 then: 1 head :not_found unless ENV["HAS_ASSESSMENTS"] == "true"
  175. end
  176. 4 def partition_inspections(all_inspections)
  177. 426 @draft_inspections = all_inspections
  178. 122 .select { |inspection| inspection.complete_date.nil? }
  179. .sort_by(&:created_at)
  180. 426 @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. 10 csv_data = InspectionCsvExportService.new(@complete_inspections).generate
  186. 9 filename = I18n.t("inspections.export.csv_filename", date: Time.zone.today)
  187. 9 send_data csv_data, filename: filename
  188. end
  189. 4 def validate_tab_parameter
  190. 186 then: 108 else: 78 return if params[:tab].blank?
  191. 78 valid_tabs = helpers.inspection_tabs(@inspection)
  192. 78 then: 76 else: 2 return if valid_tabs.include?(params[:tab])
  193. 2 redirect_to edit_inspection_path(@inspection),
  194. alert: I18n.t("inspections.messages.invalid_tab")
  195. end
  196. 4 def validate_inspection_completability
  197. 345 else: 83 then: 262 return unless @inspection.complete?
  198. 83 then: 79 else: 4 return if @inspection.can_mark_complete?
  199. 4 error_message = I18n.t(
  200. "inspections.errors.invalid_completion_state",
  201. errors: @inspection.completion_errors.join(", ")
  202. )
  203. 4 inspection_errors = @inspection.completion_errors
  204. 4 Rails.logger.error "Inspection #{@inspection.id} is marked complete " \
  205. "but has errors: #{inspection_errors}"
  206. # Only raise error in development/test environments
  207. 4 then: 3 if Rails.env.local?
  208. 3 test_message = "In tests, use create(:inspection, :completed) to avoid this."
  209. 3 raise StandardError, "DATA INTEGRITY ERROR: #{error_message}. #{test_message}"
  210. else
  211. else: 1 # In production, log the error but continue
  212. 1 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. 106 params.require(:inspection).permit(*Inspection::USER_EDITABLE_PARAMS)
  236. end
  237. 4 def add_assessment_params(base_params)
  238. 106 Inspection::ALL_ASSESSMENT_TYPES.each_key do |ass_type|
  239. 742 ass_key = "#{ass_type}_attributes"
  240. 742 then: 742 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. 464 @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: 464 else: 0 .find_by(id: params[:id]&.upcase)
  268. 464 else: 450 then: 14 head :not_found unless @inspection
  269. end
  270. 4 def check_inspection_owner
  271. 291 then: 282 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. 265 else: 10 then: 255 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. 426 title = I18n.t("inspections.titles.index")
  281. 426 else: 10 then: 416 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. 38 else: 1 then: 37 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. 32 respond_to do |format|
  299. 32 format.html do
  300. 19 flash[:notice] = I18n.t("inspections.messages.updated")
  301. 19 redirect_to @inspection
  302. end
  303. 32 format.json do
  304. 3 render json: {status: I18n.t("shared.api.success"),
  305. inspection: @inspection}
  306. end
  307. 42 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. 88 @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. 186 then: 182 else: 4 @previous_inspection = @inspection.unit&.last_inspection
  382. 186 then: 158 else: 28 return if !@previous_inspection || @previous_inspection.id == @inspection.id
  383. 28 @prefilled_fields = []
  384. 28 current_object, previous_object, column_name_syms = get_prefill_objects
  385. 28 column_name_syms.each do |field|
  386. 541 then: 188 else: 353 next if NOT_COPIED_FIELDS.include?(field)
  387. 353 then: 353 else: 0 then: 42 else: 311 next if previous_object&.send(field).nil?
  388. 311 else: 204 then: 107 next unless current_object.send(field).nil?
  389. 204 @prefilled_fields << translate_field_name(field)
  390. end
  391. end
  392. 4 def get_prefill_objects
  393. 28 case params[:tab]
  394. when: 14 when "inspection", "", nil
  395. 14 [@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: 4 # NOT_COPIED_FIELDS will filter out fields that shouldn't be prefilled
  400. 4 results_fields = [:passed, :risk_assessment, :photo_1, :photo_2, :photo_3]
  401. 4 [@inspection, @previous_inspection, results_fields]
  402. else: 10 else
  403. 10 assessment_method = ASSESSMENT_TAB_MAPPING[params[:tab]]
  404. 10 assessment_class = ASSESSMENT_CLASS_MAPPING[params[:tab]]
  405. [
  406. 10 @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. 204 is_comment = ChobbleForms::FieldUtils.is_comment_field?(field)
  415. 204 is_pass = ChobbleForms::FieldUtils.is_pass_field?(field)
  416. 204 field_base = ChobbleForms::FieldUtils.strip_field_suffix(field)
  417. 204 tab_name = params[:tab] || :inspection
  418. 204 i18n_base = "forms.#{tab_name}.fields"
  419. 204 translated = I18n.t("#{i18n_base}.#{field_base}", default: nil)
  420. 204 translated ||= I18n.t("#{i18n_base}.#{field}")
  421. 204 then: 78 if is_comment
  422. 78 else: 126 translated += " (#{I18n.t("shared.comment")})"
  423. 126 then: 59 else: 67 elsif is_pass
  424. 59 translated += " (#{I18n.t("shared.pass")}/#{I18n.t("shared.fail")})"
  425. end
  426. 204 translated
  427. end
  428. 4 def log_inspection_event(action, inspection, details = nil, changed_data = nil)
  429. 76 else: 76 then: 0 return unless current_user
  430. 76 then: 66 if inspection
  431. 66 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: 10 # For events without a specific inspection (like CSV export)
  440. 10 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. 1 Rails.logger.error "Failed to log inspection event: #{e.message}"
  449. end
  450. 4 def calculate_changes(previous_attributes, current_attributes, changed_keys)
  451. 32 changes = {}
  452. 32 changed_keys.map(&:to_s).each do |key|
  453. 85 previous_value = previous_attributes[key]
  454. 85 current_value = current_attributes[key]
  455. 85 else: 38 then: 47 next unless previous_value != current_value
  456. 38 changes[key] = {
  457. "from" => previous_value,
  458. "to" => current_value
  459. }
  460. end
  461. 32 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: 46 if @page.nil? && slug == "/"
  14. 46 @page = Page.new(
  15. slug: "/",
  16. content: "",
  17. meta_title: "play-test",
  18. meta_description: ""
  19. else: 39 )
  20. 39 else: 38 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.68% lines covered

88.57% branches covered

152 relevant lines. 150 lines covered and 2 lines missed.
35 total branches, 31 branches covered and 4 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. 6 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. 7 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. 6 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. 8 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. 19 EN14960.calculate_wall_height(platform_height, user_height)
  225. end
  226. 6 sig { returns(T::Hash[Symbol, T.untyped]) }
  227. 4 def build_json_response
  228. 40 type = params[:calculation][:type]
  229. 40 else: 30 then: 10 return invalid_type_response(type) unless CALCULATION_TYPES.include?(type)
  230. 30 build_typed_json_response(type)
  231. end
  232. 5 sig { params(type: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
  233. 4 def invalid_type_response(type)
  234. 10 {
  235. passed: false,
  236. status: t("safety_standards.api.invalid_calculation_type",
  237. type: type || t("safety_standards.api.none_provided")),
  238. result: nil
  239. }
  240. end
  241. 4 sig { params(message: String).returns(T::Hash[Symbol, T.untyped]) }
  242. 4 def error_response(message)
  243. {
  244. passed: false,
  245. status: t("safety_standards.api.calculation_failed", error: message),
  246. result: nil
  247. }
  248. end
  249. 6 sig { params(type: String).returns(T::Hash[Symbol, T.untyped]) }
  250. 4 def build_typed_json_response(type)
  251. 30 calculate_safety_standard
  252. 30 result, error = get_calculation_results(type)
  253. 30 then: 19 if result
  254. 19 build_success_response(type, result)
  255. else: 11 else
  256. 11 build_error_response(error)
  257. end
  258. end
  259. 6 sig { params(type: String).returns([T.nilable(T.untyped), T.nilable(String)]) }
  260. 4 def get_calculation_results(type)
  261. 30 result_var = "@#{type}_result"
  262. 30 error_var = "@#{type}_error"
  263. 30 result = instance_variable_get(result_var)
  264. 30 error = instance_variable_get(error_var)
  265. 30 [result, error]
  266. end
  267. 6 sig { params(type: String, result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
  268. 4 def build_success_response(type, result)
  269. 19 then: 4 json_result = if type == "user_capacity"
  270. 4 else: 15 build_user_capacity_json(result)
  271. 15 then: 15 elsif result.is_a?(EN14960::CalculatorResponse)
  272. {
  273. 15 value: result.value,
  274. value_suffix: result.value_suffix || "",
  275. breakdown: result.breakdown
  276. }
  277. else: 0 else
  278. result
  279. end
  280. 19 {
  281. passed: true,
  282. status: t("safety_standards.api.calculation_success"),
  283. result: json_result
  284. }
  285. end
  286. 6 sig { params(error: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
  287. 4 def build_error_response(error)
  288. 11 {
  289. passed: false,
  290. status: error || t("safety_standards.api.unknown_error"),
  291. result: nil
  292. }
  293. end
  294. 5 sig { params(result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
  295. 4 def build_user_capacity_json(result)
  296. 4 else: 4 then: 0 return result unless result.is_a?(EN14960::CalculatorResponse)
  297. 4 length = param_to_float(:length)
  298. 4 width = param_to_float(:width)
  299. 4 max_user_height = param_to_float(:max_user_height)
  300. 4 then: 0 else: 4 max_user_height = nil if max_user_height.zero?
  301. 4 neg_adj = param_to_float(:negative_adjustment_area)
  302. {
  303. 4 length: length,
  304. width: width,
  305. 4 area: (length * width).round(2),
  306. negative_adjustment_area: neg_adj,
  307. 4 usable_area: [(length * width) - neg_adj, 0].max.round(2),
  308. max_user_height: max_user_height,
  309. capacities: result.value,
  310. breakdown: result.breakdown
  311. }
  312. end
  313. 8 sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
  314. 4 def calculation_metadata
  315. {
  316. 128 anchors: anchor_metadata,
  317. slide_runout: slide_runout_metadata,
  318. wall_height: wall_height_metadata,
  319. user_capacity: user_capacity_metadata
  320. }
  321. end
  322. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  323. 4 def anchor_metadata
  324. {
  325. 128 title: t("safety_standards.metadata.anchor_title"),
  326. description: t("safety_standards.calculators.anchor.description"),
  327. method_name: :calculate_required_anchors,
  328. module_name: EN14960::Calculators::AnchorCalculator,
  329. example_input: 25.0,
  330. input_unit: "m²",
  331. output_unit: "anchors",
  332. formula_text: t("safety_standards.metadata.anchor_formula"),
  333. standard_reference: t("safety_standards.metadata.standard_reference")
  334. }
  335. end
  336. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  337. 4 def slide_runout_metadata
  338. {
  339. 128 title: t("safety_standards.metadata.slide_runout_title"),
  340. description: t("safety_standards.metadata.slide_runout_description"),
  341. method_name: :calculate_required_runout,
  342. additional_methods: [:calculate_runout_value],
  343. module_name: EN14960::Calculators::SlideCalculator,
  344. example_input: 2.5,
  345. input_unit: "m",
  346. output_unit: "m",
  347. formula_text: t("safety_standards.metadata.runout_formula"),
  348. standard_reference: t("safety_standards.metadata.standard_reference")
  349. }
  350. end
  351. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  352. 4 def wall_height_metadata
  353. {
  354. 128 title: t("safety_standards.metadata.wall_height_title"),
  355. description: t("safety_standards.metadata.wall_height_description"),
  356. method_name: :meets_height_requirements?,
  357. module_name: EN14960::Calculators::SlideCalculator,
  358. example_input: {platform_height: 2.0, user_height: 1.5},
  359. input_unit: "m",
  360. output_unit: t("safety_standards.metadata.requirement_text_unit"),
  361. formula_text: t("safety_standards.metadata.wall_height_formula"),
  362. standard_reference: t("safety_standards.metadata.standard_reference")
  363. }
  364. end
  365. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  366. 4 def user_capacity_metadata
  367. {
  368. 128 title: t("safety_standards.metadata.user_capacity_title"),
  369. description: t("safety_standards.calculators.user_capacity.description"),
  370. method_name: :calculate,
  371. module_name: EN14960::Calculators::UserCapacityCalculator,
  372. example_input: {length: 10.0, width: 8.0},
  373. input_unit: "m",
  374. output_unit: "users",
  375. formula_text: t("safety_standards.metadata.user_capacity_formula"),
  376. standard_reference: t("safety_standards.metadata.standard_reference")
  377. }
  378. end
  379. 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

100.0% lines covered

83.33% branches covered

48 relevant lines. 48 lines covered and 0 lines missed.
6 total branches, 5 branches covered and 1 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. 620 else: 620 then: 0 sleep(rand(0.5..1.0)) unless Rails.env.test?
  13. 620 email = params.dig(:session, :email)
  14. 620 password = params.dig(:session, :password)
  15. 620 then: 612 if (user = authenticate_user(email, password))
  16. 612 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. 11 all_credentials = Credential.all.map do |cred|
  31. {
  32. 2 id: cred.external_id,
  33. type: "public-key"
  34. }
  35. end
  36. # Initiate passkey authentication
  37. 11 get_options = WebAuthn::Credential.options_for_get(
  38. user_verification: "required",
  39. allow_credentials: all_credentials
  40. )
  41. 11 session[:passkey_authentication] = {challenge: get_options.challenge}
  42. 11 render json: get_options
  43. end
  44. 4 def passkey_callback
  45. 9 webauthn_credential = WebAuthn::Credential.from_get(params)
  46. 9 credential = find_credential(webauthn_credential)
  47. 9 then: 7 if credential
  48. 7 verify_and_sign_in_with_passkey(credential, webauthn_credential)
  49. else: 2 else
  50. 2 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. 9 encoded_id = Base64.strict_encode64(webauthn_credential.raw_id)
  57. 9 Credential.find_by(external_id: encoded_id)
  58. end
  59. 4 def verify_and_sign_in_with_passkey(credential, webauthn_credential)
  60. 7 challenge = session[:passkey_authentication]["challenge"]
  61. 7 webauthn_credential.verify(
  62. challenge,
  63. public_key: credential.public_key,
  64. sign_count: credential.sign_count,
  65. user_verification: true
  66. )
  67. 3 credential.update!(sign_count: webauthn_credential.sign_count)
  68. 3 user = User.find(credential.user_id)
  69. # Create session for passkey login
  70. 3 establish_user_session(user)
  71. 3 render json: {status: "ok"}, status: :ok
  72. rescue WebAuthn::Error => e
  73. 4 error_msg = I18n.t("sessions.messages.passkey_login_failed")
  74. 4 render json: "#{error_msg}: #{e.message}",
  75. status: :unprocessable_content
  76. ensure
  77. 7 session.delete(:passkey_authentication)
  78. end
  79. 4 def handle_successful_login(user)
  80. 612 establish_user_session(user)
  81. 612 flash[:notice] = I18n.t("session.login.success")
  82. 612 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. 107 then: 1 else: 106 return head :ok if request.head?
  30. 106 @inspections = @unit.inspections
  31. .includes(inspector_company: {logo_attachment: :blob})
  32. .order(inspection_date: :desc)
  33. 106 respond_to do |format|
  34. 175 format.html { render_show_html }
  35. 122 format.pdf { send_unit_pdf }
  36. 110 format.png { send_unit_qr_code }
  37. 106 format.json do
  38. 17 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: 140 unless @unit
  214. then: 11 # Always return 404 for non-existent resources regardless of login status
  215. 11 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. 6 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. 6 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

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 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. 10 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. 6 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: 301 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. 9 content_tag(:div, class: "table-container") do
  13. 9 content_tag(:table, html_options, &block)
  14. end
  15. end
  16. 8 sig { returns(String) }
  17. 4 def effective_theme
  18. 1571 then: 1072 else: 499 Rails.configuration.forced_theme || current_user&.theme || "light"
  19. end
  20. 8 sig { returns(T::Boolean) }
  21. 4 def theme_selector_disabled? = Rails.configuration.forced_theme.present?
  22. 8 sig { returns(String) }
  23. 4 def logo_path
  24. 1571 Rails.configuration.logo_path
  25. end
  26. 8 sig { returns(String) }
  27. 4 def logo_alt_text
  28. 1571 Rails.configuration.logo_alt
  29. end
  30. 8 sig { returns(T.nilable(String)) }
  31. 4 def left_logo_path
  32. 1571 Rails.configuration.left_logo_path
  33. end
  34. 4 sig { returns(String) }
  35. 4 def left_logo_alt
  36. Rails.configuration.left_logo_alt
  37. end
  38. 8 sig { returns(T.nilable(String)) }
  39. 4 def right_logo_path
  40. 1571 Rails.configuration.right_logo_path
  41. end
  42. 4 sig { returns(String) }
  43. 4 def right_logo_alt
  44. Rails.configuration.right_logo_alt
  45. end
  46. 8 sig { params(slug: String).returns(T.any(String, ActiveSupport::SafeBuffer)) }
  47. 4 def page_snippet(slug)
  48. 1571 snippet = Page.snippets.find_by(slug: slug)
  49. 1571 else: 51 then: 1520 return "" unless snippet
  50. 51 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. 6632 then: 1018 css_class = if current_page?(path) || controller_matches?(path)
  55. 1018 "active"
  56. else: 5614 else
  57. 5614 ""
  58. end
  59. 6632 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. 224 else: 218 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. 224 else: 65 then: 159 return value unless value.is_a?(Numeric)
  69. 65 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. 6124 route = Rails.application.routes.recognize_path(path)
  100. 6124 path_controller = route[:controller]
  101. 6124 controller_name == path_controller
  102. rescue ActionController::RoutingError
  103. false
  104. end
  105. end

app/helpers/concerns/controller_context.rb

100.0% lines covered

100.0% branches covered

20 relevant lines. 20 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. # Provides Sorbet type declarations for Rails controller methods
  4. # that are available when a helper module is included in a controller.
  5. # Include this concern in any helper that needs access to controller methods.
  6. 4 module ControllerContext
  7. 4 extend T::Sig
  8. 4 extend T::Helpers
  9. 4 abstract!
  10. # These methods are provided by ActionController/ActionView
  11. # We declare them as abstract so Sorbet knows about them
  12. # but doesn't provide implementations that would override Rails
  13. 8 sig { abstract.returns(T.untyped) }
  14. 4 def session
  15. end
  16. 8 sig { abstract.returns(T.untyped) }
  17. 4 def cookies
  18. end
  19. 8 sig { abstract.returns(T.untyped) }
  20. 4 def params
  21. end
  22. 8 sig { abstract.returns(T.untyped) }
  23. 4 def request
  24. end
  25. 8 sig { abstract.returns(T.untyped) }
  26. 4 def flash
  27. end
  28. 8 sig { abstract.params(args: T.untyped).returns(T.untyped) }
  29. 4 def redirect_to(*args)
  30. end
  31. 8 sig { abstract.params(args: T.untyped).returns(T.untyped) }
  32. 4 def render(*args)
  33. end
  34. 8 sig { abstract.params(args: T.untyped, block: T.untyped).returns(T.untyped) }
  35. 4 def respond_to(*args, &block)
  36. end
  37. end

app/helpers/inspections_helper.rb

97.26% lines covered

85.29% branches covered

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

59 relevant lines. 59 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. 4 include ControllerContext
  6. 8 sig { void }
  7. 4 def remember_user
  8. 634 else: 633 then: 1 return unless session[:session_token]
  9. 633 cookies.permanent.signed[:session_token] = session[:session_token]
  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. 19306 @current_user ||= fetch_current_user
  18. end
  19. 4 private
  20. 8 sig { returns(T.nilable(User)) }
  21. 4 def fetch_current_user
  22. 5521 then: 1486 if session[:session_token]
  23. 1486 else: 4035 user_from_session_token
  24. 4035 then: 6 else: 4029 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. 1486 user_session = UserSession.find_by(session_token: session[:session_token])
  31. 1486 then: 1480 if user_session
  32. 1480 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. 2529 !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 do
  67. 4 params(
  68. email: T.nilable(String),
  69. password: T.nilable(String)
  70. ).returns(T.nilable(T.any(User, T::Boolean)))
  71. end
  72. 4 def authenticate_user(email, password)
  73. 626 else: 622 then: 4 return nil unless email.present? && password.present?
  74. 622 then: 619 else: 3 User.find_by(email: email.downcase)&.authenticate(password)
  75. end
  76. 8 sig { void }
  77. 4 def create_user_session
  78. 631 remember_user
  79. end
  80. 8 sig { returns(T.nilable(UserSession)) }
  81. 4 def current_session
  82. 1466 else: 1465 then: 1 return unless session[:session_token]
  83. 1465 @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. 8 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. 6 sig { returns(Integer) }
  54. 12 def total_anchors = (num_low_anchors || 0) + (num_high_anchors || 0)
  55. 7 sig { returns(Integer) }
  56. 4 def required_anchors
  57. 12 then: 4 else: 8 return 0 if inspection.volume.blank?
  58. 8 anchorage_result.value
  59. end
  60. 6 sig { returns(T::Array[T.untyped]) }
  61. 4 def anchorage_breakdown
  62. 4 else: 4 then: 0 return [] unless inspection.volume
  63. 4 anchorage_result.breakdown
  64. end
  65. 4 private
  66. 6 sig { returns(T.any(Object, NilClass)) }
  67. 4 def anchorage_result
  68. 14 @anchor_result ||= EN14960.calculate_anchors(
  69. length: inspection.length.to_f,
  70. width: inspection.width.to_f,
  71. height: inspection.height.to_f
  72. )
  73. end
  74. 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

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. # == 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. end

app/models/assessments/slide_assessment.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 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. 9 else: 6 then: 3 return false unless runout.present? && slide_platform_height.present?
  50. 6 EN14960::Calculators::SlideCalculator.meets_runout_requirements?(
  51. runout.to_f, slide_platform_height.to_f
  52. )
  53. end
  54. 5 sig { returns(T.nilable(Integer)) }
  55. 4 def required_runout_length
  56. 5 then: 1 else: 4 return nil if slide_platform_height.blank?
  57. 4 EN14960::Calculators::SlideCalculator.calculate_runout_value(
  58. slide_platform_height.to_f
  59. )
  60. end
  61. 6 sig { returns(T::Boolean) }
  62. 4 def meets_wall_height_requirements?
  63. 17 else: 13 then: 4 return false unless slide_platform_height.present? &&
  64. slide_wall_height.present? && !slide_permanent_roof.nil?
  65. # Check if wall height requirements are met for all preset user heights
  66. 13 [1.0, 1.2, 1.5, 1.8].all? do |user_height|
  67. 40 EN14960::Calculators::SlideCalculator.meets_height_requirements?(
  68. slide_platform_height.to_f,
  69. user_height,
  70. slide_wall_height.to_f,
  71. slide_permanent_roof
  72. )
  73. end
  74. end
  75. 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. 6 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. 6 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

96.3% lines covered

50.0% branches covered

27 relevant lines. 26 lines covered and 1 lines missed.
2 total branches, 1 branches covered and 1 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. 6 sig { returns(T::Hash[Symbol, T.untyped]) }
  46. 4 def validate_play_area
  47. 5 unit_length = inspection.length
  48. 5 unit_width = inspection.width
  49. measurements = [
  50. 5 unit_length,
  51. unit_width,
  52. play_area_length,
  53. play_area_width
  54. ]
  55. 5 then: 0 else: 5 return missing_measurements_result if measurements.any?(&:nil?)
  56. 5 call_en14960_validator(unit_length, unit_width)
  57. end
  58. 4 private
  59. 4 sig {
  60. 2 params(
  61. unit_length: T.nilable(BigDecimal),
  62. unit_width: T.nilable(BigDecimal)
  63. ).returns(T::Hash[Symbol, T.untyped])
  64. }
  65. 4 def call_en14960_validator(unit_length, unit_width)
  66. 5 adjustment = negative_adjustment || 0
  67. 5 EN14960.validate_play_area(
  68. unit_length: unit_length.to_f,
  69. unit_width: unit_width.to_f,
  70. play_area_length: play_area_length.to_f,
  71. play_area_width: play_area_width.to_f,
  72. negative_adjustment_area: adjustment.to_f
  73. )
  74. end
  75. 4 sig { returns(T::Hash[Symbol, T.untyped]) }
  76. 4 def missing_measurements_result
  77. {
  78. valid: false,
  79. errors: [I18n.t("assessments.user_height.errors.missing_measurements")],
  80. measurements: {}
  81. }
  82. end
  83. end
  84. 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. 2010 incomplete_fields.empty?
  15. end
  16. 8 sig { returns(T::Array[Symbol]) }
  17. 4 def incomplete_fields
  18. 5233 (self.class.column_name_syms - SYSTEM_FIELDS)
  19. 89148 .reject { |f| f.end_with?("_comment") }
  20. 50749 .select { |f| field_is_incomplete?(f) }
  21. 29094 .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. 3214 field_to_partial = build_field_to_partial_mapping
  26. 3214 incomplete = incomplete_fields
  27. 3214 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. 3214 form_config = get_form_config
  33. 3214 field_to_partial = {}
  34. 3214 form_config.each do |section|
  35. 6458 else: 6458 then: 0 next unless section[:fields]
  36. 6458 map_section_fields(section, field_to_partial)
  37. end
  38. 3214 field_to_partial
  39. end
  40. 8 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  41. 4 def get_form_config
  42. 3214 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. 6458 section[:fields].each do |field_config|
  54. 26995 field = field_config[:field]
  55. 26995 partial = field_config[:partial]
  56. 26995 field_to_partial[field.to_sym] = partial
  57. # Also map composite fields
  58. 26995 partial_sym = partial.to_sym
  59. 26995 composite_fields = ChobbleForms::FieldUtils
  60. .get_composite_fields(field.to_sym, partial_sym)
  61. 26995 composite_fields.each do |cf|
  62. 41187 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. 3214 grouped = {}
  74. 3214 processed = Set.new
  75. 3214 incomplete.each do |field|
  76. 20969 then: 3123 else: 17846 next if processed.include?(field)
  77. 17846 process_field_group(
  78. field, incomplete, field_to_partial, grouped, processed
  79. )
  80. end
  81. 3214 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. 17846 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  96. 17846 partial = field_to_partial[field] || field_to_partial[base_field]
  97. # Find all related incomplete fields for this base
  98. 17846 related = incomplete.select do |f|
  99. 214320 ChobbleForms::FieldUtils.strip_field_suffix(f) == base_field
  100. end
  101. 17846 then: 3123 else: 14723 key = (related.size > 1) ? base_field : field
  102. 17846 grouped[key] = {
  103. fields: related,
  104. partial: partial
  105. }
  106. 17846 processed.merge(related)
  107. end
  108. 8 sig { params(field: Symbol).returns(T::Boolean) }
  109. 4 def field_is_incomplete?(field)
  110. 50749 value = send(field)
  111. # Field is incomplete if nil
  112. 50749 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. 29094 then: 17156 else: 11938 return false if field.end_with?("_pass")
  118. # Only allow nil for value fields when corresponding _pass field is "na"
  119. 11938 pass_field = "#{field}_pass"
  120. 11938 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. 4644 assessment_type = self.class.name.underscore.humanize
  13. 4644 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. 5410 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. 5691 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. 7144 loop do
  21. 7145 raw_id = SecureRandom.alphanumeric(32).upcase
  22. 7145 filtered_chars = raw_id.chars.reject do |char|
  23. 228640 AMBIGUOUS_CHARS.include?(char)
  24. end
  25. 7145 id = filtered_chars.first(ID_LENGTH).join
  26. 7145 then: 0 else: 7145 next if id.length < ID_LENGTH
  27. 7145 else: 1 then: 7144 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. 7040 then: 1 else: 7039 scope_conditions = respond_to?(:uniqueness_scope) ? uniqueness_scope : {}
  35. 7040 self.id = self.class.generate_random_id(scope_conditions)
  36. end
  37. end

app/models/concerns/form_configurable.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. # 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. 4086 @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.deep_symbolize_keys!
  19. 40 yaml_content[:form_fields]
  20. end
  21. end
  22. 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. 4757 unless: -> { resource_type == "System" }
  37. # Scopes for common queries
  38. 10 scope :recent, -> { order(created_at: :desc) }
  39. 4 scope :for_user, ->(user) { where(user: user) }
  40. 13 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. 4742 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

98.39% lines covered

85.0% branches covered

249 relevant lines. 245 lines covered and 4 lines missed.
80 total branches, 68 branches covered and 12 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. 15018 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. 556 scope :complete, -> { where.not(complete_date: nil) }
  128. 6 scope :draft, -> { where(complete_date: nil) }
  129. 4 scope :search, lambda { |query|
  130. 433 then: 5 if query.present?
  131. 5 joins("LEFT JOIN units ON units.id = inspections.unit_id")
  132. .where(search_conditions, *search_values(query))
  133. else: 428 else
  134. 428 all
  135. end
  136. }
  137. 4 scope :filter_by_result, lambda { |result|
  138. 432 when: 10 else: 418 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. 429 then: 3 else: 426 where(unit_id: unit_id) if unit_id.present?
  145. }
  146. 4 scope :filter_by_operator, lambda { |operator|
  147. 429 then: 1 if operator.present?
  148. 1 joins(:unit).where(units: {operator: operator})
  149. else: 428 else
  150. 428 all
  151. end
  152. }
  153. 4 scope :filter_by_date_range, lambda { |start_date, end_date|
  154. 6 range = start_date..end_date
  155. 6 then: 3 else: 3 where(inspection_date: range) if both_dates_present?(start_date, end_date)
  156. }
  157. 7 scope :overdue, -> { where("inspection_date < ?", Time.zone.today - 1.year) }
  158. # Helper methods for scopes
  159. 5 sig { returns(String) }
  160. 4 def self.search_conditions
  161. 6 "inspections.id LIKE ? OR units.serial LIKE ? OR " \
  162. "units.manufacturer LIKE ? OR units.name LIKE ?"
  163. end
  164. 5 sig { params(query: String).returns(T::Array[String]) }
  165. 32 def self.search_values(query) = Array.new(4) { "%#{query}%" }
  166. 5 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. 252 then: 16 else: 236 return nil if inspection_date.blank?
  173. 236 (inspection_date + 1.year).to_date
  174. end
  175. 5 sig { returns(T.nilable(Numeric)) }
  176. 4 def area
  177. 3 else: 1 then: 2 return nil unless width && length
  178. 1 width * length
  179. end
  180. 7 sig { returns(T.nilable(Numeric)) }
  181. 4 def volume
  182. 20 else: 13 then: 7 return nil unless width && length && height
  183. 13 width * length * height
  184. end
  185. 8 sig { returns(T::Boolean) }
  186. 4 def complete?
  187. 1653 complete_date.present?
  188. end
  189. 8 sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
  190. 4 def assessment_types
  191. 157 then: 0 else: 157 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. 2125 then: 101 if bouncing_pillow?
  196. 101 pillow_applicable_assessments
  197. else: 2024 else
  198. 2024 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. 2024 CASTLE_ASSESSMENT_TYPES.select do |assessment_key, _|
  205. 14168 case assessment_key
  206. when: 2024 when :slide_assessment
  207. 2024 has_slide?
  208. when: 2024 when :enclosed_assessment
  209. 2024 is_totally_enclosed?
  210. when: 2024 when :anchorage_assessment
  211. 2024 !indoor_only?
  212. else: 8096 else
  213. 8096 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. 762 applicable_assessments.each do |assessment_key, assessment_class|
  226. 3831 assessment = send(assessment_key)
  227. 3831 then: 3831 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. 921 tabs = ["inspection"]
  239. # Get applicable assessments for this inspection type
  240. 6700 applicable = applicable_assessments.keys.map { |k| k.to_s.chomp("_assessment") }
  241. # Add tabs in the correct UI order
  242. 921 ordered_tabs = %w[user_height slide structure anchorage materials fan enclosed]
  243. 921 ordered_tabs.each do |tab|
  244. 6447 then: 5779 else: 668 tabs << tab if applicable.include?(tab)
  245. end
  246. # Add results tab at the end
  247. 921 tabs << "results"
  248. 921 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. 8 sig { returns(T::Boolean) }
  265. 4 def can_mark_complete? = can_be_completed?
  266. 7 sig { returns(T::Array[String]) }
  267. 4 def completion_errors
  268. 26 errors = []
  269. 26 then: 1 else: 25 errors << "Unit is required" if unit.blank?
  270. # Get detailed incomplete field information
  271. 26 incomplete_tabs = incomplete_fields
  272. 26 incomplete_tabs.each do |tab_info|
  273. 57 tab_name = tab_info[:name]
  274. 479 incomplete_field_names = tab_info[:fields].map { |f| f[:label] }.join(", ")
  275. 57 errors << "#{tab_name}: #{incomplete_field_names}"
  276. end
  277. 26 errors
  278. end
  279. 5 sig { returns(T::Array[String]) }
  280. 4 def get_missing_assessments
  281. 2 missing = []
  282. # Check for missing unit first
  283. 2 then: 1 else: 1 missing << "Unit" if unit.blank?
  284. # Check for missing assessments using the new helper
  285. 2 each_applicable_assessment do |assessment_key, _, assessment|
  286. 14 then: 14 else: 0 then: 0 else: 14 next if assessment&.complete?
  287. # Get the assessment type without "_assessment" suffix
  288. 14 assessment_type = assessment_key.to_s.sub("_assessment", "")
  289. # Get the name from the form header
  290. 14 missing << I18n.t("forms.#{assessment_type}.header")
  291. end
  292. 2 missing
  293. end
  294. 5 sig { params(user: User).void }
  295. 4 def complete!(user)
  296. 6 update!(complete_date: Time.current)
  297. 6 log_audit_action("completed", user, "Inspection completed")
  298. end
  299. 6 sig { params(user: User).void }
  300. 4 def un_complete!(user)
  301. 4 update!(complete_date: nil)
  302. 4 log_audit_action("marked_incomplete", user, "Inspection completed")
  303. end
  304. 5 sig { returns(T::Array[String]) }
  305. 4 def validate_completeness
  306. 5 assessment_validation_data.filter_map do |name, assessment, message|
  307. # Convert the symbol name (e.g., :slide) to assessment key (e.g., :slide_assessment)
  308. 35 assessment_key = :"#{name}_assessment"
  309. 35 else: 22 then: 13 next unless assessment_applicable?(assessment_key)
  310. 22 then: 0 else: 22 message if assessment.present? && !assessment.complete?
  311. end
  312. end
  313. 8 sig { params(action: String, user: T.nilable(User), details: String).void }
  314. 4 def log_audit_action(action, user, details)
  315. 4653 Event.log(
  316. user: user,
  317. action: action,
  318. resource: self,
  319. details: details
  320. )
  321. end
  322. 8 sig { params(form: T.any(Symbol, String), field: T.any(Symbol, String)).returns(String) }
  323. 4 def field_label(form, field)
  324. 19073 key = "forms.#{form}.fields.#{field}"
  325. # Try the field as-is first
  326. 19073 label = I18n.t(key, default: nil)
  327. # Try removing _pass and/or _comment suffixes
  328. 19073 then: 9383 else: 9690 if label.nil?
  329. 9383 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  330. 9383 label = I18n.t("forms.#{form}.fields.#{base_field}", default: nil)
  331. end
  332. # Try adding _pass suffix
  333. 19073 then: 0 else: 19073 label = I18n.t("#{key}_pass", default: nil) if label.nil? && !field.to_s.end_with?("_pass")
  334. # If still not found, raise for the original key
  335. 19073 label || I18n.t(key)
  336. end
  337. 8 sig { returns(T::Array[Symbol]) }
  338. 4 def inspection_tab_incomplete_fields
  339. # Fields required for the inspection tab specifically (excludes passed which is on results tab)
  340. 903 fields = REQUIRED_TO_COMPLETE_FIELDS - [:passed]
  341. 903 fields
  342. 9933 .reject { |f| f.end_with?("_comment") }
  343. 9933 .select { |f| send(f).nil? }
  344. end
  345. 8 sig { returns(T::Array[T::Hash[Symbol, T.any(Symbol, String, T::Array[T::Hash[Symbol, T.any(Symbol, String)]])]]) }
  346. 4 def incomplete_fields
  347. 513 output = []
  348. # Process tabs in the same order as applicable_tabs
  349. 513 applicable_tabs.each do |tab|
  350. 4235 case tab
  351. when "inspection"
  352. when: 513 # Get incomplete fields for the inspection tab (excluding passed)
  353. inspection_tab_fields =
  354. 513 inspection_tab_incomplete_fields
  355. 917 .map { |f| {field: f, label: field_label(:inspection, f)} }
  356. 513 then: 304 else: 209 if inspection_tab_fields.any?
  357. 304 output << {
  358. tab: :inspection,
  359. name: I18n.t("forms.inspection.header"),
  360. fields: inspection_tab_fields
  361. }
  362. end
  363. when "results"
  364. when: 513 # Get incomplete fields for the results tab
  365. 513 results_fields = []
  366. 513 then: 177 else: 336 results_fields << {field: :passed, label: field_label(:results, :passed)} if passed.nil?
  367. 513 then: 177 else: 336 if results_fields.any?
  368. 177 output << {
  369. tab: :results,
  370. name: I18n.t("forms.results.header"),
  371. fields: results_fields
  372. }
  373. end
  374. else
  375. else: 3209 # All other tabs are assessment tabs
  376. 3209 assessment_key = :"#{tab}_assessment"
  377. 3209 then: 3209 else: 0 assessment = send(assessment_key) if respond_to?(assessment_key)
  378. 3209 then: 3209 else: 0 if assessment
  379. 3209 grouped_fields = assessment.incomplete_fields_grouped
  380. 3209 assessment_fields = []
  381. 3209 grouped_fields.each do |base_field, info|
  382. # Determine what's missing
  383. 17807 has_value_missing = info[:fields].include?(base_field)
  384. 17807 has_pass_missing = info[:fields].include?(:"#{base_field}_pass")
  385. # Get the base label
  386. 17807 base_label = field_label(tab.to_sym, base_field)
  387. # Construct the full label
  388. 17807 then: 3122 label = if has_value_missing && has_pass_missing
  389. 3122 else: 14685 "#{base_label} (+ Pass/Fail)"
  390. 14685 then: 0 elsif has_pass_missing
  391. "#{base_label} Pass/Fail"
  392. else: 14685 else
  393. 14685 base_label
  394. end
  395. 17807 assessment_fields << {field: base_field, label: label}
  396. end
  397. 3209 then: 2236 else: 973 if assessment_fields.any?
  398. 2236 output << {
  399. tab: tab.to_sym,
  400. name: I18n.t("forms.#{tab}.header"),
  401. fields: assessment_fields
  402. }
  403. end
  404. end
  405. end
  406. end
  407. 513 output
  408. end
  409. 4 private
  410. 8 sig { void }
  411. 4 def set_inspector_company_from_user
  412. 1675 self.inspector_company_id ||= user.inspection_company_id
  413. end
  414. 8 sig { void }
  415. 4 def set_inspection_type_from_unit
  416. 1675 else: 1661 then: 14 return unless unit
  417. 1661 else: 1661 then: 0 return unless new_record?
  418. # Set inspection type to match unit type
  419. 1661 self.inspection_type = unit.unit_type
  420. end
  421. 8 sig { returns(T::Boolean) }
  422. 4 def all_assessments_complete?
  423. 119 required_assessment_completions.all?
  424. end
  425. 8 sig { returns(T::Array[T::Boolean]) }
  426. 4 def required_assessment_completions
  427. 119 applicable_assessments.map do |assessment_key, _|
  428. 692 then: 692 else: 0 send(assessment_key)&.complete?
  429. end
  430. end
  431. 4 sig { returns(T::Array[ApplicationRecord]) }
  432. 4 def all_assessments
  433. applicable_assessments.map { |assessment_key, _| send(assessment_key) }
  434. end
  435. 4 sig {
  436. 1 returns(
  437. T::Array[
  438. T::Array[T.any(Symbol, ActiveRecord::Base, String)]
  439. ]
  440. )
  441. }
  442. 4 def assessment_validation_data
  443. 5 assessment_types = %i[
  444. anchorage
  445. enclosed
  446. fan
  447. materials
  448. slide
  449. structure
  450. user_height
  451. ]
  452. 5 assessment_types.map do |type|
  453. 35 assessment = send("#{type}_assessment")
  454. 35 message = I18n.t("inspections.validation.#{type}_incomplete")
  455. 35 [type, assessment, message]
  456. end
  457. end
  458. 8 sig { void }
  459. 4 def photos_must_be_images
  460. 1828 [[:photo_1, photo_1], [:photo_2, photo_2], [:photo_3, photo_3]].each do |field_name, photo|
  461. 5484 else: 12 then: 5472 next unless photo.attached?
  462. # Check if blob exists and has content_type
  463. 12 then: 0 else: 12 if photo.blob && !photo.blob.content_type.to_s.start_with?("image/")
  464. errors.add(field_name, I18n.t("activerecord.errors.messages.not_an_image"))
  465. photo.purge
  466. end
  467. end
  468. end
  469. 8 sig { void }
  470. 4 def invalidate_pdf_cache
  471. # Skip cache invalidation if only pdf_last_accessed_at or updated_at changed
  472. 128 changed_attrs = saved_changes.keys
  473. 128 ignorable_attrs = ["pdf_last_accessed_at", "updated_at"]
  474. 128 then: 58 else: 70 return if (changed_attrs - ignorable_attrs).empty?
  475. 70 PdfCacheService.invalidate_inspection_cache(self)
  476. end
  477. 8 sig { void }
  478. 4 def invalidate_unit_pdf_cache
  479. 1799 then: 1785 else: 14 PdfCacheService.invalidate_unit_cache(unit) if unit
  480. end
  481. 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. 3 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. 7 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. 7 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. 7 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. 7 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. 2030 then: 0 else: 2030 return if phone.blank?
  120. # Remove all non-digit characters
  121. 2030 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. 1577 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.complete)
  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. 572 @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. 7 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. 7 sig { void }
  147. 4 def destroy_draft_inspections
  148. 52 draft_inspections.destroy_all
  149. end
  150. 4 public
  151. 6 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. 1447 else: 30 then: 1417 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. 2 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. 5096 self.webauthn_id ||= WebAuthn.generate_user_id
  73. end
  74. 8 sig { returns(T::Boolean) }
  75. 4 def is_active?
  76. 1930 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. 8 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. 1567 admin_pattern = Rails.configuration.admin_emails_pattern
  90. 1567 then: 0 else: 1567 return false if admin_pattern.blank?
  91. begin
  92. 1567 regex = Regexp.new(admin_pattern)
  93. 1567 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. 2039 @active_until_explicitly_set = true
  105. 2039 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. 6 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. 8 sig { returns(T::Boolean) }
  143. 4 def rpii_verified?
  144. 44 rpii_verified_date.present?
  145. end
  146. 6 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. 3551 new_record? || name_changed?
  163. end
  164. 6 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. 3521 self.email = email.downcase
  176. end
  177. 8 sig { void }
  178. 4 def normalize_rpii_number
  179. 3521 then: 184 else: 3337 self.rpii_inspector_number = nil if rpii_inspector_number.blank?
  180. end
  181. 8 sig { void }
  182. 4 def set_inactive_on_signup
  183. 1976 then: 1968 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. 3551 else: 12 then: 3539 return unless logo.attached?
  189. 12 then: 12 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. 3551 else: 2 then: 3549 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. 1463 update_column(:last_active_at, Time.current)
  34. end
  35. 4 private
  36. 4 def generate_session_token
  37. 635 self.session_token ||= SecureRandom.urlsafe_base64(32)
  38. end
  39. end

app/serializers/base_assessment_blueprint.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. # 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. 4 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. 19 then: 15 else: 4 return if @fields_defined
  7. 4 Inspection.column_name_syms.each do |column|
  8. 88 then: 32 else: 56 next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
  9. 56 then: 8 if %i[inspection_date complete_date].include?(column)
  10. 8 field column do |inspection|
  11. 38 value = inspection.send(column)
  12. 38 then: 34 else: 4 value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
  13. end
  14. else: 48 else
  15. 48 field column
  16. end
  17. end
  18. 4 @fields_defined = true
  19. end
  20. # Override render to ensure fields are defined
  21. 4 def self.render(object, options = {})
  22. 19 define_public_fields
  23. 19 super
  24. end
  25. 4 field :complete do |inspection|
  26. 19 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. 19 name: inspection.user.name,
  34. rpii_inspector_number: inspection.user.rpii_inspector_number
  35. }
  36. end
  37. 4 field :urls do |inspection|
  38. 19 base_url = Rails.configuration.base_url
  39. {
  40. 19 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. 19 then: 19 else: 0 if inspection.unit
  47. {
  48. 19 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. 19 assessments = {}
  58. 19 inspection.each_applicable_assessment do |key, klass, assessment|
  59. 125 else: 125 then: 0 next unless assessment
  60. 125 assessment_data = {}
  61. public_fields =
  62. 125 klass.column_name_syms -
  63. PublicFieldFiltering::EXCLUDED_FIELDS
  64. 125 public_fields.each do |field|
  65. 2127 value = assessment.send(field)
  66. 2127 else: 592 then: 1535 assessment_data[field] = value unless value.nil?
  67. end
  68. 125 assessments[key] = assessment_data
  69. end
  70. 19 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. 36 transform_value(hash)
  8. end
  9. 4 def transform_value(value)
  10. 2698 case value
  11. when: 296 when Hash
  12. 2933 value.transform_values { |v| transform_value(v) }
  13. when: 5 when Array
  14. 13 value.map { |v| transform_value(v) }
  15. when: 13 when Date, Time, DateTime
  16. 13 value.strftime(API_DATE_FORMAT)
  17. when String
  18. when: 1354 # Handle string timestamps from ActiveRecord
  19. 1354 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: 1354 else
  22. 1354 value
  23. end
  24. else: 1030 else
  25. 1030 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. 17 then: 14 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. 17 value = unit.send(column)
  12. 17 then: 17 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. 17 define_public_fields
  23. 17 super
  24. end
  25. # Add URLs (available in all views)
  26. 4 field :urls do |unit|
  27. 17 base_url = Rails.configuration.base_url
  28. {
  29. 17 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. 17 json = JSON.parse(render(unit, view: :default), symbolize_names: true)
  37. 17 completed = unit.inspections.complete.order(inspection_date: :desc)
  38. 17 then: 5 else: 12 if completed.any?
  39. 5 json[:inspection_history] = completed.map do |inspection|
  40. {
  41. 8 inspection_date: inspection.inspection_date,
  42. passed: inspection.passed,
  43. complete: inspection.complete?,
  44. then: 8 else: 0 inspector_company: inspection.inspector_company&.name
  45. }
  46. end
  47. 5 json[:total_inspections] = completed.count
  48. 5 then: 5 else: 0 json[:last_inspection_date] = completed.first&.inspection_date
  49. 5 then: 5 else: 0 json[:last_inspection_passed] = completed.first&.passed
  50. end
  51. # Apply date transformation
  52. 17 transformer = JsonDateTransformer.new
  53. 17 json = transformer.transform_value(json)
  54. 17 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. 22 db_config = Rails.configuration.database_configuration[Rails.env]
  18. # Handle multi-database configuration
  19. 22 then: 1 else: 21 db_config = db_config["primary"] if db_config.is_a?(Hash) && db_config.key?("primary")
  20. 22 else: 21 then: 1 raise "Database not configured for #{Rails.env}" unless db_config["database"]
  21. 21 path = db_config["database"]
  22. 21 then: 0 else: 21 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. 19 backup_filename = "database-#{timestamp}.sqlite3"
  27. 19 compressed_filename = "database-#{timestamp}.tar.gz"
  28. 19 source_path = temp_dir.join(backup_filename)
  29. 19 dest_path = temp_dir.join(compressed_filename)
  30. 19 dir_name = File.dirname(source_path)
  31. 19 base_name = File.basename(source_path)
  32. 19 system("tar", "-czf", dest_path.to_s, "-C", dir_name.to_s,
  33. base_name.to_s, exception: true)
  34. 17 dest_path
  35. end
  36. 5 sig { params(service: T.untyped).returns(Integer) }
  37. 4 def cleanup_old_backups(service)
  38. 15 bucket = service.send(:bucket)
  39. 15 cutoff_date = Time.current - backup_retention_days.days
  40. 15 deleted_count = 0
  41. 15 bucket.objects(prefix: "#{backup_dir}/").each do |object|
  42. 13 else: 9 then: 4 next unless object.key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
  43. 9 then: 5 else: 4 if object.last_modified < cutoff_date
  44. 5 service.delete(object.key)
  45. 5 deleted_count += 1
  46. end
  47. end
  48. 13 deleted_count
  49. end
  50. end

app/services/concerns/s3_helpers.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 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. 23 else: 22 then: 1 raise "S3 storage is not enabled" unless ENV["USE_S3_STORAGE"] == "true"
  7. end
  8. 4 def validate_s3_config
  9. 22 required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
  10. 110 missing_vars = required_vars.select { |var| ENV[var].blank? }
  11. 22 then: 2 else: 20 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. 21 @user = user
  12. 21 @unit_id = params[:unit_id]
  13. end
  14. 8 sig { returns(ServiceResult) }
  15. 4 def create
  16. 21 then: 18 else: 3 unit = find_and_validate_unit if @unit_id.present?
  17. 21 then: 1 else: 20 return invalid_unit_result if @unit_id.present? && unit.nil?
  18. 20 inspection = build_inspection(unit)
  19. 20 then: 19 if inspection.save
  20. 19 notify_if_production(inspection)
  21. 19 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. 18 @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. 20 then: 17 else: 3 last_inspection = unit&.last_inspection
  44. 20 copy_fields = {}
  45. 20 then: 5 else: 15 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. 20 @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. 19 else: 1 then: 18 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. 19 then: 2 else: 17 message_key = unit.nil? ? "created_without_unit" : "created"
  74. 19 {
  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. 3 params(
  9. inspections: T.any(
  10. ActiveRecord::Relation,
  11. T::Array[Inspection]
  12. )
  13. ).void
  14. end
  15. 4 def initialize(inspections)
  16. 10 @inspections = inspections
  17. end
  18. 7 sig { returns(String) }
  19. 4 def generate
  20. 9 CSV.generate(headers: true) do |csv|
  21. 9 csv << headers
  22. 9 @inspections.each do |inspection|
  23. 9 csv << row_data(inspection)
  24. end
  25. end
  26. end
  27. 4 private
  28. 7 sig { returns(T::Array[String]) }
  29. 4 def headers
  30. 18 excluded_columns = %i[user_id inspector_company_id unit_id]
  31. 18 inspection_columns = Inspection.column_name_syms - excluded_columns
  32. 18 headers = inspection_columns
  33. 18 headers += %i[unit_name unit_serial unit_manufacturer unit_operator unit_description]
  34. 18 headers += %i[inspector_company_name]
  35. 18 headers += %i[inspector_user_email]
  36. 18 headers += %i[complete]
  37. 18 headers
  38. end
  39. 7 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/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

98.96% lines covered

83.87% branches covered

96 relevant lines. 95 lines covered and 1 lines missed.
31 total branches, 26 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. 4 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. 8 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. 71 invalidate_cache(inspection)
  29. end
  30. 8 sig { params(unit: Unit).void }
  31. 4 def invalidate_unit_cache(unit)
  32. 1794 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. 4 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. 1865 else: 123 then: 1742 return unless caching_enabled?
  89. 123 then: 2 else: 121 record.cached_pdf.purge if record.cached_pdf.attached?
  90. end
  91. 8 sig { returns(T::Boolean) }
  92. 4 def caching_enabled?
  93. 1921 Rails.configuration.pdf_cache_enabled
  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. 4 sig { returns(T.nilable(Date)) }
  102. 4 def pdf_cache_from_date
  103. Rails.configuration.pdf_cache_from
  104. end
  105. 4 sig do
  106. 1 params(
  107. attachment: T.untyped,
  108. record: T.any(Inspection, Unit)
  109. ).returns(T::Boolean)
  110. end
  111. 4 def cached_pdf_valid?(attachment, record)
  112. 6 then: 6 else: 0 else: 6 then: 0 return false unless attachment.blob&.created_at
  113. 6 cache_created_at = attachment.blob.created_at
  114. 6 cache_threshold = pdf_cache_from_date.beginning_of_day
  115. # Check if cache is newer than the threshold date
  116. 6 else: 5 then: 1 return false unless cache_created_at > cache_threshold
  117. # Check if user assets were updated after cache
  118. 5 !user_assets_updated_after?(record.user, cache_created_at)
  119. end
  120. 4 sig do
  121. 1 params(
  122. user: T.nilable(User),
  123. cache_created_at: T.any(ActiveSupport::TimeWithZone, Date, Time)
  124. ).returns(T::Boolean)
  125. end
  126. 4 def user_assets_updated_after?(user, cache_created_at)
  127. 5 else: 5 then: 0 return false unless user
  128. 5 then: 1 else: 4 if attachment_updated_after?(user.signature, cache_created_at)
  129. 1 Rails.logger.info "User signature updated after PDF cache"
  130. 1 return true
  131. end
  132. 4 then: 2 else: 2 if attachment_updated_after?(user.logo, cache_created_at)
  133. 2 Rails.logger.info "User logo updated after PDF cache"
  134. 2 return true
  135. end
  136. 2 false
  137. end
  138. 4 sig do
  139. 1 params(
  140. attachment: T.untyped,
  141. reference_time: T.any(ActiveSupport::TimeWithZone, Date, Time)
  142. ).returns(T::Boolean)
  143. end
  144. 4 def attachment_updated_after?(attachment, reference_time)
  145. 9 then: 9 else: 0 attachment&.attached? &&
  146. attachment.blob.created_at > reference_time
  147. end
  148. 4 sig do
  149. 1 params(
  150. record: T.any(Inspection, Unit),
  151. pdf_data: String
  152. ).void
  153. end
  154. 4 def store_cached_pdf(record, pdf_data)
  155. # Purge old cached PDF if exists
  156. 2 then: 1 else: 1 record.cached_pdf.purge if record.cached_pdf.attached?
  157. # Store new cached PDF
  158. 2 type_name = record.class.name.downcase
  159. 2 filename = "#{type_name}_#{record.id}_cached_#{Time.current.to_i}.pdf"
  160. # Create a StringIO with proper positioning
  161. 2 io = StringIO.new(pdf_data)
  162. 2 io.rewind
  163. 2 record.cached_pdf.attach(
  164. io: io,
  165. filename: filename,
  166. content_type: "application/pdf"
  167. )
  168. end
  169. 5 sig { returns(T::Boolean) }
  170. 4 def redirect_to_s3?
  171. 2 Rails.configuration.redirect_to_s3_pdfs
  172. end
  173. end
  174. 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. 42 require "prawn/table"
  7. 42 Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
  8. 42 Configuration.setup_pdf_fonts(pdf)
  9. # Initialize array to collect all assessment blocks
  10. 42 assessment_blocks = []
  11. # Header section
  12. 42 HeaderGenerator.generate_inspection_pdf_header(pdf, inspection)
  13. # Unit details section
  14. 42 generate_inspection_unit_details(pdf, inspection)
  15. # Risk assessment section (if present)
  16. 42 generate_risk_assessment_section(pdf, inspection)
  17. # Generate all assessment sections in the correct UI order from applicable_tabs
  18. 42 generate_assessments_in_ui_order(inspection, assessment_blocks)
  19. # Render footer and photo first to measure actual space used
  20. 42 cursor_before_footer = pdf.cursor
  21. # Disclaimer footer (only on first page)
  22. 42 DisclaimerFooterRenderer.render_disclaimer_footer(pdf, inspection.user)
  23. 42 disclaimer_height = DisclaimerFooterRenderer.measure_footer_height(pdf)
  24. # Add unit photo in bottom right corner
  25. 42 photo_height = ImageProcessor.measure_unit_photo_height(pdf, inspection.unit, 4)
  26. 42 then: 41 else: 1 then: 41 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. 42 pdf.move_cursor_to(cursor_before_footer)
  29. # Render all collected assessments in newspaper-style columns
  30. 42 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. 42 then: 0 else: 42 Utilities.add_draft_watermark(pdf) if !inspection.complete? && !Rails.env.test?
  33. # Add photos page if photos are attached
  34. 42 PhotosRenderer.generate_photos_page(pdf, inspection)
  35. # Add debug info page if enabled (admins only)
  36. 42 then: 0 else: 42 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. 42 unit = inspection.unit
  62. 42 else: 41 then: 1 return unless unit
  63. 41 unit_data = TableBuilder.build_unit_details_table_with_inspection(unit, inspection, :inspection)
  64. 41 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. 42 then: 0 else: 42 return if inspection.risk_assessment.blank?
  99. 42 pdf.text I18n.t("pdf.inspection.risk_assessment"), size: HEADER_TEXT_SIZE, style: :bold
  100. 42 pdf.stroke_horizontal_rule
  101. 42 pdf.move_down 10
  102. # Create a text box constrained to 4 lines with shrink_to_fit
  103. 42 line_height = 10 * 1.2 # Normal font size * line height multiplier
  104. 42 max_height = line_height * 4 # 4 lines max
  105. 42 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. 42 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. 42 ui_ordered_tabs = inspection.applicable_tabs - %w[inspection results]
  117. 42 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. 42 then: 0 else: 42 return if assessment_blocks.empty?
  129. 42 pdf.text I18n.t("pdf.inspection.assessments_section"), size: 12, style: :bold
  130. 42 pdf.stroke_horizontal_rule
  131. 42 pdf.move_down 15
  132. # Calculate available height accounting for disclaimer footer only
  133. 42 then: 42 available_height = if pdf.page_number == 1
  134. 42 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. 42 min_content_height = 100 # Minimum height for meaningful content
  140. 42 then: 0 else: 42 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. 42 renderer = AssessmentColumns.new(assessment_blocks, available_height, photo_height)
  147. 42 renderer.render(pdf)
  148. 42 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. 3718 @type = type
  6. 3718 @pass_fail = pass_fail
  7. 3718 @name = name
  8. 3718 @value = value
  9. 3718 @comment = comment
  10. end
  11. 4 def header?
  12. 21038 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. 314 @assessment_type = assessment_type
  10. 314 @assessment = assessment
  11. 314 @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. 2527 main_field = fields[:base] || fields[:pass]
  26. 2527 then: 3 else: 2524 if main_field && field_is_not_applicable?(main_field)
  27. 3 next
  28. end
  29. # Add value block
  30. 2524 then: 2480 if main_field
  31. 2480 value = @assessment.send(main_field)
  32. 2480 label = get_field_label(fields)
  33. 2480 pass_value = determine_pass_value(fields, main_field, value)
  34. 2480 is_pass_field = main_field.to_s.end_with?("_pass")
  35. # For boolean fields that aren't pass/fail fields
  36. 2480 is_bool_non_pass = [true, false].include?(value) &&
  37. !is_pass_field && pass_value.nil?
  38. 2480 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: 2456 else
  45. 2456 AssessmentBlock.new(
  46. type: :value,
  47. pass_fail: pass_value,
  48. name: label,
  49. 2456 then: 1281 else: 1175 value: is_pass_field ? nil : value
  50. )
  51. else: 44 end
  52. 44 else: 0 elsif fields[:comment]
  53. then: 44 # Handle standalone comment fields (no base or pass field)
  54. 44 label = get_field_label(fields)
  55. 44 comment = @assessment.send(fields[:comment])
  56. 44 else: 20 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. 2524 then: 2222 else: 302 if fields[:comment]
  67. 2222 comment = @assessment.send(fields[:comment])
  68. 2222 then: 912 else: 1310 if comment.present?
  69. 912 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. 2570 field_name = field_config[:field]
  86. 2570 partial_name = field_config[:partial]
  87. # Get composite fields first to check if any exist
  88. 2570 composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name)
  89. # Skip if neither the base field nor any composite fields exist
  90. 2570 has_base = @assessment.respond_to?(field_name)
  91. 4779 has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) }
  92. 2570 else: 2570 then: 0 next unless has_base || has_composites
  93. # Add base field if it exists
  94. 2570 then: 1278 else: 1292 ordered_fields << field_name if has_base
  95. # Add composite fields that exist
  96. 2570 composite_fields.each do |composite_field|
  97. 3922 then: 3922 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. 300 field_keys.each_with_object({}) do |field, groups|
  105. 5146 field_str = field.to_s
  106. 5146 else: 5143 then: 3 next unless @assessment.respond_to?(field_str)
  107. 5143 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  108. 5143 groups[base_field] ||= {}
  109. 5143 when: 1707 field_type = case field_str
  110. 1707 when: 2231 when /pass$/ then :pass
  111. 2231 else: 1205 when /comment$/ then :comment
  112. 1205 else :base
  113. end
  114. 5143 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. 2528 then: 1200 if fields[:base]
  120. 1200 else: 1328 field_label(fields[:base])
  121. 1328 elsif fields[:pass]
  122. then: 1282 # For pass fields, use the base field name for the label
  123. 1282 base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:pass])
  124. 1282 else: 46 field_label(base_name)
  125. 46 elsif fields[:comment]
  126. then: 45 # For standalone comment fields, use the base field name
  127. 45 base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:comment])
  128. 45 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. 2527 I18n.t!("forms.#{@assessment_type}.fields.#{field_name}")
  135. end
  136. 4 def determine_pass_value(fields, main_field, value)
  137. 2486 then: 1703 else: 783 return @assessment.send(fields[:pass]) if fields[:pass]
  138. 783 then: 0 else: 783 return value if main_field.to_s.end_with?("_pass")
  139. 783 nil
  140. end
  141. 4 def get_not_applicable_fields
  142. 315 else: 315 then: 0 return [] unless @assessment.class.respond_to?(:form_fields)
  143. 315 @assessment.class.form_fields
  144. 641 .flat_map { |section| section[:fields] }
  145. 2751 then: 1147 else: 1604 .select { |field| field[:attributes]&.dig(:add_not_applicable) }
  146. 54 .map { |field| field[:field].to_sym }
  147. end
  148. 4 def field_is_not_applicable?(field)
  149. 2486 else: 49 then: 2437 return false unless @not_applicable_fields.include?(field)
  150. 49 value = @assessment.send(field)
  151. # Field is not applicable if it has add_not_applicable and value is 0
  152. 49 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. 21007 case block.type
  16. when: 1514 when :header
  17. 1514 render_header_fragments(block)
  18. when: 12779 when :value
  19. 12779 render_value_fragments(block)
  20. when: 6714 when :comment
  21. 6714 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. 21007 then: 1514 else: 19493 block.header? ? ASSESSMENT_TITLE_SIZE : @font_size
  28. end
  29. 4 def height_for(block, pdf)
  30. 17575 fragments = render_fragments(block)
  31. 17575 then: 0 else: 17575 return 0 if fragments.empty?
  32. 17575 font_size = font_size_for(block)
  33. # Convert fragments to formatted text array
  34. 17575 formatted_text = fragments.map do |fragment|
  35. 26968 styles = []
  36. 26968 then: 17074 else: 9894 styles << :bold if fragment[:bold]
  37. 26968 then: 5819 else: 21149 styles << :italic if fragment[:italic]
  38. {
  39. 26968 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. 17575 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. 17575 spacing = (font_size * 0.33).round(1)
  52. 17575 base_height + spacing
  53. end
  54. 4 private
  55. 4 def render_header_fragments(block)
  56. 1514 text = block.name || block.value
  57. 1514 [{text: text, bold: true, color: "000000"}]
  58. end
  59. 4 def render_value_fragments(block)
  60. 12779 fragments = []
  61. # Add pass/fail indicator if present
  62. 12779 then: 6157 else: 6622 if !block.pass_fail.nil?
  63. 6157 when: 6157 indicator, color = case block.pass_fail
  64. 6157 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. 6157 fragments << {text: "#{indicator} ", bold: true, color: color}
  69. end
  70. # Add field name
  71. 12779 then: 12779 else: 0 if block.name
  72. 12779 fragments << {text: block.name, bold: true, color: "000000"}
  73. end
  74. # Add value if present and not a pass/fail field
  75. 12779 then: 4717 else: 8062 if block.value && !block.name.to_s.end_with?("_pass")
  76. 4717 fragments << {text: ": #{block.value}", bold: false, color: "000000"}
  77. end
  78. 12779 fragments
  79. end
  80. 4 def render_comment_fragments(block)
  81. 6714 then: 0 else: 6714 return [] if block.comment.blank?
  82. 6714 [{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. 42 @assessment_blocks = assessment_blocks
  8. 42 @assessment_results_height = assessment_results_height
  9. 42 @photo_height = photo_height
  10. end
  11. 4 def render(pdf)
  12. # Try progressively smaller font sizes
  13. 42 font_size = Configuration::ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED
  14. 42 min_font_size = Configuration::MIN_ASSESSMENT_FONT_SIZE
  15. 42 body: 153 while font_size >= min_font_size
  16. 153 then: 42 else: 111 if content_fits_with_font_size?(pdf, font_size)
  17. 42 render_with_font_size(pdf, font_size)
  18. 42 return true
  19. end
  20. 111 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. 153 total_height = calculate_total_content_height(font_size, pdf)
  30. # Calculate column capacity
  31. 153 columns = calculate_column_boxes(pdf)
  32. 765 total_capacity = columns.sum { |col| col[:height] }
  33. 153 total_height <= total_capacity
  34. end
  35. 4 def calculate_total_content_height(font_size, pdf)
  36. 153 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  37. 153 total_height = 0
  38. 153 @assessment_blocks.each do |block|
  39. # Add height for this block using actual PDF document
  40. 14136 total_height += renderer.height_for(block, pdf)
  41. end
  42. 153 total_height
  43. end
  44. 4 def render_with_font_size(pdf, font_size)
  45. # Calculate column dimensions
  46. 42 columns = calculate_column_boxes(pdf)
  47. # Save the starting position
  48. 42 start_y = pdf.cursor
  49. # Track content placement across columns
  50. 42 content_blocks = prepare_content_blocks(pdf, font_size)
  51. 42 place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  52. # Move cursor to end of assessment area
  53. 42 pdf.move_cursor_to(start_y - assessment_results_height)
  54. end
  55. 4 def prepare_content_blocks(pdf, font_size)
  56. 42 blocks = []
  57. 42 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  58. 42 @assessment_blocks.each do |block|
  59. # Get rendered fragments and height for this block
  60. 3432 fragments = renderer.render_fragments(block)
  61. 3432 height = renderer.height_for(block, pdf)
  62. 3432 blocks << {
  63. type: block.type,
  64. fragments: fragments,
  65. height: height,
  66. font_size: renderer.font_size_for(block)
  67. }
  68. end
  69. 42 blocks
  70. end
  71. 4 def place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  72. 42 current_column = 0
  73. 42 column_y = start_y
  74. 42 content_blocks.each do |content|
  75. # Check if we need to move to next column
  76. 3411 then: 3411 if current_column < columns.size
  77. 3411 available = column_y - (start_y - columns[current_column][:height])
  78. 3411 else: 3286 if available < content[:height]
  79. then: 125 # Move to next column
  80. 125 current_column += 1
  81. 125 column_y = start_y
  82. # Stop if we run out of columns
  83. 125 then: 3 else: 122 break if current_column >= columns.size
  84. end
  85. else: 0 else
  86. break
  87. end
  88. # Render content in current column
  89. 3408 column = columns[current_column]
  90. 3408 render_content_at_position(pdf, content, column, column_y, font_size)
  91. # Update position
  92. 3408 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. 3408 original_y = pdf.cursor
  98. 3408 original_fill_color = pdf.fill_color
  99. # Calculate actual x position
  100. 3408 actual_x = column[:x]
  101. # Use the font size from the content block if available
  102. 3408 text_size = content[:font_size] || font_size
  103. # Convert fragments to formatted text array for proper wrapping
  104. 3408 formatted_text = content[:fragments].map do |fragment|
  105. 4877 styles = []
  106. 4877 then: 3355 else: 1522 styles << :bold if fragment[:bold]
  107. 4877 then: 883 else: 3994 styles << :italic if fragment[:italic]
  108. {
  109. 4877 text: fragment[:text],
  110. styles: styles,
  111. color: fragment[:color]
  112. }
  113. end
  114. # Render as single formatted text box for proper wrapping
  115. 3408 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. 3408 pdf.fill_color original_fill_color
  124. 3408 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. 67 font_path = Rails.root.join("app/assets/fonts")
  84. 67 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. 67 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. 71 else: 70 then: 1 return unless should_render_footer?(pdf)
  8. # Save current position
  9. 70 original_y = pdf.cursor
  10. # Move to footer position
  11. 70 footer_y = FOOTER_HEIGHT
  12. 70 pdf.move_cursor_to footer_y
  13. # Create bounding box for footer
  14. 70 bounding_box_width = pdf.bounds.width
  15. 70 bounding_box_at = [0, pdf.cursor]
  16. 70 pdf.bounding_box(bounding_box_at,
  17. width: bounding_box_width,
  18. height: FOOTER_HEIGHT) do
  19. # Add top padding
  20. 70 pdf.move_down FOOTER_TOP_PADDING
  21. 70 render_footer_content(pdf, user)
  22. end
  23. # Restore position
  24. 70 pdf.move_cursor_to original_y
  25. end
  26. 4 def self.measure_footer_height(pdf)
  27. 46 else: 44 then: 2 return 0 unless should_render_footer?(pdf)
  28. 44 FOOTER_HEIGHT
  29. end
  30. 4 def self.should_render_footer?(pdf)
  31. # Only render on first page
  32. 113 pdf.page_number == 1
  33. end
  34. 4 def self.render_footer_content(pdf, user)
  35. # Render disclaimer header
  36. 73 render_disclaimer_header(pdf)
  37. 73 pdf.move_down FOOTER_INTERNAL_PADDING
  38. # Check what content we have
  39. 73 then: 73 else: 0 then: 73 else: 0 has_signature = user&.signature&.attached?
  40. 73 then: 2 else: 0 then: 2 else: 0 has_user_logo = ENV["PDF_LOGO"].present? && user&.logo&.attached?
  41. 73 pdf.bounds.width
  42. first_row = [
  43. 73 pdf.make_cell(
  44. content: I18n.t("pdf.disclaimer.text"),
  45. size: DISCLAIMER_TEXT_SIZE,
  46. inline_format: true,
  47. valign: :top,
  48. 73 then: 3 else: 70 padding: [0, (has_signature || has_user_logo) ? 10 : 0, 0, 0]
  49. )
  50. ]
  51. 73 then: 2 else: 71 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. 73 then: 2 else: 71 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. 73 then: 2 else: 71 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. 73 first_row.length
  84. 73 table_data = [first_row]
  85. # table_data << caption_row if has_signature
  86. 73 pdf.table(table_data) do |t|
  87. 67 t.cells.borders = []
  88. end
  89. end
  90. 4 def self.render_disclaimer_header(pdf)
  91. 69 pdf.text I18n.t("pdf.disclaimer.header"),
  92. size: DISCLAIMER_HEADER_SIZE,
  93. style: :bold
  94. 69 pdf.stroke_horizontal_rule
  95. end
  96. end
  97. end

app/services/pdf_generator_service/header_generator.rb

92.21% lines covered

83.33% branches covered

77 relevant lines. 71 lines covered and 6 lines missed.
18 total branches, 15 branches covered and 3 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. 42 create_inspection_header(pdf, inspection)
  7. # Generate QR code in top left corner
  8. 42 ImageProcessor.generate_qr_code_header(pdf, inspection)
  9. end
  10. 4 def self.create_inspection_header(pdf, inspection)
  11. 42 inspector_user = inspection.user
  12. 42 report_id_text = build_report_id_text(inspection)
  13. 42 status_text, status_color = build_status_text_and_color(inspection)
  14. 42 render_header_with_logo(pdf, inspector_user) do |logo_width|
  15. 42 render_inspection_text_section(pdf, inspection, report_id_text,
  16. status_text, status_color, logo_width)
  17. end
  18. 42 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. 42 "#{I18n.t("pdf.inspection.fields.report_id")}: #{inspection.id}"
  37. end
  38. 4 def build_status_text_and_color(inspection)
  39. 42 else: 0 case inspection.passed
  40. when: 38 when true
  41. 38 [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. 67 logo_width, logo_data, logo_attachment = prepare_logo(user)
  53. 67 pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do
  54. 67 yield(logo_width)
  55. 67 then: 0 else: 67 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. 78 then: 3 else: 75 if ENV["PDF_LOGO"].present?
  63. 3 logo_filename = ENV["PDF_LOGO"]
  64. 3 logo_path = Rails.root.join("app", "assets", "images", logo_filename)
  65. 3 logo_data = File.read(logo_path, mode: "rb")
  66. 2 logo_height = Configuration::LOGO_HEIGHT
  67. 2 logo_width = logo_height * 2 + 10
  68. 2 return [logo_width, logo_data, nil]
  69. end
  70. 75 then: 74 else: 1 then: 74 else: 1 else: 5 then: 70 return [0, nil, nil] unless user&.logo&.attached?
  71. 5 logo_data = user.logo.download
  72. 4 logo_height = Configuration::LOGO_HEIGHT
  73. 4 logo_width = logo_height * 2 + 10
  74. 4 [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. 42 qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
  80. 42 width = pdf.bounds.width - logo_width - qr_offset
  81. 42 pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
  82. 42 pdf.text report_id_text, size: Configuration::HEADER_TEXT_SIZE,
  83. style: :bold
  84. 42 pdf.text status_text, size: Configuration::HEADER_TEXT_SIZE,
  85. style: :bold,
  86. color: status_color
  87. 42 expiry_label = I18n.t("pdf.inspection.fields.expiry_date")
  88. 42 expiry_value = Utilities.format_date(inspection.reinspection_date)
  89. 42 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_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. 70 qr_code_png = QrCodeService.generate_qr_code(entity)
  7. # Position QR code at top left of page
  8. 69 qr_width, qr_height = PositionCalculator.qr_code_dimensions
  9. # Use pdf.bounds.top to position from top of page
  10. image_options = {
  11. 69 at: [0, pdf.bounds.top],
  12. width: qr_width,
  13. height: qr_height
  14. }
  15. 69 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. 70 then: 70 else: 0 then: 70 else: 0 else: 8 then: 62 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. 42 then: 41 else: 1 then: 41 else: 1 else: 2 then: 40 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. # Vips automatically handles EXIF orientation
  51. 2 image.write_to_buffer(".png")
  52. end
  53. 4 def self.calculate_footer_photo_dimensions(pdf, image, column_count = 3)
  54. 10 original_width = image.width
  55. 10 original_height = image.height
  56. # Calculate column width based on PDF width and column count
  57. # Account for column spacers
  58. 10 spacer_count = column_count - 1
  59. 10 spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER
  60. 10 total_spacer_width = spacer_width * spacer_count
  61. 10 column_width = (pdf.bounds.width - total_spacer_width) / column_count.to_f
  62. # Photo width equals one column width
  63. 10 photo_width = column_width.round
  64. # Calculate height maintaining aspect ratio
  65. 10 then: 0 if original_width.zero? || original_height.zero?
  66. photo_height = photo_width
  67. else: 10 else
  68. 10 aspect_ratio = original_width.to_f / original_height.to_f
  69. 10 photo_height = (photo_width / aspect_ratio).round
  70. end
  71. 10 [photo_width, photo_height]
  72. end
  73. 4 def self.render_processed_image(pdf, image, x, y, width, height, attachment)
  74. # Vips automatically handles EXIF orientation
  75. 8 processed_image = image.write_to_buffer(".png")
  76. image_options = {
  77. 8 at: [x, y],
  78. width: width,
  79. height: height
  80. }
  81. 8 pdf.image StringIO.new(processed_image), image_options
  82. rescue Prawn::Errors::UnsupportedImageType => e
  83. raise ImageError.build_detailed_error(e, attachment)
  84. end
  85. 4 def self.create_image(attachment)
  86. 12 image_data = attachment.blob.download
  87. 12 Vips::Image.new_from_buffer(image_data, "")
  88. end
  89. 4 def self.calculate_photo_y(pdf, photo_height)
  90. 8 then: 8 if pdf.page_number == 1
  91. 8 Configuration::FOOTER_HEIGHT +
  92. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  93. else: 0 else
  94. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  95. end
  96. end
  97. end
  98. 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. 47 else: 4 then: 43 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. 49 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. 70 [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. 66 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  39. 66 pdf.stroke_horizontal_rule
  40. 66 pdf.move_down 10
  41. 66 table = create_styled_unit_table(pdf, data)
  42. 66 then: 0 else: 66 yield table if block_given?
  43. 66 pdf.move_down 15
  44. 66 table
  45. end
  46. 4 def self.create_styled_unit_table(pdf, data)
  47. 66 is_unit_pdf = data.first.length == 2
  48. 66 pdf.table(data, width: pdf.bounds.width) do |t|
  49. 66 apply_unit_table_base_styling(t, data.length)
  50. 66 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. 66 table.cells.borders = []
  55. 66 table.cells.padding = UNIT_TABLE_CELL_PADDING
  56. 66 table.cells.size = UNIT_TABLE_TEXT_SIZE
  57. 66 table.row(0..row_count - 1).background_color = "EEEEEE"
  58. 66 table.row(0..row_count - 1).borders = [:bottom]
  59. 66 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. 66 table.columns(0).font_style = :bold
  63. 66 then: 25 if is_unit_pdf
  64. 25 table.columns(0).width = I18n.t("pdf.table.unit_label_column_width_left")
  65. else: 41 else
  66. 41 apply_four_column_styling(table, pdf_width)
  67. end
  68. end
  69. 4 def self.apply_four_column_styling(table, pdf_width)
  70. 41 table.columns(2).font_style = :bold
  71. 41 left_width = I18n.t("pdf.table.unit_label_column_width_left")
  72. 41 right_width = I18n.t("pdf.table.unit_label_column_width_right")
  73. 41 table.columns(0).width = left_width
  74. 41 table.columns(2).width = right_width
  75. 41 remaining_width = pdf_width - (left_width + right_width)
  76. 41 table.columns(1).width = remaining_width / 2
  77. 41 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. 45 dimensions = []
  202. 45 then: 41 else: 4 if last_inspection
  203. 41 then: 25 else: 16 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. 41 then: 25 else: 16 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. 41 then: 25 else: 16 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. 45 then: 25 else: 20 dimensions_text = dimensions.any? ? dimensions.join(" ") : ""
  214. # Get inspector details from current inspection (for inspection PDF) or last inspection (for unit PDF)
  215. 45 then: 44 inspection = if context == :inspection
  216. 44 last_inspection
  217. else: 1 else
  218. 1 unit.last_inspection
  219. end
  220. 45 then: 41 else: 4 then: 41 else: 4 inspector_name = inspection&.user&.name
  221. 45 then: 41 else: 4 then: 41 else: 4 rpii_number = inspection&.user&.rpii_inspector_number
  222. # Combine inspector name with RPII number if present
  223. 45 then: 39 inspector_text = if rpii_number.present?
  224. 39 "#{inspector_name} (#{I18n.t("pdf.inspection.fields.rpii_inspector_no")} #{rpii_number})"
  225. else: 6 else
  226. 6 inspector_name
  227. end
  228. 45 then: 41 else: 4 then: 41 else: 4 issued_date = if inspection&.inspection_date
  229. 41 Utilities.format_date(inspection.inspection_date)
  230. end
  231. # Build the table rows
  232. [
  233. [
  234. 45 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

100.0% lines covered

100.0% branches covered

31 relevant lines. 31 lines covered and 0 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. 78 then: 3 else: 75 return "" if text.nil?
  6. 75 then: 3 else: 72 (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. 97 then: 1 else: 96 return I18n.t("pdf.inspection.fields.na") if date.nil?
  14. 96 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. 7 (1..pdf.page_count).each do |page_num|
  30. 8 pdf.go_to_page(page_num)
  31. 8 pdf.transparent(WATERMARK_TRANSPARENCY) do
  32. 8 pdf.fill_color "FF0000"
  33. # 3x3 grid positions
  34. 48 y_positions = [0.10, 0.30, 0.50, 0.70, 0.9].map { |pct| pdf.bounds.height * pct }
  35. 32 x_positions = [0.15, 0.50, 0.85].map { |pct| pdf.bounds.width * pct - (WATERMARK_WIDTH / 2) }
  36. 8 y_positions.each do |y|
  37. 40 x_positions.each do |x|
  38. 120 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. 8 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. 81 require "rqrcode"
  8. # Create QR code for the report URL using the shorter format
  9. 81 then: 49 if record.is_a?(Inspection)
  10. 49 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. 50 require "rqrcode"
  18. 50 base_url = T.must(ENV["BASE_URL"])
  19. 49 url = "#{base_url}/inspections/#{inspection.id}"
  20. 49 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. 83 qrcode = RQRCode::QRCode.new(url, qr_code_options)
  33. 83 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. 84 {
  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. 84 {
  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

87.5% lines covered

94.44% branches covered

80 relevant lines. 70 lines covered and 10 lines missed.
18 total branches, 17 branches covered and 1 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. 1 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. 1 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. 1 params(inspector_number: T.nilable(T.any(String, Integer)))
  32. .returns(T::Array[InspectorInfo])
  33. end
  34. 4 def search(inspector_number)
  35. 17 then: 3 else: 14 return [] if inspector_number.blank?
  36. 14 response = make_api_request(inspector_number)
  37. 14 then: 10 if response.code == "200"
  38. 10 parse_response(JSON.parse(response.body))
  39. else: 4 else
  40. 4 log_error(response)
  41. 4 []
  42. end
  43. end
  44. 4 sig do
  45. 1 params(inspector_number: T.nilable(T.any(String, Integer)))
  46. .returns(VerificationResult)
  47. end
  48. 4 def verify(inspector_number)
  49. 7 results = search(inspector_number)
  50. 13 then: 5 else: 1 inspector = results.find { |r| r[:number]&.to_s == inspector_number.to_s }
  51. 7 then: 3 if inspector
  52. 3 {valid: true, inspector: inspector}
  53. else: 4 else
  54. 4 {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. 1 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. 3 request = Net::HTTP::Post.new(path)
  75. 3 request["User-Agent"] = USER_AGENT
  76. 3 content_type = "application/x-www-form-urlencoded; charset=UTF-8"
  77. 3 request["Content-Type"] = content_type
  78. 3 request["X-Requested-With"] = "XMLHttpRequest"
  79. 3 request.body = URI.encode_www_form({
  80. action: "check_inspector_ajax",
  81. search: inspector_number.to_s.strip
  82. })
  83. 3 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. 5 sig { params(response: T.untyped).returns(T::Array[InspectorInfo]) }
  91. 4 def parse_response(response)
  92. 9 suggestions = extract_suggestions(response)
  93. 9 else: 9 then: 0 return [] unless suggestions.is_a?(Array)
  94. 15 suggestions.map { |item| parse_inspector_item(item) }
  95. end
  96. 5 sig { params(response: T.untyped).returns(T.untyped) }
  97. 4 def extract_suggestions(response)
  98. 14 then: 8 if response.is_a?(Hash) && response["suggestions"]
  99. 8 else: 6 response["suggestions"]
  100. 6 then: 2 elsif response.is_a?(Array)
  101. 2 response
  102. else: 4 else
  103. 4 []
  104. end
  105. end
  106. 5 sig { params(item: T.untyped).returns(InspectorInfo) }
  107. 4 def parse_inspector_item(item)
  108. 12 value = item["value"] || ""
  109. 12 data = item["data"]
  110. 12 then: 1 if /^(.+?)\s*\((.+?)\)$/.match?(value)
  111. 1 else: 11 parse_name_qualifications_format(value, data)
  112. 11 then: 1 elsif /^(.+?)\s*\((\d+)\)\s*-\s*(.+)$/.match?(value)
  113. 1 parse_name_number_qualifications_format(value, data)
  114. else: 10 else
  115. {
  116. 10 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

100.0% lines covered

66.67% branches covered

32 relevant lines. 32 lines covered and 0 lines missed.
6 total branches, 4 branches covered and 2 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. 23 ensure_s3_enabled
  8. 22 validate_s3_config
  9. 20 service = get_s3_service
  10. 20 FileUtils.mkdir_p(temp_dir)
  11. 20 timestamp = Time.current.strftime("%Y-%m-%d")
  12. 20 backup_filename = "database-#{timestamp}.sqlite3"
  13. 20 temp_backup_path = temp_dir.join(backup_filename)
  14. 20 s3_key = "#{backup_dir}/database-#{timestamp}.tar.gz"
  15. # Create SQLite backup
  16. 20 Rails.logger.info "Creating database backup..."
  17. 20 system("sqlite3", database_path.to_s, ".backup '#{temp_backup_path}'", exception: true)
  18. 18 Rails.logger.info "Database backup created successfully"
  19. # Compress the backup
  20. 18 Rails.logger.info "Compressing backup..."
  21. 18 temp_compressed_path = create_tar_gz(timestamp)
  22. 16 Rails.logger.info "Backup compressed successfully"
  23. # Upload to S3
  24. 16 Rails.logger.info "Uploading to S3 (#{s3_key})..."
  25. 16 File.open(temp_compressed_path, "rb") do |file|
  26. 16 service.upload(s3_key, file)
  27. end
  28. 14 Rails.logger.info "Backup uploaded to S3 successfully"
  29. # Clean up old backups
  30. 14 Rails.logger.info "Cleaning up old backups..."
  31. 14 deleted_count = cleanup_old_backups(service)
  32. 12 then: 4 else: 8 Rails.logger.info "Deleted #{deleted_count} old backups" if deleted_count.positive?
  33. 12 backup_size_mb = (File.size(temp_compressed_path) / 1024.0 / 1024.0).round(2)
  34. 12 Rails.logger.info "Database backup completed successfully!"
  35. 12 Rails.logger.info "Backup location: #{s3_key}"
  36. 12 Rails.logger.info "Backup size: #{backup_size_mb} MB"
  37. 12 {
  38. success: true,
  39. location: s3_key,
  40. size_mb: backup_size_mb,
  41. deleted_count: deleted_count
  42. }
  43. ensure
  44. 23 then: 23 else: 0 FileUtils.rm_f(temp_backup_path) if defined?(temp_backup_path)
  45. 23 then: 23 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 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. 7 sig { params(passed: T::Boolean).returns(String) }
  198. 4 def generate_risk_assessment(passed)
  199. 718 then: 629 if passed
  200. [
  201. 629 "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: 89 else
  211. [
  212. 89 "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. 7 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. 3560 assessment_type = assessment_key.to_s.sub(/_assessment$/, "")
  227. 3560 create_assessment(
  228. inspection,
  229. assessment_key,
  230. assessment_type,
  231. passed,
  232. is_incomplete
  233. )
  234. end
  235. end
  236. 7 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. 3560 fields = SeedData.send("#{assessment_type}_fields", passed: passed)
  245. 3560 then: 718 else: 2842 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. 3560 fields = randomly_remove_fields(fields, is_incomplete)
  250. 3560 inspection.send(assessment_key).update!(fields)
  251. end
  252. 7 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. 3560 else: 380 then: 3180 return fields unless is_incomplete
  255. 380 else: 187 then: 193 return fields unless rand(0..1) == 0 # empty 50% of assessments
  256. 2747 fields.keys.each { |field| fields[field] = nil }
  257. 187 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

100.0% lines covered

100.0% branches covered

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

100.0% lines covered

85.71% branches covered

25 relevant lines. 25 lines covered and 0 lines missed.
14 total branches, 12 branches covered and 2 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 1 module RuboCop
  4. 1 module Cop
  5. 1 module Custom
  6. # Detects one-line methods that are simple aliases
  7. # (passing same arguments through)
  8. # These should call the original method directly instead
  9. #
  10. # @example
  11. # # bad - unnecessary wrapper method
  12. # def user_name(id)
  13. # fetch_user_name(id)
  14. # end
  15. #
  16. # # good - just call fetch_user_name directly
  17. #
  18. # # good - performs calculation
  19. # def total_anchors
  20. # (num_low_anchors || 0) + (num_high_anchors || 0)
  21. # end
  22. #
  23. # # good - calls with different arguments
  24. # def full_name
  25. # format_name(first_name, last_name)
  26. # end
  27. #
  28. 1 class OneLineMethods < Base
  29. 1 MSG = "Call the original method directly instead of creating " \
  30. "an aliasing wrapper method"
  31. 1 def on_def(node)
  32. 15 else: 2 then: 13 return unless aliasing_method?(node)
  33. 2 add_offense(node)
  34. end
  35. 1 def on_defs(node)
  36. 1 else: 1 then: 0 return unless aliasing_method?(node)
  37. 1 add_offense(node)
  38. end
  39. 1 private
  40. 1 def aliasing_method?(node)
  41. 16 body = node.body
  42. 16 else: 16 then: 0 return false unless body
  43. 16 else: 14 then: 2 return false unless body.send_type?
  44. 14 then: 5 else: 9 return false if body.receiver
  45. 9 method_args = node.arguments
  46. 9 call_args = body.arguments
  47. 9 else: 6 then: 3 return false unless method_args.size == call_args.size
  48. 6 then: 1 else: 5 return false if method_args.empty?
  49. 5 arguments_match?(method_args, call_args)
  50. end
  51. 1 def arguments_match?(method_args, call_args)
  52. 5 method_args.zip(call_args).all? do |method_arg, call_arg|
  53. 6 call_arg.lvar_type? && call_arg.children.first == method_arg.name
  54. end
  55. end
  56. end
  57. end
  58. end
  59. 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. 18300 then: 16007 else: 2293 return true if inspection_passed
  7. 2293 rand < 0.9
  8. end
  9. 4 def self.check_passed_integer?(inspection_passed)
  10. 5460 then: 4775 else: 685 return :pass if inspection_passed
  11. 685 then: 621 else: 64 (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. 345 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. 345 else: 304 then: 41 fields[:anchor_type_comment] = "Some wear visible on anchor points" unless passed
  61. 345 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: 641 seam_integrity_comment: if passed
  87. 641 "All seams in good condition"
  88. else: 93 else
  89. 93 "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: 650 if passed
  108. 650 fields[:fabric_strength_comment] = "Fabric in good condition"
  109. else: 92 else
  110. 92 fields[:ropes_comment] = "Rope shows signs of wear"
  111. 92 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: 642 fan_size_type: if passed
  126. 642 "Fan operating correctly at optimal pressure"
  127. else: 92 else
  128. 92 "Fan requires servicing"
  129. end,
  130. 734 then: 642 blower_flap_comment: if passed
  131. 642 "Flap mechanism functioning correctly"
  132. else: 92 else
  133. 92 "Flap sticking occasionally"
  134. end,
  135. 734 then: 642 blower_finger_comment: if passed
  136. 642 "Guard secure, no finger trap hazards"
  137. else: 92 else
  138. 92 "Guard needs tightening"
  139. end,
  140. 734 then: 642 blower_visual_comment: if passed
  141. 642 "Visual inspection satisfactory"
  142. else: 92 else
  143. 92 "Some wear visible on housing"
  144. end,
  145. 734 then: 642 pat_comment: if passed
  146. 642 "PAT test valid until #{(Date.current + 6.months).strftime("%B %Y")}"
  147. else: 92 else
  148. 92 "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: 241 runout = if passed
  173. 241 (required_runout + rand(0.5..1.5)).round(1)
  174. else: 41 else
  175. 41 fail_margin = rand(0.1..0.3)
  176. 41 (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: 241 slide_platform_height_comment: if passed
  189. 241 "Platform height compliant with EN 14960:2019"
  190. else: 41 else
  191. 41 "Platform height exceeds recommended limits"
  192. end,
  193. slide_wall_height_comment: "Wall height measured from slide bed",
  194. 282 then: 241 runout_comment: if passed
  195. 241 "Runout area clear and adequate"
  196. else: 41 else
  197. 41 "Runout area needs extending"
  198. end,
  199. 282 then: 241 clamber_netting_comment: if passed
  200. 241 "Netting secure with no gaps"
  201. else: 41 else
  202. 41 "Some gaps in netting need attention"
  203. end,
  204. 282 then: 241 slip_sheet_comment: if passed
  205. 241 "Slip sheet in good condition"
  206. else: 41 else
  207. 41 "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: 105 exit_number_comment: if passed
  217. 105 "Number of exits compliant with unit size"
  218. else: 9 else
  219. 9 "Additional exit required"
  220. end,
  221. 114 then: 105 exit_sign_always_visible_comment: if passed
  222. 105 "Exit signs visible from all points"
  223. else: 9 else
  224. 9 "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

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