Class: ImageProfile

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
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',
  # Wayfair image types (WAY = Wayfair). Optional overrides — when an item has no
  # WAY_* profiles the Wayfair media push defaults to the website (WYS_*) set.
  # See Edi::Wayfair::CatalogItemMediaSender#media_for.
  WAY_MAIN: 'Wayfair Main product image (PDP hero / lead image)',
  WAY_LIFESTYLE: 'Wayfair Lifestyle / in-situ room scene image',
  WAY_I01: 'Wayfair Image 1',
  WAY_I02: 'Wayfair Image 2',
  WAY_I03: 'Wayfair Image 3',
  WAY_I04: 'Wayfair Image 4',
  WAY_I05: 'Wayfair Image 5',
  WAY_I06: 'Wayfair Image 6',
  WAY_I07: 'Wayfair Image 7',
  WAY_I08: 'Wayfair Image 8',
  WAY_I09: 'Wayfair Image 9',
  WAY_I10: 'Wayfair Image 10',
  WAY_I11: 'Wayfair Image 11',
  WAY_I12: 'Wayfair Image 12',
  WAY_I13: 'Wayfair Image 13',
  WAY_I14: 'Wayfair Image 14',
  WAY_I15: 'Wayfair Image 15'
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

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::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

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 })


183
184
185
# File 'app/models/image_profile.rb', line 183

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:



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

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

.image_orderActiveRecord::Relation<ImageProfile>

A relation of ImageProfiles that are image order. Active Record Scope

Returns:

See Also:



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

scope :image_order, -> { where(image_type: IMAGE_TYPES.keys).in_order_of(:image_type, IMAGE_TYPES.keys) }

.image_types_for_selectArray<Array(String, String)>

[label, value] pairs for image type select dropdowns.

Returns:

  • (Array<Array(String, String)>)


192
193
194
# File 'app/models/image_profile.rb', line 192

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

.ordered_image_types_for_group(prefix) ⇒ Array<String>

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

Parameters:

  • prefix (String)

    image type group prefix (e.g. 'AMZ', 'WAL')

Returns:

  • (Array<String>)

    matching image type names in IMAGE_TYPES order



268
269
270
# File 'app/models/image_profile.rb', line 268

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



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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'app/models/image_profile.rb', line 278

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')



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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'app/models/image_profile.rb', line 201

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:



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

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

.wayfair_image_profilesActiveRecord::Relation<ImageProfile>

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

Returns:

See Also:



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

scope :wayfair_image_profiles, -> { where("image_type LIKE 'WAY_%'") }

.website_image_profilesActiveRecord::Relation<ImageProfile>

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

Returns:

See Also:



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

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:



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

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

Instance Method Details

#amazon_image_typeString?

Amazon image type name with the AMZ_ prefix stripped.

Returns:

  • (String, nil)


369
370
371
# File 'app/models/image_profile.rb', line 369

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

#amazon_image_type?Boolean

Whether this is an Amazon marketplace image type (AMZ_ prefix).

Returns:

  • (Boolean)


363
364
365
# File 'app/models/image_profile.rb', line 363

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

#effective_transform_params(options = {}) ⇒ Hash

Returns the transform params in effect, with marketplace defaults merged in.

Parameters:

  • options (Hash) (defaults to: {})

    ImageKit transform params overriding the defaults

Options Hash (options):

  • named (String, Symbol)

    named transform profile

  • transformation_position (String)

    where the transform sits in the URL

  • dpr (Symbol)

    device-pixel-ratio handling

Returns:

  • (Hash)

    the effective transform params



409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'app/models/image_profile.rb', line 409

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) ⇒ String

File name to use when pushing this image to Amazon.

Parameters:

  • item (Item)

    the item the image is attached to

Returns:

  • (String)

    e.g. B000000000.AMZ_MAIN.jpg



428
429
430
# File 'app/models/image_profile.rb', line 428

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

#imageImage

The image this profile attaches to an item.

Returns:

See Also:



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

belongs_to :image

#image_url(options = {}) ⇒ String

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

Parameters:

Options Hash (options):

  • named (String, Symbol)

    named transform profile

  • transformation_position (String)

    where the transform sits in the URL

  • dpr (Symbol)

    device-pixel-ratio handling

Returns:

  • (String)

    the image URL



392
393
394
# File 'app/models/image_profile.rb', line 392

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

#itemItem

The item the image is attached to.

Returns:

See Also:



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

belongs_to :item

#thumbnail_urlString

Thumbnail URL sized for the Amazon API import.

Returns:

  • (String)

    the thumbnail image URL



398
399
400
# File 'app/models/image_profile.rb', line 398

def thumbnail_url
  image_url(named: 'amazon_api_thumb_300')
end

#walmart_marketplace_image_type?Boolean

Whether this is a Walmart marketplace image type (WAL_ prefix).

Returns:

  • (Boolean)


375
376
377
# File 'app/models/image_profile.rb', line 375

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

#website_image_type?Boolean

Whether this is a website image type (WYS_ prefix).

Returns:

  • (Boolean)


381
382
383
# File 'app/models/image_profile.rb', line 381

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