Skip to content

Commit 83487c0

Browse files
Copilotfbacall
andcommitted
Add HasPeople concern, apply to contributors like authors
Co-authored-by: fbacall <503373+fbacall@users.noreply.github.com>
1 parent 6afdfcf commit 83487c0

14 files changed

Lines changed: 245 additions & 98 deletions

app/models/concerns/has_people.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
module HasPeople
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
has_many :person_links, as: :resource, dependent: :destroy
6+
has_many :people, through: :person_links
7+
accepts_nested_attributes_for :person_links, allow_destroy: true, reject_if: :all_blank
8+
end
9+
10+
class_methods do
11+
# Define a person role association (e.g., :authors, :contributors)
12+
# This creates the association and a custom setter that accepts strings, hashes, or Person objects
13+
def has_person_role(role_name, role_key: role_name.to_s.singularize)
14+
# Define the association
15+
has_many role_name, -> { where(person_links: { role: role_key }) }, through: :person_links, source: :person
16+
17+
# Define custom setter that accepts strings (legacy), hashes, or Person objects
18+
define_method("#{role_name}=") do |value|
19+
set_people_for_role(value, role_key)
20+
end
21+
end
22+
end
23+
24+
private
25+
26+
# Set people for a specific role, accepting various input formats
27+
def set_people_for_role(value, role)
28+
return if value.nil?
29+
30+
# Convert to array if needed
31+
people_array = Array(value).reject(&:blank?)
32+
33+
# Remove existing links for this role
34+
person_links.where(role: role).destroy_all
35+
36+
people_array.each do |person_data|
37+
if person_data.is_a?(String)
38+
# Legacy format: parse string into first_name and last_name
39+
parts = person_data.strip.split(/\s+/, 2)
40+
first_name = parts.length > 1 ? parts[0] : ''
41+
last_name = parts.length > 1 ? parts[1] : parts[0]
42+
43+
person = Person.find_or_create_by!(first_name: first_name, last_name: last_name)
44+
person_links.build(person: person, role: role)
45+
elsif person_data.is_a?(Hash)
46+
# Hash format from API
47+
first_name = person_data[:first_name] || person_data['first_name'] || ''
48+
last_name = person_data[:last_name] || person_data['last_name'] || ''
49+
orcid = person_data[:orcid] || person_data['orcid']
50+
51+
person = Person.find_or_create_by!(first_name: first_name, last_name: last_name)
52+
person.update!(orcid: orcid) if orcid.present?
53+
person_links.build(person: person, role: role)
54+
elsif person_data.is_a?(Person)
55+
# Person object
56+
person_links.build(person: person_data, role: role)
57+
end
58+
end
59+
end
60+
end

app/models/material.rb

Lines changed: 13 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Material < ApplicationRecord
2121
include HasDifficultyLevel
2222
include HasEdamTerms
2323
include InSpace
24+
include HasPeople
2425

2526
if TeSS::Config.solr_enabled
2627
# :nocov:
@@ -33,7 +34,9 @@ class Material < ApplicationRecord
3334
text :authors do
3435
authors.map(&:full_name)
3536
end
36-
text :contributors
37+
text :contributors do
38+
contributors.map(&:full_name)
39+
end
3740
text :target_audience
3841
text :keywords
3942
text :resource_type
@@ -66,7 +69,9 @@ class Material < ApplicationRecord
6669
string :keywords, multiple: true
6770
string :fields, multiple: true
6871
string :resource_type, multiple: true
69-
string :contributors, multiple: true
72+
string :contributors, multiple: true do
73+
contributors.map(&:full_name)
74+
end
7075
string :content_provider do
7176
content_provider.try(:title)
7277
end
@@ -106,45 +111,9 @@ class Material < ApplicationRecord
106111

107112
has_many :stars, as: :resource, dependent: :destroy
108113

109-
has_many :person_links, as: :resource, dependent: :destroy
110-
has_many :people, through: :person_links
111-
has_many :authors, -> { where(person_links: { role: 'author' }) }, through: :person_links, source: :person
112-
accepts_nested_attributes_for :person_links, allow_destroy: true, reject_if: :all_blank
113-
114-
# Custom setter for authors that accepts both strings (legacy API) and Person objects
115-
def authors=(value)
116-
return if value.nil?
117-
118-
# Convert to array if needed
119-
authors_array = Array(value).reject(&:blank?)
120-
121-
# Remove existing author links
122-
person_links.where(role: 'author').destroy_all
123-
124-
authors_array.each do |author|
125-
if author.is_a?(String)
126-
# Legacy format: parse string into first_name and last_name
127-
parts = author.strip.split(/\s+/, 2)
128-
first_name = parts.length > 1 ? parts[0] : ''
129-
last_name = parts.length > 1 ? parts[1] : parts[0]
130-
131-
person = Person.find_or_create_by!(first_name: first_name, last_name: last_name)
132-
person_links.build(person: person, role: 'author')
133-
elsif author.is_a?(Hash)
134-
# Hash format from API
135-
first_name = author[:first_name] || author['first_name'] || ''
136-
last_name = author[:last_name] || author['last_name'] || ''
137-
orcid = author[:orcid] || author['orcid']
138-
139-
person = Person.find_or_create_by!(first_name: first_name, last_name: last_name)
140-
person.update!(orcid: orcid) if orcid.present?
141-
person_links.build(person: person, role: 'author')
142-
elsif author.is_a?(Person)
143-
# Person object
144-
person_links.build(person: author, role: 'author')
145-
end
146-
end
147-
end
114+
# Use HasPeople concern for authors and contributors
115+
has_person_role :authors, role_key: 'author'
116+
has_person_role :contributors, role_key: 'contributor'
148117

149118
# Remove trailing and squeezes (:squish option) white spaces inside the string (before_validation):
150119
# e.g. "James Bond " => "James Bond"
@@ -155,10 +124,10 @@ def authors=(value)
155124
validates :other_types, presence: true, if: proc { |m| m.resource_type.include?('other') }
156125
validates :keywords, length: { maximum: 20 }
157126

158-
clean_array_fields(:keywords, :fields, :contributors,
127+
clean_array_fields(:keywords, :fields,
159128
:target_audience, :resource_type, :subsets)
160129

161-
update_suggestions(:keywords, :contributors, :target_audience,
130+
update_suggestions(:keywords, :target_audience,
162131
:resource_type)
163132

164133
def description=(desc)
@@ -257,7 +226,7 @@ def to_oai_dc
257226
xml.tag!('dc:title', title)
258227
xml.tag!('dc:description', description)
259228
authors.each { |a| xml.tag!('dc:creator', a.full_name) }
260-
contributors.each { |a| xml.tag!('dc:contributor', a) }
229+
contributors.each { |c| xml.tag!('dc:contributor', c.full_name) }
261230
xml.tag!('dc:publisher', content_provider.title) if content_provider
262231

263232
xml.tag!('dc:format', 'text/html')

app/serializers/material_serializer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class MaterialSerializer < ApplicationSerializer
55

66
:doi, :licence, :version, :status,
77

8-
:contact, :contributors,
8+
:contact,
99

1010
:difficulty_level, :target_audience, :prerequisites, :syllabus, :learning_objectives, :subsets,
1111

@@ -19,4 +19,5 @@ class MaterialSerializer < ApplicationSerializer
1919
has_many :collections
2020
has_many :events
2121
has_many :authors, serializer: PersonSerializer
22+
has_many :contributors, serializer: PersonSerializer
2223
end

app/views/common/_extra_metadata.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
<% end %>
6161

6262
<%= display_attribute(resource, :authors) { |values| values.map(&:full_name).join(', ') } if resource.respond_to?(:authors) %>
63-
<%= display_attribute(resource, :contributors) { |values| values.join(', ') } if resource.respond_to?(:contributors) %>
63+
<%= display_attribute(resource, :contributors) { |values| values.map(&:full_name).join(', ') } if resource.respond_to?(:contributors) %>
6464
<%= display_attribute(resource, :remote_created_date) if resource.respond_to?(:remote_created_date) %>
6565
<%= display_attribute(resource, :remote_updated_date) if resource.respond_to?(:remote_updated_date) %>
6666
<%= display_attribute(resource, :scientific_topics) { |values| values.map { |x| x.preferred_label }.join(', ') } %>

app/views/materials/_form.html.erb

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,22 @@
8686
</div>
8787

8888
<!-- Field: Contributors -->
89-
<%= f.multi_input :contributors, suggestions_url: people_autocomplete_suggestions_path, title: t('materials.hints.contributors'),
90-
visibility_toggle: TeSS::Config.feature['materials_disabled'] %>
89+
<div class="form-group" id="person-contributor">
90+
<%= f.label :contributors %>
91+
<%= f.field_lock :contributors %>
92+
93+
<div id="person-contributor-list">
94+
<% @material.person_links.where(role: 'contributor').each_with_index do |person_link, index| %>
95+
<%= render partial: 'common/person_form',
96+
locals: { form_name: 'material', index: "contributor_#{index}", person_link: person_link, role: 'contributor' } %>
97+
<% end %>
98+
</div>
99+
100+
<a href="#" id="add-person-contributor" data-role="contributor" class="btn btn-icon">
101+
<i class="icon icon-h4 plus-icon"></i>
102+
</a>
103+
<span class="help-inline-block help-block">Add contributor information</span>
104+
</div>
91105

92106
<!-- Field: Target Audience -->
93107
<%= f.multi_input :target_audience, label: 'Target audiences', errors: @material.errors[:target_audience],
@@ -214,6 +228,11 @@
214228
locals: { form_name: 'material', person_link: nil, role: 'author' } %>
215229
</div>
216230

231+
<div id="person-contributor-template" style="display: none">
232+
<%= render partial: 'common/person_form',
233+
locals: { form_name: 'material', person_link: nil, role: 'contributor' } %>
234+
</div>
235+
217236
<script type="text/javascript">
218237
function removeSuggestion(suggestion) {
219238
s = suggestion.replace(/[^a-zA-Z]/g, '');

app/views/materials/show.json.jbuilder

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ json.extract! @material, :id, :title, :url, :description,
66

77
:doi, :licence, :version, :status,
88

9-
:contact, :contributors,
9+
:contact,
1010

1111
:difficulty_level, :target_audience, :prerequisites, :syllabus, :learning_objectives, :subsets,
1212

@@ -22,6 +22,7 @@ json.collections @material.collections.collect { |x| { title: x[:title], id: x[:
2222
json.events @material.events.collect { |x| { title: x[:title], id: x[:id] } }
2323

2424
json.authors @material.authors.collect { |a| { id: a.id, first_name: a.first_name, last_name: a.last_name, orcid: a.orcid, full_name: a.full_name } }
25+
json.contributors @material.contributors.collect { |c| { id: c.id, first_name: c.first_name, last_name: c.last_name, orcid: c.orcid, full_name: c.full_name } }
2526

2627
json.external_resources do
2728
@material.external_resources.each do |external_resource|

db/migrate/20251119095246_migrate_material_authors_data.rb

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,52 +2,70 @@ class MigrateMaterialAuthorsData < ActiveRecord::Migration[7.2]
22
def up
33
# Migrate existing authors from array to Person model
44
Material.find_each do |material|
5-
next if material.authors.blank?
6-
7-
material.authors.each do |author_name|
8-
next if author_name.blank?
9-
10-
# Parse the name - assume "First Last" format
11-
# Handle edge cases: single names, multiple spaces, etc.
12-
parts = author_name.strip.split(/\s+/, 2)
13-
14-
if parts.length == 1
15-
# Single name - use as last name with empty first name
16-
first_name = ''
17-
last_name = parts[0]
18-
else
19-
first_name = parts[0]
20-
last_name = parts[1] || ''
21-
end
22-
23-
# Find or create person
24-
person = Person.find_or_create_by!(
25-
first_name: first_name,
26-
last_name: last_name
27-
)
28-
29-
# Create the association if it doesn't exist
30-
PersonLink.find_or_create_by!(
31-
resource: material,
32-
person_id: person.id,
33-
role: 'author'
34-
)
35-
end
5+
# Migrate authors
6+
migrate_people_role(material, 'authors', 'author') if material.respond_to?(:read_attribute) && material.read_attribute(:authors).present?
7+
8+
# Migrate contributors
9+
migrate_people_role(material, 'contributors', 'contributor') if material.respond_to?(:read_attribute) && material.read_attribute(:contributors).present?
3610
end
3711
end
3812

3913
def down
40-
# Restore authors array from Person model
14+
# Restore arrays from Person model
4115
Material.find_each do |material|
16+
# Restore authors
4217
author_names = material.people.where(person_links: { role: 'author' }).map(&:full_name).compact
43-
# Use raw SQL to avoid validation issues
4418
Material.connection.execute(
4519
"UPDATE materials SET authors = ARRAY[#{author_names.map { |n| "'#{n.gsub("'", "''")}'" }.join(',')}]::varchar[] WHERE id = #{material.id}"
4620
)
21+
22+
# Restore contributors
23+
contributor_names = material.people.where(person_links: { role: 'contributor' }).map(&:full_name).compact
24+
Material.connection.execute(
25+
"UPDATE materials SET contributors = ARRAY[#{contributor_names.map { |n| "'#{n.gsub("'", "''")}'" }.join(',')}]::varchar[] WHERE id = #{material.id}"
26+
)
4727
end
4828

4929
# Clean up the new tables
5030
PersonLink.delete_all
5131
Person.delete_all
5232
end
33+
34+
private
35+
36+
def migrate_people_role(material, column_name, role)
37+
# Get the raw array from database
38+
people_array = material.read_attribute(column_name)
39+
return if people_array.blank?
40+
41+
people_array.each do |person_name|
42+
next if person_name.blank?
43+
44+
# Parse the name - assume "First Last" format
45+
# Handle edge cases: single names, multiple spaces, etc.
46+
parts = person_name.strip.split(/\s+/, 2)
47+
48+
if parts.length == 1
49+
# Single name - use as last name with empty first name
50+
first_name = ''
51+
last_name = parts[0]
52+
else
53+
first_name = parts[0]
54+
last_name = parts[1] || ''
55+
end
56+
57+
# Find or create person
58+
person = Person.find_or_create_by!(
59+
first_name: first_name,
60+
last_name: last_name
61+
)
62+
63+
# Create the association if it doesn't exist
64+
PersonLink.find_or_create_by!(
65+
resource: material,
66+
person_id: person.id,
67+
role: role
68+
)
69+
end
70+
end
5371
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
class RemoveAuthorsFromMaterials < ActiveRecord::Migration[7.2]
22
def change
33
remove_column :materials, :authors, :string, array: true, default: []
4+
remove_column :materials, :contributors, :string, array: true, default: []
45
end
56
end

lib/bioschemas/learning_resource_generator.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ def self.bioschemas_profile
2222
author_hash
2323
}
2424
}
25-
property :contributor, -> (material) { material.contributors.map { |p| person(p) } }
25+
property :contributor, -> (material) {
26+
material.contributors.map { |c|
27+
contributor_hash = { "@type" => "Person", "name" => c.full_name }
28+
contributor_hash["@id"] = "https://orcid.org/#{c.orcid}" if c.orcid.present?
29+
contributor_hash
30+
}
31+
}
2632
property :provider, -> (material) { provider(material) }
2733
property :audience, -> (material) {
2834
material.target_audience.map { |audience| { '@type' => 'Audience', 'audienceType' => audience } }

test/controllers/materials_controller_test.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,7 @@ class MaterialsControllerTest < ActionController::TestCase
253253
date_published: test_material.date_published,
254254
doi: test_material.doi,
255255
subsets: test_material.subsets,
256-
person_links_attributes: test_material.person_links.where(role: 'author').map { |pl| { role: 'author', person_attributes: { first_name: pl.person.first_name, last_name: pl.person.last_name, orcid: pl.person.orcid } } },
257-
contributors: test_material.contributors,
256+
person_links_attributes: test_material.person_links.map { |pl| { role: pl.role, person_attributes: { first_name: pl.person.first_name, last_name: pl.person.last_name, orcid: pl.person.orcid } } },
258257
prerequisites: test_material.prerequisites,
259258
syllabus: test_material.syllabus,
260259
learning_objectives: test_material.learning_objectives
@@ -300,7 +299,15 @@ class MaterialsControllerTest < ActionController::TestCase
300299
assert_equal expected_author.last_name, author_json['last_name'], "author #{i} last_name not matched"
301300
assert_equal expected_author.full_name, author_json['full_name'], "author #{i} full_name not matched"
302301
end
303-
assert_equal test_material.contributors, JSON.parse(response.body)['contributors'], 'contributors not matched'
302+
# Contributors is now an array of objects with id, first_name, last_name, orcid, full_name
303+
response_contributors = JSON.parse(response.body)['contributors']
304+
assert_equal test_material.contributors.size, response_contributors.size, 'contributors count not matched'
305+
response_contributors.each_with_index do |contributor_json, i|
306+
expected_contributor = test_material.contributors[i]
307+
assert_equal expected_contributor.first_name, contributor_json['first_name'], "contributor #{i} first_name not matched"
308+
assert_equal expected_contributor.last_name, contributor_json['last_name'], "contributor #{i} last_name not matched"
309+
assert_equal expected_contributor.full_name, contributor_json['full_name'], "contributor #{i} full_name not matched"
310+
end
304311
assert_equal test_material.subsets, JSON.parse(response.body)['subsets'], 'subsets not matched'
305312
assert_equal test_material.prerequisites, JSON.parse(response.body)['prerequisites'], 'prerequisites not matched'
306313
assert_equal test_material.syllabus, JSON.parse(response.body)['syllabus'], 'syllabus not matched'

0 commit comments

Comments
 (0)