Improve collection sync with scoped filtering and orphan handling (#201)
* collection sync with orphan handling - preview_sync endpoint shows what'll get deleted before you commit - import services handle reconciliation (find missing items, delete them) - grid items get flagged as orphaned when their collection source is gone - party exposes has_orphaned_items - blueprints include orphaned field * scope artifact sync by element and proficiency - accept filter param in preview_sync and import - only check/delete items matching active filter - prevents accidental deletion of filtered-out items * scope weapon sync by element and proficiency - accept filter param in preview_sync and import - element checks collection_weapon first, falls back to weapon - proficiency joins through weapon table * scope summon sync by element - accept filter param in preview_sync and import - element joins through summon table
This commit is contained in:
parent
1f80e4189f
commit
7bda9a1432
6 changed files with 143 additions and 33 deletions
|
|
@ -123,7 +123,8 @@ module Api
|
||||||
game_data,
|
game_data,
|
||||||
update_existing: import_params[:update_existing] == true,
|
update_existing: import_params[:update_existing] == true,
|
||||||
is_full_inventory: import_params[:is_full_inventory] == true,
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
reconcile_deletions: import_params[:reconcile_deletions] == true
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
)
|
)
|
||||||
|
|
||||||
result = service.import
|
result = service.import
|
||||||
|
|
@ -147,12 +148,13 @@ module Api
|
||||||
# @return [JSON] List of items that would be deleted
|
# @return [JSON] List of items that would be deleted
|
||||||
def preview_sync
|
def preview_sync
|
||||||
game_data = import_params[:data]
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
unless game_data.present?
|
unless game_data.present?
|
||||||
return render json: { error: 'No data provided' }, status: :bad_request
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
service = ArtifactImportService.new(current_user, game_data)
|
service = ArtifactImportService.new(current_user, game_data, filter: filter)
|
||||||
items_to_delete = service.preview_deletions
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -234,7 +236,8 @@ module Api
|
||||||
update_existing: params[:update_existing],
|
update_existing: params[:update_existing],
|
||||||
is_full_inventory: params[:is_full_inventory],
|
is_full_inventory: params[:is_full_inventory],
|
||||||
reconcile_deletions: params[:reconcile_deletions],
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
data: params[:data]&.to_unsafe_h
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,8 @@ module Api
|
||||||
game_data,
|
game_data,
|
||||||
update_existing: import_params[:update_existing] == true,
|
update_existing: import_params[:update_existing] == true,
|
||||||
is_full_inventory: import_params[:is_full_inventory] == true,
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
reconcile_deletions: import_params[:reconcile_deletions] == true
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
)
|
)
|
||||||
|
|
||||||
result = service.import
|
result = service.import
|
||||||
|
|
@ -151,12 +152,13 @@ module Api
|
||||||
# @return [JSON] List of items that would be deleted
|
# @return [JSON] List of items that would be deleted
|
||||||
def preview_sync
|
def preview_sync
|
||||||
game_data = import_params[:data]
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
unless game_data.present?
|
unless game_data.present?
|
||||||
return render json: { error: 'No data provided' }, status: :bad_request
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
service = SummonImportService.new(current_user, game_data)
|
service = SummonImportService.new(current_user, game_data, filter: filter)
|
||||||
items_to_delete = service.preview_deletions
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -218,7 +220,8 @@ module Api
|
||||||
update_existing: params[:update_existing],
|
update_existing: params[:update_existing],
|
||||||
is_full_inventory: params[:is_full_inventory],
|
is_full_inventory: params[:is_full_inventory],
|
||||||
reconcile_deletions: params[:reconcile_deletions],
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
data: params[:data]&.to_unsafe_h
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,8 @@ module Api
|
||||||
game_data,
|
game_data,
|
||||||
update_existing: import_params[:update_existing] == true,
|
update_existing: import_params[:update_existing] == true,
|
||||||
is_full_inventory: import_params[:is_full_inventory] == true,
|
is_full_inventory: import_params[:is_full_inventory] == true,
|
||||||
reconcile_deletions: import_params[:reconcile_deletions] == true
|
reconcile_deletions: import_params[:reconcile_deletions] == true,
|
||||||
|
filter: import_params[:filter]
|
||||||
)
|
)
|
||||||
|
|
||||||
result = service.import
|
result = service.import
|
||||||
|
|
@ -159,12 +160,13 @@ module Api
|
||||||
# @return [JSON] List of items that would be deleted
|
# @return [JSON] List of items that would be deleted
|
||||||
def preview_sync
|
def preview_sync
|
||||||
game_data = import_params[:data]
|
game_data = import_params[:data]
|
||||||
|
filter = import_params[:filter]
|
||||||
|
|
||||||
unless game_data.present?
|
unless game_data.present?
|
||||||
return render json: { error: 'No data provided' }, status: :bad_request
|
return render json: { error: 'No data provided' }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
service = WeaponImportService.new(current_user, game_data)
|
service = WeaponImportService.new(current_user, game_data, filter: filter)
|
||||||
items_to_delete = service.preview_deletions
|
items_to_delete = service.preview_deletions
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -236,7 +238,8 @@ module Api
|
||||||
update_existing: params[:update_existing],
|
update_existing: params[:update_existing],
|
||||||
is_full_inventory: params[:is_full_inventory],
|
is_full_inventory: params[:is_full_inventory],
|
||||||
reconcile_deletions: params[:reconcile_deletions],
|
reconcile_deletions: params[:reconcile_deletions],
|
||||||
data: params[:data]&.to_unsafe_h
|
data: params[:data]&.to_unsafe_h,
|
||||||
|
filter: params[:filter]&.to_unsafe_h
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class ArtifactImportService
|
||||||
@update_existing = options[:update_existing] || false
|
@update_existing = options[:update_existing] || false
|
||||||
@is_full_inventory = options[:is_full_inventory] || false
|
@is_full_inventory = options[:is_full_inventory] || false
|
||||||
@reconcile_deletions = options[:reconcile_deletions] || false
|
@reconcile_deletions = options[:reconcile_deletions] || false
|
||||||
|
@filter = options[:filter] # { elements: [...], proficiencies: [...] }
|
||||||
@created = []
|
@created = []
|
||||||
@updated = []
|
@updated = []
|
||||||
@skipped = []
|
@skipped = []
|
||||||
|
|
@ -42,6 +43,7 @@ class ArtifactImportService
|
||||||
##
|
##
|
||||||
# Previews what would be deleted in a sync operation.
|
# Previews what would be deleted in a sync operation.
|
||||||
# Does not modify any data, just returns items that would be removed.
|
# Does not modify any data, just returns items that would be removed.
|
||||||
|
# When a filter is active, only considers items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Array<CollectionArtifact>] Collection artifacts that would be deleted
|
# @return [Array<CollectionArtifact>] Collection artifacts that would be deleted
|
||||||
def preview_deletions
|
def preview_deletions
|
||||||
|
|
@ -58,10 +60,14 @@ class ArtifactImportService
|
||||||
return [] if game_ids.empty?
|
return [] if game_ids.empty?
|
||||||
|
|
||||||
# Find collection artifacts with game_ids NOT in the import
|
# Find collection artifacts with game_ids NOT in the import
|
||||||
@user.collection_artifacts
|
# Scoped to filter criteria if present
|
||||||
.includes(:artifact)
|
scope = @user.collection_artifacts
|
||||||
.where.not(game_id: nil)
|
.includes(:artifact)
|
||||||
.where.not(game_id: game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
scope
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -243,18 +249,22 @@ class ArtifactImportService
|
||||||
##
|
##
|
||||||
# Reconciles deletions by removing collection artifacts not in the processed list.
|
# Reconciles deletions by removing collection artifacts not in the processed list.
|
||||||
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
||||||
|
# When a filter is active, only deletes items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
||||||
def reconcile_deletions
|
def reconcile_deletions
|
||||||
# Find collection artifacts with game_ids NOT in our processed list
|
# Find collection artifacts with game_ids NOT in our processed list
|
||||||
missing = @user.collection_artifacts
|
# Scoped to filter criteria if present
|
||||||
.where.not(game_id: nil)
|
scope = @user.collection_artifacts
|
||||||
.where.not(game_id: @processed_game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: @processed_game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
orphaned_grid_item_ids = []
|
orphaned_grid_item_ids = []
|
||||||
|
|
||||||
missing.find_each do |coll_artifact|
|
scope.find_each do |coll_artifact|
|
||||||
# Collect IDs of grid items that will be orphaned
|
# Collect IDs of grid items that will be orphaned
|
||||||
grid_artifact_ids = GridArtifact.where(collection_artifact_id: coll_artifact.id).pluck(:id)
|
grid_artifact_ids = GridArtifact.where(collection_artifact_id: coll_artifact.id).pluck(:id)
|
||||||
orphaned_grid_item_ids.concat(grid_artifact_ids)
|
orphaned_grid_item_ids.concat(grid_artifact_ids)
|
||||||
|
|
@ -269,4 +279,28 @@ class ArtifactImportService
|
||||||
orphaned_grid_items: orphaned_grid_item_ids
|
orphaned_grid_items: orphaned_grid_item_ids
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Applies element and proficiency filters to a collection artifacts scope.
|
||||||
|
# Used to scope deletion checks to only items matching the current game filter.
|
||||||
|
#
|
||||||
|
# @param scope [ActiveRecord::Relation] The collection artifacts relation to filter
|
||||||
|
# @return [ActiveRecord::Relation] Filtered relation
|
||||||
|
def apply_filter_scope(scope)
|
||||||
|
return scope unless @filter.present?
|
||||||
|
|
||||||
|
# Filter by elements if specified
|
||||||
|
if @filter[:elements].present? || @filter['elements'].present?
|
||||||
|
elements = @filter[:elements] || @filter['elements']
|
||||||
|
scope = scope.where(element: elements)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter by proficiencies if specified
|
||||||
|
if @filter[:proficiencies].present? || @filter['proficiencies'].present?
|
||||||
|
proficiencies = @filter[:proficiencies] || @filter['proficiencies']
|
||||||
|
scope = scope.where(proficiency: proficiencies)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class SummonImportService
|
||||||
@update_existing = options[:update_existing] || false
|
@update_existing = options[:update_existing] || false
|
||||||
@is_full_inventory = options[:is_full_inventory] || false
|
@is_full_inventory = options[:is_full_inventory] || false
|
||||||
@reconcile_deletions = options[:reconcile_deletions] || false
|
@reconcile_deletions = options[:reconcile_deletions] || false
|
||||||
|
@filter = options[:filter] # { elements: [...] }
|
||||||
@created = []
|
@created = []
|
||||||
@updated = []
|
@updated = []
|
||||||
@skipped = []
|
@skipped = []
|
||||||
|
|
@ -30,6 +31,7 @@ class SummonImportService
|
||||||
##
|
##
|
||||||
# Previews what would be deleted in a sync operation.
|
# Previews what would be deleted in a sync operation.
|
||||||
# Does not modify any data, just returns items that would be removed.
|
# Does not modify any data, just returns items that would be removed.
|
||||||
|
# When a filter is active, only considers items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Array<CollectionSummon>] Collection summons that would be deleted
|
# @return [Array<CollectionSummon>] Collection summons that would be deleted
|
||||||
def preview_deletions
|
def preview_deletions
|
||||||
|
|
@ -45,10 +47,14 @@ class SummonImportService
|
||||||
return [] if game_ids.empty?
|
return [] if game_ids.empty?
|
||||||
|
|
||||||
# Find collection summons with game_ids NOT in the import
|
# Find collection summons with game_ids NOT in the import
|
||||||
@user.collection_summons
|
# Scoped to filter criteria if present
|
||||||
.includes(:summon)
|
scope = @user.collection_summons
|
||||||
.where.not(game_id: nil)
|
.includes(:summon)
|
||||||
.where.not(game_id: game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
scope
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -193,18 +199,22 @@ class SummonImportService
|
||||||
##
|
##
|
||||||
# Reconciles deletions by removing collection summons not in the processed list.
|
# Reconciles deletions by removing collection summons not in the processed list.
|
||||||
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
||||||
|
# When a filter is active, only deletes items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
||||||
def reconcile_deletions
|
def reconcile_deletions
|
||||||
# Find collection summons with game_ids NOT in our processed list
|
# Find collection summons with game_ids NOT in our processed list
|
||||||
missing = @user.collection_summons
|
# Scoped to filter criteria if present
|
||||||
.where.not(game_id: nil)
|
scope = @user.collection_summons
|
||||||
.where.not(game_id: @processed_game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: @processed_game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
orphaned_grid_item_ids = []
|
orphaned_grid_item_ids = []
|
||||||
|
|
||||||
missing.find_each do |coll_summon|
|
scope.find_each do |coll_summon|
|
||||||
# Collect IDs of grid items that will be orphaned
|
# Collect IDs of grid items that will be orphaned
|
||||||
grid_summon_ids = GridSummon.where(collection_summon_id: coll_summon.id).pluck(:id)
|
grid_summon_ids = GridSummon.where(collection_summon_id: coll_summon.id).pluck(:id)
|
||||||
orphaned_grid_item_ids.concat(grid_summon_ids)
|
orphaned_grid_item_ids.concat(grid_summon_ids)
|
||||||
|
|
@ -219,4 +229,23 @@ class SummonImportService
|
||||||
orphaned_grid_items: orphaned_grid_item_ids
|
orphaned_grid_items: orphaned_grid_item_ids
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Applies element filter to a collection summons scope.
|
||||||
|
# Used to scope deletion checks to only items matching the current game filter.
|
||||||
|
#
|
||||||
|
# @param scope [ActiveRecord::Relation] The collection summons relation to filter
|
||||||
|
# @return [ActiveRecord::Relation] Filtered relation
|
||||||
|
def apply_filter_scope(scope)
|
||||||
|
return scope unless @filter.present?
|
||||||
|
|
||||||
|
# Element: always join through summon (no element on collection_summons)
|
||||||
|
if @filter[:elements].present? || @filter['elements'].present?
|
||||||
|
elements = @filter[:elements] || @filter['elements']
|
||||||
|
scope = scope.joins(:summon).where(summons: { element: elements })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Summons don't have proficiency - ignore if present in filter
|
||||||
|
scope
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class WeaponImportService
|
||||||
@update_existing = options[:update_existing] || false
|
@update_existing = options[:update_existing] || false
|
||||||
@is_full_inventory = options[:is_full_inventory] || false
|
@is_full_inventory = options[:is_full_inventory] || false
|
||||||
@reconcile_deletions = options[:reconcile_deletions] || false
|
@reconcile_deletions = options[:reconcile_deletions] || false
|
||||||
|
@filter = options[:filter] # { elements: [...], proficiencies: [...] }
|
||||||
@created = []
|
@created = []
|
||||||
@updated = []
|
@updated = []
|
||||||
@skipped = []
|
@skipped = []
|
||||||
|
|
@ -42,6 +43,7 @@ class WeaponImportService
|
||||||
##
|
##
|
||||||
# Previews what would be deleted in a sync operation.
|
# Previews what would be deleted in a sync operation.
|
||||||
# Does not modify any data, just returns items that would be removed.
|
# Does not modify any data, just returns items that would be removed.
|
||||||
|
# When a filter is active, only considers items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Array<CollectionWeapon>] Collection weapons that would be deleted
|
# @return [Array<CollectionWeapon>] Collection weapons that would be deleted
|
||||||
def preview_deletions
|
def preview_deletions
|
||||||
|
|
@ -57,10 +59,14 @@ class WeaponImportService
|
||||||
return [] if game_ids.empty?
|
return [] if game_ids.empty?
|
||||||
|
|
||||||
# Find collection weapons with game_ids NOT in the import
|
# Find collection weapons with game_ids NOT in the import
|
||||||
@user.collection_weapons
|
# Scoped to filter criteria if present
|
||||||
.includes(:weapon)
|
scope = @user.collection_weapons
|
||||||
.where.not(game_id: nil)
|
.includes(:weapon)
|
||||||
.where.not(game_id: game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
scope
|
||||||
end
|
end
|
||||||
|
|
||||||
##
|
##
|
||||||
|
|
@ -357,18 +363,22 @@ class WeaponImportService
|
||||||
##
|
##
|
||||||
# Reconciles deletions by removing collection weapons not in the processed list.
|
# Reconciles deletions by removing collection weapons not in the processed list.
|
||||||
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
# Only called when @is_full_inventory and @reconcile_deletions are both true.
|
||||||
|
# When a filter is active, only deletes items matching that filter.
|
||||||
#
|
#
|
||||||
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
# @return [Hash] Reconciliation result with deleted count and orphaned grid item IDs
|
||||||
def reconcile_deletions
|
def reconcile_deletions
|
||||||
# Find collection weapons with game_ids NOT in our processed list
|
# Find collection weapons with game_ids NOT in our processed list
|
||||||
missing = @user.collection_weapons
|
# Scoped to filter criteria if present
|
||||||
.where.not(game_id: nil)
|
scope = @user.collection_weapons
|
||||||
.where.not(game_id: @processed_game_ids)
|
.where.not(game_id: nil)
|
||||||
|
.where.not(game_id: @processed_game_ids)
|
||||||
|
|
||||||
|
scope = apply_filter_scope(scope)
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
orphaned_grid_item_ids = []
|
orphaned_grid_item_ids = []
|
||||||
|
|
||||||
missing.find_each do |coll_weapon|
|
scope.find_each do |coll_weapon|
|
||||||
# Collect IDs of grid items that will be orphaned
|
# Collect IDs of grid items that will be orphaned
|
||||||
grid_weapon_ids = GridWeapon.where(collection_weapon_id: coll_weapon.id).pluck(:id)
|
grid_weapon_ids = GridWeapon.where(collection_weapon_id: coll_weapon.id).pluck(:id)
|
||||||
orphaned_grid_item_ids.concat(grid_weapon_ids)
|
orphaned_grid_item_ids.concat(grid_weapon_ids)
|
||||||
|
|
@ -383,4 +393,32 @@ class WeaponImportService
|
||||||
orphaned_grid_items: orphaned_grid_item_ids
|
orphaned_grid_items: orphaned_grid_item_ids
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Applies element and proficiency filters to a collection weapons scope.
|
||||||
|
# Used to scope deletion checks to only items matching the current game filter.
|
||||||
|
#
|
||||||
|
# @param scope [ActiveRecord::Relation] The collection weapons relation to filter
|
||||||
|
# @return [ActiveRecord::Relation] Filtered relation
|
||||||
|
def apply_filter_scope(scope)
|
||||||
|
return scope unless @filter.present?
|
||||||
|
|
||||||
|
# Element: check collection_weapon.element first (for element-changeable weapons),
|
||||||
|
# fall back to weapon.element if nil
|
||||||
|
if @filter[:elements].present? || @filter['elements'].present?
|
||||||
|
elements = @filter[:elements] || @filter['elements']
|
||||||
|
scope = scope.joins(:weapon).where(
|
||||||
|
'collection_weapons.element IN (?) OR (collection_weapons.element IS NULL AND weapons.element IN (?))',
|
||||||
|
elements, elements
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Proficiency: join through weapon
|
||||||
|
if @filter[:proficiencies].present? || @filter['proficiencies'].present?
|
||||||
|
proficiencies = @filter[:proficiencies] || @filter['proficiencies']
|
||||||
|
scope = scope.joins(:weapon).where(weapons: { proficiency: proficiencies })
|
||||||
|
end
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue