Merge branch 'release/4.2' into release/4.3

pull/6827/head
Jan Sandbrink 9 years ago
commit 237741106e
  1. 8
      README.md
  2. 21
      app/controllers/cost_reports_controller.rb
  3. 75
      app/models/cost_query/cache.rb
  4. 13
      app/models/cost_query/custom_field_mixin.rb
  5. 5
      lib/open_project/reporting/engine.rb
  6. 31
      lib/open_project/reporting/patches/open_project/configuration_patch.rb
  7. 47
      spec/lib/open_project/configuration_spec.rb
  8. 124
      spec/models/cost_query/cache_spec.rb
  9. 45
      spec/models/cost_query/filter_spec.rb
  10. 72
      spec/models/cost_query/group_by_spec.rb
  11. 129
      spec/requests/custom_field_cache_spec.rb
  12. 31
      spec/support/configuration_helper.rb
  13. 34
      spec/support/custom_field_filter.rb

@ -46,6 +46,14 @@ from your `Gemfile.plugins` in your OpenProject installation folder and run:
to uninstall the ReportingEngine and the OpenProject Reporting plugin.
Configuration
-------------
* `cost_reporting_cache_filter_classes: true`
OpenProject Reporting, when not configured otherwise, optimizes response time by caching the filters and group by options generated for work package custom fields. Only when the custom fields are invalidated, does reporting recreate the elements by information from the database. In some scenarios, such a behavior might not be desirable. Especially, when databases are switched between requests to serve information from another installation, caching will almost always fail as the information is outdated and in some edge cases, filters and group by options are displayed erroneously. In such a setting, it is advisible to deactivate the caching by setting `cost_reporting_cache_filter_classes` to `false` in OpenProject's `config/configuration.yml`
Bug Reporting
-------------

@ -46,29 +46,12 @@ class CostReportsController < ApplicationController
helper_method :private_queries
attr_accessor :cost_types, :unit_id, :cost_type
cattr_accessor :custom_fields_updated_on, :custom_fields_id_sum
# Checks if custom fields have been updated, added or removed since we
# last saw them, to rebuild the filters and group bys.
# Called once per request.
def check_cache(force_update = false)
custom_fields_updated_on = WorkPackageCustomField.maximum(:updated_at)
custom_fields_id_sum = WorkPackageCustomField.sum(:id) + WorkPackageCustomField.count
if force_update or (custom_fields_updated_on && custom_fields_id_sum)
if force_update or (
self.class.custom_fields_updated_on != custom_fields_updated_on ||
self.class.custom_fields_id_sum != custom_fields_id_sum)
self.class.custom_fields_updated_on = custom_fields_updated_on
self.class.custom_fields_id_sum = custom_fields_id_sum
CostQuery::Filter.reset!
CostQuery::Filter::CustomFieldEntries.reset!
CostQuery::GroupBy.reset!
CostQuery::GroupBy::CustomFieldEntries.reset!
end
end
def check_cache
CostQuery::Cache.check
end
##

@ -0,0 +1,75 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
module CostQuery::Cache
class << self
def check
reset! if reset_required?
end
def reset!
update_reset_on
CostQuery::Filter.reset!
CostQuery::Filter::CustomFieldEntries.reset!
CostQuery::GroupBy.reset!
CostQuery::GroupBy::CustomFieldEntries.reset!
end
protected
attr_accessor :latest_custom_field_change,
:custom_field_count
def invalid?
changed_on = fetch_latest_custom_field_change
field_count = fetch_current_custom_field_count
latest_custom_field_change != changed_on ||
custom_field_count != field_count
end
def update_reset_on
return if caching_disabled?
self.latest_custom_field_change = fetch_latest_custom_field_change
self.custom_field_count = fetch_current_custom_field_count
end
def fetch_latest_custom_field_change
WorkPackageCustomField.maximum(:updated_at)
end
def fetch_current_custom_field_count
WorkPackageCustomField.count
end
def caching_disabled?
!OpenProject::Configuration.cost_reporting_cache_filter_classes
end
def reset_required?
caching_disabled? || invalid?
end
end
# initialize to 0 to avoid forced cache reset on first request
self.custom_field_count = 0
end

@ -27,7 +27,8 @@ module CostQuery::CustomFieldMixin
'text' => mysql? ? 'char' : 'text',
'bool' => mysql? ? 'unsigned' : 'boolean',
'date' => 'date',
'int' => 'decimal(60,3)', 'float' => 'decimal(60,3)' }
'int' => 'decimal(60,3)',
'float' => 'decimal(60,3)' }
def self.extended(base)
base.inherited_attribute :factory
@ -41,6 +42,8 @@ module CostQuery::CustomFieldMixin
def reset!
@all = nil
remove_subclasses
end
def generate_subclasses
@ -52,6 +55,14 @@ module CostQuery::CustomFieldMixin
end
end
def remove_subclasses
parent.constants.each do |constant|
if constant.to_s.match /^CustomField\d+/
parent.send(:remove_const, constant)
end
end
end
def factory?
factory == self
end

@ -98,6 +98,9 @@ module OpenProject::Reporting
require_dependency 'cost_query/group_by'
end
patches [:CostlogController, :TimelogController, :CustomFieldsController]
patches [:CostlogController,
:TimelogController,
:CustomFieldsController,
:'OpenProject::Configuration']
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
require_dependency 'open_project/configuration'
module OpenProject::Reporting::Patches
module OpenProject::ConfigurationPatch
def self.included(base)
base.class_eval do
@defaults['cost_reporting_cache_filter_classes'] = true
end
end
end
end

@ -0,0 +1,47 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
require 'spec_helper'
describe 'OpenProject::Configuration' do
context '.cost_reporting_cache_filter_classes' do
before do
# This prevents the values from the actual configuration file to influence
# the test outcome.
#
# TODO: I propose to port this over to the core to always prevent this for specs.
OpenProject::Configuration.load(file: 'bogus')
end
after do
# resetting for now to avoid braking specs, who by now rely on having the file read.
OpenProject::Configuration.load
end
it 'is a true by default via the method' do
expect(OpenProject::Configuration.cost_reporting_cache_filter_classes).to be_truthy
end
it 'is true by default via the hash' do
expect(OpenProject::Configuration['cost_reporting_cache_filter_classes']).to be_truthy
end
end
end

@ -0,0 +1,124 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
require 'spec_helper'
require File.join(File.dirname(__FILE__), '..', '..', 'support', 'configuration_helper')
describe CostQuery::Cache do
include OpenProject::Reporting::SpecHelper::ConfigurationHelper
def all_caches
[ CostQuery::GroupBy::CustomFieldEntries,
CostQuery::GroupBy,
CostQuery::Filter::CustomFieldEntries,
CostQuery::Filter ]
end
def expect_reset_on_caches
all_caches.each do |klass|
expect(klass).to receive(:reset!)
end
end
def expect_no_reset_on_caches
all_caches.each do |klass|
expect(klass).to_not receive(:reset!)
end
end
def reset_cache_keys
# resetting internal caching keys to avoid dependencies with other specs
described_class.send(:latest_custom_field_change=, nil)
described_class.send(:custom_field_count=, 0)
end
def custom_fields_exist
allow(WorkPackageCustomField).to receive(:maximum).and_return(Time.now)
allow(WorkPackageCustomField).to receive(:count).and_return(23)
end
def no_custom_fields_exist
allow(WorkPackageCustomField).to receive(:maximum).and_return(nil)
allow(WorkPackageCustomField).to receive(:count).and_return(0)
end
before do
reset_cache_keys
end
after do
reset_cache_keys
end
describe '.check' do
context 'with cache_classes configuration enabled' do
before do
mock_cache_classes_setting_with(true)
end
it 'resets the caches on filters and group by' do
custom_fields_exist
expect_reset_on_caches
described_class.check
end
it 'stores when the last update was made and does not reset again if nothing changed' do
custom_fields_exist
expect_reset_on_caches
described_class.check
expect_no_reset_on_caches
described_class.check
end
it 'does reset the cache if last CustomField is removed' do
custom_fields_exist
expect_reset_on_caches
described_class.check
no_custom_fields_exist
expect_reset_on_caches
described_class.check
end
end
context 'with_cache_classes configuration disabled' do
before do
mock_cache_classes_setting_with(false)
end
it 'resets the cache again even if nothing changed' do
custom_fields_exist
expect_reset_on_caches
described_class.check
expect_reset_on_caches
described_class.check
end
end
end
end

@ -18,6 +18,7 @@
#++
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper')
require File.join(File.dirname(__FILE__), '..', '..', 'support', 'custom_field_filter')
describe CostQuery, type: :model, reporting_query_helper: true do
minimal_query
@ -324,17 +325,23 @@ describe CostQuery, type: :model, reporting_query_helper: true do
cf
end
let(:custom_field2) do
FactoryGirl.build(:work_package_custom_field, name: 'Database',
field_format: "list",
possible_values: ['value'])
end
after(:all) do
clear_cache
end
def clear_cache
CostReportsController.new.check_cache(true)
CostQuery::Cache.reset!
CostQuery::Filter::CustomFieldEntries.all
end
def delete_work_package_custom_field(name)
WorkPackageCustomField.find_by_name(name).destroy
def delete_work_package_custom_field(cf)
cf.destroy
clear_cache
end
@ -345,44 +352,40 @@ describe CostQuery, type: :model, reporting_query_helper: true do
clear_cache
end
def class_name_for(name)
"CostQuery::Filter::CustomField#{WorkPackageCustomField.find_by_name(name).id}"
end
include OpenProject::Reporting::SpecHelper::CustomFieldFilterHelper
it "should create classes for custom fields that get added after starting the server" do
custom_field
expect { class_name_for('My custom field').constantize }.not_to raise_error
expect { filter_class_name_string(custom_field).constantize }.not_to raise_error
end
it "should remove the custom field classes after it is deleted" do
custom_field
class_name = class_name_for('My custom field')
delete_work_package_custom_field("My custom field")
expect(CostQuery::Filter.all).not_to include class_name.constantize
class_name = filter_class_name_string(custom_field)
delete_work_package_custom_field(custom_field)
expect { filter_class_name_string(custom_field).constantize }.to raise_error NameError
end
it "should provide the correct available values" do
FactoryGirl.create(:work_package_custom_field, name: 'Database',
field_format: "list",
possible_values: ['value'])
custom_field2.save
clear_cache
ao = class_name_for('Database').constantize.available_operators.map(&:name)
ao = filter_class_name_string(custom_field2).constantize.available_operators.map(&:name)
CostQuery::Operator.null_operators.each do |o|
expect(ao).to include o.name
end
end
it "should update the available values on change" do
FactoryGirl.create(:work_package_custom_field, name: 'Database',
field_format: "list",
possible_values: ['value'])
custom_field2.save
update_work_package_custom_field("Database", field_format: "string")
ao = class_name_for('Database').constantize.available_operators.map(&:name)
ao = filter_class_name_string(custom_field2).constantize.available_operators.map(&:name)
CostQuery::Operator.string_operators.each do |o|
expect(ao).to include o.name
end
update_work_package_custom_field("Database", field_format: "int")
ao = class_name_for('Database').constantize.available_operators.map(&:name)
ao = filter_class_name_string(custom_field2).constantize.available_operators.map(&:name)
CostQuery::Operator.integer_operators.each do |o|
expect(ao).to include o.name
end
@ -391,13 +394,13 @@ describe CostQuery, type: :model, reporting_query_helper: true do
it "includes custom fields classes in CustomFieldEntries.all" do
custom_field
expect(CostQuery::Filter::CustomFieldEntries.all).
to include(class_name_for('My custom field').constantize)
to include(filter_class_name_string(custom_field).constantize)
end
it "includes custom fields classes in Filter.all" do
custom_field
expect(CostQuery::Filter.all).
to include(class_name_for('My custom field').constantize)
to include(filter_class_name_string(custom_field).constantize)
end
def create_searchable_fields_and_values

@ -18,6 +18,7 @@
#++
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper')
require File.join(File.dirname(__FILE__), '..', '..', 'support', 'custom_field_filter')
describe CostQuery, type: :model, reporting_query_helper: true do
let!(:type) { FactoryGirl.create(:type) }
@ -216,74 +217,75 @@ describe CostQuery, type: :model, reporting_query_helper: true do
describe CostQuery::GroupBy::CustomFieldEntries do
let!(:project){ FactoryGirl.create(:project_with_types) }
let!(:custom_field) do
FactoryGirl.create(:work_package_custom_field)
end
let(:custom_field2) do
FactoryGirl.build(:work_package_custom_field)
end
before do
create_work_package_custom_field("Searchable Field")
check_cache
CostQuery::GroupBy.all.merge CostQuery::GroupBy::CustomFieldEntries.all
end
def check_cache
CostReportsController.new.check_cache
CostQuery::Cache.reset!
CostQuery::GroupBy::CustomFieldEntries.all
end
def create_work_package_custom_field(name)
WorkPackageCustomField.create(name: name,
min_length: 1,
regexp: "",
is_for_all: true,
max_length: 100,
possible_values: "",
is_required: false,
field_format: "string",
searchable: true,
default_value: "Default string",
editable: true)
def delete_work_package_custom_field(custom_field)
custom_field.destroy
check_cache
end
def delete_work_package_custom_field(name)
WorkPackageCustomField.find_by_name(name).destroy
check_cache
end
def class_name_for(name)
"CostQuery::GroupBy::CustomField#{WorkPackageCustomField.find_by_name(name).id}"
end
include OpenProject::Reporting::SpecHelper::CustomFieldFilterHelper
it "should create classes for custom fields" do
# Would raise a name error
expect { class_name_for('Searchable Field').constantize }.to_not raise_error
expect { group_by_class_name_string(custom_field).constantize }.to_not raise_error
end
it "should create new classes for custom fields that get added after starting the server" do
create_work_package_custom_field("AFreshCustomField")
custom_field2.save!
check_cache
# Would raise a name error
expect { class_name_for('AFreshCustomField').constantize }.to_not raise_error
WorkPackageCustomField.find_by_name("AFreshCustomField").destroy
expect { group_by_class_name_string(custom_field2).constantize }.to_not raise_error
custom_field2.destroy
end
it "should remove the custom field classes after it is deleted" do
create_work_package_custom_field("AFreshCustomField")
name = class_name_for('AFreshCustomField')
delete_work_package_custom_field("AFreshCustomField")
expect(CostQuery::GroupBy.all).not_to include name.constantize
custom_field2.save!
check_cache
custom_field2.destroy
check_cache
expect { group_by_class_name_string(custom_field2).constantize }.to raise_error NameError
end
it "includes custom fields classes in CustomFieldEntries.all" do
expect(CostQuery::GroupBy::CustomFieldEntries.all).
to include(class_name_for('Searchable Field').constantize)
to include(group_by_class_name_string(custom_field).constantize)
end
it "includes custom fields classes in GroupBy.all" do
expect(CostQuery::GroupBy.all).
to include(class_name_for('Searchable Field').constantize)
to include(group_by_class_name_string(custom_field).constantize)
end
it "is usable as filter" do
create_work_package_custom_field("Database")
id = WorkPackageCustomField.find_by_name('Database').id
@query.group_by "custom_field_#{id}".to_sym
custom_field2.save!
check_cache
@query.group_by "custom_field_#{custom_field2.id}".to_sym
footprint = @query.result.each_direct_result.map { |c| [c.count, c.units.to_i] }.sort
expect(footprint).to eq([[8, 8]])
end

@ -0,0 +1,129 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
require 'spec_helper'
require File.join(File.dirname(__FILE__), '..', 'support', 'custom_field_filter')
require File.join(File.dirname(__FILE__), '..', 'support', 'configuration_helper')
describe 'Custom field filter and group by caching', type: :request do
include OpenProject::Reporting::SpecHelper::CustomFieldFilterHelper
include OpenProject::Reporting::SpecHelper::ConfigurationHelper
let(:project) { FactoryGirl.create(:valid_project) }
let(:user) { FactoryGirl.create(:admin) }
let(:custom_field) { FactoryGirl.build(:work_package_custom_field) }
let(:custom_field2) { FactoryGirl.build(:work_package_custom_field) }
before do
allow(User).to receive(:current).and_return(user)
custom_field.save!
end
def expect_group_by_all_to_include(custom_field)
expect(CostQuery::GroupBy.all).to include(group_by_class_name_string(custom_field).constantize)
end
def expect_filter_all_to_include(custom_field)
expect(CostQuery::Filter.all).to include(filter_class_name_string(custom_field).constantize)
end
def expect_group_by_all_to_not_exist(custom_field)
# can not check for whether the element is included in CostQuery::GroupBy.all if it does not exist
expect { group_by_class_name_string(custom_field).constantize }.to raise_error NameError
end
def expect_filter_all_to_not_exist(custom_field)
# can not check for whether the element is included in CostQuery::Filter.all if it does not exist
expect { filter_class_name_string(custom_field).constantize }.to raise_error NameError
end
def visit_cost_reports_index
get "projects/#{project.id}/cost_reports"
end
it 'removes the filter/group_by if the custom field is removed' do
custom_field2.save!
visit_cost_reports_index
expect_group_by_all_to_include(custom_field)
expect_group_by_all_to_include(custom_field2)
expect_filter_all_to_include(custom_field)
expect_filter_all_to_include(custom_field2)
custom_field2.destroy
visit_cost_reports_index
expect_group_by_all_to_include(custom_field)
expect_group_by_all_to_not_exist(custom_field2)
expect_filter_all_to_include(custom_field)
expect_filter_all_to_not_exist(custom_field2)
end
it 'removes the filter/group_by if the last custom field is removed' do
visit_cost_reports_index
expect_group_by_all_to_include(custom_field)
expect_filter_all_to_include(custom_field)
custom_field.destroy
visit_cost_reports_index
expect_group_by_all_to_not_exist(custom_field)
expect_filter_all_to_not_exist(custom_field)
end
it 'allows for changing the db table between requests if no caching is done' do
old_table_name = WorkPackageCustomField.table_name
new_table_name = 'custom_fields_clone'
new_id = custom_field.id + 1
begin
mock_cache_classes_setting_with(false)
visit_cost_reports_index
expect_group_by_all_to_include(custom_field)
expect_filter_all_to_include(custom_field)
ActiveRecord::Base.connection.execute("CREATE TABLE #{new_table_name} AS SELECT * from custom_fields;")
ActiveRecord::Base.connection.execute("UPDATE #{new_table_name} SET id = #{new_id} WHERE id = #{custom_field.id};")
CustomField::Translation.where(custom_field_id: custom_field.id).update_all(custom_field_id: new_id)
WorkPackageCustomField.table_name = new_table_name
visit_cost_reports_index
expect_group_by_all_to_not_exist(custom_field)
expect_filter_all_to_not_exist(custom_field)
expect_group_by_all_to_include(new_id)
expect_filter_all_to_include(new_id)
ensure
WorkPackageCustomField.table_name = old_table_name
ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{new_table_name}")
end
end
end

@ -0,0 +1,31 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
module OpenProject::Reporting::SpecHelper
module ConfigurationHelper
def mock_cache_classes_setting_with(value)
allow(OpenProject::Configuration).to receive(:[]).and_call_original
allow(OpenProject::Configuration).to receive(:[])
.with('cost_reporting_cache_filter_classes')
.and_return(value)
allow(OpenProject::Configuration).to receive(:cost_reporting_cache_filter_classes)
.and_return(value)
end
end
end

@ -0,0 +1,34 @@
#-- copyright
# OpenProject Reporting Plugin
#
# Copyright (C) 2010 - 2014 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#++
module OpenProject::Reporting::SpecHelper
module CustomFieldFilterHelper
def group_by_class_name_string(custom_field)
id = custom_field.is_a?(ActiveRecord::Base) ? custom_field.id : custom_field
"CostQuery::GroupBy::CustomField#{id}"
end
def filter_class_name_string(custom_field)
id = custom_field.is_a?(ActiveRecord::Base) ? custom_field.id : custom_field
"CostQuery::Filter::CustomField#{id}"
end
end
end
Loading…
Cancel
Save