diff --git a/.gitignore b/.gitignore index d3fd6cc3ab..5c5f043efe 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ public/javascripts/i18n.js public/javascripts/translations.js public/stylesheets/*_cached.css public/system/ +public/sitemaps/* public/assets/ public/assets_dev/ diff --git a/Gemfile b/Gemfile index fd40d4e720..619ca2babd 100644 --- a/Gemfile +++ b/Gemfile @@ -109,6 +109,7 @@ gem 'ransack' gem 'terser', '~> 1.1', '>= 1.1.1' + # Rails 4 upgrade gem 'activerecord-session_store' gem 'rails-observers' @@ -177,6 +178,8 @@ group :development do gem 'web-console', '>= 4.1.0' gem 'rack-mini-profiler', '~> 2.0' + gem "flamegraph", "~> 0.9.5" + gem "stackprof", "~> 0.2.25" gem 'listen', '~> 3.3' end @@ -202,3 +205,5 @@ group :test, :development do gem 'teaspoon' gem 'teaspoon-mocha' end + +gem "sitemap_generator", "~> 6.3" diff --git a/Gemfile.lock b/Gemfile.lock index b2797432af..192e716d39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -286,6 +286,7 @@ GEM loofah (>= 2.3.1) sax-machine (>= 1.0) ffi (1.15.5) + flamegraph (0.9.5) fssm (0.2.10) gem-licenses (0.2.2) gitlab_omniauth-ldap (2.2.0) @@ -828,6 +829,8 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.3) + sitemap_generator (6.3.0) + builder (~> 3.0) slop (3.6.0) snaky_hash (2.0.0) hashie @@ -852,6 +855,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) sqlite3 (1.4.2) + stackprof (0.2.25) stringio (3.0.1) sunspot (2.6.0) pr_geohash (~> 1.0) @@ -990,6 +994,7 @@ DEPENDENCIES exception_notification factory_bot (~> 6.2.1) feedjira + flamegraph (~> 0.9.5) fleximage! fssm gem-licenses @@ -1073,8 +1078,10 @@ DEPENDENCIES seedbank simple-spreadsheet-extractor (~> 0.18.0) simplecov + sitemap_generator (~> 6.3) sprockets-rails sqlite3 (~> 1.4) + stackprof (~> 0.2.25) stringio (= 3.0.1) sunspot_matchers sunspot_rails diff --git a/app/controllers/assays_controller.rb b/app/controllers/assays_controller.rb index 48c13e0dd1..796642c82d 100644 --- a/app/controllers/assays_controller.rb +++ b/app/controllers/assays_controller.rb @@ -6,6 +6,7 @@ class AssaysController < ApplicationController before_action :assays_enabled? before_action :find_assets, :only=>[:index] before_action :find_and_authorize_requested_item, :only=>[:edit, :update, :destroy, :manage, :manage_update, :show, :new_object_based_on_existing_one] + before_action :delete_linked_sample_types, only: [:destroy] #project_membership_required_appended is an alias to project_membership_required, but is necessary to include the actions #defined in the application controller @@ -18,7 +19,7 @@ class AssaysController < ApplicationController api_actions :index, :show, :create, :update, :destroy def new_object_based_on_existing_one - @existing_assay = Assay.find(params[:id]) + @existing_assay = Assay.find(params[:id]) @assay = @existing_assay.clone_with_associations if @existing_assay.can_view? @@ -62,9 +63,7 @@ def new_object_based_on_existing_one flash[:error]="You do not have the necessary permissions to copy this #{t('assays.assay')}" redirect_to @existing_assay end - - - end + end def new @assay=setup_new_asset @@ -112,6 +111,13 @@ def create end end + + def delete_linked_sample_types + return unless is_single_page_assay? + + @assay.sample_type.destroy + end + def update update_assay_organisms @assay, params update_assay_human_diseases @assay, params @@ -177,4 +183,10 @@ def assay_params assay_params[:model_ids].select! { |id| Model.find_by_id(id).try(:can_view?) } if assay_params.key?(:model_ids) end end + + def is_single_page_assay? + return false unless params.key?(:return_to) + + params[:return_to].start_with? '/single_pages/' + end end diff --git a/app/controllers/studies_controller.rb b/app/controllers/studies_controller.rb index 2aed0d36a1..bec5ef69fc 100644 --- a/app/controllers/studies_controller.rb +++ b/app/controllers/studies_controller.rb @@ -6,6 +6,7 @@ class StudiesController < ApplicationController before_action :studies_enabled? before_action :find_assets, only: [:index] before_action :find_and_authorize_requested_item, only: %i[edit update destroy manage manage_update show new_object_based_on_existing_one] + before_action :delete_linked_sample_types, only: [:destroy] # project_membership_required_appended is an alias to project_membership_required, but is necesary to include the actions # defined in the application controller @@ -88,6 +89,16 @@ def update end end + def delete_linked_sample_types + return unless is_single_page_study? + + # The study sample types must be destroyed in reversed order + # otherwise the first sample type won't be removed becaused it is linked from the second + study_st_ids = @study.sample_types.map(&:id).sort { |a, b| b <=> a } + SampleType.destroy(study_st_ids) + end + + def show @study = Study.find(params[:id]) @@ -201,7 +212,7 @@ def batch_create study_params = { title: params[:studies][:title][index], description: params[:studies][:description][index], - investigation_id: params[:study][:investigation_id], + investigation_id: params[:study][:investigation_id], custom_metadata: CustomMetadata.new( custom_metadata_type: metadata_types, data: metadata @@ -351,3 +362,9 @@ def study_params { custom_metadata_attributes: determine_custom_metadata_keys }) end end + +def is_single_page_study? + return false unless params.key?(:return_to) + + params[:return_to].start_with? '/single_pages/' +end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 53d070285a..dcb5dace04 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -87,7 +87,7 @@ def task_status def populate_template uploaded_file = params[:template_json_file] - dir = Rails.root.join('config', 'default_data', 'source_types') + dir = Seek::Config.append_filestore_path('source_types') if Dir.exist?(dir) `rm #{dir}/*` @@ -146,11 +146,11 @@ def set_status end def lockfile - Rails.root.join('tmp', 'populate_templates.lock') + Rails.root.join(Seek::Config.temporary_filestore_path, 'populate_templates.lock') end def resultfile - Rails.root.join('tmp', 'populate_templates.result') + Rails.root.join(Seek::Config.temporary_filestore_path, 'populate_templates.result') end def running! diff --git a/app/models/assay.rb b/app/models/assay.rb index d8bb9b9a68..13b2b9279e 100644 --- a/app/models/assay.rb +++ b/app/models/assay.rb @@ -77,7 +77,7 @@ def short_description end def state_allows_delete?(*args) - assets.empty? && publications.empty? && super + assets.empty? && publications.empty? && associated_samples_through_sample_type.empty? && super end # returns true if this is a modelling class of assay @@ -90,6 +90,10 @@ def is_experimental? !assay_class.nil? && assay_class.key == 'EXP' end + def associated_samples_through_sample_type + (sample_type.nil? || sample_type.samples.nil?) ? [] : sample_type.samples + end + # Create or update relationship of this assay to another, with a specific relationship type and version def associate(asset, options = {}) if asset.is_a?(Organism) diff --git a/app/models/study.rb b/app/models/study.rb index 944373fc9b..bc1691852e 100644 --- a/app/models/study.rb +++ b/app/models/study.rb @@ -2,7 +2,7 @@ class Study < ApplicationRecord enum status: [:planned, :running, :completed, :cancelled, :failed] belongs_to :assignee, class_name: 'Person' - + searchable(:auto_index => false) do text :experimentalists end if Seek::Config.solr_enabled @@ -22,7 +22,7 @@ class Study < ApplicationRecord has_many :sop_versions, through: :assays has_one :external_asset, as: :seek_entity, dependent: :destroy - + has_and_belongs_to_many :sops has_and_belongs_to_many :sample_types @@ -41,7 +41,16 @@ def assets end def state_allows_delete? *args - assays.empty? && super + assays.empty? && associated_samples_through_sample_type.empty? && super + end + + def associated_samples_through_sample_type + return [] if sample_types.nil? + st_samples = [] + sample_types.map do |st| + st.samples.map { |sts| st_samples.push sts } + end + st_samples end def clone_with_associations diff --git a/app/views/general/_index.html.erb b/app/views/general/_index.html.erb index 574ef2ffc1..37f9ff69e2 100644 --- a/app/views/general/_index.html.erb +++ b/app/views/general/_index.html.erb @@ -5,10 +5,14 @@ show_new_button = true unless local_assigns.has_key?(:show_new_button) title ||= nil subtitle ||= nil + isa_templates_enabled = Seek::Config.sample_type_template_enabled && Seek::Config.project_single_page_advanced_enabled %>
<% if show_new_button && controller_model.can_create? %> + <% if isa_templates_enabled %> + <%= link_to "Query by #{t('template').pluralize}", query_form_samples_path, class: "btn btn-default btn" %> + <% end %> <%= button_link_to(new_item_label, "new", new_item_path) %> <% end %> diff --git a/app/views/programmes/activation_review.html.erb b/app/views/programmes/activation_review.html.erb index b7562f03b1..d373da2cd8 100644 --- a/app/views/programmes/activation_review.html.erb +++ b/app/views/programmes/activation_review.html.erb @@ -1,4 +1,4 @@ -<%= render :partial => "general/item_title",:locals => {:item=>@programme, :title_prefix=>"Acitivation required for: "} %> +<%= render :partial => "general/item_title",:locals => {:item=>@programme, :title_prefix=>"Activation required for: "} %>

A <%= t('programme') %> has been created by a user, and requires activation. The <%= t('programme') %> created is <%= link_to @programme.title, @programme %>. diff --git a/config/schedule.rb b/config/schedule.rb index 5216b0f962..743d0d60ed 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -71,6 +71,11 @@ def offset(off_hours) runner "Seek::BioSchema::DataDump.generate_dumps" end +# Generate a new sitemap... +every 1.day, at: '12:45 am' do + rake "-s sitemap:refresh" +end + # not safe to automatically add in a non containerised environment if Seek::Docker.using_docker? every 10.minutes do diff --git a/config/sitemap.rb b/config/sitemap.rb new file mode 100644 index 0000000000..90cfab2813 --- /dev/null +++ b/config/sitemap.rb @@ -0,0 +1,19 @@ +# https://github.com/kjvarga/sitemap_generator#sitemapgenerator +SitemapGenerator::Sitemap.sitemaps_path = "sitemaps" +SitemapGenerator::Sitemap.create_index = "auto" +SitemapGenerator::Sitemap.compress = false +SitemapGenerator::Sitemap.default_host = URI.parse(Seek::Config.site_base_url) + +SitemapGenerator::Sitemap.create do + Seek::Util.searchable_types.each do |type| + add polymorphic_path(type), lastmod: type.maximum(:updated_at), changefreq: 'daily', priority: 0.7 + end +end + +Seek::Util.searchable_types.each do |type| + SitemapGenerator::Sitemap.create(filename: type.table_name, include_root: false) do + type.authorized_for('view', nil).find_all do |obj| + add polymorphic_path(obj), lastmod: obj.updated_at, changefreq: 'daily', priority: 0.7 + end + end +end diff --git a/lib/seek/isa_templates/template_extractor.rb b/lib/seek/isa_templates/template_extractor.rb index b0487a7e24..236b8d5606 100644 --- a/lib/seek/isa_templates/template_extractor.rb +++ b/lib/seek/isa_templates/template_extractor.rb @@ -12,7 +12,7 @@ def self.extract_templates disable_authorization_checks do client = Ebi::OlsClient.new project = Project.find_or_create_by(title: 'Default Project') - directory = Rails.root.join('config', 'default_data', 'source_types') + directory = Seek::Config.append_filestore_path('source_types') directory_files = Dir.exist?(directory) ? Dir.glob("#{directory}/*.json") : [] raise '

' if directory_files == [] @@ -172,11 +172,11 @@ def self.seed_isa_tags end def self.lockfile - Rails.root.join('tmp', 'populate_templates.lock') + Rails.root.join(Seek::Config.temporary_filestore_path, 'populate_templates.lock') end def self.resultfile - Rails.root.join('tmp', 'populate_templates.result') + Rails.root.join(Seek::Config.temporary_filestore_path, 'populate_templates.result') end end end diff --git a/lib/tasks/seek_upgrades.rake b/lib/tasks/seek_upgrades.rake index ca6b6e607a..51ef422a1c 100644 --- a/lib/tasks/seek_upgrades.rake +++ b/lib/tasks/seek_upgrades.rake @@ -8,28 +8,6 @@ namespace :seek do # these are the tasks required for this version upgrade task upgrade_version_tasks: %i[ environment - db:seed:007_sample_attribute_types - update_missing_openbis_istest - update_missing_publication_versions - update_edam_controlled_vocab_keys - db:seed:011_topics_controlled_vocab - db:seed:012_operations_controlled_vocab - db:seed:013_formats_controlled_vocab - db:seed:014_data_controlled_vocab - db:seed:015_isa_tags - db:seed:003_model_formats - db:seed:004_model_recommended_environments - remove_orphaned_versions - refresh_workflow_internals - remove_scale_annotations - remove_spreadsheet_annotations - remove_node_annotations - convert_roles - update_edam_annotation_attributes - remove_orphaned_project_subscriptions - remove_node_activity_logs - remove_node_asset_creators - set_default_sample_type_creators ] # these are the tasks that are executes for each upgrade as standard, and rarely change @@ -66,201 +44,6 @@ namespace :seek do end end - task(update_missing_openbis_istest: :environment) do - puts '... creating missing is_test for OpenbisEndpoint...' - create = 0 - disable_authorization_checks do - OpenbisEndpoint.find_each do |openbis_endpoint| - # check if the publication has a version - # then create one if missing - if openbis_endpoint.is_test.nil? - openbis_endpoint.is_test = false # default -> prod, https - openbis_endpoint.save - unless openbis_endpoint.is_test.nil? - create += 1 - end - end - # publication.save - end - end - puts " ... finished creating missing is_test for #{create.to_s} OpenbisEndpoint(s)" - end - - task(update_missing_publication_versions: :environment) do - puts '... creating missing publications versions ...' - create = 0 - disable_authorization_checks do - Publication.find_each do |publication| - # check if the publication has a version - # then create one if missing - if publication.latest_version.nil? - publication.save_as_new_version 'Version for legacy entries' - unless publication.latest_version.nil? - create += 1 - end - end - # publication.save - end - end - puts " ... finished creating missing publications versions for #{create.to_s} publications" - end - - task(remove_orphaned_versions: [:environment]) do - puts 'Removing orphaned versions ...' - count = 0 - types = [DataFile::Version, Document::Version, Sop::Version, Model::Version, Presentation::Version, - Sop::Version, Workflow::Version] - disable_authorization_checks do - types.each do |type| - found = type.where.missing(:parent) - count += found.length - found.each(&:destroy) - end - end - puts "... finished removing #{count} orphaned versions" - end - - task(remove_scale_annotations: [:environment]) do - a = Annotation.joins(:annotation_attribute).where(annotation_attribute: { name: ['additional_scale_info', 'scale'] }) - count = a.count - a.destroy_all - AnnotationAttribute.where(name:['scale','additional_scale_info']).destroy_all - puts "Removed #{count} scale related annotations" if count > 0 - end - - task(remove_spreadsheet_annotations: [:environment]) do - annotations = Annotation.where(annotatable_type: 'CellRange') - count = annotations.count - values = TextValue.joins(:annotations).where(annotations: { annotatable_type: 'CellRange' }) - values.select{|v| v.annotations.count == 1}.each(&:destroy) - annotations.destroy_all - AnnotationAttribute.where(name:'annotation').destroy_all - puts "Removed #{count} spreadsheet related annotations" if count > 0 - end - - task(remove_node_annotations: [:environment]) do - annotations = Annotation.where(annotatable_type: 'Node') - count = annotations.count - values = TextValue.joins(:annotations).where(annotations: { annotatable_type: 'Node' }) - values.select{|v| v.annotations.count == 1}.each(&:destroy) - annotations.destroy_all - puts "Removed #{count} Node related annotations" if count > 0 - end - - task(convert_roles: [:environment]) do - puts 'Converting roles...' - disable_authorization_checks do - Person.find_each do |person| - RoleType.for_system.each do |rt| - mask = rt.id - if (person.roles_mask & mask) != 0 - Role.where(role_type_id: rt.id, person_id: person.id, scope: nil).first_or_create! - end - end - end - - class AdminDefinedRoleProject < ActiveRecord::Base; end - - AdminDefinedRoleProject.find_each do |role| - RoleType.for_projects.each do |rt| - mask = rt.id - if (role.role_mask & mask) != 0 - Role.where(role_type_id: rt.id, person_id: role.person_id, - scope_type: 'Project', scope_id: role.project_id).first_or_create! - end - end - end - - class AdminDefinedRoleProgramme < ActiveRecord::Base; end - - AdminDefinedRoleProgramme.find_each do |role| - RoleType.for_programmes.each do |rt| - mask = rt.id - if (role.role_mask & mask) != 0 - Role.where(role_type_id: rt.id, person_id: role.person_id, - scope_type: 'Programme', scope_id: role.programme_id).first_or_create! - end - end - end - end - end - - task(update_edam_annotation_attributes: [:environment]) do - defs = { - "edam_formats": "data_format_annotations", - "edam_topics": "topic_annotations", - "edam_operations": "operation_annotations", - "edam_data": "data_type_annotations" - } - defs.each do |old_name,new_name| - query = AnnotationAttribute.where(name: old_name) - if query.any? - puts "Updating EDAM based #{old_name} Annotation Attributes" - query.update_all(name: new_name) - end - end - end - - task(update_edam_controlled_vocab_keys: [:environment]) do - defs = { - topics: 'edam_topics', - operations: 'edam_operations', - data_formats: 'edam_formats', - data_types: 'edam_data' - } - - defs.each do |property, old_key| - new_key = SampleControlledVocab::SystemVocabs.database_key_for_property(property) - query = SampleControlledVocab.where(key: old_key) - if query.any? - puts "Updating key for #{old_key} controlled vocabulary" - query.update_all(key: new_key) - end - end - end - - task(remove_orphaned_project_subscriptions: [:environment]) do - disable_authorization_checks do - ProjectSubscription.where.missing(:project).destroy_all - end - end - - task(remove_node_activity_logs: [:environment]) do - logs = ActivityLog.where(activity_loggable_type: 'Node') - puts "Removing #{logs.count} Node related activity logs" if logs.count > 0 - logs.delete_all - end - - task(remove_node_asset_creators: [:environment]) do - creators = AssetsCreator.where(asset_type: 'Node') - puts "Removing #{creators.count} Node related asset creators" if creators.count > 0 - creators.delete_all - end - - task(refresh_workflow_internals: [:environment]) do |task| - ran = only_once(task) do - Rake::Task['seek:rebuild_workflow_internals'].invoke - end - - puts "Skipping workflow internals rebuild, already done" unless ran - end - - task(set_default_sample_type_creators: [:environment]) do - ran = only_once('set_default_sample_type_creators') do - puts "Setting default Sample Type creators" - count = 0 - SampleType.all.each do |sample_type| - if sample_type.assets_creators.empty? - sample_type.assets_creators.build(creator: sample_type.contributor).save! - count += 1 - end - end - puts "#{count} Sample Types updated" - end - - puts "Skipping setting default Sample Type creators, as already set" unless ran - end - private ## diff --git a/test/functional/assays_controller_test.rb b/test/functional/assays_controller_test.rb index 41b1d850d1..86b8c10ef0 100644 --- a/test/functional/assays_controller_test.rb +++ b/test/functional/assays_controller_test.rb @@ -1921,4 +1921,20 @@ def check_fixtures_for_authorization_of_sops_and_datafiles_links assert_select 'span.updated_last_by a', false, 'Last editor should not be shown if editor user has been deleted' end + test 'should delete empty assay with linked sample type' do + person = FactoryBot.create(:person) + assay_sample_type = FactoryBot.create :linked_sample_type, contributor: person + assay = FactoryBot.create(:assay, + policy:FactoryBot.create(:private_policy, permissions:[FactoryBot.create(:permission,contributor: person, access_type:Policy::EDITING)]), + sample_type: assay_sample_type, + contributor: person) + + login_as(person) + + assert_difference('SampleType.count', -1) do + assert_difference('Assay.count', -1) do + delete :destroy, params: { id: assay.id, return_to: '/single_pages/' } + end + end + end end diff --git a/test/functional/studies_controller_test.rb b/test/functional/studies_controller_test.rb index 70db123ffd..4810d31a41 100644 --- a/test/functional/studies_controller_test.rb +++ b/test/functional/studies_controller_test.rb @@ -11,7 +11,7 @@ class StudiesControllerTest < ActionController::TestCase def setup login_as FactoryBot.create(:admin).user end - + test 'should get index' do FactoryBot.create :study, policy: FactoryBot.create(:public_policy) get :index @@ -1229,7 +1229,7 @@ def test_should_show_investigation_tab policy: FactoryBot.create(:public_policy), contributor: person) get :show, params: { id: study.id } - + assert_response :success assert_select 'a[href=?]', order_assays_study_path(study), count: 0 @@ -1394,4 +1394,22 @@ def test_should_show_investigation_tab assert_equal 'my_tag', assigns(:study).tags_as_text_array.first end + test 'should delete empty study with linked sample type' do + person = FactoryBot.create(:person) + study_source_sample_type = FactoryBot.create :linked_sample_type, contributor: person + study_sample_sample_type = FactoryBot.create :linked_sample_type, contributor: person + study = FactoryBot.create(:study, + policy:FactoryBot.create(:private_policy, permissions:[FactoryBot.create(:permission,contributor: person, access_type:Policy::EDITING)]), + sample_types: [study_source_sample_type, study_sample_sample_type], + contributor: person) + + login_as(person) + + assert_difference('SampleType.count', -2) do + assert_difference('Study.count', -1) do + delete :destroy, params: { id: study.id, return_to: '/single_pages/' } + end + end + end + end diff --git a/test/unit/assay_test.rb b/test/unit/assay_test.rb index 106f225503..1a948b9a21 100644 --- a/test/unit/assay_test.rb +++ b/test/unit/assay_test.rb @@ -260,6 +260,25 @@ class AssayTest < ActiveSupport::TestCase assert !one_assay_with_publication.can_delete?(User.current_user.person) end + # Users shouldn't be able to delete assays populated with samples through their linked sample types + test 'can only delete assays with empty sample types' do + assay_sample_type = FactoryBot.create(:simple_sample_type, title: "Assay Sample Type with samples") + empty_sample_type = FactoryBot.create(:simple_sample_type, title: "Empty assay Sample Type") + assay_samples = (0..4).map do |i| + FactoryBot.create(:sample, title: "DNA Extract nr. #{i}", sample_type: assay_sample_type) + end + + assay = FactoryBot.create(:assay, title: "First Assay", sample_type: assay_sample_type) + empty_assay = FactoryBot.create(:assay, title: "Empty assay", sample_type:empty_sample_type) + + assert_equal(assay.sample_type.samples.size, 5) + assert(empty_assay.sample_type.samples.none?) + + assert_equal(assay.state_allows_delete?, false) + assert_equal(empty_assay.state_allows_delete?, true) + end + + test 'assets' do assay = assays(:metabolomics_assay) assert_equal 3, assay.assets.size, 'should be 2 sops and 1 data file' diff --git a/test/unit/study_test.rb b/test/unit/study_test.rb index d22ded3792..593a12b45e 100644 --- a/test/unit/study_test.rb +++ b/test/unit/study_test.rb @@ -45,6 +45,24 @@ class StudyTest < ActiveSupport::TestCase assert !study.can_delete?(study.contributor) end + # Users shouldn't be able to delete studies populated with samples through their linked sample types + test 'can only delete studies with empty sample types' do + study_source_sample_type = FactoryBot.create(:simple_sample_type, title: "Source Sample Type") + empty_sample_type = FactoryBot.create(:simple_sample_type, title: "Empty study Sample Type") + sources = (0..4).map do |i| + FactoryBot.create(:sample, title: "Source nr. #{i}", sample_type: study_source_sample_type) + end + + study = FactoryBot.create(:study, title: "First study", sample_types: [study_source_sample_type]) + empty_study = FactoryBot.create(:study, title: "Empty study", sample_types:[empty_sample_type]) + + assert_equal(study.sample_types.first.samples.size, 5) + assert(empty_study.sample_types.first.samples.none?) + + assert_equal(study.state_allows_delete?, false) + assert_equal(empty_study.state_allows_delete?, true) + end + test 'publications through assays' do assay1 = FactoryBot.create(:assay) study = assay1.study