Overview
The photo management system allows technicians to attach multiple photos to inspection forms, organize them by section, and automatically generate professional photo annex pages in the final PDF report.
Photo Field Types
Inspection forms support two types of photo fields:
Standard Photo Fields
Used for documenting equipment, installations, and general inspection areas:
{
"name" : "fire_alarm_panel" ,
"type" : "Photo" ,
"section_name" : "Fire Alarm System | Control Panel" ,
"label_name" : "Main Control Panel Photo"
}
Pass Photo Fields
Used for documenting items that passed inspection:
{
"name" : "sprinkler_head_photo" ,
"type" : "pass_photo" ,
"section_name" : "Sprinkler System | Device Photos" ,
"label_name" : "Sprinkler Head - Passed"
}
Both types support multiple photo uploads and are rendered identically in the PDF output.
Uploading Photos
Photos can be uploaded during inspection form completion:
Navigate to Form Fill
Open the inspection and click on the form fill you want to add photos to
Find Photo Field
Scroll to a Photo or pass_photo field in the form
Select Photos
Click the upload button or drag and drop image files Supported formats: JPEG, PNG. Maximum file size: 10MB per photo.
Upload Multiple Photos
You can attach multiple photos to a single photo field. Each upload adds to the existing collection.
Photo Storage
Photos are stored using Rails Active Storage with unique identifiers:
def generate_unique_photo_attachment_id ( field_section )
safe_section_name = field_section. gsub ( "|" , "__" )
parameterized_name = safe_section_name. parameterize . underscore
random_suffix = SecureRandom . hex ( 4 )
"inspection_ #{ inspection. id } _ #{ parameterized_name } _ #{ random_suffix } "
end
Filename Convention
Photos are stored with descriptive filenames:
inspection_123_fire_alarm_system_control_panel_a3f2.jpg
inspection_123_sprinkler_system_riser_1_b8c1.jpg
inspection_456_emergency_lighting_unit_5_c9d4.jpg
This ensures:
Photos are grouped by inspection
Section context is preserved in filename
No naming conflicts between inspections
Photo Attachment Workflow
The attach_photo_for_field method handles photo uploads:
def attach_photo_for_field ( field_name , photo_file )
structure = JSON . parse (form_structure)
field_data = structure. find { | field | field[ "name" ] == field_name }
# Use section_name if available, otherwise field_name
field_section = field_data &. dig ( "section_name" ). presence || field_name
# Generate unique attachment ID
unique_attachment_id = generate_unique_photo_attachment_id (field_section)
# Attach photo to form fill
photos. attach (
io: photo_file,
filename: " #{ unique_attachment_id } .jpg" ,
content_type: photo_file. content_type || "image/jpeg"
)
# Update form structure to track attachment
add_photo_attachment_id_to_structure (field_name, unique_attachment_id)
end
Photos are attached to the FormFill model, not individual fields. The field name is stored in the data column to maintain the association.
Multiple Photos Per Field
Each photo field supports multiple uploads:
def add_photo_attachment_id_to_structure ( field_name , attachment_id )
photo_data_key = " #{ field_name } _photo_attachment_id"
current_value = get_field_value (photo_data_key)
# Normalize to array
ids = if current_value. is_a? ( Array )
current_value
elsif current_value. present?
[current_value]
else
[]
end
# Add new ID if not already present
unless ids. include? (attachment_id)
ids << attachment_id
set_field_value (photo_data_key, ids)
end
end
This allows technicians to:
Document equipment from multiple angles
Upload before/after photos
Capture deficiencies and corrections
Retrieving Photos
Photos are retrieved by field name:
Get All Photos for a Field
def get_photos_for_field ( field_name )
photo_data_key = " #{ field_name } _photo_attachment_id"
attachment_ids = get_field_value (photo_data_key)
# Normalize to array
target_ids = if attachment_ids. is_a? ( Array )
attachment_ids. map ( & :to_s )
elsif attachment_ids. present?
[attachment_ids. to_s ]
else
[]
end
# Find photos matching any of the IDs
photos. select do | photo |
target_ids. any? { | id | photo. filename . to_s . start_with? (id) }
end
end
Get Photos Organized by Field
def get_photos_by_field
photos_hash = {}
data. each do | key , value |
next unless key. end_with? ( "_photo_attachment_id" ) && value. present?
field_name = key. gsub ( "_photo_attachment_id" , "" )
attachment_ids = value. is_a? ( Array ) ? value : [value]
field_photos = attachment_ids. filter_map do | attachment_id |
photo = photos. find { | p | p . filename . to_s . start_with? (attachment_id) }
next unless photo
{ photo: photo, attachment_id: attachment_id }
end
photos_hash[field_name] = field_photos if field_photos. any?
end
photos_hash
end
Removing Photos
Technicians can remove individual photos or all photos from a field:
Remove Specific Photo
def remove_specific_photo ( field_name , photo_id )
# Find and purge the photo
photo = photos. find { | p | p . filename . to_s . start_with? (photo_id) }
photo. purge if photo
# Remove from data array
photo_data_key = " #{ field_name } _photo_attachment_id"
current_value = get_field_value (photo_data_key)
ids = current_value. is_a? ( Array ) ? current_value : [current_value]
ids. delete (photo_id)
set_field_value (photo_data_key, ids)
end
Remove All Photos from Field
def remove_all_photos_for_field ( field_name )
structure = JSON . parse (form_structure)
field_data = structure &. find { | field | field[ "name" ] == field_name }
field_section = field_data &. dig ( "section_name" ). presence || field_name
safe_section_name = field_section. gsub ( "|" , "__" )
parameterized_name = safe_section_name. parameterize . underscore
# Find all photos matching the field pattern
field_pattern = "inspection_ #{ inspection. id } _ #{ parameterized_name } _"
photos_to_remove = photos. select do | photo |
photo. filename . to_s . include? (field_pattern)
end
# Purge all matching photos
photos_to_remove. each ( & :purge )
end
Photo Annex Generation
When generating the final PDF, photos are automatically organized and appended as annex pages.
Photo Organization
Photos are grouped by the main section (before the | separator):
grouped_photos = photos_with_context. group_by do | h |
(h[ :section_name ]. presence || "Uncategorized Photos" ). split ( "|" ). first . strip
end
For example:
“Fire Alarm System | Control Panel” → Fire Alarm System
“Sprinkler System | Riser 1” → Sprinkler System
“Sprinkler System | Riser 2” → Sprinkler System
Grid Layout
Photos are rendered in a 4-column grid with captions:
num_columns = 4
padding = 10
label_height = 15
cell_width = (pdf. bounds . width - (padding * (num_columns - 1 ))) / num_columns
cell_height = cell_width + label_height
photos_in_section. each_slice (num_columns) do | row_of_photos |
row_of_photos. each_with_index do | photo_data , col_index |
# Render photo
image_blob_data = photo_data[ :photo ]. download
pdf. image ( StringIO . new (image_blob_data), fit: [cell_width, cell_height])
# Add caption from section name (part after '|')
section_parts = photo_data[ :section_name ]. split ( "|" )
caption = section_parts. length > 1 ? section_parts[ 1 ]. strip : photo_data[ :label_name ]
pdf. text caption, size: 7 , align: :center
end
end
Example Annex Page
┌─────────────────────────────────────────────────────┐
│ FIRE INSPECTION PHOTO DOCUMENTATION │
├─────────────────────────────────────────────────────┤
│ │
│ Section: Fire Alarm System │
│ ───────────────────────────── │
│ │
│ [Photo] [Photo] [Photo] [Photo] │
│ Control Annun- Battery Phone │
│ Panel ciator Backup Jack │
│ │
│ [Photo] [Photo] │
│ Pull Smoke │
│ Station Detector │
│ │
│ Section: Sprinkler System │
│ ───────────────────────── │
│ │
│ [Photo] [Photo] [Photo] [Photo] │
│ Riser 1 Riser 2 Inspector Flow Test │
│ Test Results │
│ │
└─────────────────────────────────────────────────────┘
Photo Context Metadata
Each photo includes context metadata for PDF generation:
def get_photos_with_context
structure_map = JSON . parse (form_structure). index_by { | field | field[ "name" ] }
photos_by_field = get_photos_by_field
photos_by_field. flat_map do | field_name , photo_list |
field_info = structure_map[field_name]
Array (photo_list). map do | photo_data |
{
photo: photo_data[ :photo ],
field_type: field_info[ "type" ],
section_name: field_info[ "section_name" ],
label_name: field_info[ "label_name" ]
}
end
end
end
This metadata is used to:
Group photos by section
Generate captions
Exclude signatures from photo pages
Order photos logically
Signature Filtering
Signatures are automatically excluded from photo annex pages:
photos_with_context = Array (photos_with_context). reject do | h |
fname = h[ :photo ] &. filename . to_s . downcase
fname. include? ( "_signature_" ) || fname. start_with? ( "signature_" ) || fname. include? ( "firma" )
end
This prevents signature images from appearing in the photo documentation section.
Client signatures are rendered on dedicated signature annex pages with inspection report headers. See Digital Signatures for details.
Photo Cleanup
The system includes utilities to clean up duplicate or orphaned photos:
Remove Duplicates
def cleanup_duplicate_photos!
structure = JSON . parse (form_structure)
photo_fields = structure. select { | field | field[ "type" ] == "Photo" }
photo_fields. each do | field |
field_name = field[ "name" ]
field_photos = get_photos_for_field (field_name)
next unless field_photos. count > 1
# Keep only the most recent photo
photos_to_keep = field_photos. sort_by ( & :created_at ). last ( 1 )
photos_to_remove = field_photos - photos_to_keep
photos_to_remove. each ( & :purge )
end
end
Sync Photos with Structure
def sync_photos_with_structure!
structure = JSON . parse (form_structure)
photo_fields = structure. select { | field | field[ "type" ] == "Photo" }
photo_fields. each do | field |
field_name = field[ "name" ]
# If photo_attachment_id exists, verify the photo still exists
if field[ "photo_attachment_id" ]. present?
existing_photo = get_photo_for_field (field_name)
field[ "photo_attachment_id" ] = nil if existing_photo. blank?
end
end
update ( form_structure: structure. to_json )
end
Best Practices
Descriptive Section Names Use clear section names with pipe separators for better organization: “System | Specific Component”
Consistent Angles Take photos from similar angles and distances for professional appearance in the final report
Lighting Quality Ensure adequate lighting when photographing equipment. Use flash if needed.
Upload During Inspection Upload photos on-site rather than afterward to ensure you don’t miss critical documentation
Photos larger than 5MB should be compressed before upload. The system does not automatically resize images, so large files will increase PDF generation time. Recommendation : Use a resolution of 1920x1080 or lower for equipment photos.
Many Photos Per Inspection
Inspections with 50+ photos may take longer to generate PDFs (30-60 seconds). This is normal and handled by background jobs. User Experience : A loading indicator shows during PDF generation.
Active Storage stores photos in cloud storage (AWS S3, Azure, etc.). Monitor storage usage if you have many inspections with high-resolution photos. Recommendation : Configure lifecycle policies to archive old inspection photos after retention period.
Inspections Complete inspections and upload photos on-site
Form Management Add Photo fields to form templates
Digital Signatures Understand how signature images are handled separately
PDF Parsing How photo fields are detected and configured