Class: ImageProfile

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, OrderQuery
Defined in:
app/models/image_profile.rb

Overview

== Schema Information

Table name: image_profiles
Database name: primary

id :bigint not null, primary key
image_type :string
locale :string(5) default("en"), not null
transform_params :jsonb
created_at :datetime
updated_at :datetime
creator_id :integer
image_id :integer not null
item_id :integer not null
updater_id :integer

Indexes

index_image_profiles_on_category_item_image_locale ((\nCASE\n WHEN ((image_type)::text ~~ 'WYS_I%'::text) THEN 'WYS_I'::text\n WHEN ((image_type)::text = 'WYS_LIFESTYLE'::text) THEN 'WYS_LIFESTYLE'::text\n WHEN ((image_type)::text ~~ 'WYS_L%'::text) THEN 'WYS_L'::text\n WHEN ((image_type)::text = 'WYS_CARD'::text) THEN 'WYS_CARD'::text\n WHEN ((image_type)::text = 'WYS_MAIN'::text) THEN 'WYS_MAIN'::text\n ELSE "left"((image_type)::text, 3)\nEND), item_id, image_id, locale) UNIQUE
index_image_profiles_on_image_id (image_id)
index_image_profiles_on_image_type (image_type)
index_image_profiles_on_item_id_and_image_type_and_locale (item_id,image_type,locale) UNIQUE

Foreign Keys

fk_rails_... (item_id => items.id) ON DELETE => cascade

Constant Summary collapse

IMAGE_TYPES =

For Image Type, ensure that the first three characters are unique to a group, e.g AMZ for Amazon, WAL for Walmart

{
  AMZ_MAIN: 'Amazon Main image, the primary image on your products detail page',
  AMZ_FRNT: 'Amazon Front angle shot',
  AMZ_SIDE: 'Amazon Side angle shot',
  AMZ_BACK: 'Amazon Back angle shot',
  AMZ_PT01: 'Amazon Part shot 1, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT02: 'Amazon Part shot 2, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT03: 'Amazon Part shot 3, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT04: 'Amazon Part shot 4, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT05: 'Amazon Part shot 5, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT06: 'Amazon Part shot 6, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT07: 'Amazon Part shot 7, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT08: 'Amazon Part shot 8, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT09: 'Amazon Part shot 9, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT10: 'Amazon Part shot 10, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_PT11: 'Amazon Part shot 11, additional angles, product in use, screen shots, accessories, or product details',
  AMZ_SWCH: 'Amazon Swatch shots, shows up in the thumbnail underneath or to the right of the larger image on a detail page. Usually a color sample',
  WAL_MAIN: 'Walmart Marketplaces Main image of the item',
  WAL_AD01: 'Walmart Marketplaces Additional image 1',
  WAL_AD02: 'Walmart Marketplaces Additional image 2',
  WAL_AD03: 'Walmart Marketplaces Additional image 3',
  WAL_AD04: 'Walmart Marketplaces Additional image 4',
  WAL_AD05: 'Walmart Marketplaces Additional image 5',
  WAL_AD06: 'Walmart Marketplaces Additional image 6',
  WAL_AD07: 'Walmart Marketplaces Additional image 7',
  WAL_AD08: 'Walmart Marketplaces Additional image 8',
  WAL_AD09: 'Walmart Marketplaces Additional image 9',
  WAL_AD10: 'Walmart Marketplaces Additional image 10',
  WAL_SWCH: 'Walmart Marketplaces Swatch shot',
  # WarmlyYours Website image types (WYS = WarmlyYours Site)
  WYS_CARD: 'WarmlyYours.com Product card / listing thumbnail (not used on PDP carousel or merchant feeds)',
  WYS_MAIN: 'WarmlyYours.com Main product image',
  WYS_LIFESTYLE: 'WarmlyYours.com Lifestyle / in-situ room scene image (detail page gallery, after main)',
  WYS_I01: 'WarmlyYours.com Image 1',
  WYS_I02: 'WarmlyYours.com Image 2',
  WYS_I03: 'WarmlyYours.com Image 3',
  WYS_I04: 'WarmlyYours.com Image 4',
  WYS_I05: 'WarmlyYours.com Image 5',
  WYS_I06: 'WarmlyYours.com Image 6',
  WYS_I07: 'WarmlyYours.com Image 7',
  WYS_I08: 'WarmlyYours.com Image 8',
  WYS_I09: 'WarmlyYours.com Image 9',
  WYS_I10: 'WarmlyYours.com Image 10',
  WYS_I11: 'WarmlyYours.com Image 11',
  WYS_I12: 'WarmlyYours.com Image 12',
  WYS_I13: 'WarmlyYours.com Image 13',
  WYS_I14: 'WarmlyYours.com Image 14',
  WYS_I15: 'WarmlyYours.com Image 15'
}

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#image_typeObject (readonly)

The image type must be specified, you can only define one image type per item/locale

Validations:

  • Presence
  • Inclusion ({ in: IMAGE_TYPES.keys.map(&:to_s) + ['TEMPORARY'] })
  • Uniqueness ({ scope: %i[item_id locale], unless: :skip_uniqueness_validation })


159
160
161
# File 'app/models/image_profile.rb', line 159

validates :image_type, presence: true,
inclusion: { in: IMAGE_TYPES.keys.map(&:to_s) + ['TEMPORARY'] },
uniqueness: { scope: %i[item_id locale], unless: :skip_uniqueness_validation }

Class Method Details

.amazon_image_profilesActiveRecord::Relation<ImageProfile>

A relation of ImageProfiles that are amazon image profiles. Active Record Scope

Returns:

See Also:



145
# File 'app/models/image_profile.rb', line 145

scope :amazon_image_profiles, -> { where("image_type LIKE 'AMZ_%'") }

.image_types_for_selectObject



166
167
168
# File 'app/models/image_profile.rb', line 166

def self.image_types_for_select
  IMAGE_TYPES.map { |k, v| ["#{k} - #{v}", k.to_s] }
end

.ordered_image_types_for_group(prefix) ⇒ Object

Returns an ordered list of image types for a specific group prefix



239
240
241
# File 'app/models/image_profile.rb', line 239

def self.ordered_image_types_for_group(prefix)
  IMAGE_TYPES.keys.select { |type| type.to_s.starts_with?(prefix) }.map(&:to_s)
end

.shift_images_for_all_items(locale: 'en', prefix: nil, batch_size: 100, dry_run: false) ⇒ Hash

Shifts images for all items that have image profiles

Parameters:

  • locale (String) (defaults to: 'en')

    The locale for the images (default: 'en')

  • prefix (String, nil) (defaults to: nil)

    Optional prefix to limit shifting to a specific group (e.g., 'AMZ', 'WAL')

  • batch_size (Integer) (defaults to: 100)

    Number of items to process in each batch (default: 100)

  • dry_run (Boolean) (defaults to: false)

    If true, only shows what would be done without making changes (default: false)

Returns:

  • (Hash)

    Summary of the operation including processed items and any errors



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'app/models/image_profile.rb', line 249

def self.shift_images_for_all_items(locale: 'en', prefix: nil, batch_size: 100, dry_run: false)
  results = {
    processed_items: 0,
    skipped_items: 0,
    errors: [],
    dry_run: dry_run
  }

  # Find all items that have image profiles
  items_with_profiles = Item.joins(:image_profiles)
                            .where(image_profiles: { locale: locale })
                            .distinct

  # Apply prefix filter if specified
  if prefix.present?
    prefix = prefix.upcase
    items_with_profiles = items_with_profiles.where('image_profiles.image_type LIKE ?', "#{prefix}%")
  end

  total_items = items_with_profiles.count
  puts "Found #{total_items} items with image profiles#{" for prefix #{prefix}" if prefix} in locale #{locale}"
  puts "Dry run mode: #{dry_run ? 'ON' : 'OFF'}"
  puts '=' * 60

  items_with_profiles.find_in_batches(batch_size: batch_size) do |batch|
    batch.each do |item|
      begin
        puts "Processing item #{item.id} (#{item.sku}): #{item.name}"

        # Show before state
        profiles_query = item.image_profiles.where(locale: locale)
        profiles_query = profiles_query.where('image_type LIKE ?', "#{prefix}%") if prefix.present?
        before_profiles = profiles_query.order(:image_type).pluck(:image_type)

        puts "  Before: #{before_profiles.join(', ')}"

        if dry_run
          puts '  [DRY RUN] Would process this item'
        else
          # Perform the shift
          shift_images_for_item(item.id, locale: locale, prefix: prefix)

          # Show after state
          profiles_query = item.image_profiles.where(locale: locale)
          profiles_query = profiles_query.where('image_type LIKE ?', "#{prefix}%") if prefix.present?
          after_profiles = profiles_query.order(:image_type).pluck(:image_type)

          puts "  After:  #{after_profiles.join(', ')}"

          # Check if any changes were made
          if before_profiles == after_profiles
            puts '  - No changes needed'
          else
            puts '  ✓ Changes made'
          end
        end

        results[:processed_items] += 1
      rescue StandardError => e
        error_msg = "Error processing item #{item.id} (#{item.sku}): #{e.message}"
        puts "#{error_msg}"
        results[:errors] << error_msg
      end

      puts ''
    end
  end

  # Print summary
  puts '=' * 60
  puts 'SUMMARY:'
  puts "  Processed items: #{results[:processed_items]}"
  puts "  Errors: #{results[:errors].count}"
  puts "  Dry run: #{results[:dry_run]}"

  if results[:errors].any?
    puts "\nERRORS:"
    results[:errors].each { |error| puts "  - #{error}" }
  end

  results
end

.shift_images_for_item(item_id, locale: 'en', prefix: nil) ⇒ Object

Shifts images up to fill empty slots in the image type order for a specific item and locale
This method will move images to fill gaps in the sequence, maintaining the proper order

Parameters:

  • item_id (Integer)

    The ID of the item to shift images for

  • locale (String) (defaults to: 'en')

    The locale for the images (default: 'en')

  • prefix (String, nil) (defaults to: nil)

    Optional prefix to limit shifting to a specific group (e.g., 'AMZ', 'WAL')



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'app/models/image_profile.rb', line 175

def self.shift_images_for_item(item_id, locale: 'en', prefix: nil)
  transaction do
    # Get all image profiles for this item and locale
    profiles = where(item_id: item_id, locale: locale).order(:image_type)

    # Group profiles by their image type prefix (first 3 characters)
    grouped_profiles = profiles.group_by { |profile| profile.image_type.to_s[0, 3] }

    # Filter by prefix if specified
    if prefix.present?
      prefix = prefix.upcase
      grouped_profiles = grouped_profiles.select { |group_prefix, _| group_prefix == prefix }
    end

    grouped_profiles.each do |group_prefix, group_profiles|
      # Get the ordered list of image types for this group
      ordered_types = ordered_image_types_for_group(group_prefix)

      # Skip if no ordered types found for this group
      next if ordered_types.empty?

      # Create a mapping of current image types to their profiles
      current_mapping = group_profiles.index_by(&:image_type)

      # Find the first available slot and shift images up
      available_slots = ordered_types.select { |type| current_mapping[type].nil? }
      occupied_slots = ordered_types.select { |type| current_mapping[type].present? }

      # If there are no gaps, no shifting needed
      next if available_slots.empty? || occupied_slots.empty?

      new_mapping = {}

      # Sort by canonical IMAGE_TYPES order so shifts always move images
      # toward lower slots — avoids unique-constraint collisions during saves.
      available_images = group_profiles
        .reject { |profile| profile.image_type.end_with?('_SWCH') }
        .sort_by { |profile| ordered_types.index(profile.image_type) || 999 }

      target_positions = ordered_types.reject { |type| type.end_with?('_SWCH') }

      target_positions.each_with_index do |target_type, index|
        break unless index < available_images.length
        new_mapping[target_type] = available_images[index]
      end

      changes = new_mapping.reject { |new_type, profile| profile.image_type == new_type }
      next if changes.empty?

      # Two-pass update to avoid unique constraint violations when slots swap.
      # Pass 1: park all moving profiles in unique temporary slots
      where(id: changes.values.map(&:id))
        .update_all(image_type: Arel.sql("'__SHIFT_' || id::text"))

      # Pass 2: assign final types
      changes.each do |new_type, profile|
        where(id: profile.id).update_all(image_type: new_type)
        profile.image_type = new_type
      end
    end
  end
end

.walmart_image_profilesActiveRecord::Relation<ImageProfile>

A relation of ImageProfiles that are walmart image profiles. Active Record Scope

Returns:

See Also:



146
# File 'app/models/image_profile.rb', line 146

scope :walmart_image_profiles, -> { where("image_type LIKE 'WAL_%'") }

.website_image_profilesActiveRecord::Relation<ImageProfile>

A relation of ImageProfiles that are website image profiles. Active Record Scope

Returns:

See Also:



147
# File 'app/models/image_profile.rb', line 147

scope :website_image_profiles, -> { where("image_type LIKE 'WYS_%'") }

.website_image_profiles_excluding_cardActiveRecord::Relation<ImageProfile>

A relation of ImageProfiles that are website image profiles excluding card. Active Record Scope

Returns:

See Also:



149
# File 'app/models/image_profile.rb', line 149

scope :website_image_profiles_excluding_card, -> { website_image_profiles.where.not(image_type: 'WYS_CARD') }

Instance Method Details

#amazon_image_typeObject



336
337
338
# File 'app/models/image_profile.rb', line 336

def amazon_image_type
  image_type.to_s.gsub('AMZ_', '').presence
end

#amazon_image_type?Boolean

Returns:

  • (Boolean)


332
333
334
# File 'app/models/image_profile.rb', line 332

def amazon_image_type?
  image_type.to_s.starts_with?('AMZ_')
end

#effective_transform_params(options = {}) ⇒ Object



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'app/models/image_profile.rb', line 357

def effective_transform_params(options = {})
  # Defaults (which can be overridden by options)
  # For simplicity, I created a named profile n-amazon_api
  # { width: 2000, height: 2000, thumbnail: true, encode_format: :jpeg, transformation_position: 'path' }
  # see https://imagekit.io/dashboard/settings/named-transforms
  options = options.reverse_merge({ named: (image_type == 'AMZ_SWCH' ? 'amazon_swatch' : 'amazon_api'), transformation_position: 'path', dpr: :ignore }) if amazon_image_type?

  # Also created a named profile n-walmart_marketplace_api
  # { width: 2200, height: 2200, thumbnail: true, encode_format: :jpeg, transformation_position: 'path' }
  # see https://imagekit.io/dashboard/settings/named-transforms
  options = options.reverse_merge({ named: 'walmart_marketplace_api', transformation_position: 'path', dpr: :ignore }) if walmart_marketplace_image_type?

  # Transform params, non negotiable when set
  options.merge(transform_params.symbolize_keys)
end

#file_name_for_amazon(item) ⇒ Object



373
374
375
# File 'app/models/image_profile.rb', line 373

def file_name_for_amazon(item)
  "#{item.amazon_asin}.#{image_type}.jpg"
end

#imageImage

Returns:

See Also:



142
# File 'app/models/image_profile.rb', line 142

belongs_to :image

#image_url(options = {}) ⇒ Object

Generates a proper thumbnail url for the image for import to amazon



349
350
351
# File 'app/models/image_profile.rb', line 349

def image_url(options = {})
  image.image_url(**effective_transform_params(options))
end

#itemItem

Returns:

See Also:



143
# File 'app/models/image_profile.rb', line 143

belongs_to :item

#thumbnail_urlObject



353
354
355
# File 'app/models/image_profile.rb', line 353

def thumbnail_url
  image_url(named: 'amazon_api_thumb_300')
end

#walmart_marketplace_image_type?Boolean

Returns:

  • (Boolean)


340
341
342
# File 'app/models/image_profile.rb', line 340

def walmart_marketplace_image_type?
  image_type.to_s.starts_with?('WAL_')
end

#website_image_type?Boolean

Returns:

  • (Boolean)


344
345
346
# File 'app/models/image_profile.rb', line 344

def website_image_type?
  image_type.to_s.starts_with?('WYS_')
end