loading
Generated 2026-01-29T04:02:48+00:00

All Files ( 93.04% covered at 725.04 hits/line )

118 files in total.
5388 relevant lines, 5013 lines covered and 375 lines missed. ( 93.04% )
1390 total branches, 1118 branches covered and 272 branches missed. ( 80.43% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/admin_controller.rb 45.83 % 135 72 33 39 1.94 0.00 % 10 0 10
app/controllers/admin_text_replacements_controller.rb 72.34 % 85 47 34 13 2.00 33.33 % 6 2 4
app/controllers/anchorage_assessments_controller.rb 100.00 % 6 3 3 0 4.00 100.00 % 0 0 0
app/controllers/application_controller.rb 96.03 % 243 126 121 5 360.23 80.49 % 41 33 8
app/controllers/backups_controller.rb 96.67 % 122 60 58 2 3.15 83.33 % 12 10 2
app/controllers/badge_batches_controller.rb 97.92 % 87 48 47 1 4.23 70.00 % 10 7 3
app/controllers/badges_controller.rb 95.24 % 37 21 20 1 4.24 50.00 % 2 1 1
app/controllers/concerns/assessment_controller.rb 94.19 % 162 86 81 5 21.23 80.00 % 10 8 2
app/controllers/concerns/change_tracking.rb 100.00 % 34 14 14 0 40.79 100.00 % 2 2 0
app/controllers/concerns/image_processable.rb 95.45 % 86 44 42 2 27.00 90.00 % 10 9 1
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 19.33 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 25.47 70.00 % 10 7 3
app/controllers/concerns/safety_standards_turbo_streams.rb 100.00 % 35 17 17 0 5.41 100.00 % 0 0 0
app/controllers/concerns/session_management.rb 100.00 % 32 16 16 0 192.44 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 13.69 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 42.75 100.00 % 2 2 0
app/controllers/credentials_controller.rb 100.00 % 76 33 33 0 5.30 100.00 % 6 6 0
app/controllers/enclosed_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/errors_controller.rb 100.00 % 47 26 26 0 4.88 100.00 % 4 4 0
app/controllers/fan_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 100.00 % 53 24 24 0 3.75 100.00 % 2 2 0
app/controllers/inspections_controller.rb 95.10 % 596 306 291 15 66.14 87.50 % 96 84 12
app/controllers/inspector_companies_controller.rb 100.00 % 64 28 28 0 9.21 100.00 % 4 4 0
app/controllers/materials_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 100.00 % 77 34 34 0 13.97 100.00 % 8 8 0
app/controllers/pat_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/safety_standards_controller.rb 98.68 % 429 152 150 2 27.55 88.57 % 35 31 4
app/controllers/search_controller.rb 100.00 % 9 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 100.00 % 104 48 48 0 126.52 83.33 % 6 5 1
app/controllers/slide_assessments_controller.rb 100.00 % 6 3 3 0 4.00 100.00 % 0 0 0
app/controllers/structure_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/units_controller.rb 94.39 % 428 214 202 12 20.26 88.71 % 62 55 7
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 % 15 7 7 0 5.14 100.00 % 0 0 0
app/helpers/application_helper.rb 96.05 % 131 76 73 3 781.09 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 186.22 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 866.17 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 79 25 25 0 55.68 100.00 % 8 8 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 % 10 1 1 0 4.00 100.00 % 0 0 0
app/jobs/s3_backup_job.rb 33.33 % 18 9 3 6 1.33 0.00 % 4 0 4
app/jobs/sentry_test_job.rb 16.67 % 32 18 3 15 0.67 0.00 % 6 0 6
app/models/application_record.rb 100.00 % 10 5 5 0 4.00 100.00 % 0 0 0
app/models/assessments/anchorage_assessment.rb 96.43 % 85 28 27 1 5.36 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/pat_assessment.rb 100.00 % 16 9 9 0 4.00 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 100.00 % 84 23 23 0 7.13 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/badge.rb 100.00 % 15 7 7 0 4.00 100.00 % 0 0 0
app/models/badge_batch.rb 100.00 % 8 3 3 0 4.00 100.00 % 0 0 0
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 14525.89 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 388.40 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 713.50 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 76 43 43 0 6819.21 77.78 % 9 7 2
app/models/concerns/form_configurable.rb 100.00 % 26 15 15 0 304.40 100.00 % 0 0 0
app/models/concerns/public_field_filtering.rb 100.00 % 52 17 17 0 4.18 100.00 % 0 0 0
app/models/concerns/validation_configurable.rb 97.83 % 86 46 45 1 82.87 76.47 % 17 13 4
app/models/credential.rb 100.00 % 36 6 6 0 4.00 100.00 % 0 0 0
app/models/event.rb 93.94 % 114 33 31 2 127.82 50.00 % 2 1 1
app/models/inspection.rb 97.71 % 598 262 256 6 1517.77 85.23 % 88 75 13
app/models/inspector_company.rb 96.55 % 144 58 56 2 84.57 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 150.43 100.00 % 0 0 0
app/models/text_replacement.rb 100.00 % 78 37 37 0 984.16 100.00 % 4 4 0
app/models/unit.rb 91.30 % 247 115 105 10 107.07 78.95 % 38 30 8
app/models/user.rb 90.91 % 246 121 110 11 354.85 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 49 13 13 0 197.46 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 100.00 % 15 6 6 0 4.17 100.00 % 0 0 0
app/serializers/concerns/dynamic_public_fields.rb 100.00 % 26 13 13 0 34.92 100.00 % 8 8 0
app/serializers/inspection_blueprint.rb 96.97 % 82 33 32 1 156.88 50.00 % 8 4 4
app/serializers/json_date_transformer.rb 94.12 % 41 17 16 1 559.88 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 61 27 27 0 8.15 62.50 % 8 5 3
app/services/badge_batch_csv_export_service.rb 100.00 % 60 25 25 0 5.28 100.00 % 0 0 0
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 % 21 10 10 0 20.10 100.00 % 4 4 0
app/services/image_processor_service.rb 100.00 % 59 26 26 0 3.88 84.62 % 13 11 2
app/services/inspection_creation_service.rb 100.00 % 124 55 55 0 15.33 94.44 % 18 17 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.61 88.00 % 25 22 3
app/services/ntfy_service.rb 94.44 % 65 36 34 2 5.08 66.67 % 6 4 2
app/services/pdf_cache_service.rb 100.00 % 211 97 97 0 49.36 81.82 % 33 27 6
app/services/pdf_generator_service.rb 90.32 % 212 93 84 9 43.02 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 27 15 15 0 2767.67 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1371.32 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 107 51 48 3 8473.98 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 195 84 80 4 1196.76 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 7.00 100.00 % 0 0 0
app/services/pdf_generator_service/debug_info_renderer.rb 13.33 % 64 30 4 26 0.53 0.00 % 4 0 4
app/services/pdf_generator_service/disclaimer_footer_renderer.rb 97.62 % 111 42 41 1 48.64 61.54 % 26 16 10
app/services/pdf_generator_service/header_generator.rb 92.21 % 149 77 71 6 24.56 85.00 % 20 17 3
app/services/pdf_generator_service/image_error.rb 100.00 % 50 17 17 0 2.24 100.00 % 0 0 0
app/services/pdf_generator_service/image_processor.rb 89.66 % 120 58 52 6 12.17 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 129 63 63 0 9.38 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 94 41 41 0 6.73 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.38 % 296 151 141 10 23.66 87.50 % 40 35 5
app/services/pdf_generator_service/utilities.rb 100.00 % 65 31 31 0 28.03 100.00 % 13 13 0
app/services/photo_processing_service.rb 95.12 % 81 41 39 2 12.46 55.00 % 20 11 9
app/services/qr_code_service.rb 100.00 % 71 31 31 0 33.84 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 % 60 34 34 0 14.94 66.67 % 6 4 2
app/services/seed_data_service.rb 97.86 % 322 140 137 3 83.48 82.86 % 35 29 6
app/services/sentry_test_service.rb 13.04 % 80 23 3 20 0.52 0.00 % 4 0 4
app/services/unit_creation_from_inspection_service.rb 100.00 % 60 36 36 0 3.92 100.00 % 8 8 0
app/services/unit_csv_export_service.rb 100.00 % 24 12 12 0 3.58 100.00 % 0 0 0
lib/code_standards_checker.rb 97.96 % 310 147 144 3 114.56 88.89 % 36 32 4
lib/database_i18n_backend.rb 100.00 % 33 18 18 0 31702.11 100.00 % 4 4 0
lib/erb_lint_runner.rb 100.00 % 127 74 74 0 13.24 100.00 % 16 16 0
lib/i18n_usage_tracker.rb 92.94 % 160 85 79 6 5916.79 81.25 % 16 13 3
lib/rubocop/cop/custom/no_class_defined_checks.rb 0.00 % 103 52 0 52 0.00 100.00 % 0 0 0
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 % 61 26 26 0 5.31 100.00 % 4 4 0
lib/s3_rake_helpers.rb 22.58 % 60 31 7 24 0.90 0.00 % 6 0 6
lib/seed_data.rb 100.00 % 289 77 77 0 133.09 75.00 % 40 30 10
lib/test_data_helpers.rb 100.00 % 75 24 24 0 12.17 100.00 % 0 0 0

Controllers ( 92.91% covered at 52.78 hits/line )

34 files in total.
1764 relevant lines, 1639 lines covered and 125 lines missed. ( 92.91% )
431 total branches, 353 branches covered and 78 branches missed. ( 81.9% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/admin_controller.rb 45.83 % 135 72 33 39 1.94 0.00 % 10 0 10
app/controllers/admin_text_replacements_controller.rb 72.34 % 85 47 34 13 2.00 33.33 % 6 2 4
app/controllers/anchorage_assessments_controller.rb 100.00 % 6 3 3 0 4.00 100.00 % 0 0 0
app/controllers/application_controller.rb 96.03 % 243 126 121 5 360.23 80.49 % 41 33 8
app/controllers/backups_controller.rb 96.67 % 122 60 58 2 3.15 83.33 % 12 10 2
app/controllers/badge_batches_controller.rb 97.92 % 87 48 47 1 4.23 70.00 % 10 7 3
app/controllers/badges_controller.rb 95.24 % 37 21 20 1 4.24 50.00 % 2 1 1
app/controllers/concerns/assessment_controller.rb 94.19 % 162 86 81 5 21.23 80.00 % 10 8 2
app/controllers/concerns/change_tracking.rb 100.00 % 34 14 14 0 40.79 100.00 % 2 2 0
app/controllers/concerns/image_processable.rb 95.45 % 86 44 42 2 27.00 90.00 % 10 9 1
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 19.33 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 25.47 70.00 % 10 7 3
app/controllers/concerns/safety_standards_turbo_streams.rb 100.00 % 35 17 17 0 5.41 100.00 % 0 0 0
app/controllers/concerns/session_management.rb 100.00 % 32 16 16 0 192.44 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 13.69 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 42.75 100.00 % 2 2 0
app/controllers/credentials_controller.rb 100.00 % 76 33 33 0 5.30 100.00 % 6 6 0
app/controllers/enclosed_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/errors_controller.rb 100.00 % 47 26 26 0 4.88 100.00 % 4 4 0
app/controllers/fan_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 100.00 % 53 24 24 0 3.75 100.00 % 2 2 0
app/controllers/inspections_controller.rb 95.10 % 596 306 291 15 66.14 87.50 % 96 84 12
app/controllers/inspector_companies_controller.rb 100.00 % 64 28 28 0 9.21 100.00 % 4 4 0
app/controllers/materials_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 100.00 % 77 34 34 0 13.97 100.00 % 8 8 0
app/controllers/pat_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/safety_standards_controller.rb 98.68 % 429 152 150 2 27.55 88.57 % 35 31 4
app/controllers/search_controller.rb 100.00 % 9 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 100.00 % 104 48 48 0 126.52 83.33 % 6 5 1
app/controllers/slide_assessments_controller.rb 100.00 % 6 3 3 0 4.00 100.00 % 0 0 0
app/controllers/structure_assessments_controller.rb 100.00 % 5 2 2 0 4.00 100.00 % 0 0 0
app/controllers/units_controller.rb 94.39 % 428 214 202 12 20.26 88.71 % 62 55 7
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.6% covered at 1778.78 hits/line )

27 files in total.
1030 relevant lines, 995 lines covered and 35 lines missed. ( 96.6% )
242 total branches, 188 branches covered and 54 branches missed. ( 77.69% )
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 % 10 5 5 0 4.00 100.00 % 0 0 0
app/models/assessments/anchorage_assessment.rb 96.43 % 85 28 27 1 5.36 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/pat_assessment.rb 100.00 % 16 9 9 0 4.00 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 100.00 % 84 23 23 0 7.13 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/badge.rb 100.00 % 15 7 7 0 4.00 100.00 % 0 0 0
app/models/badge_batch.rb 100.00 % 8 3 3 0 4.00 100.00 % 0 0 0
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 14525.89 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 388.40 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 713.50 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 76 43 43 0 6819.21 77.78 % 9 7 2
app/models/concerns/form_configurable.rb 100.00 % 26 15 15 0 304.40 100.00 % 0 0 0
app/models/concerns/public_field_filtering.rb 100.00 % 52 17 17 0 4.18 100.00 % 0 0 0
app/models/concerns/validation_configurable.rb 97.83 % 86 46 45 1 82.87 76.47 % 17 13 4
app/models/credential.rb 100.00 % 36 6 6 0 4.00 100.00 % 0 0 0
app/models/event.rb 93.94 % 114 33 31 2 127.82 50.00 % 2 1 1
app/models/inspection.rb 97.71 % 598 262 256 6 1517.77 85.23 % 88 75 13
app/models/inspector_company.rb 96.55 % 144 58 56 2 84.57 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 150.43 100.00 % 0 0 0
app/models/text_replacement.rb 100.00 % 78 37 37 0 984.16 100.00 % 4 4 0
app/models/unit.rb 91.30 % 247 115 105 10 107.07 78.95 % 38 30 8
app/models/user.rb 90.91 % 246 121 110 11 354.85 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 49 13 13 0 197.46 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.13% covered at 468.73 hits/line )

7 files in total.
268 relevant lines, 263 lines covered and 5 lines missed. ( 98.13% )
86 total branches, 80 branches covered and 6 branches missed. ( 93.02% )
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 781.09 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 186.22 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 866.17 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 79 25 25 0 55.68 100.00 % 8 8 0
app/helpers/users_helper.rb 100.00 % 23 13 13 0 3.54 100.00 % 6 6 0

Jobs ( 25.0% covered at 1.0 hits/line )

3 files in total.
28 relevant lines, 7 lines covered and 21 lines missed. ( 25.0% )
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 % 10 1 1 0 4.00 100.00 % 0 0 0
app/jobs/s3_backup_job.rb 33.33 % 18 9 3 6 1.33 0.00 % 4 0 4
app/jobs/sentry_test_job.rb 16.67 % 32 18 3 15 0.67 0.00 % 6 0 6

Libraries ( 84.79% covered at 1971.79 hits/line )

10 files in total.
559 relevant lines, 474 lines covered and 85 lines missed. ( 84.79% )
136 total branches, 111 branches covered and 25 branches missed. ( 81.62% )
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.96 % 310 147 144 3 114.56 88.89 % 36 32 4
lib/database_i18n_backend.rb 100.00 % 33 18 18 0 31702.11 100.00 % 4 4 0
lib/erb_lint_runner.rb 100.00 % 127 74 74 0 13.24 100.00 % 16 16 0
lib/i18n_usage_tracker.rb 92.94 % 160 85 79 6 5916.79 81.25 % 16 13 3
lib/rubocop/cop/custom/no_class_defined_checks.rb 0.00 % 103 52 0 52 0.00 100.00 % 0 0 0
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 % 61 26 26 0 5.31 100.00 % 4 4 0
lib/s3_rake_helpers.rb 22.58 % 60 31 7 24 0.90 0.00 % 6 0 6
lib/seed_data.rb 100.00 % 289 77 77 0 133.09 75.00 % 40 30 10
lib/test_data_helpers.rb 100.00 % 75 24 24 0 12.17 100.00 % 0 0 0

Ungrouped ( 94.02% covered at 433.24 hits/line )

37 files in total.
1739 relevant lines, 1635 lines covered and 104 lines missed. ( 94.02% )
485 total branches, 386 branches covered and 99 branches missed. ( 79.59% )
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 % 15 7 7 0 5.14 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 100.00 % 15 6 6 0 4.17 100.00 % 0 0 0
app/serializers/concerns/dynamic_public_fields.rb 100.00 % 26 13 13 0 34.92 100.00 % 8 8 0
app/serializers/inspection_blueprint.rb 96.97 % 82 33 32 1 156.88 50.00 % 8 4 4
app/serializers/json_date_transformer.rb 94.12 % 41 17 16 1 559.88 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 61 27 27 0 8.15 62.50 % 8 5 3
app/services/badge_batch_csv_export_service.rb 100.00 % 60 25 25 0 5.28 100.00 % 0 0 0
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 % 21 10 10 0 20.10 100.00 % 4 4 0
app/services/image_processor_service.rb 100.00 % 59 26 26 0 3.88 84.62 % 13 11 2
app/services/inspection_creation_service.rb 100.00 % 124 55 55 0 15.33 94.44 % 18 17 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.61 88.00 % 25 22 3
app/services/ntfy_service.rb 94.44 % 65 36 34 2 5.08 66.67 % 6 4 2
app/services/pdf_cache_service.rb 100.00 % 211 97 97 0 49.36 81.82 % 33 27 6
app/services/pdf_generator_service.rb 90.32 % 212 93 84 9 43.02 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 27 15 15 0 2767.67 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1371.32 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 107 51 48 3 8473.98 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 195 84 80 4 1196.76 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 7.00 100.00 % 0 0 0
app/services/pdf_generator_service/debug_info_renderer.rb 13.33 % 64 30 4 26 0.53 0.00 % 4 0 4
app/services/pdf_generator_service/disclaimer_footer_renderer.rb 97.62 % 111 42 41 1 48.64 61.54 % 26 16 10
app/services/pdf_generator_service/header_generator.rb 92.21 % 149 77 71 6 24.56 85.00 % 20 17 3
app/services/pdf_generator_service/image_error.rb 100.00 % 50 17 17 0 2.24 100.00 % 0 0 0
app/services/pdf_generator_service/image_processor.rb 89.66 % 120 58 52 6 12.17 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 129 63 63 0 9.38 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 94 41 41 0 6.73 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.38 % 296 151 141 10 23.66 87.50 % 40 35 5
app/services/pdf_generator_service/utilities.rb 100.00 % 65 31 31 0 28.03 100.00 % 13 13 0
app/services/photo_processing_service.rb 95.12 % 81 41 39 2 12.46 55.00 % 20 11 9
app/services/qr_code_service.rb 100.00 % 71 31 31 0 33.84 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 % 60 34 34 0 14.94 66.67 % 6 4 2
app/services/seed_data_service.rb 97.86 % 322 140 137 3 83.48 82.86 % 35 29 6
app/services/sentry_test_service.rb 13.04 % 80 23 3 20 0.52 0.00 % 4 0 4
app/services/unit_creation_from_inspection_service.rb 100.00 % 60 36 36 0 3.92 100.00 % 8 8 0
app/services/unit_csv_export_service.rb 100.00 % 24 12 12 0 3.58 100.00 % 0 0 0

app/controllers/admin_controller.rb

45.83% lines covered

0.0% branches covered

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

app/controllers/admin_text_replacements_controller.rb

72.34% lines covered

33.33% branches covered

47 relevant lines. 34 lines covered and 13 lines missed.
6 total branches, 2 branches covered and 4 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class AdminTextReplacementsController < ApplicationController
  4. 4 include TurboStreamResponders
  5. 4 before_action :require_admin
  6. 4 before_action :set_text_replacement, only: %i[edit update destroy]
  7. 4 def index
  8. 7 @text_replacements = TextReplacement.order(:i18n_key)
  9. 7 @tree = TextReplacement.tree_structure
  10. end
  11. 4 def new
  12. 2 @text_replacement = TextReplacement.new
  13. 2 @available_keys = TextReplacement.available_i18n_keys
  14. end
  15. 4 def create
  16. 2 @text_replacement = TextReplacement.new(text_replacement_params)
  17. 2 then: 1 if @text_replacement.save
  18. 1 msg = I18n.t("admin_text_replacements.messages.created")
  19. 1 respond_to do |format|
  20. 1 format.html do
  21. 1 flash[:notice] = msg
  22. 1 redirect_to admin_text_replacements_path
  23. end
  24. 1 format.turbo_stream do
  25. render_save_message_stream(success: true, message: msg)
  26. end
  27. end
  28. else: 1 else
  29. 1 @available_keys = TextReplacement.available_i18n_keys
  30. 1 handle_create_failure(@text_replacement)
  31. end
  32. end
  33. 4 def edit
  34. 1 @available_keys = TextReplacement.available_i18n_keys
  35. end
  36. 4 def update
  37. then: 0 if @text_replacement.update(text_replacement_params)
  38. handle_update_success(
  39. @text_replacement,
  40. "admin_text_replacements.messages.updated",
  41. admin_text_replacements_path
  42. )
  43. else: 0 else
  44. @available_keys = TextReplacement.available_i18n_keys
  45. handle_update_failure(@text_replacement)
  46. end
  47. end
  48. 4 def destroy
  49. 1 @text_replacement.destroy
  50. 1 msg = I18n.t("admin_text_replacements.messages.destroyed")
  51. 1 redirect_to admin_text_replacements_path, notice: msg
  52. end
  53. 4 def i18n_value
  54. key = params[:key]
  55. then: 0 else: 0 if key.blank?
  56. render json: {value: ""}, status: :ok
  57. return
  58. end
  59. i18n_key = key.sub(/^en\./, "")
  60. value = I18n.t(i18n_key)
  61. render json: {value: value.to_s}
  62. rescue I18n::MissingTranslationData
  63. render json: {value: ""}, status: :ok
  64. end
  65. 4 private
  66. 4 def set_text_replacement
  67. 2 @text_replacement = TextReplacement.find(params[:id])
  68. end
  69. 4 def text_replacement_params
  70. 2 params.require(:text_replacement).permit(:i18n_key, :value)
  71. end
  72. 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. # typed: false
  2. 4 class AnchorageAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. 4 include SafetyStandardsTurboStreams
  5. end

app/controllers/application_controller.rb

96.03% lines covered

80.49% branches covered

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

app/controllers/backups_controller.rb

96.67% lines covered

83.33% branches covered

60 relevant lines. 58 lines covered and 2 lines missed.
12 total branches, 10 branches covered and 2 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class BackupsController < ApplicationController
  4. 4 before_action :require_admin
  5. 4 before_action :ensure_s3_enabled
  6. 4 def index
  7. 2 @backups = fetch_backups
  8. end
  9. 4 def download
  10. 5 date = params[:date]
  11. 5 else: 2 then: 3 return redirect_with_error("invalid_date") unless valid_date?(date)
  12. 2 backup_key = build_backup_key(date)
  13. 2 else: 1 then: 1 unless backup_exists?(backup_key)
  14. 1 return redirect_with_error("backup_not_found")
  15. end
  16. 1 presigned_url = generate_download_url(backup_key)
  17. 1 redirect_to presigned_url, allow_other_host: true
  18. end
  19. 4 private
  20. 4 def ensure_s3_enabled
  21. 8 then: 7 else: 1 return if Rails.configuration.s3.enabled
  22. 1 flash[:error] = t("backups.errors.s3_not_enabled")
  23. 1 redirect_to admin_path
  24. end
  25. 4 def get_s3_service
  26. 5 service = ActiveStorage::Blob.service
  27. # Verify we're using S3 storage (s3.enabled checked in before_action)
  28. 5 else: 5 then: 0 unless service.is_a?(ActiveStorage::Service::S3Service)
  29. raise t("backups.errors.s3_not_configured")
  30. end
  31. 5 service
  32. end
  33. 4 def fetch_backups
  34. 4 service = get_s3_service
  35. 4 bucket = service.send(:bucket)
  36. 3 backups = build_backup_list(bucket)
  37. 6 backups.sort_by { |b| b[:last_modified] }.reverse
  38. end
  39. 4 def backup_exists?(key)
  40. 3 fetch_backups.any? { |backup| backup[:key] == key }
  41. end
  42. 4 def redirect_with_error(error_key)
  43. 4 flash[:error] = t("backups.errors.#{error_key}")
  44. 4 redirect_to backups_path
  45. end
  46. 4 def generate_download_url(backup_key)
  47. 1 service = get_s3_service
  48. 1 bucket = service.send(:bucket)
  49. 1 object = bucket.object(backup_key)
  50. 1 filename = File.basename(backup_key)
  51. 1 disposition = build_content_disposition(filename)
  52. 1 object.presigned_url(
  53. :get,
  54. expires_in: 300,
  55. response_content_disposition: disposition
  56. )
  57. end
  58. 4 def build_backup_list(bucket)
  59. 3 backups = []
  60. 3 bucket.objects(prefix: "db_backups/").each do |object|
  61. 3 else: 3 then: 0 next unless valid_backup_filename?(object.key)
  62. 3 backups << build_backup_info(object)
  63. end
  64. 3 backups
  65. end
  66. 4 def valid_backup_filename?(key)
  67. 3 key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
  68. end
  69. 4 def build_backup_info(object)
  70. {
  71. 3 key: object.key,
  72. filename: File.basename(object.key),
  73. size: object.size,
  74. last_modified: object.last_modified,
  75. size_mb: calculate_size_in_mb(object.size)
  76. }
  77. end
  78. 4 def calculate_size_in_mb(size_bytes)
  79. 3 (size_bytes / 1024.0 / 1024.0).round(2)
  80. end
  81. 4 def build_content_disposition(filename)
  82. # HTTP header format per RFC 6266 (not user-facing, no i18n needed)
  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/badge_batches_controller.rb

97.92% lines covered

70.0% branches covered

48 relevant lines. 47 lines covered and 1 lines missed.
10 total branches, 7 branches covered and 3 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class BadgeBatchesController < ApplicationController
  4. 4 before_action :require_admin
  5. 4 before_action :set_badge_batch, only: %i[edit export update]
  6. 4 def index
  7. 7 @badge_batches = BadgeBatch.includes(:badges).order(created_at: :desc)
  8. end
  9. 4 def new
  10. 1 @badge_batch = BadgeBatch.new
  11. end
  12. 4 def create
  13. 3 count = badge_batch_params[:count].to_i
  14. 3 note = badge_batch_params[:note]
  15. 3 badge_batch = BadgeBatch.create!(note: note, count: count)
  16. 3 badge_ids = Badge.generate_random_ids(count)
  17. 3 timestamp = Time.current
  18. 3 badge_records = badge_ids.map do |id|
  19. 30 {id: id, badge_batch_id: badge_batch.id, created_at: timestamp,
  20. updated_at: timestamp}
  21. end
  22. 3 Badge.insert_all(badge_records)
  23. 3 flash[:success] = t("badges.messages.batch_created", count: count)
  24. 3 redirect_to edit_badge_batch_path(badge_batch)
  25. end
  26. 4 def edit
  27. 11 @badges = @badge_batch.badges.order(:id)
  28. end
  29. 4 def export
  30. 4 csv_data = BadgeBatchCsvExportService.new(@badge_batch).generate
  31. 4 batch_id = @badge_batch.id
  32. 4 today = Time.zone.today
  33. 4 send_data csv_data, filename: "badge-batch-#{batch_id}-#{today}.csv"
  34. end
  35. 4 def update
  36. 1 then: 1 if @badge_batch.update(note_param)
  37. 1 flash[:success] = t("badges.messages.batch_updated")
  38. 1 redirect_to edit_badge_batch_path(@badge_batch)
  39. else: 0 else
  40. render :edit
  41. end
  42. end
  43. 4 def search
  44. 5 then: 5 else: 0 then: 5 else: 0 query = params[:query]&.strip&.upcase
  45. 5 then: 1 else: 4 if query.blank?
  46. 1 redirect_to badge_batches_path
  47. 1 return
  48. end
  49. 4 badge = Badge.find_by(id: query)
  50. 4 then: 2 if badge
  51. 2 flash[:success] = t("badges.messages.search_success")
  52. 2 redirect_to edit_badge_path(badge)
  53. else: 2 else
  54. 2 flash[:alert] = t("badges.messages.search_not_found", query: query)
  55. 2 redirect_to badge_batches_path
  56. end
  57. end
  58. 4 private
  59. 4 def set_badge_batch
  60. 17 @badge_batch = BadgeBatch.includes(badges: :unit).find(params[:id])
  61. end
  62. 4 def badge_batch_params
  63. 6 params.require(:badge_batch).permit(:count, :note)
  64. end
  65. 4 def note_param
  66. 1 params.require(:badge_batch).permit(:note)
  67. end
  68. end

app/controllers/badges_controller.rb

95.24% lines covered

50.0% branches covered

21 relevant lines. 20 lines covered and 1 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class BadgesController < ApplicationController
  4. 4 extend T::Sig
  5. 4 before_action :require_admin
  6. 4 before_action :set_badge, only: %i[edit update]
  7. 6 sig { void }
  8. 4 def edit
  9. 6 @badge_batch = @badge.badge_batch
  10. 6 @units = Unit.where(id: @badge.id)
  11. end
  12. 6 sig { void }
  13. 4 def update
  14. 2 then: 2 if @badge.update(note_params)
  15. 2 flash[:success] = t("badges.messages.badge_updated")
  16. 2 redirect_to edit_badge_batch_path(@badge.badge_batch)
  17. else: 0 else
  18. render :edit
  19. end
  20. end
  21. 4 private
  22. 6 sig { void }
  23. 4 def set_badge
  24. 9 @badge = Badge.find(params[:id])
  25. end
  26. 6 sig { returns(ActionController::Parameters) }
  27. 4 def note_params
  28. 2 params.require(:badge).permit(:note)
  29. end
  30. 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. 32 before_action :set_inspection
  10. 32 before_action :check_inspection_owner
  11. 32 before_action :require_user_active
  12. 32 before_action :set_assessment
  13. 32 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. 59 then: 15 else: 44 preprocess_values if respond_to?(:preprocess_values, true)
  19. 59 then: 58 if @assessment.update(assessment_params)
  20. 58 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. 58 additional_info = build_additional_info
  29. 58 respond_to do |format|
  30. 58 format.html do
  31. 39 flash[:notice] = build_flash_message(additional_info)
  32. 39 redirect_to @inspection
  33. end
  34. 58 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. 58 format.turbo_stream do
  42. 19 render turbo_stream: success_turbo_streams(additional_info:)
  43. end
  44. end
  45. end
  46. # Override in specific controllers to provide additional info
  47. 8 sig { returns(T.nilable(String)) }
  48. 4 def build_additional_info
  49. 44 nil
  50. end
  51. 8 sig { params(additional_info: T.nilable(String)).returns(String) }
  52. 4 def build_flash_message(additional_info)
  53. 39 base_message = I18n.t("inspections.messages.updated")
  54. 39 else: 6 then: 33 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. 59 @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. 59 else: 59 then: 0 head :not_found unless @inspection.user == current_user
  88. end
  89. 8 sig { void }
  90. 4 def set_assessment
  91. 59 @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. 59 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. 59 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. 59 excluded_attrs = %w[id inspection_id created_at updated_at]
  108. 59 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. 60 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. 135 "Assessments::#{controller_name.singularize.camelize}".constantize
  128. end
  129. 8 sig { void }
  130. 4 def set_previous_inspection
  131. 59 else: 59 then: 0 return unless @inspection.unit
  132. 59 @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/change_tracking.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module ChangeTracking
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 private
  7. 4 sig do
  8. 4 params(
  9. previous_attributes: T::Hash[String, T.untyped],
  10. current_attributes: T::Hash[String, T.untyped],
  11. changed_keys: T::Array[T.any(String, Symbol)]
  12. ).returns(T.nilable(T::Hash[String, T::Hash[String, T.untyped]]))
  13. end
  14. 4 def calculate_changes(previous_attributes, current_attributes, changed_keys)
  15. 43 changes = {}
  16. 43 changed_keys.map(&:to_s).each do |key|
  17. 121 previous_value = previous_attributes[key]
  18. 121 current_value = current_attributes[key]
  19. 121 else: 51 then: 70 next unless previous_value != current_value
  20. 51 changes[key] = {
  21. "from" => previous_value,
  22. "to" => current_value
  23. }
  24. end
  25. 43 changes.presence
  26. end
  27. 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. 187 image_fields.each do |field|
  26. 455 then: 429 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. 187 params_hash
  39. end
  40. 8 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. 8 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. 2 params(additional_info: T.nilable(String)).returns(T::Array[T.untyped])
  9. end
  10. 4 def success_turbo_streams(additional_info: nil)
  11. [
  12. 29 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. 6 sig { returns(T.untyped) }
  27. 4 def mark_complete_section_stream
  28. 30 turbo_stream.replace(
  29. "mark_complete_section_#{@inspection.id}",
  30. partial: "inspections/mark_complete_section",
  31. locals: {inspection: @inspection}
  32. )
  33. end
  34. 6 sig { params(success: T::Boolean).returns(T.untyped) }
  35. 4 def save_message_stream(success:)
  36. 30 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. 2 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. 30 locals = save_message_locals(success:, dom_id: "form_save_message")
  51. 30 then: 2 else: 28 locals[:additional_info] = additional_info if additional_info
  52. 30 turbo_stream.replace(
  53. "form_save_message",
  54. partial: "shared/save_message",
  55. locals:
  56. )
  57. end
  58. 4 sig do
  59. 2 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. 60 then: 58 if success
  64. 58 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. 6 sig { params(dom_id: String).returns(T::Hash[Symbol, T.untyped]) }
  74. 4 def success_message_locals(dom_id)
  75. 58 current_tab_name = params[:tab].presence || "inspection"
  76. 58 nav_info = helpers.next_tab_navigation_info(@inspection, current_tab_name)
  77. {
  78. 58 dom_id: dom_id,
  79. success: true,
  80. message: t("inspections.messages.updated"),
  81. inspection: @inspection
  82. }.tap do |locals|
  83. 58 then: 56 else: 2 add_navigation_info(locals, nav_info) if nav_info
  84. end
  85. end
  86. 4 sig do
  87. 2 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. 56 locals[:next_tab] = nav_info[:tab]
  94. 56 locals[:skip_incomplete] = nav_info[:skip_incomplete]
  95. 56 then: 54 else: 2 if nav_info[:skip_incomplete]
  96. 54 locals[:incomplete_count] = nav_info[:incomplete_count]
  97. end
  98. end
  99. 6 sig { returns(T::Array[T.untyped]) }
  100. 4 def photo_update_streams
  101. 29 else: 10 then: 19 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. 309 then: 103 else: 206 return if request.format.pdf? || request.format.json? || request.format.png?
  29. # Rule 2: Always allow HTML access (show action decides the view)
  30. 206 then: 200 else: 6 return if request.format.html? && action_name == "show"
  31. # Rule 3: Always allow HEAD requests for federation
  32. 6 then: 6 else: 0 return if request.head?
  33. # Rule 4: All other cases require ownership
  34. check_resource_owner
  35. end
  36. # To be implemented by including controllers
  37. 4 sig { void }
  38. 4 def check_resource_owner
  39. raise NotImplementedError
  40. end
  41. # Determine if current user owns the resource
  42. 4 sig { returns(T::Boolean) }
  43. 4 def owns_resource?
  44. raise NotImplementedError
  45. end
  46. # Render appropriate view for show action
  47. 8 sig { void }
  48. 4 def render_show_html
  49. 184 else: 161 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. 2 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. 6 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. 6 sig { returns(String) }
  22. 4 def safety_results_frame_id
  23. 9 "#{assessment_type}_safety_results"
  24. end
  25. 6 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

16 relevant lines. 16 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 module SessionManagement
  4. 4 extend T::Sig
  5. 4 extend T::Helpers
  6. 4 extend ActiveSupport::Concern
  7. 4 private
  8. 8 sig { params(user: User).returns(UserSession) }
  9. 4 def establish_user_session(user)
  10. 733 user_session = user.user_sessions.create!(
  11. ip_address: request.remote_ip,
  12. user_agent: request.user_agent,
  13. last_active_at: Time.current
  14. )
  15. 733 session[:session_token] = user_session.session_token
  16. 733 create_user_session
  17. 733 user_session
  18. end
  19. 8 sig { void }
  20. 4 def terminate_current_session
  21. 35 else: 34 then: 1 return unless session[:session_token]
  22. 34 then: 34 else: 0 UserSession.find_by(session_token: session[:session_token])&.destroy
  23. 34 session.delete(:session_token)
  24. end
  25. 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.nilable(T.any(String, ActiveRecord::Base)), additional_streams: T::Array[Turbo::Streams::TagBuilder]).void }
  24. 4 def handle_update_success(model, message_key = nil, redirect_path = nil, additional_streams: [])
  25. 35 message_key ||= "#{model.class.table_name}.messages.updated"
  26. 35 redirect_path ||= model
  27. 35 respond_to do |format|
  28. 35 format.html do
  29. 30 flash[:notice] = I18n.t(message_key)
  30. 30 redirect_to redirect_path
  31. end
  32. 35 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. 7 sig { params(model: ActiveRecord::Base, message_key: T.nilable(String)).void }
  62. 4 def handle_create_success(model, message_key = nil)
  63. 29 message_key ||= "#{model.class.table_name}.messages.created"
  64. 29 respond_to do |format|
  65. 29 format.html do
  66. 29 flash[:notice] = I18n.t(message_key)
  67. 29 redirect_to model
  68. end
  69. 29 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. 13 respond_to do |format|
  80. 26 format.html { render view, status: :unprocessable_content }
  81. 13 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. 453 then: 441 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. # typed: false
  2. 4 class CredentialsController < ApplicationController
  3. 4 before_action :require_login
  4. 4 def create
  5. 5 create_options = WebAuthn::Credential.options_for_create(
  6. user: {
  7. id: current_user.webauthn_id,
  8. name: current_user.email
  9. },
  10. exclude: current_user.credentials.pluck(:external_id),
  11. authenticator_selection: {user_verification: "required"}
  12. )
  13. 5 session[:current_registration] = {challenge: create_options.challenge}
  14. 5 respond_to do |format|
  15. 10 format.json { render json: create_options }
  16. end
  17. end
  18. 4 def callback
  19. 12 webauthn_credential = WebAuthn::Credential.from_create(params)
  20. 8 verify_and_save_credential(webauthn_credential)
  21. rescue WebAuthn::Error => e
  22. 4 error_msg = I18n.t("credentials.messages.verification_failed")
  23. 4 render json: "#{error_msg}: #{e.message}",
  24. status: :unprocessable_content
  25. ensure
  26. 12 session.delete(:current_registration)
  27. end
  28. 4 def destroy
  29. 4 credential = current_user.credentials.find(params[:id])
  30. 2 then: 1 if current_user.can_delete_credentials?
  31. 1 credential.destroy
  32. 1 flash[:notice] = I18n.t("credentials.messages.deleted")
  33. else: 1 else
  34. 1 flash[:error] = I18n.t("credentials.messages.cannot_delete_last")
  35. end
  36. 2 redirect_to change_settings_user_path(current_user)
  37. end
  38. 4 private
  39. 4 def verify_and_save_credential(webauthn_credential)
  40. 8 challenge = session[:current_registration]["challenge"]
  41. 8 webauthn_credential.verify(challenge, user_verification: true)
  42. 8 credential = current_user.credentials.find_or_initialize_by(
  43. external_id: Base64.strict_encode64(webauthn_credential.raw_id)
  44. )
  45. 8 credential_attrs = credential_params(webauthn_credential)
  46. # Ensure user_id is set for new records
  47. 8 then: 7 else: 1 credential_attrs[:user_id] = current_user.id if credential.new_record?
  48. 8 then: 5 if credential.update(credential_attrs)
  49. 5 render json: {status: "ok"}, status: :ok
  50. else: 3 else
  51. 3 error_msg = I18n.t("credentials.messages.could_not_add")
  52. 3 render json: error_msg, status: :unprocessable_content
  53. end
  54. end
  55. 4 def credential_params(webauthn_credential)
  56. {
  57. 8 nickname: params[:credential_nickname],
  58. public_key: webauthn_credential.public_key,
  59. sign_count: webauthn_credential.sign_count
  60. }
  61. end
  62. 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. # typed: false
  2. 4 class EnclosedAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. end

app/controllers/errors_controller.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. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class ErrorsController < ApplicationController
  4. 4 extend T::Sig
  5. 4 skip_before_action :require_login
  6. 4 skip_before_action :update_last_active_at
  7. 6 sig { void }
  8. 4 def not_found
  9. 5 capture_exception_for_sentry
  10. 5 respond_to do |format|
  11. 7 format.html { render status: :not_found }
  12. 5 format.json do
  13. 1 render json: {error: I18n.t("errors.not_found.title")},
  14. status: :not_found
  15. end
  16. 7 format.any { head :not_found }
  17. end
  18. end
  19. 6 sig { void }
  20. 4 def internal_server_error
  21. 5 capture_exception_for_sentry
  22. 5 respond_to do |format|
  23. 7 format.html { render status: :internal_server_error }
  24. 5 format.json do
  25. 1 render json: {error: I18n.t("errors.internal_server_error.title")},
  26. status: :internal_server_error
  27. end
  28. 7 format.any { head :internal_server_error }
  29. end
  30. end
  31. 4 private
  32. 6 sig { void }
  33. 4 def capture_exception_for_sentry
  34. 13 else: 2 then: 11 return unless Rails.env.production?
  35. 2 exception = request.env["action_dispatch.exception"]
  36. 2 then: 1 else: 1 Sentry.capture_exception(exception) if exception
  37. end
  38. 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. # typed: false
  2. 4 class FanAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. 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. # typed: false
  2. 4 class GuidesController < ApplicationController
  3. 4 skip_before_action :require_login
  4. 4 def index
  5. 1 @guides = collect_guides
  6. end
  7. 4 def show
  8. 5 guide_path = params[:path]
  9. 5 metadata_file = guide_screenshots_root.join(guide_path, "metadata.json")
  10. 5 then: 3 if metadata_file.exist?
  11. 3 @guide_data = JSON.parse(metadata_file.read)
  12. 3 @guide_path = guide_path
  13. 3 @guide_title = humanize_guide_title(guide_path)
  14. else: 2 else
  15. 2 redirect_to guides_path, alert: I18n.t("guides.messages.not_found")
  16. end
  17. end
  18. 4 private
  19. 4 def guide_screenshots_root
  20. 10 Rails.public_path.join("guide_screenshots")
  21. end
  22. 4 def collect_guides
  23. 2 guides = []
  24. # Find all metadata.json files
  25. 2 Dir.glob(guide_screenshots_root.join("**", "metadata.json")).each do |metadata_path|
  26. 2 relative_path = Pathname.new(metadata_path).relative_path_from(guide_screenshots_root).dirname.to_s
  27. 2 metadata = JSON.parse(File.read(metadata_path))
  28. 2 guides << {
  29. path: relative_path,
  30. title: humanize_guide_title(relative_path),
  31. screenshot_count: metadata["screenshots"].size,
  32. updated_at: metadata["updated_at"],
  33. first_screenshot: metadata["screenshots"].first
  34. }
  35. end
  36. 4 guides.sort_by { |g| g[:title] }
  37. end
  38. 4 def humanize_guide_title(path)
  39. # Convert spec/features/inspections/inspection_creation_workflow_spec to "Inspection Creation Workflow"
  40. 7 path.split("/").last.gsub(/_spec$/, "").humanize
  41. end
  42. end

app/controllers/inspections_controller.rb

95.1% lines covered

87.5% branches covered

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

app/controllers/pat_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. # typed: false
  2. 4 class PatAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. 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. 7 sig { void }
  136. 4 def redirect_with_calculation_params
  137. 16 redirect_to safety_standards_path(
  138. calculation: params[:calculation].to_unsafe_h
  139. )
  140. end
  141. 8 sig { void }
  142. 4 def calculate_safety_standard
  143. 116 type = params[:calculation][:type]
  144. 116 else: 0 case type
  145. when: 56 when "anchors"
  146. 56 calculate_anchors
  147. when: 23 when "slide_runout"
  148. 23 calculate_slide_runout
  149. when: 25 when "wall_height"
  150. 25 calculate_wall_height
  151. when: 12 when "user_capacity"
  152. 12 calculate_user_capacity
  153. end
  154. end
  155. 8 sig { void }
  156. 4 def calculate_anchors
  157. 56 dimensions = extract_dimensions(:length, :width, :height)
  158. 56 then: 41 if dimensions.values.all?(&:positive?)
  159. 41 @anchors_result = EN14960.calculate_anchors(**dimensions)
  160. else: 15 else
  161. 15 set_error(:anchors, :invalid_dimensions)
  162. end
  163. end
  164. 8 sig { void }
  165. 4 def calculate_slide_runout
  166. 23 height = param_to_float(:platform_height)
  167. 23 has_stop_wall = params[:calculation][:has_stop_wall] == "1"
  168. 23 then: 18 if height.positive?
  169. 18 @slide_runout_result = build_runout_result(height, has_stop_wall)
  170. else: 5 else
  171. 5 set_error(:slide_runout, :invalid_height)
  172. end
  173. end
  174. 8 sig { void }
  175. 4 def calculate_wall_height
  176. 25 platform_height = param_to_float(:platform_height)
  177. 25 user_height = param_to_float(:user_height)
  178. 25 then: 19 if platform_height.positive? && user_height.positive?
  179. 19 @wall_height_result = build_wall_height_result(
  180. platform_height, user_height
  181. )
  182. else: 6 else
  183. 6 set_error(:wall_height, :invalid_height)
  184. end
  185. end
  186. 5 sig { void }
  187. 4 def calculate_user_capacity
  188. 12 length = param_to_float(:length)
  189. 12 width = param_to_float(:width)
  190. 12 max_user_height = param_to_float(:max_user_height)
  191. 12 then: 2 else: 10 max_user_height = nil if max_user_height.zero?
  192. 12 negative_adjustment_area = param_to_float(:negative_adjustment_area)
  193. 12 then: 8 if length.positive? && width.positive?
  194. 8 @user_capacity_result = EN14960.calculate_user_capacity(
  195. length, width, max_user_height, negative_adjustment_area
  196. )
  197. else: 4 else
  198. 4 set_error(:user_capacity, :invalid_dimensions)
  199. end
  200. end
  201. 8 sig { params(keys: Symbol).returns(T::Hash[Symbol, Float]) }
  202. 4 def extract_dimensions(*keys)
  203. 224 keys.index_with { |key| param_to_float(key) }
  204. end
  205. 8 sig { params(key: Symbol).returns(Float) }
  206. 4 def param_to_float(key)
  207. 305 params[:calculation][key].to_f
  208. end
  209. 7 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. 8 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. 7 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. 7 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. 7 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. 7 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. 7 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. # typed: false
  2. 4 class SearchController < ApplicationController
  3. 4 skip_before_action :require_login
  4. 4 def index
  5. 6 @federated_sites = Federation.sites(request.host, current_user)
  6. end
  7. 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. 723 else: 723 then: 0 sleep(rand(0.5..1.0)) unless Rails.env.test?
  13. 723 email = params.dig(:session, :email)
  14. 723 password = params.dig(:session, :password)
  15. 723 then: 715 if (user = authenticate_user(email, password))
  16. 715 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. 28 terminate_current_session
  24. 28 log_out
  25. 28 flash[:notice] = I18n.t("session.logout.success")
  26. 28 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. 715 establish_user_session(user)
  81. 715 flash[:notice] = I18n.t("session.login.success")
  82. 715 redirect_back_or(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. # typed: false
  2. 4 class SlideAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. 4 include SafetyStandardsTurboStreams
  5. 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. # typed: false
  2. 4 class StructureAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. end

app/controllers/units_controller.rb

94.39% lines covered

88.71% branches covered

214 relevant lines. 202 lines covered and 12 lines missed.
62 total branches, 55 branches covered and 7 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 class UnitsController < ApplicationController
  4. 4 extend T::Sig
  5. 4 include ChangeTracking
  6. 4 include TurboStreamResponders
  7. 4 include PublicViewable
  8. 4 include UserActivityCheck
  9. 4 skip_before_action :require_login, only: %i[show]
  10. 4 before_action :check_assessments_enabled
  11. 4 before_action :require_admin, only: %i[all]
  12. 4 before_action :set_unit, only: %i[destroy edit log show update]
  13. 4 before_action :check_unit_owner, only: %i[destroy edit update]
  14. 4 before_action :check_log_access, only: %i[log]
  15. 4 before_action :require_user_active, only: %i[create new edit update]
  16. 4 before_action :validate_badge_id_param, only: %i[new create]
  17. 4 before_action :no_index
  18. 4 def index
  19. 71 @units = apply_filters(current_user.units)
  20. 71 @title = build_index_title
  21. 71 respond_to do |format|
  22. 71 format.html
  23. 71 format.csv do
  24. 2 log_unit_event("exported", nil, "Exported #{@units.count} units to CSV")
  25. 2 csv_data = UnitCsvExportService.new(@units).generate
  26. 2 send_data csv_data, filename: "units-#{Time.zone.today}.csv"
  27. end
  28. end
  29. end
  30. 4 def all
  31. 5 @units = apply_filters(Unit.all)
  32. 5 @title = I18n.t("units.titles.all_units")
  33. 5 respond_to do |format|
  34. 10 format.html { render :index }
  35. 5 format.csv do
  36. log_unit_event("exported", nil, "Exported #{@units.count} units to CSV")
  37. csv_data = UnitCsvExportService.new(@units).generate
  38. send_data csv_data, filename: "all-units-#{Time.zone.today}.csv"
  39. end
  40. end
  41. end
  42. 4 def show
  43. # Handle federation HEAD requests
  44. 116 then: 1 else: 115 return head :ok if request.head?
  45. 115 @inspections = @unit.inspections
  46. .includes(:user, inspector_company: {logo_attachment: :blob})
  47. .order(inspection_date: :desc)
  48. 115 respond_to do |format|
  49. 193 format.html { render_show_html }
  50. 131 format.pdf { send_unit_pdf }
  51. 119 format.png { send_unit_qr_code }
  52. 115 format.json do
  53. 17 render json: UnitBlueprint.render_with_inspections(@unit)
  54. end
  55. end
  56. end
  57. 4 def new
  58. 27 @unit = Unit.new
  59. 27 then: 1 else: 26 if @validated_badge_id.present?
  60. 1 @unit.id = @validated_badge_id
  61. 1 @prefilled_badge = true
  62. end
  63. end
  64. 4 def create
  65. 21 @unit = current_user.units.build(unit_params)
  66. 21 @prefilled_badge = @validated_badge_id.present?
  67. 21 then: 0 else: 21 if @image_processing_error
  68. flash.now[:alert] = @image_processing_error.message
  69. handle_create_failure(@unit)
  70. return
  71. end
  72. 21 then: 15 if @unit.save
  73. 15 log_unit_event("created", @unit)
  74. 15 handle_create_success(@unit)
  75. else: 6 else
  76. 6 handle_create_failure(@unit)
  77. end
  78. end
  79. 4 def edit = nil
  80. 4 def update
  81. 8 then: 0 else: 8 return handle_image_processing_error if @image_processing_error
  82. 8 previous_attributes = @unit.attributes.dup
  83. 8 then: 7 if @unit.update(unit_params)
  84. 7 log_unit_changes(previous_attributes)
  85. 7 handle_update_success(@unit, nil, nil, additional_streams: photo_turbo_streams)
  86. else: 1 else
  87. 1 handle_update_failure(@unit)
  88. end
  89. end
  90. 4 def destroy
  91. 5 unit_details = capture_unit_details_for_deletion
  92. 5 then: 4 if @unit.destroy
  93. 4 log_unit_deletion(unit_details)
  94. 4 flash[:notice] = I18n.t("units.messages.deleted")
  95. 4 redirect_to units_path
  96. else: 1 else
  97. 1 flash[:alert] = unit_deletion_error_message
  98. 1 redirect_to @unit
  99. end
  100. end
  101. 4 def log
  102. 2 @events = Event.for_resource(@unit).recent.includes(:user)
  103. 2 @title = I18n.t("units.titles.log", unit: @unit.name)
  104. end
  105. 4 def new_from_inspection
  106. 4 @inspection = current_user.inspections.find_by(id: params[:id])
  107. 4 else: 3 then: 1 unless @inspection
  108. 1 flash[:alert] = I18n.t("units.errors.inspection_not_found")
  109. 1 redirect_to root_path and return
  110. end
  111. 3 then: 1 else: 2 if @inspection.unit
  112. 1 flash[:alert] = I18n.t("units.errors.inspection_has_unit")
  113. 1 redirect_to inspection_path(@inspection) and return
  114. end
  115. 2 @unit = Unit.new(user: current_user)
  116. end
  117. 4 def create_from_inspection
  118. 5 service = build_unit_creation_service
  119. 5 then: 2 if service.create
  120. 2 else: 3 handle_unit_creation_success(service)
  121. 3 then: 2 elsif service.error_message
  122. 2 handle_unit_creation_error(service)
  123. else: 1 else
  124. 1 render_unit_creation_form(service)
  125. end
  126. end
  127. 4 private
  128. 4 def validate_badge_id_param
  129. 49 else: 14 then: 35 return unless unit_badges_enabled?
  130. 14 id_param = extract_badge_id_param
  131. 14 then: 8 else: 6 return if id_param.blank?
  132. 6 normalized_id = normalize_unit_id(id_param)
  133. 6 then: 1 else: 5 return redirect_to_existing_unit(normalized_id) if unit_exists?(normalized_id)
  134. # Only set validated badge ID if it exists, otherwise let model validation handle it
  135. 5 then: 4 else: 1 @validated_badge_id = normalized_id if badge_exists?(normalized_id)
  136. end
  137. 4 def log_unit_event(action, unit, details = nil, changed_data = nil)
  138. 26 else: 26 then: 0 return unless current_user
  139. 26 then: 24 if unit
  140. 24 log_resource_event(action, unit, details, changed_data)
  141. else: 2 else
  142. 2 log_system_unit_event(action, details)
  143. end
  144. rescue => e
  145. log_event_error(e)
  146. end
  147. 4 def unit_params
  148. 41 permitted_fields = build_unit_permitted_fields
  149. 41 permitted_params = params.require(:unit).permit(*permitted_fields)
  150. 41 process_image_params(permitted_params, :photo)
  151. end
  152. 7 sig { returns(T::Boolean) }
  153. 4 def unit_badges_enabled?
  154. 90 Rails.configuration.units.badges_enabled
  155. end
  156. 6 sig { params(raw_id: String).returns(String) }
  157. 4 def normalize_unit_id(raw_id)
  158. 6 raw_id.gsub(/\s+/, "").upcase[0, 8]
  159. end
  160. 4 def no_index = response.set_header("X-Robots-Tag", "noindex,nofollow")
  161. 4 def set_unit
  162. 169 @unit = Unit.includes(photo_attachment: :blob)
  163. .find_by(id: params[:id].upcase)
  164. 169 else: 157 unless @unit
  165. then: 12 # Always return 404 for non-existent resources regardless of login status
  166. 12 head :not_found
  167. end
  168. end
  169. 4 def check_unit_owner
  170. 39 else: 34 then: 5 head :not_found unless owns_resource?
  171. end
  172. 4 def check_log_access
  173. # Only unit owners can view logs
  174. 2 else: 2 then: 0 head :not_found unless owns_resource?
  175. end
  176. 4 def check_assessments_enabled
  177. 308 else: 308 then: 0 head :not_found unless Rails.configuration.app.has_assessments
  178. end
  179. 4 def send_unit_pdf
  180. # Unit already has photo loaded from set_unit
  181. 16 result = PdfCacheService.fetch_or_generate_unit_pdf(
  182. @unit,
  183. debug_enabled: admin_debug_enabled?,
  184. debug_queries: debug_sql_queries
  185. )
  186. 16 handle_pdf_response(result, pdf_filename)
  187. end
  188. 4 def send_unit_qr_code
  189. 4 qr_code_png = QrCodeService.generate_qr_code(@unit)
  190. 4 send_data qr_code_png,
  191. filename: "#{@unit.serial}_QR.png",
  192. type: "image/png",
  193. disposition: "inline"
  194. end
  195. # PublicViewable implementation
  196. 4 def check_resource_owner
  197. check_unit_owner
  198. end
  199. 4 def owns_resource?
  200. 112 @unit && current_user && @unit.user_id == current_user.id
  201. end
  202. 4 def pdf_filename
  203. 26 prefix = Rails.configuration.units.pdf_filename_prefix
  204. 26 type_name = I18n.t("units.export.pdf_type")
  205. 26 "#{prefix}#{type_name}-#{@unit.id}.pdf"
  206. end
  207. 4 def resource_pdf_url
  208. 10 unit_path(@unit, format: :pdf)
  209. end
  210. 4 def apply_filters(units)
  211. 76 units = units.includes(photo_attachment: :blob)
  212. 76 units = units.search(params[:query])
  213. 76 then: 3 else: 73 units = units.overdue if params[:status] == "overdue"
  214. 76 units = units.by_manufacturer(params[:manufacturer])
  215. 76 units = units.by_operator(params[:operator])
  216. 76 units.order(created_at: :desc)
  217. end
  218. 4 def require_admin
  219. 6 then: 6 else: 0 else: 5 then: 1 head :not_found unless current_user&.admin?
  220. end
  221. 4 def build_index_title
  222. 71 title_parts = [I18n.t("units.titles.index")]
  223. 71 then: 3 else: 68 if params[:status] == "overdue"
  224. 3 title_parts << I18n.t("units.status.overdue")
  225. end
  226. 71 then: 7 else: 64 title_parts << params[:manufacturer] if params[:manufacturer].present?
  227. 71 then: 5 else: 66 title_parts << params[:operator] if params[:operator].present?
  228. 71 title_parts.join(" - ")
  229. end
  230. 4 def handle_inactive_user_redirect
  231. 5 redirect_to units_path
  232. end
  233. 4 def handle_image_processing_error
  234. flash.now[:alert] = @image_processing_error.message
  235. handle_update_failure(@unit)
  236. end
  237. 4 def log_unit_changes(previous_attributes)
  238. 7 changed_data = calculate_changes(
  239. previous_attributes,
  240. @unit.attributes,
  241. unit_params.keys
  242. )
  243. 7 log_unit_event("updated", @unit, nil, changed_data)
  244. end
  245. 4 def photo_turbo_streams
  246. 7 then: 7 else: 0 return [] if params[:unit][:photo].blank?
  247. [turbo_stream.replace(
  248. "unit_photo_preview",
  249. partial: "chobble_forms/file_field_turbo_response",
  250. locals: {
  251. model: @unit,
  252. field: :photo,
  253. turbo_frame_id: "unit_photo_preview",
  254. i18n_base: "forms.units",
  255. accept: "image/*"
  256. }
  257. )]
  258. end
  259. 4 def capture_unit_details_for_deletion
  260. {
  261. 5 name: @unit.name,
  262. serial: @unit.serial,
  263. operator: @unit.operator,
  264. manufacturer: @unit.manufacturer
  265. }
  266. end
  267. 4 def log_unit_deletion(unit_details)
  268. 4 Event.log(
  269. user: current_user,
  270. action: "deleted",
  271. resource: @unit,
  272. details: nil,
  273. metadata: unit_details
  274. )
  275. end
  276. 4 def unit_deletion_error_message
  277. 1 @unit.errors.full_messages.first || I18n.t("units.messages.delete_failed")
  278. end
  279. 4 def build_unit_creation_service
  280. 5 UnitCreationFromInspectionService.new(
  281. user: current_user,
  282. inspection_id: params[:id],
  283. unit_params: unit_params
  284. )
  285. end
  286. 4 def handle_unit_creation_success(service)
  287. 2 log_unit_event("created", service.unit)
  288. 2 flash[:notice] = I18n.t("units.messages.created_from_inspection")
  289. 2 redirect_to edit_inspection_path(service.inspection)
  290. end
  291. 4 def handle_unit_creation_error(service)
  292. 2 flash[:alert] = service.error_message
  293. 2 redirect_to unit_creation_error_path(service)
  294. end
  295. 4 def unit_creation_error_path(service)
  296. 2 then: 1 else: 1 service.inspection ? inspection_path(service.inspection) : root_path
  297. end
  298. 4 def render_unit_creation_form(service)
  299. 1 @unit = service.unit
  300. 1 @inspection = service.inspection
  301. 1 render :new_from_inspection, status: :unprocessable_content
  302. end
  303. 4 def extract_badge_id_param
  304. 14 then: 8 else: 6 (action_name == "new") ? params[:id] : params.dig(:unit, :id)
  305. end
  306. 4 def unit_exists?(normalized_id)
  307. 6 Unit.exists?(id: normalized_id)
  308. end
  309. 4 def badge_exists?(normalized_id)
  310. 5 Badge.exists?(id: normalized_id)
  311. end
  312. 4 def redirect_to_existing_unit(normalized_id)
  313. 1 flash[:notice] = I18n.t("units.messages.existing_unit_found")
  314. 1 redirect_to Unit.find(normalized_id)
  315. end
  316. 4 def log_resource_event(action, unit, details, changed_data)
  317. 24 Event.log(
  318. user: current_user,
  319. action: action,
  320. resource: unit,
  321. details: details,
  322. changed_data: changed_data
  323. )
  324. end
  325. 4 def log_system_unit_event(action, details)
  326. 2 Event.log_system_event(
  327. user: current_user,
  328. action: action,
  329. details: details,
  330. metadata: {resource_type: "Unit"}
  331. )
  332. end
  333. 4 def log_event_error(error)
  334. Rails.logger.error I18n.t("units.errors.log_failed", message: error.message)
  335. end
  336. 4 def build_unit_permitted_fields
  337. 41 fields = %i[
  338. description
  339. manufacture_date
  340. manufacturer
  341. name
  342. operator
  343. photo
  344. serial
  345. unit_type
  346. ]
  347. 41 then: 5 else: 36 fields << :id if allow_badge_id_in_params?
  348. 41 fields
  349. end
  350. 4 def allow_badge_id_in_params?
  351. 41 create_actions = %w[create create_from_inspection]
  352. 41 unit_badges_enabled? && create_actions.include?(action_name)
  353. end
  354. 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. # typed: false
  2. 4 module ApplicationErrors
  3. 4 class NotAnImageError < StandardError
  4. 4 def initialize(message = nil)
  5. 10 super(message || I18n.t("errors.messages.invalid_image_format"))
  6. end
  7. end
  8. 4 class ImageProcessingError < StandardError
  9. 4 def initialize(message = nil)
  10. 6 super(message || I18n.t("errors.messages.image_processing_failed"))
  11. end
  12. end
  13. 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: 316 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. 7 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. 1841 then: 1269 else: 572 Rails.configuration.theme.forced_theme || current_user&.theme || "light"
  19. end
  20. 8 sig { returns(T::Boolean) }
  21. 4 def theme_selector_disabled? = Rails.configuration.theme.forced_theme.present?
  22. 8 sig { returns(String) }
  23. 4 def logo_path
  24. 1841 Rails.configuration.theme.logo_path
  25. end
  26. 8 sig { returns(String) }
  27. 4 def logo_alt_text
  28. 1841 Rails.configuration.theme.logo_alt
  29. end
  30. 8 sig { returns(T.nilable(String)) }
  31. 4 def left_logo_path
  32. 1841 Rails.configuration.theme.left_logo_path
  33. end
  34. 4 sig { returns(String) }
  35. 4 def left_logo_alt
  36. Rails.configuration.theme.left_logo_alt
  37. end
  38. 8 sig { returns(T.nilable(String)) }
  39. 4 def right_logo_path
  40. 1841 Rails.configuration.theme.right_logo_path
  41. end
  42. 4 sig { returns(String) }
  43. 4 def right_logo_alt
  44. Rails.configuration.theme.right_logo_alt
  45. end
  46. 8 sig { params(slug: String).returns(T.any(String, ActiveSupport::SafeBuffer)) }
  47. 4 def page_snippet(slug)
  48. 1841 snippet = Page.snippets.find_by(slug: slug)
  49. 1841 else: 16 then: 1825 return "" unless snippet
  50. 16 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. 7891 then: 1179 css_class = if current_page?(path) || controller_matches?(path)
  55. 1179 "active"
  56. else: 6712 else
  57. 6712 ""
  58. end
  59. 7891 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. 225 else: 219 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. 225 else: 65 then: 160 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. 7303 route = Rails.application.routes.recognize_path(path)
  100. 7303 path_controller = route[:controller]
  101. 7303 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. 224 else: 0 case inspection.passed
  14. when: 156 when true
  15. 156 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: 49 when nil
  19. 49 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. 96 actions = T.let([], T::Array[T::Hash[Symbol, T.any(String, Symbol, T::Boolean)]])
  29. 96 if inspection.complete?
  30. then: 33 # Complete inspections: Switch to In Progress / Log
  31. 33 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. 33 actions << {
  39. label: t("inspections.buttons.log"),
  40. url: log_inspection_path(inspection)
  41. }
  42. else
  43. else: 63 # Incomplete inspections: Update Inspection / Log / Delete Inspection
  44. 63 actions << {
  45. label: t("inspections.buttons.update"),
  46. url: edit_inspection_path(inspection)
  47. }
  48. 63 actions << {
  49. label: t("inspections.buttons.log"),
  50. url: log_inspection_path(inspection)
  51. }
  52. 63 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. 96 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. 286 inspection.applicable_tabs
  66. end
  67. 8 sig { returns(String) }
  68. 4 def current_tab
  69. 1886 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. 1764 case tab
  74. when "inspection"
  75. when: 262 # For the main inspection tab, check if required fields are filled (excluding passed)
  76. 262 inspection.inspection_tab_incomplete_fields.empty?
  77. when "results"
  78. when: 203 # For results tab, check if passed field is filled (risk_assessment is optional)
  79. 203 inspection.passed.present?
  80. else
  81. else: 1299 # For assessment tabs, check the corresponding assessment
  82. 1299 assessment_method = "#{tab}_assessment"
  83. 1299 assessment = inspection.public_send(assessment_method)
  84. 1299 then: 1299 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. 1624 name = t("forms.#{tab}.header")
  90. 1624 then: 573 else: 1051 assessment_complete?(inspection, tab) ? "#{name} ✓" : name
  91. end
  92. 7 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. 63 then: 1 else: 62 return nil if current_tab == "results"
  96. 62 all_tabs = inspection.applicable_tabs
  97. 62 current_index = all_tabs.index(current_tab)
  98. 62 else: 62 then: 0 return nil unless current_index
  99. 62 tabs_after = all_tabs[(current_index + 1)..]
  100. # Check if current tab is incomplete
  101. 62 current_tab_incomplete = !assessment_complete?(inspection, current_tab)
  102. # Find first incomplete tab after current (excluding results for now)
  103. 62 next_incomplete = tabs_after.find do |tab|
  104. 82 tab != "results" && !assessment_complete?(inspection, tab)
  105. end
  106. # If current tab is incomplete and there's a next tab available
  107. 62 then: 57 else: 5 if current_tab_incomplete && tabs_after.any?
  108. 57 incomplete_count = incomplete_fields_count(inspection, current_tab)
  109. # If there's an incomplete tab after, user should skip current incomplete
  110. 57 then: 56 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. 7 sig { params(inspection: Inspection, tab: String).returns(Integer) }
  125. 4 def incomplete_fields_count(inspection, tab)
  126. 63 @incomplete_fields_cache = T.let(@incomplete_fields_cache, T.nilable(T::Hash[String, Integer])) || {}
  127. 63 cache_key = "#{inspection.id}_#{tab}"
  128. 63 @incomplete_fields_cache[cache_key] ||= case tab
  129. when: 32 when "inspection"
  130. 32 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. 737 else: 736 then: 1 return unless session[:session_token]
  9. 736 cookies.permanent.signed[:session_token] = session[:session_token]
  10. end
  11. 8 sig { void }
  12. 4 def forget_user
  13. 33 cookies.delete(:session_token)
  14. end
  15. 8 sig { returns(T.nilable(User)) }
  16. 4 def current_user
  17. 22625 @current_user ||= fetch_current_user
  18. end
  19. 4 private
  20. 8 sig { returns(T.nilable(User)) }
  21. 4 def fetch_current_user
  22. 6373 then: 1756 if session[:session_token]
  23. 1756 else: 4617 user_from_session_token
  24. 4617 then: 6 else: 4611 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. 1756 user_session = UserSession.find_by(session_token: session[:session_token])
  31. 1756 then: 1750 if user_session
  32. 1750 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. 2970 !current_user.nil?
  58. end
  59. 8 sig { void }
  60. 4 def log_out
  61. 33 session.delete(:session_token)
  62. 33 session.delete(:original_admin_id) # Clear impersonation tracking
  63. 33 forget_user
  64. 33 @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. 729 else: 725 then: 4 return nil unless email.present? && password.present?
  74. 725 then: 722 else: 3 User.find_by(email: email.downcase)&.authenticate(password)
  75. end
  76. 8 sig { void }
  77. 4 def create_user_session
  78. 734 remember_user
  79. end
  80. 8 sig { returns(T.nilable(UserSession)) }
  81. 4 def current_session
  82. 1736 else: 1735 then: 1 return unless session[:session_token]
  83. 1735 @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

25 relevant lines. 25 lines covered and 0 lines missed.
8 total branches, 8 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: T.nilable(User)).returns(T::Array[String]) }
  6. 4 def manufacturer_options(user)
  7. 63 then: 58 else: 5 units = user ? user.units : Unit.all
  8. 63 units.distinct.pluck(:manufacturer).compact.compact_blank.sort
  9. end
  10. 8 sig { params(user: T.nilable(User)).returns(T::Array[String]) }
  11. 4 def operator_options(user)
  12. 195 then: 190 else: 5 units = user ? user.units : Unit.all
  13. 195 units.distinct.pluck(:operator).compact.compact_blank.sort
  14. end
  15. 8 sig { returns(String) }
  16. 4 def unit_search_placeholder
  17. 40 serial_label = ChobbleForms::FieldUtils.form_field_label(:units, :serial)
  18. 40 name_label = ChobbleForms::FieldUtils.form_field_label(:units, :name)
  19. 40 "#{serial_label} or #{name_label.downcase}"
  20. end
  21. 4 sig {
  22. 4 params(unit: Unit).returns(
  23. T::Array[
  24. T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
  25. ]
  26. )
  27. }
  28. 4 def unit_actions(unit)
  29. 104 actions = T.let([
  30. {
  31. label: I18n.t("units.buttons.view"),
  32. url: unit_path(unit, anchor: "inspections")
  33. },
  34. {
  35. label: I18n.t("ui.edit"),
  36. url: edit_unit_path(unit)
  37. },
  38. {
  39. label: I18n.t("units.buttons.pdf_report"),
  40. url: unit_path(unit, format: :pdf)
  41. }
  42. ], T::Array[
  43. T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
  44. ])
  45. # Add activity log link for admins and unit owners
  46. 104 then: 89 else: 15 if current_user && (current_user.admin? || unit.user_id == current_user.id)
  47. 89 actions << {
  48. label: I18n.t("units.links.view_log"),
  49. url: log_unit_path(unit)
  50. }
  51. end
  52. 104 then: 91 else: 13 if unit.deletable?
  53. 91 actions << {
  54. label: I18n.t("units.buttons.delete"),
  55. url: unit,
  56. method: :delete,
  57. danger: true,
  58. confirm: I18n.t("units.messages.delete_confirm")
  59. }
  60. end
  61. 104 actions << {
  62. label: I18n.t("units.buttons.add_inspection"),
  63. url: inspections_path,
  64. method: :post,
  65. params: {unit_id: unit.id},
  66. confirm: I18n.t("units.messages.add_inspection_confirm")
  67. }
  68. 104 actions
  69. end
  70. 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. # typed: false
  2. # frozen_string_literal: true
  3. 4 class ApplicationJob < ActiveJob::Base
  4. # Automatically retry jobs that encountered a deadlock
  5. # retry_on ActiveRecord::Deadlocked
  6. # Most jobs are safe to ignore if the underlying records are no longer available
  7. # discard_on ActiveJob::DeserializationError
  8. 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. # typed: false
  2. # frozen_string_literal: true
  3. 4 class S3BackupJob < ApplicationJob
  4. 4 queue_as :default
  5. 4 def perform
  6. # Ensure Rails is fully loaded for background jobs
  7. then: 0 else: 0 Rails.application.eager_load! if Rails.env.production?
  8. result = S3BackupService.new.perform
  9. Rails.logger.info "S3BackupJob completed successfully"
  10. Rails.logger.info "Backup location: #{result[:location]}"
  11. Rails.logger.info "Backup size: #{result[:size_mb]} MB"
  12. then: 0 else: 0 Rails.logger.info "Deleted #{result[:deleted_count]} old backups" if result[:deleted_count].positive?
  13. end
  14. end

app/jobs/sentry_test_job.rb

16.67% lines covered

0.0% branches covered

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

app/models/application_record.rb

100.0% lines covered

100.0% branches covered

5 relevant lines. 5 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. 4 class ApplicationRecord < ActiveRecord::Base
  3. 4 extend T::Sig
  4. 4 extend T::Helpers
  5. 4 primary_abstract_class
  6. 4 include ColumnNameSyms
  7. end

app/models/assessments/anchorage_assessment.rb

96.43% lines covered

66.67% branches covered

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

100.0% lines covered

100.0% branches covered

9 relevant lines. 9 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 Assessments::PatAssessment < ApplicationRecord
  4. 4 extend T::Sig
  5. 4 include AssessmentLogging
  6. 4 include AssessmentCompletion
  7. 4 include FormConfigurable
  8. 4 include ValidationConfigurable
  9. 4 self.primary_key = "inspection_id"
  10. 4 belongs_to :inspection
  11. 4 after_update :log_assessment_update, if: :saved_changes?
  12. 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. 7 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/badge.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. # typed: true
  2. # frozen_string_literal: true
  3. 4 class Badge < ApplicationRecord
  4. 4 extend T::Sig
  5. 4 include CustomIdGenerator
  6. 4 belongs_to :badge_batch
  7. 4 has_one :unit, foreign_key: :id, primary_key: :id
  8. 4 def badge_id = id
  9. 4 def batch_note = badge_batch.note
  10. end

app/models/badge_batch.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: true
  2. # frozen_string_literal: true
  3. 4 class BadgeBatch < ApplicationRecord
  4. 4 extend T::Sig
  5. 4 has_many :badges, dependent: :destroy
  6. 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. 2094 incomplete_fields.empty?
  15. end
  16. 8 sig { returns(T::Array[Symbol]) }
  17. 4 def incomplete_fields
  18. 5496 (self.class.column_name_syms - SYSTEM_FIELDS)
  19. 93895 .reject { |f| f.end_with?("_comment") }
  20. 53498 .select { |f| field_is_incomplete?(f) }
  21. 31477 .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. 3393 field_to_partial = build_field_to_partial_mapping
  26. 3393 incomplete = incomplete_fields
  27. 3393 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. 3393 form_config = get_form_config
  33. 3393 field_to_partial = {}
  34. 3393 form_config.each do |section|
  35. 6878 else: 6878 then: 0 next unless section[:fields]
  36. 6878 map_section_fields(section, field_to_partial)
  37. end
  38. 3393 field_to_partial
  39. end
  40. 8 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  41. 4 def get_form_config
  42. 3393 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. 6878 section[:fields].each do |field_config|
  54. 28569 field = field_config[:field]
  55. 28569 partial = field_config[:partial]
  56. 28569 field_to_partial[field.to_sym] = partial
  57. # Also map composite fields
  58. 28569 partial_sym = partial.to_sym
  59. 28569 composite_fields = ChobbleForms::FieldUtils
  60. .get_composite_fields(field.to_sym, partial_sym)
  61. 28569 composite_fields.each do |cf|
  62. 43704 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. 3393 grouped = {}
  74. 3393 processed = Set.new
  75. 3393 incomplete.each do |field|
  76. 22631 then: 3393 else: 19238 next if processed.include?(field)
  77. 19238 process_field_group(
  78. field, incomplete, field_to_partial, grouped, processed
  79. )
  80. end
  81. 3393 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. 19238 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  96. 19238 partial = field_to_partial[field] || field_to_partial[base_field]
  97. # Find all related incomplete fields for this base
  98. 19238 related = incomplete.select do |f|
  99. 232158 ChobbleForms::FieldUtils.strip_field_suffix(f) == base_field
  100. end
  101. 19238 then: 3393 else: 15845 key = (related.size > 1) ? base_field : field
  102. 19238 grouped[key] = {
  103. fields: related,
  104. partial: partial
  105. }
  106. 19238 processed.merge(related)
  107. end
  108. 8 sig { params(field: Symbol).returns(T::Boolean) }
  109. 4 def field_is_incomplete?(field)
  110. 53498 value = send(field)
  111. # Field is incomplete if nil
  112. 53498 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. 31477 then: 18569 else: 12908 return false if field.end_with?("_pass")
  118. # Only allow nil for value fields when corresponding _pass field is "na"
  119. 12908 pass_field = "#{field}_pass"
  120. 12908 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. 32 after_update :log_assessment_update, if: :saved_changes?
  8. end
  9. 4 private
  10. 8 sig { void }
  11. 4 def log_assessment_update
  12. 1910 assessment_type = self.class.name.underscore.humanize
  13. 1910 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. 5676 column_names.map(&:to_sym).sort
  11. end
  12. end
  13. end

app/models/concerns/custom_id_generator.rb

100.0% lines covered

77.78% branches covered

43 relevant lines. 43 lines covered and 0 lines missed.
9 total branches, 7 branches covered and 2 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. 36 self.primary_key = "id"
  12. 5758 before_create :generate_custom_id, if: -> { id.blank? }
  13. end
  14. 4 class_methods do
  15. 4 extend T::Sig
  16. 8 sig { returns(String) }
  17. 4 def generate_random_id
  18. 7163 loop do
  19. 7164 raw_id = SecureRandom.alphanumeric(32).upcase
  20. 7164 filtered_chars = raw_id.chars.reject do |char|
  21. 229248 AMBIGUOUS_CHARS.include?(char)
  22. end
  23. 7164 id = filtered_chars.first(ID_LENGTH).join
  24. 7164 then: 0 else: 7164 next if id.length < ID_LENGTH
  25. 7164 else: 1 then: 7163 break id unless exists?(id: id)
  26. end
  27. end
  28. 6 sig { params(count: Integer).returns(T::Array[String]) }
  29. 4 def generate_random_ids(count)
  30. 10 then: 2 else: 8 return [] if count <= 0
  31. 8 needed = count
  32. 8 generated_ids = []
  33. 8 while needed > 0
  34. body: 8 # Generate a batch of candidate IDs
  35. 218 candidates = needed.times.map { generate_single_id_string }
  36. # Check which ones already exist (single DB query)
  37. 8 existing = where(id: candidates).pluck(:id)
  38. 8 new_ids = candidates - existing
  39. 8 generated_ids.concat(new_ids)
  40. 8 needed -= new_ids.length
  41. end
  42. 8 generated_ids.first(count)
  43. end
  44. 6 sig { returns(String) }
  45. 4 def generate_single_id_string
  46. 210 loop do
  47. 210 raw_id = SecureRandom.alphanumeric(32).upcase
  48. 210 filtered_chars = raw_id.chars.reject do |char|
  49. 6720 AMBIGUOUS_CHARS.include?(char)
  50. end
  51. 210 id = filtered_chars.first(ID_LENGTH).join
  52. 210 then: 210 else: 0 return id if id.length == ID_LENGTH
  53. end
  54. end
  55. end
  56. 4 private
  57. 8 sig { void }
  58. 4 def generate_custom_id
  59. 7059 self.id = self.class.generate_random_id
  60. end
  61. 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. 4302 @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. 44 file_name = name.demodulize.underscore
  16. 44 config_path = Rails.root.join("config/forms/#{file_name}.yml")
  17. 44 yaml_content = YAML.load_file(config_path)
  18. 44 yaml_content.deep_symbolize_keys!
  19. 44 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

17 relevant lines. 17 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. # 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 T::Sig
  7. 4 extend T::Helpers
  8. 4 extend ActiveSupport::Concern
  9. 4 include ColumnNameSyms
  10. # System/metadata fields to exclude from public outputs (shared)
  11. 4 EXCLUDED_FIELDS = %i[
  12. id
  13. created_at
  14. updated_at
  15. pdf_last_accessed_at
  16. user_id
  17. unit_id
  18. inspector_company_id
  19. inspection_id
  20. is_seed
  21. ].freeze
  22. # Additional fields to exclude from PDFs specifically
  23. 4 PDF_EXCLUDED_FIELDS = %i[
  24. complete_date
  25. inspection_date
  26. ].freeze
  27. # Fields excluded from PDFs (combines shared + PDF-specific)
  28. 4 PDF_TOTAL_EXCLUDED_FIELDS = (EXCLUDED_FIELDS + PDF_EXCLUDED_FIELDS).freeze
  29. # Computed fields to exclude from public outputs
  30. 4 EXCLUDED_COMPUTED_FIELDS = %i[
  31. reinspection_date
  32. ].freeze
  33. 4 class_methods do
  34. 4 extend T::Sig
  35. 5 sig { returns(T::Array[Symbol]) }
  36. 4 def public_fields
  37. 3 column_name_syms - EXCLUDED_FIELDS
  38. end
  39. 5 sig { params(klass_name: T.untyped).returns(T::Array[Symbol]) }
  40. 4 def excluded_fields_for_assessment(klass_name)
  41. 6 EXCLUDED_FIELDS
  42. end
  43. end
  44. 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. 47 then: 47 else: 0 if ancestors.include?(FormConfigurable)
  9. 47 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. 47 form_fields
  18. rescue
  19. nil
  20. end
  21. 47 else: 47 then: 0 return unless form_config
  22. 47 form_config.each do |section|
  23. 107 else: 107 then: 0 next unless section[:fields]
  24. 107 section[:fields].each do |field_config|
  25. 369 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. 369 field = field_config[:field]
  33. 369 attributes = field_config[:attributes] || {}
  34. 369 partial = field_config[:partial]
  35. 369 else: 369 then: 0 return unless field
  36. 369 then: 30 else: 339 if attributes[:required]
  37. 30 validates field, presence: true
  38. end
  39. 369 else: 295 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. # typed: false
  2. # == Schema Information
  3. #
  4. # Table name: credentials
  5. #
  6. # id :integer not null, primary key
  7. # nickname :string not null
  8. # public_key :string not null
  9. # sign_count :integer default(0), not null
  10. # created_at :datetime not null
  11. # updated_at :datetime not null
  12. # external_id :string not null
  13. # user_id :string(12) not null
  14. #
  15. # Indexes
  16. #
  17. # index_credentials_on_external_id (external_id) UNIQUE
  18. # index_credentials_on_user_id (user_id)
  19. #
  20. # Foreign Keys
  21. #
  22. # user_id (user_id => users.id)
  23. #
  24. 4 class Credential < ApplicationRecord
  25. 4 belongs_to :user
  26. 4 validates :external_id, :public_key, :nickname, :sign_count, presence: true
  27. 4 validates :external_id, uniqueness: true
  28. 4 validates :sign_count,
  29. numericality: {
  30. only_integer: true,
  31. greater_than_or_equal_to: 0,
  32. 4 less_than_or_equal_to: (2**32) - 1
  33. }
  34. 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. 2051 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.nilable(T.any(String, Integer, T::Boolean))]),
  52. metadata: T.nilable(T::Hash[String, T.nilable(T.any(String, Integer, T::Boolean))])
  53. ).returns(Event)
  54. end
  55. 4 def self.log(user:, action:, resource:, details: nil,
  56. changed_data: nil, metadata: nil)
  57. 2036 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.nilable(T.any(String, Integer, T::Boolean))])
  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

97.71% lines covered

85.23% branches covered

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

app/models/inspector_company.rb

96.55% lines covered

77.78% branches covered

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

app/models/text_replacement.rb

100.0% lines covered

100.0% branches covered

37 relevant lines. 37 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. # == Schema Information
  4. #
  5. # Table name: text_replacements
  6. #
  7. # id :integer not null, primary key
  8. # i18n_key :string not null
  9. # value :text not null
  10. # created_at :datetime not null
  11. # updated_at :datetime not null
  12. #
  13. 4 class TextReplacement < ApplicationRecord
  14. 4 extend T::Sig
  15. 4 include FormConfigurable
  16. 4 validates :i18n_key, presence: true, uniqueness: true
  17. 4 validates :value, presence: true
  18. 4 after_commit :reload_i18n_cache
  19. # Returns all i18n keys available in the application
  20. 6 sig { returns(T::Array[String]) }
  21. 4 def self.available_i18n_keys
  22. 5 keys = []
  23. 5 I18n.backend.send(:translations).each do |locale, translations|
  24. 5 keys.concat(flatten_keys(locale.to_s, translations))
  25. end
  26. 5 keys.sort
  27. end
  28. # Recursively flattens nested i18n hash into dot-notation keys
  29. 4 sig {
  30. 2 params(
  31. prefix: String,
  32. hash: T::Hash[String, T.any(String, T::Hash[String, T.untyped])]
  33. ).returns(T::Array[String])
  34. }
  35. 4 def self.flatten_keys(prefix, hash)
  36. 1950 keys = []
  37. 1950 hash.each do |key, value|
  38. 10110 full_key = "#{prefix}.#{key}"
  39. 10110 then: 1945 if value.is_a?(Hash)
  40. 1945 keys.concat(flatten_keys(full_key, value))
  41. else: 8165 else
  42. 8165 keys << full_key
  43. end
  44. end
  45. 1950 keys
  46. end
  47. # Returns a nested hash structure for displaying in tree view
  48. 6 sig { returns(T::Hash[String, T::Hash[String, T.untyped]]) }
  49. 4 def self.tree_structure
  50. 10 all.each_with_object({}) do |replacement, tree|
  51. 7 parts = replacement.i18n_key.split(".")
  52. 7 current = tree
  53. 7 parts.each_with_index do |part, index|
  54. 31 current[part] ||= {}
  55. 31 then: 7 if index == parts.length - 1
  56. 7 current[part][:_value] = replacement.value
  57. 7 current[part][:_id] = replacement.id
  58. else: 24 else
  59. 24 current = current[part]
  60. end
  61. end
  62. end
  63. end
  64. 4 private
  65. 6 sig { void }
  66. 4 def reload_i18n_cache
  67. 15 DatabaseI18nBackend.reload_cache
  68. end
  69. end

app/models/unit.rb

91.3% lines covered

78.95% branches covered

115 relevant lines. 105 lines covered and 10 lines missed.
38 total branches, 30 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. pat_testable: "PAT_TESTABLE"
  40. }
  41. 4 belongs_to :user
  42. 4 has_many :inspections
  43. 248 has_many :complete_inspections, -> { where.not(complete_date: nil) }, class_name: "Inspection"
  44. 34 has_many :draft_inspections, -> { where(complete_date: nil) }, class_name: "Inspection"
  45. # File attachments
  46. 4 has_one_attached :photo
  47. 4 has_one_attached :cached_pdf
  48. 4 validate :photo_must_be_image
  49. # Callbacks
  50. 1425 before_validation :normalize_id, on: :create, if: -> { unit_badges_enabled? }
  51. 1406 before_create :generate_custom_id, unless: -> { unit_badges_enabled? }
  52. 4 after_update :invalidate_pdf_cache
  53. 4 before_destroy :check_complete_inspections
  54. 4 before_destroy :destroy_draft_inspections
  55. # All fields are required for Units
  56. 4 validates :name, :serial, :description, :manufacturer, :operator, presence: true
  57. 4 validates :serial, uniqueness: {scope: [:user_id]}
  58. 1425 validate :badge_id_valid, on: :create, if: -> { unit_badges_enabled? }
  59. # Scopes - enhanced from original Equipment and new Unit functionality
  60. 92 scope :seed_data, -> { where(is_seed: true) }
  61. 8 scope :non_seed_data, -> { where(is_seed: false) }
  62. 4 scope :search, ->(query) {
  63. 100 then: 10 if query.present?
  64. 10 search_term = "%#{query}%"
  65. 10 where(<<~SQL, *([search_term] * 5))
  66. serial LIKE ?
  67. OR name LIKE ?
  68. OR description LIKE ?
  69. OR manufacturer LIKE ?
  70. OR operator LIKE ?
  71. SQL
  72. else: 90 else
  73. 90 all
  74. end
  75. }
  76. 103 then: 10 else: 89 scope :by_manufacturer, ->(manufacturer) { where(manufacturer: manufacturer) if manufacturer.present? }
  77. 84 then: 7 else: 73 scope :by_operator, ->(operator) { where(operator: operator) if operator.present? }
  78. 4 scope :with_recent_inspections, -> {
  79. cutoff_date = EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days.ago
  80. joins(:inspections)
  81. .where(inspections: {inspection_date: cutoff_date..})
  82. .distinct
  83. }
  84. 4 scope :inspection_due, -> {
  85. joins(:inspections)
  86. .merge(Inspection.complete)
  87. .group("units.id")
  88. .having("MAX(inspections.complete_date) + INTERVAL #{EN14960::Constants::REINSPECTION_INTERVAL_DAYS} DAY <= CURRENT_DATE")
  89. }
  90. # Instance methods
  91. 8 sig { returns(T.nilable(Inspection)) }
  92. 4 def last_inspection
  93. 642 @last_inspection ||= inspections.merge(Inspection.complete).order(complete_date: :desc).first
  94. end
  95. 4 sig { returns(String) }
  96. 4 def last_inspection_status
  97. then: 0 else: 0 then: 0 else: 0 last_inspection&.passed? ? "Passed" : "Failed"
  98. end
  99. 4 sig { returns(ActiveRecord::Relation) }
  100. 4 def inspection_history
  101. inspections.includes(:user).order(inspection_date: :desc)
  102. end
  103. 5 sig { returns(T.nilable(Date)) }
  104. 4 def next_inspection_due
  105. 17 else: 14 then: 3 return nil unless last_inspection
  106. 14 (last_inspection.inspection_date + EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days).to_date
  107. end
  108. 5 sig { returns(T::Boolean) }
  109. 4 def inspection_overdue?
  110. 7 else: 6 then: 1 return false unless next_inspection_due
  111. 6 next_inspection_due < Date.current
  112. end
  113. 5 sig { returns(String) }
  114. 4 def compliance_status
  115. 5 else: 3 then: 2 return "Never Inspected" unless last_inspection
  116. 3 then: 1 if inspection_overdue?
  117. 1 else: 2 "Overdue"
  118. 2 then: 1 elsif last_inspection.passed?
  119. 1 "Compliant"
  120. else: 1 else
  121. 1 "Non-Compliant"
  122. end
  123. end
  124. 4 sig {
  125. 1 returns(T::Hash[Symbol, T.any(Integer, T.nilable(Date), T.nilable(String))])
  126. }
  127. 4 def inspection_summary
  128. {
  129. 1 total_inspections: inspections.count,
  130. passed_inspections: inspections.passed.count,
  131. failed_inspections: inspections.failed.count,
  132. then: 0 else: 1 last_inspection_date: last_inspection&.inspection_date,
  133. next_due_date: next_inspection_due,
  134. compliance_status: compliance_status
  135. }
  136. end
  137. 8 sig { returns(T::Boolean) }
  138. 4 def deletable?
  139. 127 !complete_inspections.exists?
  140. end
  141. 4 private
  142. 8 sig { void }
  143. 4 def check_complete_inspections
  144. 16 then: 1 else: 15 if complete_inspections.exists?
  145. 1 errors.add(:base, :has_complete_inspections)
  146. 1 throw(:abort)
  147. end
  148. end
  149. 8 sig { void }
  150. 4 def destroy_draft_inspections
  151. 15 draft_inspections.destroy_all
  152. end
  153. 4 public
  154. 6 sig { returns(ActiveRecord::Relation) }
  155. 4 def self.overdue
  156. # Find units where their most recent inspection is older than the interval
  157. # Using Date.current instead of Date.today for Rails timezone consistency
  158. 8 cutoff_date = Date.current - EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days
  159. 8 joins(:inspections)
  160. .group("units.id")
  161. .having("MAX(inspections.inspection_date) <= ?", cutoff_date)
  162. end
  163. 4 private
  164. 4 sig { void }
  165. 4 def check_for_complete_inspections
  166. then: 0 else: 0 if complete_inspections.exists?
  167. errors.add(:base, :has_complete_inspections)
  168. throw(:abort)
  169. end
  170. end
  171. 8 sig { void }
  172. 4 def photo_must_be_image
  173. 1482 else: 30 then: 1452 return unless photo.attached?
  174. 30 else: 30 then: 0 unless photo.blob.content_type.start_with?("image/")
  175. errors.add(:photo, "must be an image file")
  176. photo.purge
  177. end
  178. end
  179. 7 sig { void }
  180. 4 def invalidate_pdf_cache
  181. # Skip cache invalidation if only updated_at changed
  182. 41 changed_attrs = saved_changes.keys
  183. 41 ignorable_attrs = ["updated_at"]
  184. 41 then: 29 else: 12 return if (changed_attrs - ignorable_attrs).empty?
  185. 12 PdfCacheService.invalidate_unit_cache(self)
  186. end
  187. 8 sig { returns(T::Boolean) }
  188. 4 def unit_badges_enabled?
  189. 4244 Rails.configuration.units.badges_enabled
  190. end
  191. 7 sig { void }
  192. 4 def normalize_id
  193. 48 then: 2 else: 46 return if id.blank?
  194. # Strip spaces, uppercase, and trim to 8 characters
  195. 46 normalized = id.gsub(/\s+/, "").upcase[0, 8]
  196. 46 self.id = normalized
  197. end
  198. 7 sig { void }
  199. 4 def badge_id_valid
  200. 48 then: 2 else: 46 if id.blank?
  201. 2 error_msg = I18n.t("units.validations.id_blank")
  202. 2 errors.add(:id, error_msg)
  203. 2 return
  204. end
  205. # Check if badge exists
  206. 46 else: 40 then: 6 unless Badge.exists?(id: id)
  207. 6 error_msg = I18n.t("units.validations.invalid_badge_id")
  208. 6 errors.add(:base, error_msg)
  209. end
  210. end
  211. 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. 5713 self.webauthn_id ||= WebAuthn.generate_user_id
  73. end
  74. 8 sig { returns(T::Boolean) }
  75. 4 def is_active?
  76. 2305 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. 1910 admin_pattern = Rails.configuration.users.admin_emails_pattern
  90. 1910 then: 0 else: 1910 return false if admin_pattern.blank?
  91. begin
  92. 1910 regex = Regexp.new(admin_pattern)
  93. 1910 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. 2239 @active_until_explicitly_set = true
  105. 2239 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. 77 units.seed_data.exists? || inspections.seed_data.exists?
  159. end
  160. 8 sig { returns(T::Boolean) }
  161. 4 def validate_name?
  162. 4018 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. 3988 self.email = email.downcase
  176. end
  177. 8 sig { void }
  178. 4 def normalize_rpii_number
  179. 3988 then: 184 else: 3804 self.rpii_inspector_number = nil if rpii_inspector_number.blank?
  180. end
  181. 8 sig { void }
  182. 4 def set_inactive_on_signup
  183. 2172 then: 2164 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. 4018 else: 13 then: 4005 return unless logo.attached?
  189. 13 then: 13 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. 4018 else: 2 then: 4016 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. # typed: false
  2. # == Schema Information
  3. #
  4. # Table name: user_sessions
  5. #
  6. # id :integer not null, primary key
  7. # ip_address :string
  8. # last_active_at :datetime not null
  9. # session_token :string not null
  10. # user_agent :string
  11. # created_at :datetime not null
  12. # updated_at :datetime not null
  13. # user_id :string(12) not null
  14. #
  15. # Indexes
  16. #
  17. # index_user_sessions_on_session_token (session_token) UNIQUE
  18. # index_user_sessions_on_user_id (user_id)
  19. # index_user_sessions_on_user_id_and_last_active_at (user_id,last_active_at)
  20. #
  21. # Foreign Keys
  22. #
  23. # user_id (user_id => users.id)
  24. #
  25. 4 class UserSession < ApplicationRecord
  26. 4 belongs_to :user
  27. 4 validates :session_token, presence: true, uniqueness: true
  28. 4 validates :last_active_at, presence: true
  29. 4 before_validation :generate_session_token, on: :create
  30. 30 scope :active, -> { where("last_active_at > ?", 30.days.ago) }
  31. 30 scope :recent, -> { order(last_active_at: :desc) }
  32. 4 def active? = last_active_at > 30.days.ago
  33. 4 def touch_last_active
  34. 1733 update_column(:last_active_at, Time.current)
  35. end
  36. 4 private
  37. 4 def generate_session_token
  38. 738 self.session_token ||= SecureRandom.urlsafe_base64(32)
  39. end
  40. end

app/serializers/base_assessment_blueprint.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. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class BaseAssessmentBlueprint < Blueprinter::Base
  4. 4 extend T::Sig
  5. # Define public fields from model columns excluding system fields
  6. 5 sig { params(klass: T.untyped).returns(T::Array[Symbol]) }
  7. 4 def self.public_fields_for(klass)
  8. 4 klass.column_name_syms - PublicFieldFiltering::EXCLUDED_FIELDS
  9. end
  10. # Use transformer to format dates consistently
  11. 4 transform JsonDateTransformer
  12. end

app/serializers/concerns/dynamic_public_fields.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 module DynamicPublicFields
  4. 4 extend ActiveSupport::Concern
  5. 4 class_methods do
  6. 4 def define_public_fields_for(model_class, date_fields: [])
  7. 36 then: 29 else: 7 return if @fields_defined
  8. 7 model_class.column_name_syms.each do |column|
  9. 124 then: 47 else: 77 next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
  10. 77 then: 11 if date_fields.include?(column)
  11. 11 field column do |record|
  12. 55 value = record.send(column)
  13. 55 then: 51 else: 4 value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
  14. end
  15. else: 66 else
  16. 66 field column
  17. end
  18. end
  19. 7 @fields_defined = true
  20. end
  21. end
  22. end

app/serializers/inspection_blueprint.rb

96.97% lines covered

50.0% branches covered

33 relevant lines. 32 lines covered and 1 lines missed.
8 total branches, 4 branches covered and 4 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class InspectionBlueprint < Blueprinter::Base
  4. 4 extend T::Sig
  5. 4 include DynamicPublicFields
  6. 4 DATE_FIELDS = T.let(
  7. %i[complete_date inspection_date].freeze, T::Array[Symbol]
  8. )
  9. 4 sig do
  10. 4 params(
  11. object: T.untyped,
  12. options: T::Hash[T.untyped, T.untyped]
  13. ).returns(String)
  14. end
  15. 4 def self.render(object, options = {})
  16. 19 define_public_fields_for(Inspection, date_fields: DATE_FIELDS)
  17. 19 super
  18. end
  19. 4 field :complete do |inspection|
  20. 19 inspection.complete?
  21. end
  22. 4 field :passed do |inspection|
  23. then: 0 else: 0 inspection.passed? if inspection.complete?
  24. end
  25. 4 field :inspector do |inspection|
  26. {
  27. 19 name: inspection.user.name,
  28. rpii_inspector_number: inspection.user.rpii_inspector_number
  29. }
  30. end
  31. 4 field :urls do |inspection|
  32. 19 base_url = Rails.configuration.app.base_url
  33. {
  34. 19 report_pdf: "#{base_url}/inspections/#{inspection.id}.pdf",
  35. report_json: "#{base_url}/inspections/#{inspection.id}.json",
  36. qr_code: "#{base_url}/inspections/#{inspection.id}.png"
  37. }
  38. end
  39. 4 field :unit do |inspection|
  40. 19 then: 19 else: 0 if inspection.unit
  41. {
  42. 19 id: inspection.unit.id,
  43. name: inspection.unit.name,
  44. serial: inspection.unit.serial,
  45. manufacturer: inspection.unit.manufacturer,
  46. operator: inspection.unit.operator
  47. }
  48. end
  49. end
  50. 4 field :assessments do |inspection|
  51. 19 assessments = {}
  52. 19 inspection.each_applicable_assessment do |key, klass, assessment|
  53. 126 else: 126 then: 0 next unless assessment
  54. 126 assessment_data = {}
  55. public_fields =
  56. 126 klass.column_name_syms -
  57. PublicFieldFiltering::EXCLUDED_FIELDS
  58. 126 public_fields.each do |field|
  59. 2141 value = assessment.send(field)
  60. 2141 else: 592 then: 1549 assessment_data[field] = value unless value.nil?
  61. end
  62. 126 assessments[key] = assessment_data
  63. end
  64. 19 assessments
  65. end
  66. # Use transformer to format dates
  67. 4 transform JsonDateTransformer
  68. end

app/serializers/json_date_transformer.rb

94.12% lines covered

85.71% branches covered

17 relevant lines. 16 lines covered and 1 lines missed.
7 total branches, 6 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class JsonDateTransformer < Blueprinter::Transformer
  4. 4 extend T::Sig
  5. # ISO 8601 date format for JSON API responses
  6. 4 API_DATE_FORMAT = "%Y-%m-%d"
  7. 4 sig do
  8. 4 params(
  9. hash: T::Hash[T.untyped, T.untyped],
  10. _object: T.untyped,
  11. _options: T.untyped
  12. ).returns(T.untyped)
  13. end
  14. 4 def transform(hash, _object, _options)
  15. 36 transform_value(hash)
  16. end
  17. 8 sig { params(value: T.untyped).returns(T.untyped) }
  18. 4 def transform_value(value)
  19. 2713 case value
  20. when: 297 when Hash
  21. 2949 value.transform_values { |v| transform_value(v) }
  22. when: 5 when Array
  23. 13 value.map { |v| transform_value(v) }
  24. when: 13 when Date, Time, DateTime
  25. 13 value.strftime(API_DATE_FORMAT)
  26. when String
  27. when: 1360 # Handle string timestamps from ActiveRecord
  28. 1360 then: 0 if /\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.match?(value)
  29. value.split(" ").first # Extract just the date part
  30. else: 1360 else
  31. 1360 value
  32. end
  33. else: 1038 else
  34. 1038 value
  35. end
  36. end
  37. end

app/serializers/unit_blueprint.rb

100.0% lines covered

62.5% branches covered

27 relevant lines. 27 lines covered and 0 lines missed.
8 total branches, 5 branches covered and 3 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class UnitBlueprint < Blueprinter::Base
  4. 4 extend T::Sig
  5. 4 include DynamicPublicFields
  6. 4 DATE_FIELDS = T.let(%i[manufacture_date].freeze, T::Array[Symbol])
  7. 4 sig do
  8. 3 params(
  9. object: T.untyped,
  10. options: T::Hash[T.untyped, T.untyped]
  11. ).returns(String)
  12. end
  13. 4 def self.render(object, options = {})
  14. 17 define_public_fields_for(Unit, date_fields: DATE_FIELDS)
  15. 17 super
  16. end
  17. # Add URLs (available in all views)
  18. 4 field :urls do |unit|
  19. 17 base_url = Rails.configuration.app.base_url
  20. {
  21. 17 report_pdf: "#{base_url}/units/#{unit.id}.pdf",
  22. report_json: "#{base_url}/units/#{unit.id}.json",
  23. qr_code: "#{base_url}/units/#{unit.id}.png"
  24. }
  25. end
  26. 7 sig { params(unit: Unit).returns(String) }
  27. 4 def self.render_with_inspections(unit)
  28. 17 json = JSON.parse(render(unit, view: :default), symbolize_names: true)
  29. 17 completed = unit.inspections.complete.order(inspection_date: :desc)
  30. 17 then: 5 else: 12 add_inspection_history(json, completed) if completed.any?
  31. 17 JsonDateTransformer.new.transform_value(json).to_json
  32. end
  33. 4 sig do
  34. 2 params(
  35. json: T::Hash[Symbol, T.untyped],
  36. completed: T.untyped
  37. ).void
  38. end
  39. 4 def self.add_inspection_history(json, completed)
  40. 5 json[:inspection_history] = completed.map do |inspection|
  41. {
  42. 8 inspection_date: inspection.inspection_date,
  43. passed: inspection.passed,
  44. complete: inspection.complete?,
  45. then: 8 else: 0 inspector_company: inspection.inspector_company&.name
  46. }
  47. end
  48. 5 json[:total_inspections] = completed.count
  49. 5 then: 5 else: 0 json[:last_inspection_date] = completed.first&.inspection_date
  50. 5 then: 5 else: 0 json[:last_inspection_passed] = completed.first&.passed
  51. end
  52. # Use transformer to format dates
  53. 4 transform JsonDateTransformer
  54. end

app/services/badge_batch_csv_export_service.rb

100.0% lines covered

100.0% branches covered

25 relevant lines. 25 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 BadgeBatchCsvExportService
  4. 4 extend T::Sig
  5. 6 sig { params(badge_batch: BadgeBatch).void }
  6. 4 def initialize(badge_batch)
  7. 4 @badge_batch = badge_batch
  8. end
  9. 6 sig { returns(String) }
  10. 4 def generate
  11. 4 CSV.generate(headers: true) do |csv|
  12. 4 csv << headers
  13. 4 @badge_batch.badges.includes(:unit).order(:id).each do |badge|
  14. 8 csv << row_for_badge(badge)
  15. end
  16. end
  17. end
  18. 4 private
  19. 6 sig { returns(T::Array[String]) }
  20. 4 def headers
  21. 4 [
  22. "Badge ID",
  23. "Batch Creation Date",
  24. "Batch Notes",
  25. "Badge Notes",
  26. "Used",
  27. "URL"
  28. ]
  29. end
  30. 6 sig { params(badge: Badge).returns(T::Array[String]) }
  31. 4 def row_for_badge(badge)
  32. 8 batch_creation_date = @badge_batch.created_at.strftime("%Y-%m-%d %H:%M:%S")
  33. [
  34. 8 badge.id,
  35. batch_creation_date,
  36. @badge_batch.note || "",
  37. badge.note || "",
  38. badge.unit.present?.to_s,
  39. unit_url(badge.id)
  40. ]
  41. end
  42. 6 sig { params(badge_id: String).returns(String) }
  43. 4 def unit_url(badge_id)
  44. 8 "#{base_url}/units/#{badge_id}.pdf"
  45. end
  46. 6 sig { returns(String) }
  47. 4 def base_url
  48. 8 Rails.configuration.app.base_url
  49. end
  50. 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. # typed: false
  2. # frozen_string_literal: true
  3. 4 module S3Helpers
  4. 4 extend ActiveSupport::Concern
  5. 4 private
  6. 4 def ensure_s3_enabled
  7. 23 else: 22 then: 1 raise "S3 storage is not enabled" unless Rails.configuration.s3.enabled
  8. end
  9. 4 def validate_s3_config
  10. 22 required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
  11. 110 missing_vars = required_vars.select { |var| ENV[var].blank? }
  12. 22 then: 2 else: 20 raise "Missing S3 config: #{missing_vars.join(", ")}" if missing_vars.any?
  13. end
  14. 4 def get_s3_service = ActiveStorage::Blob.service
  15. 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. # typed: false
  2. 4 class ImageProcessorService
  3. 4 FULL_SIZE = 1200
  4. 4 THUMBNAIL_SIZE = 200
  5. 4 DEFAULT_SIZE = 800
  6. 4 def self.thumbnail(image)
  7. 3 then: 3 else: 0 else: 2 then: 1 return nil unless image&.attached?
  8. 2 image.variant(
  9. format: :jpeg,
  10. resize_to_limit: [THUMBNAIL_SIZE, THUMBNAIL_SIZE],
  11. saver: {quality: 75}
  12. )
  13. end
  14. 4 def self.default(image)
  15. 4 then: 4 else: 0 else: 3 then: 1 return nil unless image&.attached?
  16. 3 image.variant(
  17. format: :jpeg,
  18. resize_to_limit: [DEFAULT_SIZE, DEFAULT_SIZE],
  19. saver: {quality: 75}
  20. )
  21. end
  22. # Calculate actual dimensions after resize_to_limit transformation
  23. # Pass in metadata hash with "width" and "height" keys
  24. # Size can be :full, :thumbnail, or :default (defaults to :default)
  25. 4 def self.calculate_dimensions(metadata, size = :default)
  26. 5 max_size = max_size_for(size)
  27. 5 original_width = metadata["width"].to_f
  28. 5 original_height = metadata["height"].to_f
  29. 5 resize_dimensions(original_width, original_height, max_size)
  30. end
  31. 4 def self.max_size_for(size)
  32. 5 when: 1 case size
  33. 1 when: 2 when :full then FULL_SIZE
  34. 2 else: 2 when :thumbnail then THUMBNAIL_SIZE
  35. 2 else DEFAULT_SIZE
  36. end
  37. end
  38. 4 def self.resize_dimensions(original_width, original_height, max_size)
  39. 5 ratio = max_size / [original_width, original_height].max
  40. 5 then: 4 if ratio < 1
  41. {
  42. 8 width: (original_width * ratio).round,
  43. 4 height: (original_height * ratio).round
  44. }
  45. else: 1 else
  46. 1 {width: original_width.to_i, height: original_height.to_i}
  47. end
  48. end
  49. end

app/services/inspection_creation_service.rb

100.0% lines covered

94.44% branches covered

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

app/services/inspection_csv_export_service.rb

100.0% lines covered

88.0% branches covered

38 relevant lines. 38 lines covered and 0 lines missed.
25 total branches, 22 branches covered and 3 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 require "sorbet-runtime"
  4. 4 require "csv"
  5. 4 class InspectionCsvExportService
  6. 4 extend T::Sig
  7. 4 sig do
  8. 2 params(
  9. inspections: T.any(
  10. ActiveRecord::Relation,
  11. T::Array[Inspection]
  12. )
  13. ).void
  14. end
  15. 4 def initialize(inspections)
  16. 10 @inspections = inspections
  17. end
  18. 6 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. 6 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. 6 sig { params(inspection: Inspection).returns(T::Array[T.untyped]) }
  40. 4 def row_data(inspection)
  41. 9 headers.map do |header|
  42. 243 in: 9 case header
  43. 9 in: 9 then: 8 else: 1 in :unit_name then inspection.unit&.name
  44. 9 in: 9 then: 8 else: 1 in :unit_serial then inspection.unit&.serial
  45. 9 in: 9 then: 8 else: 1 in :unit_manufacturer then inspection.unit&.manufacturer
  46. 9 in: 9 then: 8 else: 1 in :unit_operator then inspection.unit&.operator
  47. 9 in: 9 then: 8 else: 1 in :unit_description then inspection.unit&.description
  48. 9 in: 9 then: 9 else: 0 in :inspector_company_name then inspection.inspector_company&.name
  49. 9 in: 9 then: 9 else: 0 in :inspector_user_email then inspection.user&.email
  50. 9 else: 171 in :complete then inspection.complete?
  51. 171 then: 171 else: 0 else inspection.send(header) if inspection.respond_to?(header)
  52. end
  53. end
  54. end
  55. end

app/services/ntfy_service.rb

94.44% lines covered

66.67% branches covered

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

app/services/pdf_cache_service.rb

100.0% lines covered

81.82% branches covered

97 relevant lines. 97 lines covered and 0 lines missed.
33 total branches, 27 branches covered and 6 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. 76 invalidate_cache(inspection)
  29. end
  30. 8 sig { params(unit: Unit).void }
  31. 4 def invalidate_unit_cache(unit)
  32. 1298 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. 1374 else: 13 then: 1361 return unless caching_enabled?
  89. 13 then: 2 else: 11 record.cached_pdf.purge if record.cached_pdf.attached?
  90. end
  91. 8 sig { returns(T::Boolean) }
  92. 4 def caching_enabled?
  93. 1430 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. 5 sig { returns(T.nilable(Date)) }
  102. 4 def pdf_cache_from_date
  103. 12 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 else: 6 then: 0 return false unless pdf_cache_from_date
  114. 6 cache_created_at = attachment.blob.created_at
  115. 6 cache_threshold = pdf_cache_from_date.beginning_of_day
  116. # Check if cache is newer than the threshold date
  117. 6 else: 5 then: 1 return false unless cache_created_at > cache_threshold
  118. # Check if user assets were updated after cache
  119. 5 !user_assets_updated_after?(record.user, cache_created_at)
  120. end
  121. 4 sig do
  122. 1 params(
  123. user: T.nilable(User),
  124. cache_created_at: T.any(ActiveSupport::TimeWithZone, Date, Time)
  125. ).returns(T::Boolean)
  126. end
  127. 4 def user_assets_updated_after?(user, cache_created_at)
  128. 5 else: 5 then: 0 return false unless user
  129. 5 then: 1 else: 4 if attachment_updated_after?(user.signature, cache_created_at)
  130. 1 Rails.logger.info "User signature updated after PDF cache"
  131. 1 return true
  132. end
  133. 4 then: 2 else: 2 if attachment_updated_after?(user.logo, cache_created_at)
  134. 2 Rails.logger.info "User logo updated after PDF cache"
  135. 2 return true
  136. end
  137. 2 false
  138. end
  139. 4 sig do
  140. 1 params(
  141. attachment: T.untyped,
  142. reference_time: T.any(ActiveSupport::TimeWithZone, Date, Time)
  143. ).returns(T::Boolean)
  144. end
  145. 4 def attachment_updated_after?(attachment, reference_time)
  146. 9 then: 9 else: 0 attachment&.attached? &&
  147. attachment.blob.created_at > reference_time
  148. end
  149. 4 sig do
  150. 1 params(
  151. record: T.any(Inspection, Unit),
  152. pdf_data: String
  153. ).void
  154. end
  155. 4 def store_cached_pdf(record, pdf_data)
  156. # Purge old cached PDF if exists
  157. 2 then: 1 else: 1 record.cached_pdf.purge if record.cached_pdf.attached?
  158. # Store new cached PDF
  159. 2 type_name = record.class.name.downcase
  160. 2 filename = "#{type_name}_#{record.id}_cached_#{Time.current.to_i}.pdf"
  161. # Create a StringIO with proper positioning
  162. 2 io = StringIO.new(pdf_data)
  163. 2 io.rewind
  164. 2 record.cached_pdf.attach(
  165. io: io,
  166. filename: filename,
  167. content_type: "application/pdf"
  168. )
  169. end
  170. 5 sig { returns(T::Boolean) }
  171. 4 def redirect_to_s3?
  172. 2 Rails.configuration.pdf.redirect_to_s3
  173. end
  174. end
  175. end

app/services/pdf_generator_service.rb

90.32% lines covered

60.0% branches covered

93 relevant lines. 84 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(unbranded: false)
  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. 28 require "prawn/table"
  41. 28 unbranded = Rails.configuration.units.reports_unbranded
  42. # Preload all inspections once to avoid N+1 queries
  43. 28 completed_inspections = unit.inspections
  44. .includes(:user, inspector_company: {logo_attachment: :blob})
  45. .complete
  46. .order(inspection_date: :desc)
  47. 28 last_inspection = completed_inspections.first
  48. 28 Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
  49. 28 Configuration.setup_pdf_fonts(pdf)
  50. 28 HeaderGenerator.generate_unit_pdf_header(pdf, unit, unbranded: unbranded)
  51. 28 generate_unit_details_with_inspection(pdf, unit, last_inspection)
  52. 28 generate_unit_inspection_history_with_data(pdf, unit, completed_inspections)
  53. # Disclaimer footer (only on first page, not for unbranded reports)
  54. 28 DisclaimerFooterRenderer.render_disclaimer_footer(pdf, unit.user, unbranded: unbranded)
  55. # Add unit photo in bottom right corner (for unit PDFs, always use 3 columns)
  56. 28 then: 28 else: 0 ImageProcessor.add_unit_photo_footer(pdf, unit, 3) if unit.photo
  57. # Add debug info page if enabled (admins only)
  58. 28 then: 0 else: 28 DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present?
  59. end
  60. end
  61. 4 def self.generate_inspection_unit_details(pdf, inspection)
  62. 42 unit = inspection.unit
  63. 42 else: 41 then: 1 return unless unit
  64. 41 unit_data = TableBuilder.build_unit_details_table_with_inspection(unit, inspection, :inspection)
  65. 41 TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.inspection.equipment_details"), unit_data)
  66. # Hide the table entirely when no unit is associated
  67. end
  68. 4 def self.generate_unit_details(pdf, unit)
  69. unit_data = TableBuilder.build_unit_details_table(unit, :unit)
  70. TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
  71. end
  72. 4 def self.generate_unit_details_with_inspection(pdf, unit, _last_inspection)
  73. 28 unit_data = TableBuilder.build_unit_details_table(unit, :unit)
  74. 28 TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
  75. end
  76. 4 def self.generate_unit_inspection_history(pdf, unit)
  77. # Check for completed inspections - preload associations to avoid N+1 queries
  78. # Since all inspections belong to the same unit, we don't need to reload the unit
  79. completed_inspections = unit.inspections
  80. .includes(:user, inspector_company: {logo_attachment: :blob})
  81. .complete
  82. .order(inspection_date: :desc)
  83. then: 0 if completed_inspections.empty?
  84. TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
  85. [[I18n.t("pdf.unit.no_completed_inspections"), ""]])
  86. else: 0 else
  87. TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
  88. end
  89. end
  90. 4 def self.generate_unit_inspection_history_with_data(pdf, _unit, completed_inspections)
  91. 28 then: 26 if completed_inspections.empty?
  92. 26 TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
  93. [[I18n.t("pdf.unit.no_completed_inspections"), ""]])
  94. else: 2 else
  95. 2 TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
  96. end
  97. end
  98. 4 def self.generate_risk_assessment_section(pdf, inspection)
  99. 42 then: 0 else: 42 return if inspection.risk_assessment.blank?
  100. 42 pdf.text I18n.t("pdf.inspection.risk_assessment"), size: HEADER_TEXT_SIZE, style: :bold
  101. 42 pdf.stroke_horizontal_rule
  102. 42 pdf.move_down 10
  103. # Create a text box constrained to 4 lines with shrink_to_fit
  104. 42 line_height = 10 * 1.2 # Normal font size * line height multiplier
  105. 42 max_height = line_height * 4 # 4 lines max
  106. 42 pdf.text_box inspection.risk_assessment,
  107. at: [0, pdf.cursor],
  108. width: pdf.bounds.width,
  109. height: max_height,
  110. size: 10,
  111. overflow: :shrink_to_fit,
  112. min_font_size: 5
  113. 42 pdf.move_down max_height + 15
  114. end
  115. 4 def self.generate_assessments_in_ui_order(inspection, assessment_blocks)
  116. # Get the UI order from applicable_tabs (excluding non-assessment tabs)
  117. 42 ui_ordered_tabs = inspection.applicable_tabs - %w[inspection results]
  118. 42 ui_ordered_tabs.each do |tab_name|
  119. 280 assessment_key = :"#{tab_name}_assessment"
  120. 280 else: 280 then: 0 next unless inspection.assessment_applicable?(assessment_key)
  121. 280 assessment = inspection.send(assessment_key)
  122. 280 else: 280 then: 0 next unless assessment
  123. # Build blocks for this assessment and add to the main array
  124. 280 blocks = AssessmentBlockBuilder.build_from_assessment(tab_name, assessment)
  125. 280 assessment_blocks.concat(blocks)
  126. end
  127. end
  128. 4 def self.render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height)
  129. 42 then: 0 else: 42 return if assessment_blocks.empty?
  130. 42 pdf.text I18n.t("pdf.inspection.assessments_section"), size: 12, style: :bold
  131. 42 pdf.stroke_horizontal_rule
  132. 42 pdf.move_down 15
  133. # Calculate available height accounting for disclaimer footer only
  134. 42 then: 42 available_height = if pdf.page_number == 1
  135. 42 pdf.cursor - disclaimer_height
  136. else: 0 else
  137. pdf.cursor
  138. end
  139. # Check if we have enough space for at least some content
  140. 42 min_content_height = 100 # Minimum height for meaningful content
  141. 42 then: 0 else: 42 if available_height < min_content_height
  142. pdf.start_new_page
  143. available_height = pdf.cursor
  144. 0 # No footer on new pages
  145. end
  146. # Render assessments using the column layout with measured footer space
  147. 42 renderer = AssessmentColumns.new(assessment_blocks, available_height, photo_height)
  148. 42 renderer.render(pdf)
  149. 42 pdf.move_down 20
  150. end
  151. # Helper methods for backward compatibility and testing
  152. 4 def self.truncate_text(text, max_length)
  153. 3 Utilities.truncate_text(text, max_length)
  154. end
  155. 4 def self.format_pass_fail(value)
  156. 3 Utilities.format_pass_fail(value)
  157. end
  158. 4 def self.format_measurement(value, unit = "")
  159. 3 Utilities.format_measurement(value, unit)
  160. end
  161. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class AssessmentBlock
  4. 4 attr_reader :type, :pass_fail, :name, :value, :comment
  5. 4 def initialize(type:, pass_fail: nil, name: nil, value: nil, comment: nil)
  6. 3835 @type = type
  7. 3835 @pass_fail = pass_fail
  8. 3835 @name = name
  9. 3835 @value = value
  10. 3835 @comment = comment
  11. end
  12. 4 def header?
  13. 22113 type == :header
  14. end
  15. 4 def value?
  16. 107 type == :value
  17. end
  18. 4 def comment?
  19. 92 type == :comment
  20. end
  21. end
  22. 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. 300 new(assessment_type, assessment).build
  7. end
  8. 4 def initialize(assessment_type, assessment)
  9. 323 @assessment_type = assessment_type
  10. 323 @assessment = assessment
  11. 323 @not_applicable_fields = get_not_applicable_fields
  12. end
  13. 4 def build
  14. 304 blocks = []
  15. # Add header block
  16. 304 blocks << AssessmentBlock.new(
  17. type: :header,
  18. name: I18n.t("forms.#{@assessment_type}.header")
  19. )
  20. # Process fields
  21. 304 ordered_fields = get_form_config_fields
  22. 304 field_groups = group_assessment_fields(ordered_fields)
  23. 304 field_groups.each do |base, fields|
  24. # Skip if this is a not-applicable field with value 0
  25. 2581 main_field = fields[:base] || fields[:pass]
  26. 2581 then: 3 else: 2578 if main_field && field_is_not_applicable?(main_field)
  27. 3 next
  28. end
  29. # Add value block
  30. 2578 then: 2534 if main_field
  31. 2534 value = @assessment.send(main_field)
  32. 2534 label = get_field_label(fields)
  33. 2534 pass_value = determine_pass_value(fields, main_field, value)
  34. 2534 is_pass_field = main_field.to_s.end_with?("_pass")
  35. # For boolean fields that aren't pass/fail fields
  36. 2534 is_bool_non_pass = [true, false].include?(value) &&
  37. !is_pass_field && pass_value.nil?
  38. 2534 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: 2510 else
  45. 2510 AssessmentBlock.new(
  46. type: :value,
  47. pass_fail: pass_value,
  48. name: label,
  49. 2510 then: 1317 else: 1193 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. 2578 then: 2276 else: 302 if fields[:comment]
  67. 2276 comment = @assessment.send(fields[:comment])
  68. 2276 then: 966 else: 1310 if comment.present?
  69. 966 blocks << AssessmentBlock.new(
  70. type: :comment,
  71. comment: comment
  72. )
  73. end
  74. end
  75. end
  76. 304 blocks
  77. end
  78. 4 private
  79. 4 def get_form_config_fields
  80. 307 else: 307 then: 0 return [] unless @assessment.class.respond_to?(:form_fields)
  81. 307 form_config = @assessment.class.form_fields
  82. 307 ordered_fields = []
  83. 307 form_config.each do |section|
  84. 627 section[:fields].each do |field_config|
  85. 2624 field_name = field_config[:field]
  86. 2624 partial_name = field_config[:partial]
  87. # Get composite fields first to check if any exist
  88. 2624 composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name)
  89. # Skip if neither the base field nor any composite fields exist
  90. 2624 has_base = @assessment.respond_to?(field_name)
  91. 4887 has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) }
  92. 2624 else: 2624 then: 0 next unless has_base || has_composites
  93. # Add base field if it exists
  94. 2624 then: 1296 else: 1328 ordered_fields << field_name if has_base
  95. # Add composite fields that exist
  96. 2624 composite_fields.each do |composite_field|
  97. 4030 then: 4030 else: 0 ordered_fields << composite_field if @assessment.respond_to?(composite_field)
  98. end
  99. end
  100. end
  101. 307 ordered_fields
  102. end
  103. 4 def group_assessment_fields(field_keys)
  104. 309 field_keys.each_with_object({}) do |field, groups|
  105. 5272 field_str = field.to_s
  106. 5272 else: 5269 then: 3 next unless @assessment.respond_to?(field_str)
  107. 5269 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  108. 5269 groups[base_field] ||= {}
  109. 5269 when: 1761 field_type = case field_str
  110. 1761 when: 2285 when /pass$/ then :pass
  111. 2285 else: 1223 when /comment$/ then :comment
  112. 1223 else :base
  113. end
  114. 5269 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. 2582 then: 1218 if fields[:base]
  120. 1218 else: 1364 field_label(fields[:base])
  121. 1364 elsif fields[:pass]
  122. then: 1318 # For pass fields, use the base field name for the label
  123. 1318 base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:pass])
  124. 1318 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. 2581 I18n.t!("forms.#{@assessment_type}.fields.#{field_name}")
  135. end
  136. 4 def determine_pass_value(fields, main_field, value)
  137. 2540 then: 1757 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. 324 else: 324 then: 0 return [] unless @assessment.class.respond_to?(:form_fields)
  143. 324 @assessment.class.form_fields
  144. 659 .flat_map { |section| section[:fields] }
  145. 2805 then: 1165 else: 1640 .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. 2540 else: 49 then: 2491 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class AssessmentBlockRenderer
  4. 4 include Configuration
  5. 4 ASSESSMENT_MARGIN_AFTER_TITLE = 3
  6. 4 ASSESSMENT_TITLE_SIZE = 9
  7. 4 ASSESSMENT_FIELD_TEXT_SIZE = 7
  8. # Calculate column width (1/4 of page width minus spacing)
  9. 4 PAGE_WIDTH = 595.28 - (2 * 36) # A4 width minus margins
  10. 4 TOTAL_SPACER_WIDTH = Configuration::ASSESSMENT_COLUMN_SPACER * 3
  11. 4 COLUMN_WIDTH = (PAGE_WIDTH - TOTAL_SPACER_WIDTH) / 4.0
  12. 4 def initialize(font_size: ASSESSMENT_FIELD_TEXT_SIZE)
  13. 199 @font_size = font_size
  14. end
  15. 4 def render_fragments(block)
  16. 22082 case block.type
  17. when: 1592 when :header
  18. 1592 render_header_fragments(block)
  19. when: 13264 when :value
  20. 13264 render_value_fragments(block)
  21. when: 7226 when :comment
  22. 7226 render_comment_fragments(block)
  23. else: 0 else
  24. raise ArgumentError, "Unknown block type: #{block.type}"
  25. end
  26. end
  27. 4 def font_size_for(block)
  28. 22082 then: 1592 else: 20490 block.header? ? ASSESSMENT_TITLE_SIZE : @font_size
  29. end
  30. 4 def height_for(block, pdf)
  31. 18533 fragments = render_fragments(block)
  32. 18533 then: 0 else: 18533 return 0 if fragments.empty?
  33. 18533 font_size = font_size_for(block)
  34. # Convert fragments to formatted text array
  35. 18533 formatted_text = fragments.map do |fragment|
  36. 28489 styles = []
  37. 28489 then: 17985 else: 10504 styles << :bold if fragment[:bold]
  38. 28489 then: 6277 else: 22212 styles << :italic if fragment[:italic]
  39. {
  40. 28489 text: fragment[:text],
  41. styles: styles,
  42. color: fragment[:color]
  43. }
  44. end
  45. # Use height_of_formatted to get the actual height with wrapping
  46. 18533 base_height = pdf.height_of_formatted(
  47. formatted_text,
  48. width: COLUMN_WIDTH,
  49. size: font_size
  50. )
  51. # Add 33% of font size as spacing
  52. 18533 spacing = (font_size * 0.33).round(1)
  53. 18533 base_height + spacing
  54. end
  55. 4 private
  56. 4 def render_header_fragments(block)
  57. 1592 text = block.name || block.value
  58. 1592 [{text: text, bold: true, color: "000000"}]
  59. end
  60. 4 def render_value_fragments(block)
  61. 13264 fragments = []
  62. # Add pass/fail indicator if present
  63. 13264 then: 6622 else: 6642 if !block.pass_fail.nil?
  64. 6622 when: 6622 indicator, color = case block.pass_fail
  65. 6622 when: 0 when true, "pass" then [I18n.t("shared.pass_pdf"), Configuration::PASS_COLOR]
  66. else: 0 when false, "fail" then [I18n.t("shared.fail_pdf"), Configuration::FAIL_COLOR]
  67. else [I18n.t("shared.na_pdf"), Configuration::NA_COLOR]
  68. end
  69. 6622 fragments << {text: "#{indicator} ", bold: true, color: color}
  70. end
  71. # Add field name
  72. 13264 then: 13264 else: 0 if block.name
  73. 13264 fragments << {text: block.name, bold: true, color: "000000"}
  74. end
  75. # Add value if present and not a pass/fail field
  76. 13264 then: 4887 else: 8377 if block.value && !block.name.to_s.end_with?("_pass")
  77. 4887 fragments << {text: ": #{block.value}", bold: false, color: "000000"}
  78. end
  79. 13264 fragments
  80. end
  81. 4 def render_comment_fragments(block)
  82. 7226 then: 0 else: 7226 return [] if block.comment.blank?
  83. 7226 [{text: block.comment, bold: false, color: Configuration::HEADER_COLOR, italic: true}]
  84. end
  85. end
  86. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class AssessmentColumns
  4. # Include configuration for column spacing and font sizes
  5. 4 include Configuration
  6. 4 attr_reader :assessment_blocks, :assessment_results_height, :photo_height
  7. 4 def initialize(assessment_blocks, assessment_results_height, photo_height)
  8. 42 @assessment_blocks = assessment_blocks
  9. 42 @assessment_results_height = assessment_results_height
  10. 42 @photo_height = photo_height
  11. end
  12. 4 def render(pdf)
  13. # Try progressively smaller font sizes
  14. 42 font_size = Configuration::ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED
  15. 42 min_font_size = Configuration::MIN_ASSESSMENT_FONT_SIZE
  16. 42 body: 154 while font_size >= min_font_size
  17. 154 then: 42 else: 112 if content_fits_with_font_size?(pdf, font_size)
  18. 42 render_with_font_size(pdf, font_size)
  19. 42 return true
  20. end
  21. 112 font_size -= 1
  22. end
  23. # If we still can't fit, render with minimum font size anyway
  24. render_with_font_size(pdf, min_font_size)
  25. false
  26. end
  27. 4 private
  28. 4 def content_fits_with_font_size?(pdf, font_size)
  29. # Calculate total content height
  30. 154 total_height = calculate_total_content_height(font_size, pdf)
  31. # Calculate column capacity
  32. 154 columns = calculate_column_boxes(pdf)
  33. 770 total_capacity = columns.sum { |col| col[:height] }
  34. 154 total_height <= total_capacity
  35. end
  36. 4 def calculate_total_content_height(font_size, pdf)
  37. 154 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  38. 154 total_height = 0
  39. 154 @assessment_blocks.each do |block|
  40. # Add height for this block using actual PDF document
  41. 14977 total_height += renderer.height_for(block, pdf)
  42. end
  43. 154 total_height
  44. end
  45. 4 def render_with_font_size(pdf, font_size)
  46. # Calculate column dimensions
  47. 42 columns = calculate_column_boxes(pdf)
  48. # Save the starting position
  49. 42 start_y = pdf.cursor
  50. # Track content placement across columns
  51. 42 content_blocks = prepare_content_blocks(pdf, font_size)
  52. 42 place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  53. # Move cursor to end of assessment area
  54. 42 pdf.move_cursor_to(start_y - assessment_results_height)
  55. end
  56. 4 def prepare_content_blocks(pdf, font_size)
  57. 42 blocks = []
  58. 42 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  59. 42 @assessment_blocks.each do |block|
  60. # Get rendered fragments and height for this block
  61. 3549 fragments = renderer.render_fragments(block)
  62. 3549 height = renderer.height_for(block, pdf)
  63. 3549 blocks << {
  64. type: block.type,
  65. fragments: fragments,
  66. height: height,
  67. font_size: renderer.font_size_for(block)
  68. }
  69. end
  70. 42 blocks
  71. end
  72. 4 def place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  73. 42 current_column = 0
  74. 42 column_y = start_y
  75. 42 content_blocks.each do |content|
  76. # Check if we need to move to next column
  77. 3488 then: 3488 if current_column < columns.size
  78. 3488 available = column_y - (start_y - columns[current_column][:height])
  79. 3488 else: 3362 if available < content[:height]
  80. then: 126 # Move to next column
  81. 126 current_column += 1
  82. 126 column_y = start_y
  83. # Stop if we run out of columns
  84. 126 then: 4 else: 122 break if current_column >= columns.size
  85. end
  86. else: 0 else
  87. break
  88. end
  89. # Render content in current column
  90. 3484 column = columns[current_column]
  91. 3484 render_content_at_position(pdf, content, column, column_y, font_size)
  92. # Update position
  93. 3484 column_y -= content[:height]
  94. end
  95. end
  96. 4 def render_content_at_position(pdf, content, column, y_pos, font_size)
  97. # Save original state
  98. 3484 original_y = pdf.cursor
  99. 3484 original_fill_color = pdf.fill_color
  100. # Calculate actual x position
  101. 3484 actual_x = column[:x]
  102. # Use the font size from the content block if available
  103. 3484 text_size = content[:font_size] || font_size
  104. # Convert fragments to formatted text array for proper wrapping
  105. 3484 formatted_text = content[:fragments].map do |fragment|
  106. 4995 styles = []
  107. 4995 then: 3426 else: 1569 styles << :bold if fragment[:bold]
  108. 4995 then: 925 else: 4070 styles << :italic if fragment[:italic]
  109. {
  110. 4995 text: fragment[:text],
  111. styles: styles,
  112. color: fragment[:color]
  113. }
  114. end
  115. # Render as single formatted text box for proper wrapping
  116. 3484 pdf.formatted_text_box(
  117. formatted_text,
  118. at: [actual_x, y_pos],
  119. width: column[:width],
  120. size: text_size,
  121. overflow: :truncate
  122. )
  123. # Restore original state
  124. 3484 pdf.fill_color original_fill_color
  125. 3484 pdf.move_cursor_to original_y
  126. end
  127. 4 def calculate_column_boxes(pdf)
  128. 196 total_spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER * 3
  129. 196 column_width = (pdf.bounds.width - total_spacer_width) / 4.0
  130. 196 columns = []
  131. # First three columns - full height
  132. 196 3.times do |i|
  133. 588 x = i * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER)
  134. 588 columns << {
  135. x: x,
  136. y: pdf.cursor,
  137. width: column_width,
  138. height: assessment_results_height
  139. }
  140. end
  141. # Fourth column - reduced by photo height
  142. 196 fourth_column_height = [assessment_results_height - photo_height - 5, 0].max # 5pt buffer
  143. 196 columns << {
  144. 196 x: 3 * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER),
  145. y: pdf.cursor,
  146. width: column_width,
  147. height: fourth_column_height
  148. }
  149. 196 columns
  150. end
  151. 4 def calculate_line_height(font_size)
  152. font_size * 1.2
  153. end
  154. end
  155. 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. 70 font_path = Rails.root.join("app/assets/fonts")
  84. 70 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. 70 pdf.font "NotoSans"
  96. end
  97. end
  98. end

app/services/pdf_generator_service/debug_info_renderer.rb

13.33% lines covered

0.0% branches covered

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

app/services/pdf_generator_service/disclaimer_footer_renderer.rb

97.62% lines covered

61.54% branches covered

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

app/services/pdf_generator_service/header_generator.rb

92.21% lines covered

85.0% branches covered

77 relevant lines. 71 lines covered and 6 lines missed.
20 total branches, 17 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, unbranded: false)
  21. 28 create_unit_header(pdf, unit, unbranded: unbranded)
  22. # Generate QR code in top left corner
  23. 28 ImageProcessor.generate_qr_code_header(pdf, unit)
  24. end
  25. 4 def self.create_unit_header(pdf, unit, unbranded: false)
  26. 28 then: 2 else: 26 user = unbranded ? nil : unit.user
  27. 28 unit_id_text = build_unit_id_text(unit)
  28. 28 render_header_with_logo(pdf, user) do |logo_width|
  29. 28 render_unit_text_section(pdf, unit, unit_id_text, logo_width)
  30. end
  31. 28 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. 28 "#{I18n.t("pdf.unit.fields.unit_id")}: #{unit.id}"
  50. end
  51. 4 def render_header_with_logo(pdf, user)
  52. 70 logo_width, logo_data, logo_attachment = prepare_logo(user)
  53. 70 pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do
  54. 70 yield(logo_width)
  55. 70 then: 0 else: 70 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 config is set to override user logo
  62. 81 pdf_logo = Rails.configuration.pdf.logo
  63. 81 then: 3 else: 78 if pdf_logo.present?
  64. 3 logo_path = Rails.root.join("app", "assets", "images", pdf_logo)
  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. 78 then: 75 else: 3 then: 75 else: 3 else: 5 then: 73 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. 28 qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
  96. 28 width = pdf.bounds.width - logo_width - qr_offset
  97. 28 pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
  98. 28 pdf.text unit_id_text, size: Configuration::HEADER_TEXT_SIZE,
  99. style: :bold
  100. 28 expiry_label = I18n.t("pdf.unit.fields.expiry_date")
  101. 28 then: 2 else: 26 then: 2 expiry_value = if unit.last_inspection&.reinspection_date
  102. 2 Utilities.format_date(unit.last_inspection.reinspection_date)
  103. else: 26 else
  104. 26 I18n.t("pdf.unit.fields.na")
  105. end
  106. 28 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. 28 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class ImageError
  4. 4 def self.build_detailed_error(original_error, attachment)
  5. 1 blob = attachment.blob
  6. 1 details = extract_image_details(blob, attachment)
  7. 1 service_url = build_service_url(blob)
  8. 1 detailed_message = format_error_message(
  9. original_error, details, service_url
  10. )
  11. 1 original_error.class.new(detailed_message)
  12. end
  13. 4 def self.extract_image_details(blob, attachment)
  14. 1 record = attachment.record
  15. {
  16. 1 filename: blob.filename.to_s,
  17. byte_size: blob.byte_size,
  18. content_type: blob.content_type,
  19. record_type: record.class.name,
  20. record_id: record.try(:serial) || record.try(:id) || "unknown"
  21. }
  22. end
  23. 4 def self.build_service_url(blob)
  24. 1 "/rails/active_storage/blobs/#{blob.signed_id}/#{blob.filename}"
  25. end
  26. 4 def self.format_error_message(original_error, details, service_url)
  27. 1 size_kb = (details[:byte_size] / 1024.0).round(2)
  28. <<~MESSAGE
  29. 1 #{original_error.message}
  30. Image details:
  31. Filename: #{details[:filename]}
  32. Size: #{details[:byte_size]} bytes (#{size_kb} KB)
  33. Content-Type: #{details[:content_type]}
  34. Record: #{details[:record_type]} #{details[:record_id]}
  35. ActiveStorage URL: #{service_url}
  36. MESSAGE
  37. end
  38. 4 private_class_method :extract_image_details, :build_service_url,
  39. :format_error_message
  40. end
  41. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class ImageProcessor
  4. 4 require "vips"
  5. 4 include Configuration
  6. 4 def self.generate_qr_code_header(pdf, entity)
  7. 73 qr_code_png = QrCodeService.generate_qr_code(entity)
  8. # Position QR code at top left of page
  9. 72 qr_width, qr_height = PositionCalculator.qr_code_dimensions
  10. # Use pdf.bounds.top to position from top of page
  11. image_options = {
  12. 72 at: [0, pdf.bounds.top],
  13. width: qr_width,
  14. height: qr_height
  15. }
  16. 72 pdf.image StringIO.new(qr_code_png), image_options
  17. end
  18. 4 def self.add_unit_photo_footer(pdf, unit, column_count = 3)
  19. 73 then: 73 else: 0 then: 73 else: 0 else: 8 then: 65 return unless unit&.photo&.blob
  20. # Calculate photo position in bottom right corner
  21. 8 pdf_width = pdf.bounds.width
  22. # Calculate photo dimensions based on column count
  23. 8 attachment = unit.photo
  24. 8 image = create_image(attachment)
  25. 8 dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
  26. 8 photo_width, photo_height = dimensions
  27. # Position photo in bottom right corner
  28. 8 photo_x = pdf_width - photo_width
  29. # Account for footer height on first page
  30. 8 photo_y = calculate_photo_y(pdf, photo_height)
  31. 8 render_processed_image(pdf, image, photo_x, photo_y,
  32. photo_width, photo_height, attachment)
  33. rescue Prawn::Errors::UnsupportedImageType => e
  34. raise ImageError.build_detailed_error(e, attachment)
  35. end
  36. 4 def self.measure_unit_photo_height(pdf, unit, column_count = 3)
  37. 42 then: 41 else: 1 then: 41 else: 1 else: 2 then: 40 return 0 unless unit&.photo&.blob
  38. 2 attachment = unit.photo
  39. 2 image = create_image(attachment)
  40. 2 dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
  41. 2 _photo_width, photo_height = dimensions
  42. 2 then: 0 else: 2 if photo_height <= 0
  43. raise I18n.t("pdf_generator.errors.zero_photo_height", unit_id: unit.id)
  44. end
  45. 2 photo_height
  46. rescue Prawn::Errors::UnsupportedImageType => e
  47. raise ImageError.build_detailed_error(e, attachment)
  48. end
  49. 4 def self.process_image_with_orientation(attachment)
  50. 2 image = create_image(attachment)
  51. # Vips automatically handles EXIF orientation
  52. 2 image.write_to_buffer(".png")
  53. end
  54. 4 def self.calculate_footer_photo_dimensions(pdf, image, column_count = 3)
  55. 10 original_width = image.width
  56. 10 original_height = image.height
  57. # Calculate column width based on PDF width and column count
  58. # Account for column spacers
  59. 10 spacer_count = column_count - 1
  60. 10 spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER
  61. 10 total_spacer_width = spacer_width * spacer_count
  62. 10 column_width = (pdf.bounds.width - total_spacer_width) / column_count.to_f
  63. # Photo width equals one column width
  64. 10 photo_width = column_width.round
  65. # Calculate height maintaining aspect ratio
  66. 10 then: 0 if original_width.zero? || original_height.zero?
  67. photo_height = photo_width
  68. else: 10 else
  69. 10 aspect_ratio = original_width.to_f / original_height.to_f
  70. 10 photo_height = (photo_width / aspect_ratio).round
  71. end
  72. 10 [photo_width, photo_height]
  73. end
  74. 4 def self.render_processed_image(pdf, image, x, y, width, height, attachment)
  75. # Vips automatically handles EXIF orientation
  76. 8 processed_image = image.write_to_buffer(".png")
  77. image_options = {
  78. 8 at: [x, y],
  79. width: width,
  80. height: height
  81. }
  82. 8 pdf.image StringIO.new(processed_image), image_options
  83. rescue Prawn::Errors::UnsupportedImageType => e
  84. raise ImageError.build_detailed_error(e, attachment)
  85. end
  86. 4 def self.create_image(attachment)
  87. 12 image_data = attachment.blob.download
  88. 12 Vips::Image.new_from_buffer(image_data, "")
  89. end
  90. 4 def self.calculate_photo_y(pdf, photo_height)
  91. 8 then: 8 if pdf.page_number == 1
  92. 8 Configuration::FOOTER_HEIGHT +
  93. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  94. else: 0 else
  95. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  96. end
  97. end
  98. end
  99. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class PhotosRenderer
  4. 4 def self.generate_photos_page(pdf, inspection)
  5. 47 else: 4 then: 43 return unless has_photos?(inspection)
  6. 4 pdf.start_new_page
  7. 4 add_photos_header(pdf)
  8. 4 max_photo_height = calculate_max_photo_height(pdf)
  9. 4 process_all_photos(pdf, inspection, max_photo_height)
  10. end
  11. 4 def self.has_photos?(inspection)
  12. 49 inspection.photo_1.attached? ||
  13. inspection.photo_2.attached? ||
  14. inspection.photo_3.attached?
  15. end
  16. 4 def self.add_photos_header(pdf)
  17. header_options = {
  18. 5 size: Configuration::HEADER_TEXT_SIZE,
  19. style: :bold
  20. }
  21. 5 pdf.text I18n.t("pdf.inspection.photos_section"), header_options
  22. 5 pdf.stroke_horizontal_rule
  23. 5 pdf.move_down 15
  24. end
  25. 4 def self.calculate_max_photo_height(pdf)
  26. 4 height_percent = Configuration::PHOTO_MAX_HEIGHT_PERCENT
  27. 4 pdf.bounds.height * height_percent
  28. end
  29. 4 def self.process_all_photos(pdf, inspection, max_photo_height)
  30. 9 current_y = pdf.cursor
  31. 9 photo_fields.each do |photo_field, label|
  32. 26 photo = inspection.send(photo_field)
  33. 26 else: 18 then: 8 next unless photo.attached?
  34. 18 current_y = handle_page_break_if_needed(
  35. pdf, current_y, max_photo_height
  36. )
  37. 18 render_photo(pdf, photo, label, max_photo_height)
  38. 17 current_y = pdf.cursor - Configuration::PHOTO_SPACING
  39. 17 pdf.move_down Configuration::PHOTO_SPACING
  40. end
  41. end
  42. 4 def self.photo_fields
  43. [
  44. 13 [:photo_1, I18n.t("pdf.inspection.fields.photo_1_label")],
  45. [:photo_2, I18n.t("pdf.inspection.fields.photo_2_label")],
  46. [:photo_3, I18n.t("pdf.inspection.fields.photo_3_label")]
  47. ]
  48. end
  49. 4 def self.handle_page_break_if_needed(pdf, current_y, max_photo_height)
  50. 7 needed_space = calculate_needed_space(max_photo_height)
  51. 7 then: 2 if current_y < needed_space
  52. 2 pdf.start_new_page
  53. 2 pdf.cursor
  54. else: 5 else
  55. 5 current_y
  56. end
  57. end
  58. 4 def self.calculate_needed_space(max_photo_height)
  59. 8 label_size = Configuration::PHOTO_LABEL_SIZE
  60. 8 label_spacing = Configuration::PHOTO_LABEL_SPACING
  61. 8 photo_spacing = Configuration::PHOTO_SPACING
  62. 8 max_photo_height + label_size + label_spacing + photo_spacing
  63. end
  64. 4 def self.render_photo(pdf, photo, label, max_height)
  65. 13 photo.blob.download
  66. 13 processed_image = ImageProcessor.process_image_with_orientation(photo)
  67. 11 image_width, image_height = calculate_photo_dimensions_from_blob(
  68. photo, pdf.bounds.width, max_height
  69. )
  70. 11 x_position = (pdf.bounds.width - image_width) / 2
  71. 11 render_image_to_pdf(
  72. pdf, processed_image, x_position, image_width, image_height, photo
  73. )
  74. 10 add_photo_label(pdf, label, image_height)
  75. rescue Prawn::Errors::UnsupportedImageType => e
  76. 3 raise ImageError.build_detailed_error(e, photo)
  77. end
  78. 4 def self.calculate_photo_dimensions_from_blob(photo, max_width, max_height)
  79. 12 original_width = photo.blob.metadata[:width].to_f
  80. 12 original_height = photo.blob.metadata[:height].to_f
  81. 12 width_scale = max_width / original_width
  82. 12 height_scale = max_height / original_height
  83. 12 scale = [width_scale, height_scale].min
  84. 12 [original_width * scale, original_height * scale]
  85. end
  86. 4 def self.render_image_to_pdf(pdf, image_data, x_position, width, height,
  87. photo)
  88. image_options = {
  89. 12 at: [x_position, pdf.cursor],
  90. width: width,
  91. height: height
  92. }
  93. 12 pdf.image StringIO.new(image_data), image_options
  94. rescue Prawn::Errors::UnsupportedImageType => e
  95. 1 raise ImageError.build_detailed_error(e, photo)
  96. end
  97. 4 def self.add_photo_label(pdf, label, image_height)
  98. 6 pdf.move_down image_height + Configuration::PHOTO_LABEL_SPACING
  99. label_options = {
  100. 6 size: Configuration::PHOTO_LABEL_SIZE,
  101. align: :center
  102. }
  103. 6 pdf.text label, label_options
  104. end
  105. end
  106. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class PositionCalculator
  4. 4 include Configuration
  5. # Calculate QR code position in top left corner
  6. # QR code's top-left corner aligns with page top left, matching header spacing
  7. 4 def self.qr_code_position(pdf_bounds_width, pdf_page_number = 1)
  8. 4 x = QR_CODE_MARGIN
  9. # In Prawn, Y coordinates are from bottom, so we need to calculate from page top
  10. # This positions the QR code at the very top of the page
  11. 4 y = QR_CODE_SIZE
  12. 4 [x, y]
  13. end
  14. # Calculate photo position aligned with QR code
  15. # Photo width is twice QR code size, height maintains aspect ratio
  16. # Photo's bottom-right corner aligns with QR code's bottom-right corner
  17. 4 def self.photo_footer_position(qr_x, qr_y, photo_width = nil, photo_height = nil)
  18. 5 photo_width ||= QR_CODE_SIZE * 2
  19. 5 photo_height ||= photo_width # Default to square if no height provided
  20. # Photo's right edge aligns with QR's right edge (both align with table right edge)
  21. 5 photo_x = qr_x + QR_CODE_SIZE - photo_width
  22. # Photo's bottom edge aligns with QR's bottom edge (both match header spacing)
  23. 5 photo_y = qr_y - QR_CODE_SIZE + photo_height
  24. 5 [photo_x, photo_y]
  25. end
  26. # Calculate photo dimensions for footer (width = 2x QR size, height maintains aspect ratio)
  27. # Note: original_width and original_height should be post-EXIF-rotation dimensions
  28. 4 def self.footer_photo_dimensions(original_width, original_height)
  29. 4 footer_photo_dimensions_with_multiplier(original_width, original_height, 2.0)
  30. end
  31. # Calculate photo dimensions with custom width multiplier
  32. 4 def self.footer_photo_dimensions_with_multiplier(original_width, original_height, width_multiplier)
  33. 4 target_width = (QR_CODE_SIZE * width_multiplier).round
  34. 4 then: 1 else: 3 return [target_width, target_width] if original_width.zero? || original_height.zero?
  35. 3 aspect_ratio = calculate_aspect_ratio(original_width, original_height)
  36. 3 target_height = (target_width / aspect_ratio).round
  37. 3 [target_width, target_height]
  38. end
  39. # Get QR code dimensions
  40. 4 def self.qr_code_dimensions
  41. 73 [QR_CODE_SIZE, QR_CODE_SIZE]
  42. end
  43. # Check if coordinates are within PDF bounds
  44. 4 def self.within_bounds?(x, y, width, height, pdf_bounds_width, pdf_bounds_height)
  45. 9 x >= 0 &&
  46. y >= 0 &&
  47. 7 (x + width) <= pdf_bounds_width &&
  48. 5 (y + height) <= pdf_bounds_height
  49. end
  50. # Calculate aspect ratio for image fitting
  51. 4 def self.calculate_aspect_ratio(original_width, original_height)
  52. 15 then: 1 else: 14 return 1.0 if original_height.zero?
  53. 14 original_width.to_f / original_height.to_f
  54. end
  55. # Calculate dimensions to fit within constraints while maintaining aspect ratio
  56. 4 def self.fit_dimensions(original_width, original_height, max_width, max_height)
  57. 9 then: 2 else: 7 return [max_width, max_height] if original_width.zero? || original_height.zero?
  58. # If original already fits within constraints, return original dimensions
  59. 7 then: 1 else: 6 if original_width <= max_width && original_height <= max_height
  60. 1 return [original_width, original_height]
  61. end
  62. 6 aspect_ratio = calculate_aspect_ratio(original_width, original_height)
  63. # Try fitting by width first
  64. 6 fitted_width = max_width
  65. 6 fitted_height = (fitted_width / aspect_ratio).round
  66. # If height is too big, fit by height instead
  67. 6 then: 2 else: 4 if fitted_height > max_height
  68. 2 fitted_height = max_height
  69. 2 fitted_width = (fitted_height * aspect_ratio).round
  70. end
  71. 6 [fitted_width, fitted_height]
  72. end
  73. end
  74. end

app/services/pdf_generator_service/table_builder.rb

93.38% lines covered

87.5% branches covered

151 relevant lines. 141 lines covered and 10 lines missed.
40 total branches, 35 branches covered and 5 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. 26 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  21. 26 pdf.stroke_horizontal_rule
  22. 26 pdf.move_down 10
  23. 26 table = pdf.table(data, width: pdf.bounds.width) do |t|
  24. 26 t.cells.borders = []
  25. 26 t.cells.padding = NICE_TABLE_CELL_PADDING
  26. 26 t.cells.size = NICE_TABLE_TEXT_SIZE
  27. 26 t.columns(0).font_style = :bold
  28. 26 t.columns(0).width = TABLE_FIRST_COLUMN_WIDTH
  29. 26 t.row(0..data.length - 1).background_color = "EEEEEE"
  30. 26 t.row(0..data.length - 1).borders = [:bottom]
  31. 26 t.row(0..data.length - 1).border_color = "DDDDDD"
  32. end
  33. 26 then: 0 else: 26 yield table if block_given?
  34. 26 pdf.move_down 15
  35. 26 table
  36. end
  37. 4 def self.create_unit_details_table(pdf, title, data)
  38. 69 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  39. 69 pdf.stroke_horizontal_rule
  40. 69 pdf.move_down 10
  41. 69 table = create_styled_unit_table(pdf, data)
  42. 69 then: 0 else: 69 yield table if block_given?
  43. 69 pdf.move_down 15
  44. 69 table
  45. end
  46. 4 def self.create_styled_unit_table(pdf, data)
  47. 69 is_unit_pdf = data.first.length == 2
  48. 69 pdf.table(data, width: pdf.bounds.width) do |t|
  49. 69 apply_unit_table_base_styling(t, data.length)
  50. 69 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. 69 table.cells.borders = []
  55. 69 table.cells.padding = UNIT_TABLE_CELL_PADDING
  56. 69 table.cells.size = UNIT_TABLE_TEXT_SIZE
  57. 69 table.row(0..row_count - 1).background_color = "EEEEEE"
  58. 69 table.row(0..row_count - 1).borders = [:bottom]
  59. 69 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. 69 table.columns(0).font_style = :bold
  63. 69 then: 28 if is_unit_pdf
  64. 28 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. 33 last_inspection = unit.last_inspection
  170. 33 then: 29 if context == :unit
  171. 29 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. 29 dimensions_text = build_dimensions_text(last_inspection)
  178. # Build simple two-column table for unit PDFs
  179. [
  180. 29 [ChobbleForms::FieldUtils.form_field_label(:units, :name),
  181. Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH)],
  182. [ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer), unit.manufacturer],
  183. [ChobbleForms::FieldUtils.form_field_label(:units, :operator), unit.operator],
  184. [ChobbleForms::FieldUtils.form_field_label(:units, :serial), unit.serial],
  185. [I18n.t("pdf.inspection.fields.size_m"), dimensions_text]
  186. ]
  187. end
  188. 4 def self.build_unit_details_table_with_inspection(unit, last_inspection, context)
  189. 45 dimensions_text = build_dimensions_text(last_inspection)
  190. # Get inspector details from current inspection (for inspection PDF) or last inspection (for unit PDF)
  191. 45 then: 44 inspection = if context == :inspection
  192. 44 last_inspection
  193. else: 1 else
  194. 1 unit.last_inspection
  195. end
  196. 45 then: 41 else: 4 then: 41 else: 4 inspector_name = inspection&.user&.name
  197. 45 then: 41 else: 4 then: 41 else: 4 rpii_number = inspection&.user&.rpii_inspector_number
  198. # Combine inspector name with RPII number if present
  199. 45 then: 39 inspector_text = if rpii_number.present?
  200. 39 "#{inspector_name} (#{I18n.t("pdf.inspection.fields.rpii_inspector_no")} #{rpii_number})"
  201. else: 6 else
  202. 6 inspector_name
  203. end
  204. 45 then: 41 else: 4 then: 41 else: 4 issued_date = if inspection&.inspection_date
  205. 41 Utilities.format_date(inspection.inspection_date)
  206. end
  207. # Build the table rows
  208. [
  209. [
  210. 45 ChobbleForms::FieldUtils.form_field_label(:units, :name),
  211. Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH),
  212. I18n.t("pdf.inspection.fields.inspected_by"),
  213. inspector_text
  214. ],
  215. [
  216. ChobbleForms::FieldUtils.form_field_label(:units, :description),
  217. unit.description,
  218. ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer),
  219. unit.manufacturer
  220. ],
  221. [
  222. I18n.t("pdf.inspection.fields.size_m"),
  223. dimensions_text,
  224. ChobbleForms::FieldUtils.form_field_label(:units, :operator),
  225. unit.operator
  226. ],
  227. [
  228. ChobbleForms::FieldUtils.form_field_label(:units, :serial),
  229. unit.serial,
  230. I18n.t("pdf.inspection.fields.issued_date"),
  231. issued_date
  232. ]
  233. ]
  234. end
  235. 4 def self.build_dimensions_text(inspection)
  236. 74 else: 43 then: 31 return "" unless inspection
  237. 43 dimensions = []
  238. 43 %i[width length height].each do |dimension|
  239. 129 then: 48 else: 81 next if inspection.send(dimension).blank?
  240. 81 label = ChobbleForms::FieldUtils
  241. .form_field_label(:inspection, dimension)
  242. .sub(" (m)", "")
  243. 81 value = Utilities.format_dimension(inspection.send(dimension))
  244. 81 dimensions << "#{label}: #{value}"
  245. end
  246. 43 dimensions.join(" ")
  247. end
  248. end
  249. 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. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class Utilities
  4. 4 include Configuration
  5. 4 def self.truncate_text(text, max_length)
  6. 81 then: 3 else: 78 return "" if text.nil?
  7. 78 then: 3 else: 75 (text.length > max_length) ? "#{text[0...max_length]}..." : text
  8. end
  9. 4 def self.format_dimension(value)
  10. 85 then: 1 else: 84 return "" if value.nil?
  11. 84 value.to_s.sub(/\.0$/, "")
  12. end
  13. 4 def self.format_date(date)
  14. 97 then: 1 else: 96 return I18n.t("pdf.inspection.fields.na") if date.nil?
  15. 96 date.strftime("%-d %B, %Y")
  16. end
  17. 4 def self.format_pass_fail(value)
  18. 7 when: 2 case value
  19. 2 when: 2 when true then I18n.t("shared.pass_pdf")
  20. 2 else: 3 when false then I18n.t("shared.fail_pdf")
  21. 3 else I18n.t("pdf.inspection.fields.na")
  22. end
  23. end
  24. 4 def self.format_measurement(value, unit = "")
  25. 7 then: 3 else: 4 return I18n.t("pdf.inspection.fields.na") if value.nil?
  26. 4 "#{value}#{unit}"
  27. end
  28. 4 def self.add_draft_watermark(pdf)
  29. # Add 3x3 grid of DRAFT watermarks to each page
  30. 7 (1..pdf.page_count).each do |page_num|
  31. 8 pdf.go_to_page(page_num)
  32. 8 pdf.transparent(WATERMARK_TRANSPARENCY) do
  33. 8 pdf.fill_color "FF0000"
  34. # 3x3 grid positions
  35. 48 y_positions = [0.10, 0.30, 0.50, 0.70, 0.9].map { |pct| pdf.bounds.height * pct }
  36. 32 x_positions = [0.15, 0.50, 0.85].map { |pct| pdf.bounds.width * pct - (WATERMARK_WIDTH / 2) }
  37. 8 y_positions.each do |y|
  38. 40 x_positions.each do |x|
  39. 120 pdf.text_box I18n.t("pdf.inspection.watermark.draft"),
  40. at: [x, y],
  41. width: WATERMARK_WIDTH,
  42. height: WATERMARK_HEIGHT,
  43. size: WATERMARK_TEXT_SIZE,
  44. style: :bold,
  45. align: :center,
  46. valign: :top
  47. end
  48. end
  49. end
  50. 8 pdf.fill_color "000000"
  51. end
  52. end
  53. end
  54. 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. # typed: false
  2. 4 class PhotoProcessingService
  3. 4 require "vips"
  4. # Process uploaded photo data: resize to max 1200px, convert to JPEG 75%
  5. 4 def self.process_upload_data(image_data, original_filename = "photo")
  6. 20 then: 0 else: 20 return nil if image_data.blank?
  7. begin
  8. 20 image = Vips::Image.new_from_buffer(image_data, "")
  9. 19 image = resize_image(image)
  10. 19 then: 0 else: 19 image = add_white_background(image) if image.has_alpha?
  11. 19 processed_data = image.jpegsave_buffer(Q: 75, strip: true)
  12. 19 processed_filename = change_extension_to_jpg(original_filename)
  13. {
  14. 19 io: StringIO.new(processed_data),
  15. filename: processed_filename,
  16. content_type: "image/jpeg"
  17. }
  18. rescue => e
  19. 1 Rails.logger.error "Photo processing failed: #{e.message}"
  20. 1 nil
  21. end
  22. end
  23. 4 def self.process_upload(uploaded_file)
  24. 14 then: 0 else: 14 return nil if uploaded_file.blank?
  25. 14 then: 14 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  26. 14 process_upload_data(uploaded_file.read, uploaded_file.original_filename)
  27. end
  28. # Validate that data is a processable image
  29. 4 def self.valid_image_data?(image_data)
  30. 21 then: 2 else: 19 return false if image_data.blank?
  31. 19 image = Vips::Image.new_from_buffer(image_data, "")
  32. # Try to get basic image properties to ensure it's valid
  33. 15 image.width && image.height
  34. 15 true
  35. rescue Vips::Error
  36. 4 false
  37. end
  38. 4 def self.valid_image?(uploaded_file)
  39. 17 then: 0 else: 17 return false if uploaded_file.blank?
  40. 17 then: 17 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  41. 17 data = uploaded_file.read
  42. 17 then: 17 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  43. 17 valid_image_data?(data)
  44. end
  45. 4 def self.change_extension_to_jpg(filename)
  46. 19 then: 0 else: 19 return "photo.jpg" if filename.blank?
  47. 19 basename = File.basename(filename, ".*")
  48. 19 "#{basename}.jpg"
  49. end
  50. 4 def self.resize_image(image)
  51. 19 max_size = ImageProcessorService::FULL_SIZE
  52. 19 else: 19 then: 0 return image unless image.width > max_size || image.height > max_size
  53. 19 scale = [max_size.to_f / image.width, max_size.to_f / image.height].min
  54. 19 image.resize(scale)
  55. end
  56. 4 def self.add_white_background(image)
  57. background = Vips::Image.black(image.width, image.height).add(255)
  58. background.composite2(image, :over)
  59. end
  60. 4 private_class_method :change_extension_to_jpg, :resize_image,
  61. :add_white_background
  62. 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. 84 require "rqrcode"
  8. # Create QR code for the report URL using the shorter format
  9. 84 then: 49 if record.is_a?(Inspection)
  10. 49 else: 35 generate_inspection_qr_code(record)
  11. 35 then: 35 else: 0 elsif record.is_a?(Unit)
  12. 35 generate_unit_qr_code(record)
  13. end
  14. end
  15. 8 sig { params(inspection: Inspection).returns(String) }
  16. 4 def self.generate_inspection_qr_code(inspection)
  17. 49 require "rqrcode"
  18. 49 base_url = T.must(Rails.configuration.app.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. 35 require "rqrcode"
  25. 35 base_url = T.must(Rails.configuration.app.base_url)
  26. 35 url = "#{base_url}/units/#{unit.id}"
  27. 35 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. 86 qrcode = RQRCode::QRCode.new(url, qr_code_options)
  33. 86 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. 87 {
  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. 87 {
  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

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

app/services/seed_data_service.rb

97.86% lines covered

82.86% branches covered

140 relevant lines. 137 lines covered and 3 lines missed.
35 total branches, 29 branches covered and 6 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. 6 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. 21 then: 1 else: 20 raise "User already has seed data" if user.has_seed_data?
  29. 20 ActiveRecord::Base.transaction do
  30. 20 Rails.logger.info I18n.t("seed_data.logging.starting_creation", user_id: user.id)
  31. 20 ensure_castle_blobs_exist
  32. 20 Rails.logger.info I18n.t("seed_data.logging.castle_images_found", count: @castle_images.size)
  33. 20 create_seed_units_for_user(user, unit_count, inspection_count)
  34. 19 Rails.logger.info I18n.t("seed_data.logging.creation_completed")
  35. end
  36. 19 true
  37. end
  38. 6 sig { params(user: User).returns(T::Boolean) }
  39. 4 def delete_seeds_for_user(user)
  40. 7 ActiveRecord::Base.transaction do
  41. # Delete inspections first (due to foreign key constraints)
  42. 7 user.inspections.seed_data.destroy_all
  43. # Then delete units with preloaded attachments to avoid N+1
  44. 5 user.units.seed_data.includes(photo_attachment: :blob, cached_pdf_attachment: :blob).destroy_all
  45. end
  46. 5 true
  47. end
  48. 4 private
  49. 6 sig { void }
  50. 4 def ensure_castle_blobs_exist
  51. 20 @castle_images = T.let([], T::Array[T::Hash[Symbol, T.untyped]])
  52. 20 (1..CASTLE_IMAGE_COUNT).each do |i|
  53. 100 filename = "castle-#{i}.jpg"
  54. 100 filepath = Rails.root.join("app/assets/castles", filename)
  55. 100 else: 100 then: 0 next unless File.exist?(filepath)
  56. # Read and cache the file content in memory
  57. 100 @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. 20 then: 0 else: 20 Rails.logger.warn I18n.t("seed_data.logging.no_castle_images") if @castle_images.empty?
  64. end
  65. 6 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. 20 {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. 20 unit_ids = generate_unit_ids_batch(user, unit_count)
  80. # Pre-load existing unit IDs to avoid repeated existence checks
  81. 20 existing_ids = user.units.pluck(:id).to_set
  82. 20 unit_count.times do |i|
  83. 55 config = unit_configs[i % unit_configs.length]
  84. 55 unit = create_unit_from_config(user, config, i, unit_ids[i], existing_ids)
  85. # Make half of units have incomplete most recent inspection
  86. 54 should_have_incomplete_inspection = i.even?
  87. 54 create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: should_have_incomplete_inspection)
  88. end
  89. end
  90. 6 sig { params(user: User, count: Integer).returns(T::Array[String]) }
  91. 4 def generate_unit_ids_batch(user, count)
  92. 20 ids = []
  93. 20 existing_ids = user.units.pluck(:id).to_set
  94. # When unit_badges is enabled, create badges for seed units
  95. 20 then: 7 if Rails.configuration.units.badges_enabled
  96. 7 batch = BadgeBatch.create!(
  97. note: "Seed data badges for #{user.email}"
  98. )
  99. 7 count.times do
  100. 13 loop do
  101. 13 id = SecureRandom.alphanumeric(CustomIdGenerator::ID_LENGTH).upcase
  102. 13 else: 0 then: 13 unless existing_ids.include?(id) || Badge.exists?(id: id)
  103. 13 Badge.create!(id: id, badge_batch: batch)
  104. 13 ids << id
  105. 13 existing_ids << id
  106. 13 break
  107. end
  108. end
  109. end
  110. else
  111. else: 13 # Original behavior when unit_badges is disabled
  112. 13 count.times do
  113. 61 loop do
  114. 61 id = SecureRandom.alphanumeric(CustomIdGenerator::ID_LENGTH).upcase
  115. 61 else: 0 then: 61 unless existing_ids.include?(id)
  116. 61 ids << id
  117. 61 existing_ids << id
  118. 61 break
  119. end
  120. end
  121. end
  122. end
  123. 20 ids
  124. end
  125. 6 sig { params(user: User, config: T::Hash[Symbol, T.untyped], index: Integer, unit_id: String, existing_ids: T::Set[String]).returns(Unit) }
  126. 4 def create_unit_from_config(user, config, index, unit_id, existing_ids)
  127. # Always use the unit_id - when unit_badges is enabled, generate_unit_ids_batch
  128. # creates badges for these IDs
  129. 55 unit = user.units.build(
  130. id: unit_id,
  131. name: "#{config[:name]} ##{index + 1}",
  132. serial: "SEED-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
  133. description: generate_description(config[:name]),
  134. manufacturer: config[:manufacturer],
  135. operator: STEFAN_OWNER_NAMES.sample,
  136. is_seed: true
  137. )
  138. 55 unit.save!
  139. # Attach random castle image if available
  140. # For test environment, skip images as castle files don't exist
  141. 54 then: 0 else: 54 if @castle_images.any? && !Rails.env.test?
  142. castle_image = @castle_images.sample
  143. # Create a new attachment - ActiveStorage will dedupe the blob automatically
  144. unit.photo.attach(
  145. io: StringIO.new(castle_image[:content]),
  146. filename: castle_image[:filename],
  147. content_type: "image/jpeg"
  148. )
  149. end
  150. 54 unit
  151. end
  152. 6 sig { params(name: String).returns(String) }
  153. 4 def generate_description(name)
  154. 55 case name
  155. when: 44 when /Castle/
  156. 44 I18n.t("seed_data.descriptions.traditional_castle")
  157. when: 2 when /Slide/
  158. 2 I18n.t("seed_data.descriptions.combination_slide")
  159. when: 3 when /Soft Play/
  160. 3 I18n.t("seed_data.descriptions.soft_play")
  161. when: 2 when /Assault Course/
  162. 2 I18n.t("seed_data.descriptions.assault_course")
  163. when: 2 when /Gladiator/
  164. 2 I18n.t("seed_data.descriptions.gladiator")
  165. when: 2 when /Bungee/
  166. 2 I18n.t("seed_data.descriptions.bungee_run")
  167. else: 0 else
  168. I18n.t("seed_data.descriptions.default")
  169. end
  170. end
  171. 6 sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], inspection_count: Integer, has_incomplete_recent: T::Boolean).void }
  172. 4 def create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: false)
  173. 54 offset_days = rand(INSPECTION_OFFSET_RANGE)
  174. 54 inspection_count.times do |i|
  175. 145 create_single_inspection(unit, user, config, offset_days, i, has_incomplete_recent)
  176. end
  177. end
  178. 6 sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], offset_days: Integer, index: Integer, has_incomplete_recent: T::Boolean).void }
  179. 4 def create_single_inspection(unit, user, config, offset_days, index, has_incomplete_recent)
  180. 145 inspection_date = calculate_inspection_date(offset_days, index)
  181. 145 passed = determine_pass_status(index)
  182. 145 is_complete = !(index == 0 && has_incomplete_recent)
  183. 145 inspection = user.inspections.create!(
  184. build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
  185. )
  186. 145 create_assessments_for_inspection(inspection, unit, config, passed: passed)
  187. end
  188. 6 sig { params(offset_days: Integer, index: Integer).returns(Date) }
  189. 4 def calculate_inspection_date(offset_days, index)
  190. 145 days_ago = offset_days + (index * INSPECTION_INTERVAL_DAYS)
  191. 145 Date.current - days_ago.days
  192. end
  193. 6 sig { params(index: Integer).returns(T::Boolean) }
  194. 4 def determine_pass_status(index)
  195. 145 then: 54 else: 91 (index == 0) ? (rand < HIGH_PASS_RATE) : (rand < NORMAL_PASS_RATE)
  196. end
  197. 6 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]) }
  198. 4 def build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
  199. {
  200. 145 unit: unit,
  201. inspector_company: user.inspection_company,
  202. inspection_date: inspection_date,
  203. 145 then: 109 complete_date: is_complete ?
  204. 109 else: 36 inspection_date.to_time + rand(INSPECTION_DURATION_RANGE).hours :
  205. 36 nil,
  206. is_seed: true,
  207. 145 then: 109 else: 36 passed: is_complete ? passed : nil,
  208. risk_assessment: generate_risk_assessment(passed),
  209. # Copy dimensions from config
  210. width: config[:width],
  211. length: config[:length],
  212. height: config[:height],
  213. has_slide: config[:has_slide] || false,
  214. is_totally_enclosed: config[:is_totally_enclosed] || false,
  215. indoor_only: [true, false].sample
  216. }
  217. end
  218. 6 sig { params(passed: T::Boolean).returns(String) }
  219. 4 def generate_risk_assessment(passed)
  220. 145 then: 129 if passed
  221. [
  222. 129 "Unit inspected and found to be in good operational condition. All safety features functioning correctly. Suitable for continued use with standard supervision requirements.",
  223. "Comprehensive safety assessment completed. Unit meets all EN 14960:2019 requirements. No significant hazards identified. Regular maintenance schedule should be maintained.",
  224. "Risk assessment indicates low risk profile. All structural elements secure, adequate ventilation present, and safety markings clearly visible. Recommend continued operation with routine checks.",
  225. "Safety evaluation satisfactory. Anchoring system robust, materials show no signs of degradation. Unit provides safe environment for users within specified age and height limits.",
  226. "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.",
  227. "Detailed inspection reveals unit maintains structural integrity. All seams intact, proper inflation pressure maintained. Risk level assessed as minimal with appropriate supervision.",
  228. "Unit passes comprehensive safety review. Emergency exits clearly marked and functional. Blower system operating within specifications. Low risk rating assigned.",
  229. "Risk evaluation complete. Unit demonstrates good stability under load conditions. Safety padding adequate where required. Suitable for continued commercial operation."
  230. ].sample
  231. else: 16 else
  232. [
  233. 16 "Risk assessment identifies multiple safety concerns requiring immediate attention. Unit should not be used until repairs completed and re-inspected. High risk rating assigned.",
  234. "Critical safety deficiencies noted during inspection. Structural integrity compromised in several areas. Unit poses unacceptable risk to users and must be withdrawn from service.",
  235. "Significant hazards identified including inadequate anchoring and material degradation. Risk level unacceptable for public use. Comprehensive repairs required before recertification.",
  236. "Safety assessment failed. Multiple non-conformances with EN 14960:2019 identified. Unit presents substantial risk of injury. Recommend immediate decommissioning or major refurbishment.",
  237. "High risk factors present including compromised seams and insufficient inflation. Unit unsafe for operation. Client advised to cease use pending extensive remedial work.",
  238. "Risk evaluation reveals dangerous conditions. Emergency exits partially obstructed, significant wear to load-bearing elements. Unit fails safety standards and requires urgent attention.",
  239. "Assessment indicates elevated risk profile due to equipment failures and material defects. Unit not suitable for use. Full replacement of critical components necessary."
  240. ].sample
  241. end
  242. end
  243. 6 sig { params(inspection: Inspection, unit: Unit, config: T::Hash[Symbol, T.untyped], passed: T::Boolean).void }
  244. 4 def create_assessments_for_inspection(inspection, unit, config, passed: true)
  245. 145 is_incomplete = inspection.complete_date.nil?
  246. 145 inspection.each_applicable_assessment do |assessment_key, assessment_class, _|
  247. 716 assessment_type = assessment_key.to_s.sub(/_assessment$/, "")
  248. 716 create_assessment(
  249. inspection,
  250. assessment_key,
  251. assessment_type,
  252. passed,
  253. is_incomplete
  254. )
  255. end
  256. end
  257. 6 sig { params(inspection: Inspection, assessment_key: Symbol, assessment_type: String, passed: T::Boolean, is_incomplete: T::Boolean).void }
  258. 4 def create_assessment(
  259. inspection,
  260. assessment_key,
  261. assessment_type,
  262. passed,
  263. is_incomplete
  264. )
  265. 716 fields = SeedData.send("#{assessment_type}_fields", passed: passed)
  266. 716 then: 145 else: 571 if assessment_key == :user_height_assessment && inspection.length && inspection.width
  267. 145 fields[:play_area_length] = inspection.length * 0.8
  268. 145 fields[:play_area_width] = inspection.width * 0.8
  269. end
  270. 716 fields = randomly_remove_fields(fields, is_incomplete)
  271. 716 inspection.send(assessment_key).update!(fields)
  272. end
  273. 6 sig { params(fields: T::Hash[Symbol, T.untyped], is_incomplete: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
  274. 4 def randomly_remove_fields(fields, is_incomplete)
  275. 716 else: 177 then: 539 return fields unless is_incomplete
  276. 177 else: 98 then: 79 return fields unless rand(0..1) == 0 # empty 50% of assessments
  277. 1488 fields.keys.each { |field| fields[field] = nil }
  278. 98 fields
  279. end
  280. end
  281. 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. # typed: false
  2. # frozen_string_literal: true
  3. 4 class SentryTestService
  4. 4 def perform
  5. results = []
  6. # Test 1: Send a test message
  7. begin
  8. Sentry.capture_message("Test message from Rails app")
  9. results << {test: "message", status: "success", message: "Test message sent to Sentry"}
  10. rescue => e
  11. results << {test: "message", status: "failed", message: "Failed to send test message: #{e.message}"}
  12. end
  13. # Test 2: Send a test exception
  14. begin
  15. 1 / 0
  16. rescue ZeroDivisionError => e
  17. Sentry.capture_exception(e)
  18. results << {test: "exception", status: "success", message: "Test exception sent to Sentry"}
  19. end
  20. # Test 3: Send exception with extra context
  21. begin
  22. Sentry.with_scope do |scope|
  23. scope.set_context("test_info", {
  24. source: "SentryTestService",
  25. timestamp: Time.current.iso8601,
  26. rails_env: Rails.env
  27. })
  28. scope.set_tags(test_type: "integration_test")
  29. raise "This is a test error with context"
  30. end
  31. rescue => e
  32. Sentry.capture_exception(e)
  33. results << {test: "exception_with_context", status: "success", message: "Test exception with context sent to Sentry"}
  34. end
  35. # Return results and configuration info
  36. {
  37. results: results,
  38. configuration: {
  39. dsn_configured: Sentry.configuration.dsn.present?,
  40. environment: Sentry.configuration.environment,
  41. enabled_environments: Sentry.configuration.enabled_environments
  42. }
  43. }
  44. end
  45. 4 def test_error_type(error_type)
  46. case error_type
  47. when :database_not_found
  48. when: 0 # Simulate database not found error
  49. Sentry.capture_message("Test: Database file not found", level: "error", extra: {
  50. database_path: "/nonexistent/database.sqlite3",
  51. test_type: "simulated_error"
  52. })
  53. when :missing_config
  54. when: 0 # Simulate missing configuration error
  55. Sentry.capture_message("Test: Missing S3 configuration", level: "error", extra: {
  56. missing_vars: ["S3_ENDPOINT", "S3_BUCKET"],
  57. test_type: "simulated_error"
  58. })
  59. when :generic_exception
  60. when: 0 # Raise and capture a generic exception
  61. begin
  62. raise StandardError, "This is a test exception from SentryTestService"
  63. rescue => e
  64. Sentry.capture_exception(e, extra: {
  65. test_type: "generic_exception",
  66. source: "SentryTestService#test_error_type"
  67. })
  68. end
  69. else: 0 else
  70. raise ArgumentError, "Unknown error type: #{error_type}"
  71. end
  72. end
  73. 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.96% lines covered

88.89% branches covered

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

lib/database_i18n_backend.rb

100.0% lines covered

100.0% branches covered

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

lib/i18n_usage_tracker.rb

92.94% lines covered

81.25% branches covered

85 relevant lines. 79 lines covered and 6 lines missed.
16 total branches, 13 branches covered and 3 branches missed.
    
  1. # typed: false
  2. 4 module I18nUsageTracker
  3. SKIPPED_PREFIXES = %w[
  4. 4 activemodel.
  5. activerecord.
  6. date.
  7. errors.
  8. helpers.
  9. number.
  10. support.
  11. time.
  12. ].freeze
  13. 4 class << self
  14. 4 attr_accessor :tracking_enabled
  15. 4 attr_reader :used_keys
  16. 4 def reset!
  17. 49 @used_keys = Set.new
  18. 49 @tracking_enabled = false
  19. end
  20. 4 def load_tracked_keys(keys_array)
  21. keys_array.each { |key| @used_keys << key }
  22. end
  23. 4 def track_key(key, options = {})
  24. 26 else: 24 then: 2 return unless tracking_enabled && key
  25. 24 key_string = key.to_s
  26. 24 then: 8 else: 16 return if skip_key?(key_string)
  27. 16 track_key_and_parents(key_string)
  28. 16 then: 4 else: 12 track_scoped_key(key_string, options[:scope]) if options[:scope]
  29. end
  30. 4 def all_locale_keys
  31. 9 @all_locale_keys ||= begin
  32. 1 keys = Set.new
  33. 1 locale_files = Rails.root.glob("config/locales/**/*.yml")
  34. 1 locale_files.each do |file|
  35. 48 yaml_content = YAML.load_file(file)
  36. 48 yaml_content.each do |_locale, content|
  37. 48 extract_keys_from_hash(content, [], keys)
  38. end
  39. end
  40. 1 keys
  41. end
  42. end
  43. 4 def unused_keys
  44. 5 all_locale_keys - used_keys
  45. end
  46. 4 def usage_report
  47. 2 total = all_locale_keys.size
  48. 2 used = used_keys.size
  49. 2 unused = unused_keys.size
  50. {
  51. 2 total_keys: total,
  52. used_keys: used,
  53. unused_keys: unused,
  54. 2 usage_percentage: (used.to_f / total * 100).round(2),
  55. unused_key_list: unused_keys.sort
  56. }
  57. end
  58. 4 private
  59. 4 def skip_key?(key_string)
  60. 188 SKIPPED_PREFIXES.any? { |prefix| key_string.start_with?(prefix) }
  61. end
  62. 4 def track_key_and_parents(key_string)
  63. 20 @used_keys << key_string
  64. 20 parts = key_string.split(".")
  65. 20 (1...parts.length).each do |i|
  66. 20 parent = parts[0...i].join(".")
  67. 20 else: 0 then: 20 @used_keys << parent unless parent.empty?
  68. end
  69. end
  70. 4 def track_scoped_key(key_string, scope)
  71. 4 scope_string = Array(scope).join(".")
  72. 4 full_key = "#{scope_string}.#{key_string}"
  73. 4 track_key_and_parents(full_key)
  74. end
  75. 4 def extract_keys_from_hash(hash, current_path, keys)
  76. 440 hash.each do |key, value|
  77. 1907 new_path = current_path + [key.to_s]
  78. 1907 full_key = new_path.join(".")
  79. 1907 keys << full_key
  80. 1907 then: 390 else: 1517 extract_keys_from_hash(value, new_path, keys) if value.is_a?(Hash)
  81. end
  82. end
  83. end
  84. 4 @used_keys = Set.new
  85. 4 @tracking_enabled = false
  86. 4 at_exit do
  87. 4 then: 0 else: 4 if ENV["I18N_TRACKING_ENABLED"] == "true" && used_keys.any?
  88. results_path = Rails.root.join("tmp/i18n_tracking_results.json")
  89. results_path.write(used_keys.to_a.to_json)
  90. end
  91. end
  92. end
  93. 4 module I18n
  94. 4 class << self
  95. 4 alias_method :original_t, :t
  96. 4 alias_method :original_translate, :translate
  97. 4 def t(key, **options)
  98. 54489 track_usage(key, options)
  99. 54489 original_t(key, **options)
  100. end
  101. 4 def translate(key, **options)
  102. 59077 track_usage(key, options)
  103. 59077 original_translate(key, **options)
  104. end
  105. 4 private
  106. 4 def track_usage(key, options)
  107. 113566 else: 3 then: 113563 return unless I18nUsageTracker.tracking_enabled
  108. 3 I18nUsageTracker.track_key(key, options)
  109. end
  110. end
  111. end
  112. # Monkey-patch ActionView::Helpers::TranslationHelper to track i18n usage
  113. 4 module ActionView::Helpers::TranslationHelper
  114. 4 alias_method :original_t, :t
  115. 4 alias_method :original_translate, :translate
  116. 4 def t(key, **options)
  117. 51114 track_usage(key, options)
  118. 51114 original_t(key, **options)
  119. end
  120. 4 def translate(key, **options)
  121. track_usage(key, options)
  122. original_translate(key, **options)
  123. end
  124. 4 private
  125. 4 def track_usage(key, options)
  126. 51114 else: 0 then: 51114 return unless I18nUsageTracker.tracking_enabled
  127. I18nUsageTracker.track_key(key, options)
  128. end
  129. end

lib/rubocop/cop/custom/no_class_defined_checks.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. module RuboCop
  4. module Cop
  5. module Custom
  6. # Prevents checking if classes or modules are defined using `defined?()`
  7. # This is fragile and can lead to subtle bugs. Instead, use environment
  8. # variables or configuration flags to determine feature availability.
  9. #
  10. # @example
  11. # # bad
  12. # if defined?(SomeClass)
  13. # SomeClass.do_something
  14. # end
  15. #
  16. # # bad
  17. # DatabaseI18nBackend.reload_cache if defined?(DatabaseI18nBackend)
  18. #
  19. # # good
  20. # if Rails.env.production?
  21. # SomeClass.do_something
  22. # end
  23. #
  24. # # good
  25. # if Rails.configuration.feature_enabled
  26. # FeatureClass.do_something
  27. # end
  28. #
  29. class NoClassDefinedChecks < Base
  30. MSG = "Avoid checking if classes/modules are defined. " \
  31. "Use environment checks (Rails.env.production?) or " \
  32. "configuration flags instead."
  33. # Pattern to detect constant references (classes/modules)
  34. # Constants start with uppercase letter
  35. CONSTANT_PATTERN = /\A[A-Z]/
  36. def on_defined?(node)
  37. return unless node.arguments.any?
  38. argument = node.arguments.first
  39. return unless checking_constant?(argument)
  40. # Allow certain safe patterns
  41. return if allowed_pattern?(argument)
  42. add_offense(node)
  43. end
  44. private
  45. def checking_constant?(node)
  46. case node.type
  47. when :const
  48. # Single constant like `SomeClass`
  49. true
  50. when :send
  51. # Method call on a constant like `ActiveStorage::Service::S3Service`
  52. false
  53. else
  54. # Check if it's a constant reference by source
  55. source = node.source
  56. source.match?(CONSTANT_PATTERN)
  57. end
  58. end
  59. def allowed_pattern?(node)
  60. source = node.source
  61. # Allow checking instance/local variables (not classes)
  62. return true if source.start_with?("@", "@@")
  63. return true unless source.match?(CONSTANT_PATTERN)
  64. # Allow specific test-related constants in spec files
  65. return true if in_spec_file? && test_related_constant?(source)
  66. # Allow Sorbet type checking constants in RBI files
  67. return true if processed_source.path.end_with?(".rbi")
  68. false
  69. end
  70. def in_spec_file?
  71. processed_source.path.include?("/spec/")
  72. end
  73. def test_related_constant?(source)
  74. # Allow checking test frameworks and libraries in tests
  75. test_constants = %w[
  76. RSpec
  77. FactoryBot
  78. Capybara
  79. T
  80. page
  81. ]
  82. test_constants.any? { |const| source.start_with?(const) }
  83. end
  84. end
  85. end
  86. end
  87. 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. # typed: false
  2. # frozen_string_literal: true
  3. 1 module RuboCop
  4. 1 module Cop
  5. 1 module Custom
  6. # Enforces line breaks after ? and : in ternary operators when
  7. # line exceeds 80 characters
  8. #
  9. # @example
  10. # # bad (when line > 80 chars)
  11. # result = condition == 2 ? long_true_value : long_false_value
  12. #
  13. # # good
  14. # result = condition == 2 ?
  15. # long_true_value :
  16. # long_false_value
  17. #
  18. 1 class TernaryLineBreaks < Base
  19. 1 extend AutoCorrector
  20. 1 MSG = "Break ternary operator across multiple lines " \
  21. "when line exceeds 80 characters"
  22. 1 MAX_LINE_LENGTH = 80
  23. 1 def on_if(node)
  24. 16 else: 15 then: 1 return unless node.ternary?
  25. 15 else: 6 then: 9 return unless line_too_long?(node)
  26. 6 add_offense(node) do |corrector|
  27. 6 autocorrect(corrector, node)
  28. end
  29. end
  30. 1 private
  31. 1 def line_too_long?(node)
  32. 15 line = processed_source.lines[node.first_line - 1]
  33. 15 line.length > MAX_LINE_LENGTH
  34. end
  35. 1 def autocorrect(corrector, node)
  36. 6 condition = node.condition
  37. 6 if_branch = node.if_branch
  38. 6 else_branch = node.else_branch
  39. # Get the indentation of the line containing the ternary
  40. 6 indent = processed_source.lines[node.first_line - 1][/\A\s*/]
  41. 6 nested_indent = "#{indent} "
  42. # Build the corrected version
  43. 6 corrected = "#{condition.source} ?\n"
  44. 6 corrected << "#{nested_indent}#{if_branch.source} :\n"
  45. 6 corrected << "#{nested_indent}#{else_branch.source}"
  46. 6 corrector.replace(node, corrected)
  47. end
  48. end
  49. end
  50. end
  51. 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. # typed: false
  2. # frozen_string_literal: true
  3. 4 require_relative "../app/services/concerns/s3_backup_operations"
  4. # Helper methods for S3 operations in rake tasks
  5. # These wrap the S3Helpers module methods to work in rake context
  6. 4 module S3RakeHelpers
  7. 4 include S3BackupOperations
  8. 4 def ensure_s3_enabled
  9. then: 0 else: 0 return if Rails.configuration.s3.enabled
  10. error_msg = "S3 storage is not enabled. Set USE_S3_STORAGE=true in your .env file"
  11. Rails.logger.debug { "❌ #{error_msg}" }
  12. raise StandardError, error_msg
  13. end
  14. 4 def validate_s3_config
  15. required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
  16. missing_vars = required_vars.select { |var| ENV[var].blank? }
  17. then: 0 else: 0 if missing_vars.any?
  18. error_msg = "Missing required S3 environment variables: #{missing_vars.join(", ")}"
  19. Rails.logger.debug { "❌ #{error_msg}" }
  20. Sentry.capture_message(error_msg, level: "error", extra: {
  21. missing_vars: missing_vars,
  22. task: caller_locations(1, 1)[0].label,
  23. environment: Rails.env
  24. })
  25. raise StandardError, error_msg
  26. end
  27. end
  28. 4 def get_s3_service
  29. service = ActiveStorage::Blob.service
  30. else: 0 then: 0 unless service.is_a?(ActiveStorage::Service::S3Service)
  31. error_msg = "Active Storage is not configured to use S3. Current service: #{service.class.name}"
  32. Rails.logger.debug { "❌ #{error_msg}" }
  33. raise StandardError, error_msg
  34. end
  35. service
  36. end
  37. 4 def handle_s3_errors
  38. yield
  39. rescue Aws::S3::Errors::ServiceError => e
  40. Rails.logger.debug { "\n❌ S3 Error: #{e.message}" }
  41. Sentry.capture_exception(e)
  42. raise
  43. rescue => e
  44. Rails.logger.debug { "\n❌ Unexpected error: #{e.message}" }
  45. Sentry.capture_exception(e)
  46. raise
  47. end
  48. end

lib/seed_data.rb

100.0% lines covered

75.0% branches covered

77 relevant lines. 77 lines covered and 0 lines missed.
40 total branches, 30 branches covered and 10 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 module SeedData
  4. 4 PASS = "Pass"
  5. 4 FAIL = "Fail"
  6. 4 GOOD = "Good"
  7. 4 WEAR = "Wear"
  8. 4 OK = "OK"
  9. 4 def self.pass_fail_fields(passed, *fields)
  10. 1173 then: 774 else: 140 fields.to_h { |field| [field, passed ? PASS : FAIL] }
  11. end
  12. 4 def self.check_passed?(inspection_passed)
  13. 4150 then: 3636 else: 514 return true if inspection_passed
  14. 514 rand < 0.9
  15. end
  16. 4 def self.check_passed_integer?(inspection_passed)
  17. 1241 then: 1090 else: 151 return :pass if inspection_passed
  18. 151 then: 134 else: 17 (rand < 0.9) ? :pass : :fail
  19. end
  20. 4 def self.user_fields
  21. {
  22. 17 email: "test#{SecureRandom.hex(8)}@example.com",
  23. password: "password123",
  24. password_confirmation: "password123",
  25. name: "Test User #{SecureRandom.hex(4)}",
  26. rpii_inspector_number: nil # Optional field
  27. }
  28. end
  29. 4 def self.unit_fields
  30. {
  31. 12 name: "Castle #{SecureRandom.hex(4)}",
  32. serial: "BC-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
  33. manufacturer: "Test Mfg",
  34. operator: "Test Op",
  35. manufacture_date: Date.current - rand(365..1825).days,
  36. description: "Test unit"
  37. }
  38. end
  39. 4 def self.inspection_fields(passed: true)
  40. {
  41. 15 inspection_date: Date.current,
  42. is_totally_enclosed: [true, false].sample,
  43. has_slide: [true, false].sample,
  44. indoor_only: [true, false].sample,
  45. width: rand(4.0..8.0).round(1),
  46. length: rand(5.0..10.0).round(1),
  47. height: rand(3.0..6.0).round(1)
  48. }
  49. end
  50. 4 def self.results_fields(passed: true)
  51. {
  52. 4 passed: passed,
  53. risk_assessment: PASS
  54. }
  55. end
  56. 4 def self.anchorage_fields(passed: true)
  57. fields = {
  58. 90 num_low_anchors: rand(6..12),
  59. num_high_anchors: rand(4..8),
  60. num_low_anchors_pass: check_passed?(passed),
  61. num_high_anchors_pass: check_passed?(passed),
  62. anchor_accessories_pass: check_passed?(passed),
  63. anchor_degree_pass: check_passed?(passed),
  64. anchor_type_pass: check_passed?(passed),
  65. pull_strength_pass: check_passed?(passed)
  66. }
  67. 90 else: 81 then: 9 fields[:anchor_type_comment] = WEAR unless passed
  68. 90 fields
  69. end
  70. 4 def self.structure_fields(passed: true)
  71. 161 structure_pass_fields(passed)
  72. .merge(structure_numeric_fields)
  73. .merge(structure_comments(passed))
  74. end
  75. 4 def self.materials_fields(passed: true)
  76. {
  77. 169 ropes: rand(18..45),
  78. ropes_pass: check_passed_integer?(passed),
  79. retention_netting_pass: check_passed_integer?(passed),
  80. zips_pass: check_passed_integer?(passed),
  81. windows_pass: check_passed_integer?(passed),
  82. artwork_pass: check_passed_integer?(passed),
  83. thread_pass: check_passed?(passed),
  84. fabric_strength_pass: check_passed?(passed),
  85. fire_retardant_pass: check_passed?(passed)
  86. }.merge(materials_comments(passed))
  87. end
  88. 4 def self.fan_fields(passed: true)
  89. {
  90. 161 blower_flap_pass: check_passed_integer?(passed),
  91. blower_finger_pass: check_passed?(passed),
  92. blower_visual_pass: check_passed?(passed),
  93. pat_pass: check_passed_integer?(passed),
  94. blower_serial: "FAN-#{SecureRandom.hex(6).upcase}",
  95. number_of_blowers: 1,
  96. blower_tube_length: rand(2.0..5.0).round(1),
  97. blower_tube_length_pass: check_passed?(passed)
  98. }.merge(fan_comments(passed))
  99. end
  100. 4 def self.user_height_fields(passed: true)
  101. {
  102. 160 containing_wall_height: rand(1.0..2.0).round(1),
  103. users_at_1000mm: rand(0..5),
  104. users_at_1200mm: rand(2..8),
  105. users_at_1500mm: rand(4..10),
  106. users_at_1800mm: rand(2..6),
  107. custom_user_height_comment: OK,
  108. play_area_length: rand(3.0..10.0).round(1),
  109. play_area_width: rand(3.0..8.0).round(1),
  110. negative_adjustment: rand(0..2.0).round(1),
  111. containing_wall_height_comment: OK,
  112. play_area_length_comment: OK,
  113. play_area_width_comment: OK
  114. }
  115. end
  116. 4 def self.slide_fields(passed: true)
  117. 74 platform_height = rand(2.0..6.0).round(1)
  118. 74 required_runout = EN14960.calculate_slide_runout(platform_height).value
  119. 74 runout = calculate_slide_runout(required_runout, passed)
  120. {
  121. 74 slide_platform_height: platform_height,
  122. slide_wall_height: rand(1.0..2.0).round(1),
  123. runout: runout,
  124. slide_first_metre_height: rand(0.3..0.8).round(1),
  125. slide_beyond_first_metre_height: rand(0.8..1.5).round(1),
  126. clamber_netting_pass: check_passed_integer?(passed),
  127. runout_pass: check_passed?(passed),
  128. slip_sheet_pass: check_passed?(passed),
  129. slide_permanent_roof: false
  130. }.merge(slide_comments(passed))
  131. end
  132. 4 def self.enclosed_fields(passed: true)
  133. {
  134. 24 exit_number: rand(1..3),
  135. exit_number_pass: check_passed?(passed),
  136. exit_sign_always_visible_pass: check_passed?(passed)
  137. }.merge(
  138. pass_fail_fields(
  139. passed,
  140. :exit_number_comment,
  141. :exit_sign_always_visible_comment
  142. )
  143. )
  144. end
  145. 4 def self.structure_pass_fields(passed)
  146. {
  147. 161 seam_integrity_pass: check_passed?(passed),
  148. air_loss_pass: check_passed?(passed),
  149. straight_walls_pass: check_passed?(passed),
  150. sharp_edges_pass: check_passed?(passed),
  151. unit_stable_pass: check_passed?(passed),
  152. stitch_length_pass: check_passed?(passed),
  153. step_ramp_size_pass: check_passed?(passed),
  154. platform_height_pass: check_passed?(passed),
  155. critical_fall_off_height_pass: check_passed?(passed),
  156. unit_pressure_pass: check_passed?(passed),
  157. trough_pass: check_passed?(passed),
  158. entrapment_pass: check_passed?(passed),
  159. markings_pass: check_passed?(passed),
  160. grounding_pass: check_passed?(passed),
  161. evacuation_time_pass: check_passed?(passed)
  162. }
  163. end
  164. 4 def self.structure_numeric_fields
  165. {
  166. 161 unit_pressure: rand(1.0..3.0).round(1),
  167. step_ramp_size: rand(200..400),
  168. platform_height: rand(500..1500),
  169. critical_fall_off_height: rand(500..2000),
  170. trough_depth: rand(30..80),
  171. trough_adjacent_panel_width: rand(300..1000)
  172. }
  173. end
  174. 4 def self.structure_comments(passed)
  175. {
  176. 322 then: 141 else: 20 seam_integrity_comment: passed ? GOOD : WEAR,
  177. stitch_length_comment: OK,
  178. platform_height_comment: OK
  179. }
  180. end
  181. 4 def self.materials_comments(passed)
  182. 169 then: 150 else: 19 passed ? {fabric_strength_comment: GOOD} : {
  183. ropes_comment: WEAR,
  184. fabric_strength_comment: WEAR
  185. }
  186. end
  187. 4 def self.fan_comments(passed)
  188. 161 expiry = (Date.current + 6.months).strftime("%B %Y")
  189. 161 pass_fail_fields(
  190. passed,
  191. :fan_size_type,
  192. :blower_flap_comment,
  193. :blower_finger_comment,
  194. :blower_visual_comment
  195. 161 then: 142 else: 19 ).merge(pat_comment: passed ? "Valid #{expiry}" : "Overdue")
  196. end
  197. 4 def self.calculate_slide_runout(required_runout, passed)
  198. 74 then: 56 if passed
  199. 56 (required_runout + rand(0.5..1.5)).round(1)
  200. else: 18 else
  201. 18 (required_runout - rand(0.1..0.3))
  202. end
  203. end
  204. 4 def self.slide_comments(passed)
  205. 74 pass_fail_fields(
  206. passed,
  207. :slide_platform_height_comment,
  208. :runout_comment,
  209. :clamber_netting_comment
  210. ).merge(
  211. slide_wall_height_comment: OK,
  212. 74 then: 56 else: 18 slip_sheet_comment: passed ? GOOD : WEAR
  213. )
  214. end
  215. 4 def self.pat_fields(passed: true)
  216. 1 pat_numeric_fields
  217. .merge(pat_pass_fields(passed))
  218. .merge(pat_comments(passed))
  219. end
  220. 4 def self.pat_numeric_fields
  221. {
  222. 1 equipment_class: [1, 2].sample,
  223. equipment_power: rand(100..3000),
  224. fuse_rating: [3, 5, 13].sample,
  225. earth_ohms: rand(0.01..0.5).round(2),
  226. insulation_mohms: rand(100..500),
  227. leakage_ma: rand(0.1..2.0).round(2),
  228. rcd_trip_time_ms: rand(15.0..35.0).round(1)
  229. }
  230. end
  231. 4 def self.pat_pass_fields(passed)
  232. {
  233. 1 equipment_class_pass: check_passed?(passed),
  234. visual_pass: check_passed?(passed),
  235. appliance_plug_check_pass: check_passed?(passed),
  236. fuse_rating_pass: check_passed?(passed),
  237. earth_ohms_pass: check_passed?(passed),
  238. insulation_mohms_pass: check_passed?(passed),
  239. leakage_ma_pass: check_passed?(passed),
  240. load_test_pass: check_passed?(passed),
  241. rcd_trip_time_ms_pass: check_passed?(passed)
  242. }
  243. end
  244. 4 def self.pat_comments(passed)
  245. {
  246. 2 then: 1 else: 0 equipment_class_comment: passed ? OK : FAIL,
  247. 1 then: 1 else: 0 equipment_power_comment: passed ? OK : FAIL,
  248. 1 then: 1 else: 0 visual_comment: passed ? GOOD : WEAR,
  249. 1 then: 1 else: 0 appliance_plug_check_comment: passed ? GOOD : WEAR,
  250. 1 then: 1 else: 0 fuse_rating_comment: passed ? OK : FAIL,
  251. 1 then: 1 else: 0 earth_ohms_comment: passed ? OK : FAIL,
  252. 1 then: 1 else: 0 insulation_mohms_comment: passed ? OK : FAIL,
  253. 1 then: 1 else: 0 leakage_ma_comment: passed ? OK : FAIL,
  254. 1 then: 1 else: 0 load_test_comment: passed ? GOOD : FAIL,
  255. 1 then: 1 else: 0 rcd_trip_time_ms_comment: passed ? OK : FAIL
  256. }
  257. end
  258. 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. 6 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. 6 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. 6 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