Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8d5710c
Add Author model and migration for authors has_many relationship
Copilot Nov 19, 2025
b399963
Update serializers, views, bioschemas and ingestors for new Author model
Copilot Nov 19, 2025
2f8ce09
Fix tests to work with new Author model
Copilot Nov 19, 2025
ea5876a
Refactor Author to generic Person model with polymorphic PersonLink
Copilot Dec 2, 2025
d360ced
Add custom authors= setter for legacy API backwards compatibility
Copilot Dec 2, 2025
fbd140c
Add HasPeople concern, apply to contributors like authors
Copilot Dec 4, 2025
508211d
Add full_name column, rename first_name/last_name to given_name/famil…
Copilot Dec 5, 2025
4009375
Add legacy first_name/last_name to permitted params for API backwards…
Copilot Dec 5, 2025
d209e06
Add optional belongs_to :profile on Person, auto-link by ORCID on save
Copilot Dec 5, 2025
507208e
Schema
fbacall Dec 5, 2025
6d79f96
Fix missing `end`
fbacall Dec 5, 2025
97f8bcd
DRY up some ORCID handling code
fbacall Dec 5, 2025
5d8acf6
Revert CSV ingestor changes and fix test
fbacall Dec 5, 2025
0921ece
Revert material API change
fbacall Dec 5, 2025
ba745c9
Fix bad ORCIDs in tests
fbacall Dec 11, 2025
d3c818d
Remove the indirection - resources manage their own `Person` objects*
fbacall Feb 11, 2026
d840bac
Fix broken person form (it still looks bad though)
fbacall Feb 11, 2026
b8f36f8
Restore contributors and authors to materials serializer
fbacall Feb 11, 2026
8003356
Test fix
fbacall Feb 12, 2026
c28bada
Just use `full_name` for people
fbacall Feb 20, 2026
8d1af15
Migration to convert people to new table
fbacall Feb 20, 2026
2afa622
Improve person form
fbacall Feb 20, 2026
f8cb2f6
Test fix
fbacall Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/assets/javascripts/people.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
var People = {
add: function (role) {
var templateId = '#person-' + role + '-template';
var listId = '#person-' + role + '-list';
var newForm = $(templateId).clone().html();

// Ensure the index of the new form is 1 greater than the current highest index, to prevent collisions
var index = 0;
$(listId + ' .person-form').each(function () {
var newIndex = parseInt($(this).data('index'));
if (newIndex > index) {
index = newIndex;
}
});

// Replace the placeholder index with the actual index
newForm = $(newForm.replace(/replace-me/g, index + 1));
newForm.appendTo(listId);

return false; // Stop form being submitted
},

// This is just cosmetic. The actual removal is done by rails,
// by virtue of the hidden checkbox being checked when the label is clicked.
delete: function () {
$(this).parents('.person-form').fadeOut();
}
};

document.addEventListener("turbolinks:load", function() {

$('[id^="person-"]')
.on('click', '[id^="add-person-"]', function() {
var role = $(this).data('role');
People.add(role);
return false;
})
.on('change', '.delete-person-btn input.destroy-attribute', People.delete);
});
10 changes: 0 additions & 10 deletions app/assets/stylesheets/external-resources.scss
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
.external-resource-form {
padding-bottom: 10px;
margin-bottom: 10px;

&.deleted {
color: $brand-danger;
text-decoration: line-through;
}
}

.associate-resource {
cursor: pointer;
}
Expand Down
12 changes: 11 additions & 1 deletion app/assets/stylesheets/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@ label.required abbr {
margin-top: 0;
}

.delete-external-resource-btn {
.nested-resource-form {
padding-bottom: 10px;
margin-bottom: 10px;

&.deleted {
color: $brand-danger;
text-decoration: line-through;
}
}

.delete-nested-resource-btn {
@include delete-cross;

cursor: pointer;
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/materials_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,12 @@ def material_params
:content_provider_id, :difficulty_level, :version, :status,
:date_created, :date_modified, :date_published, :other_types,
:prerequisites, :syllabus, :visible, :learning_objectives, { subsets: [] },
{ contributors: [] }, { authors: [] }, { target_audience: [] },
{ authors: [] }, { contributors: [] }, { target_audience: [] },
{ collection_ids: [] }, { keywords: [] }, { resource_type: [] },
{ scientific_topic_names: [] }, { scientific_topic_uris: [] },
{ operation_names: [] }, { operation_uris: [] },
{ node_ids: [] }, { node_names: [] }, { fields: [] },
people_attributes: %i[id role _destroy full_name orcid],
external_resources_attributes: %i[id url title _destroy],
external_resources: %i[url title],
event_ids: [], locked_fields: [])
Expand Down
18 changes: 18 additions & 0 deletions app/models/concerns/has_orcid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module HasOrcid
extend ActiveSupport::Concern

included do
auto_strip_attributes :orcid
before_validation :normalize_orcid
end

def orcid_url
return nil if orcid.blank?
"#{OrcidValidator::ORCID_PREFIX}#{orcid}"
end

def normalize_orcid
return if orcid.blank?
self.orcid = orcid.strip.sub(OrcidValidator::ORCID_DOMAIN_REGEX, '')
end
end
42 changes: 42 additions & 0 deletions app/models/concerns/has_people.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module HasPeople
extend ActiveSupport::Concern

included do
has_many :people, as: :resource, dependent: :destroy, inverse_of: :resource
accepts_nested_attributes_for :people, allow_destroy: true, reject_if: :all_blank
end

class_methods do
# Define a person role association (e.g., :authors, :contributors)
# This creates the association and a custom setter that accepts strings, hashes, or Person objects
def has_person_role(role_name, role_key: role_name.to_s.singularize)
# Define the association
has_many role_name, -> { where(role: role_key) }, class_name: 'Person', as: :resource, inverse_of: :resource

# Define custom setter that accepts strings (legacy), hashes, or Person objects
define_method("#{role_name}=") do |value|
super(set_people_for_role(value, role_key))
end
end
end

private

# Set people for a specific role, accepting various input formats
def set_people_for_role(value, role_key)
# Remove existing links for this role
people.where(role: role_key).destroy_all

Array(value).reject(&:blank?).map do |person_data|
if person_data.is_a?(String)
# Legacy format: store as full_name directly
people.build(full_name: person_data.strip, role: role_key)
elsif person_data.is_a?(Hash)
people.build(**person_data, role: role_key)
elsif person_data.is_a?(Person)
person_data.role = role_key
person_data
end
end
end
end
29 changes: 21 additions & 8 deletions app/models/material.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Material < ApplicationRecord
include HasDifficultyLevel
include HasEdamTerms
include InSpace
include HasPeople

if TeSS::Config.solr_enabled
# :nocov:
Expand All @@ -30,8 +31,12 @@ class Material < ApplicationRecord
text :description
text :contact
text :doi
text :authors
text :contributors
text :authors do
authors.map(&:display_name)
end
text :contributors do
contributors.map(&:display_name)
end
text :target_audience
text :keywords
text :resource_type
Expand All @@ -51,7 +56,9 @@ class Material < ApplicationRecord
end
# other fields
string :title
string :authors, multiple: true
string :authors, multiple: true do
authors.map(&:display_name)
end
string :scientific_topics, multiple: true do
scientific_topics_and_synonyms
end
Expand All @@ -62,7 +69,9 @@ class Material < ApplicationRecord
string :keywords, multiple: true
string :fields, multiple: true
string :resource_type, multiple: true
string :contributors, multiple: true
string :contributors, multiple: true do
contributors.map(&:display_name)
end
string :content_provider do
content_provider.try(:title)
end
Expand Down Expand Up @@ -102,6 +111,10 @@ class Material < ApplicationRecord

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

# Use HasPeople concern for authors and contributors
has_person_role :authors, role_key: 'author'
has_person_role :contributors, role_key: 'contributor'

# Remove trailing and squeezes (:squish option) white spaces inside the string (before_validation):
# e.g. "James Bond " => "James Bond"
auto_strip_attributes :title, :description, :url, squish: false
Expand All @@ -111,10 +124,10 @@ class Material < ApplicationRecord
validates :other_types, presence: true, if: proc { |m| m.resource_type.include?('other') }
validates :keywords, length: { maximum: 20 }

clean_array_fields(:keywords, :fields, :contributors, :authors,
clean_array_fields(:keywords, :fields,
:target_audience, :resource_type, :subsets)

update_suggestions(:keywords, :contributors, :authors, :target_audience,
update_suggestions(:keywords, :target_audience,
:resource_type)

def description=(desc)
Expand Down Expand Up @@ -212,8 +225,8 @@ def to_oai_dc
'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd') do
xml.tag!('dc:title', title)
xml.tag!('dc:description', description)
authors.each { |a| xml.tag!('dc:creator', a) }
contributors.each { |a| xml.tag!('dc:contributor', a) }
authors.each { |a| xml.tag!('dc:creator', a.display_name) }
contributors.each { |c| xml.tag!('dc:contributor', c.display_name) }
xml.tag!('dc:publisher', content_provider.title) if content_provider

xml.tag!('dc:format', 'text/html')
Expand Down
38 changes: 38 additions & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class Person < ApplicationRecord
include HasOrcid

belongs_to :profile, optional: true
belongs_to :resource, polymorphic: true

validates :resource, :role, :full_name, presence: true

# Automatically link to profile based on ORCID on save
before_save :link_to_profile_by_orcid

# Return the display name - currently just the full_name
def display_name
full_name
end

# Extract person attributes from a string containing a person's name and possibly an ORCID.
def self.attr_from_string(person_string)
orcid = nil
name = person_string.gsub(/\s*\(?(orcid: )?(https?:\/\/orcid\.org\/)?(\d\d\d\d-\d\d\d\d-\d\d\d\d-\d\d\d[\dxX])[ \)]*/) do |_|
orcid = $3
''
end.strip
{ full_name: name, orcid: orcid }
end

private

# Automatically link to a Profile if one exists with a matching ORCID
def link_to_profile_by_orcid
if orcid.blank?
self.profile = nil
else
matching_profile = Profile.find_by(orcid: orcid)
self.profile = matching_profile if matching_profile.present?
end
end
end
14 changes: 3 additions & 11 deletions app/models/profile.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
require 'uri'

class Profile < ApplicationRecord
auto_strip_attributes :firstname, :surname, :website, :orcid, squish: false
include HasOrcid

auto_strip_attributes :firstname, :surname, :website, squish: false
belongs_to :user, inverse_of: :profile

before_validation :normalize_orcid
Expand All @@ -25,11 +27,6 @@ def full_name
"#{firstname} #{surname}".strip
end

def orcid_url
return nil if orcid.blank?
"#{OrcidValidator::ORCID_PREFIX}#{orcid}"
end

def merge(*others)
Profile.transaction do
attrs = attributes
Expand Down Expand Up @@ -66,11 +63,6 @@ def authenticate_orcid(orcid)

private

def normalize_orcid
return if orcid.blank?
self.orcid = orcid.strip.sub(OrcidValidator::ORCID_DOMAIN_REGEX, '')
end

def check_public
public ? self.type = 'Trainer' : self.type = 'Profile'
end
Expand Down
4 changes: 4 additions & 0 deletions app/serializers/application_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ def ontology_terms(type)
object.send(type).map { |t| { preferred_label: t.preferred_label, uri: t.uri } }
end

def people(type)
object.send(type).map(&:display_name)
end

link(:self) { polymorphic_path(object) }
end
8 changes: 8 additions & 0 deletions app/serializers/material_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@ class MaterialSerializer < ApplicationSerializer
has_many :nodes
has_many :collections
has_many :events

def contributors
people(:contributors)
end

def authors
people(:authors)
end
end
4 changes: 2 additions & 2 deletions app/views/common/_external_resource_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<%# which can be dynamically cloned using JavaScript to add more ExternalResources to the main material form %>
<% field_name_prefix = "#{form_name}[external_resources_attributes][#{index}]" %> <%# This format is dictated by "accepts_nested_attributes_for" %>

<div class="form-inline external-resource-form" data-index="<%= index -%>">
<div class="form-inline external-resource-form nested-resource-form" data-index="<%= index -%>">
<%= hidden_field_tag("#{field_name_prefix}[id]", external_resource.id) %>

<div class="form-group">
Expand All @@ -15,7 +15,7 @@
<%= text_field_tag "#{field_name_prefix}[url]", external_resource.url, :class => 'form-control external-resource-url', :placeholder => 'URL' %>
</div>

<label class="ml-2 delete-external-resource-btn">
<label class="ml-2 delete-external-resource-btn delete-nested-resource-btn">
<i class="icon icon-sm cross-icon icon-greyscale"></i>
<%= hidden_field_tag "#{field_name_prefix}[_destroy]", '0', :autocomplete => 'off' %>
<%= check_box_tag "#{field_name_prefix}[_destroy]", '1', false, :class => 'destroy-attribute', :autocomplete => 'off' %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/common/_extra_metadata.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
<%= display_attribute(resource, :sponsors) { |values| values.join(', ') } %>
<% end %>

<%= display_attribute(resource, :authors) { |values| values.join(', ') } if resource.respond_to?(:authors) %>
<%= display_attribute(resource, :contributors) { |values| values.join(', ') } if resource.respond_to?(:contributors) %>
<%= display_attribute(resource, :authors) { |values| values.map(&:display_name).join(', ') } if resource.respond_to?(:authors) %>
<%= display_attribute(resource, :contributors) { |values| values.map(&:display_name).join(', ') } if resource.respond_to?(:contributors) %>
<%= display_attribute(resource, :remote_created_date) if resource.respond_to?(:remote_created_date) %>
<%= display_attribute(resource, :remote_updated_date) if resource.respond_to?(:remote_updated_date) %>
<%= display_attribute(resource, :scientific_topics) { |values| values.map { |x| x.preferred_label }.join(', ') } %>
Expand Down
25 changes: 25 additions & 0 deletions app/views/common/_person_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<% index ||= 'replace-me' %> <%# This is so we can render a blank version of this sub-form in the page, %>
<%# which can be dynamically cloned using JavaScript to add more People to the main form %>
<% field_name_prefix = "#{form_name}[people_attributes][#{index}]" %> <%# This format is dictated by "accepts_nested_attributes_for" %>

<div class="form-inline person-form nested-resource-form" data-index="<%= index -%>" data-role="<%= role -%>">
<%= hidden_field_tag("#{field_name_prefix}[id]", person&.id) %>
<%= hidden_field_tag("#{field_name_prefix}[role]", role) %>

<div class="form-group">
<%= text_field_tag "#{field_name_prefix}[full_name]", person&.full_name, size: 32,
class: 'form-control person-full-name',
placeholder: Person.human_attribute_name(:full_name) %>
</div>

<div class="form-group">
<%= text_field_tag "#{field_name_prefix}[orcid]", person&.orcid, size: 20, class: 'form-control person-orcid',
placeholder: Person.human_attribute_name(:orcid) %>
</div>

<label class="ml-2 delete-person-btn delete-nested-resource-btn">
<i class="icon icon-sm cross-icon icon-greyscale"></i>
<%= hidden_field_tag "#{field_name_prefix}[_destroy]", '0', autocomplete: 'off' %>
<%= check_box_tag "#{field_name_prefix}[_destroy]", '1', false, class: 'destroy-attribute', autocomplete: 'off' %>
</label>
</div>
Loading