Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions app/controllers/thesis_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def publication_statuses
end

def edit
@thesis = Thesis.includes([degrees: :degree_type]).find(params[:id])
@thesis = Thesis.includes([{ degrees: :degree_type }]).find(params[:id])
@thesis.association(:advisors).add_to_target(Advisor.new) if @thesis.advisors.count.zero?
end

Expand Down Expand Up @@ -100,7 +100,7 @@ def start
end

def update
@thesis = Thesis.includes([authors: :user]).find(params[:id])
@thesis = Thesis.includes([{ authors: :user }]).find(params[:id])
if @thesis.update(thesis_params)
flash[:success] = "#{@thesis.title} has been updated."
ReceiptMailer.receipt_email(@thesis, current_user).deliver_later
Expand All @@ -114,6 +114,7 @@ def process_theses
@thesis = Thesis.with_attached_files.includes([:departments, {
authors: [:user], degrees: [:degree_type]
}]).find(params[:id])
@other_theses_with_holds = @thesis.other_theses_with_holds.to_a
end

def process_theses_update
Expand All @@ -139,7 +140,7 @@ def process_theses_update
end

def proquest_export_preview
@theses = Thesis.includes([authors: :user]).ready_for_proquest_export
@theses = Thesis.includes([{ authors: :user }]).ready_for_proquest_export
end

# TODO: we need to generate and send a budget report CSV for partially harvested theses (spec TBD).
Expand Down
35 changes: 33 additions & 2 deletions app/jobs/registrar_import_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class RegistrarImportJob < ActiveJob::Base
def perform(registrar)
results = { read: 0, processed: 0, new_users: 0, new_theses: 0, updated_theses: 0, new_degrees: [], new_depts: [],
new_degree_periods: [], errors: [] }
new_theses = [] # Track newly created theses to detect duplicate theses with holds

CSV.new(registrar.graduation_list.download, headers: true).each.with_index(1) do |row, i|
Rails.logger.info("Parsing row #{i}")
Expand Down Expand Up @@ -39,7 +40,12 @@ def perform(registrar)
# Set whodunnit for thesis transaction
PaperTrail.request.whodunnit = 'registrar'
thesis = Thesis.create_or_update_from_csv(user, degree, department, grad_date, row)
thesis.new_thesis? ? results[:new_theses] += 1 : results[:updated_theses] += 1
if thesis.new_thesis?
results[:new_theses] += 1
new_theses << thesis

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of keeping this larger list and then winnowing it down at the end to the (hopefully much smaller or empty) list I feel like we could update collect_users_with_multiple_hold_theses to act on a single thesis and call it here and refactor to only be tracking multiple_hold_users array. I think this is both more memory efficient and easier to follow unless we need the full list for something I'm not seeing yet

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I misunderstand the flow, I think we need the full list to refer back to for later loop iterations.

Let's say we have a thesis with two coauthors. Author 1 is earlier in the CSV and has no other held theses; Author 2 is later and does have another held thesis. If the hold check only runs when the thesis is first created, we would miss the hold warning for Author 2. It's an unlikely use case, but one that we ought to consider.

I agree that we can refactor such that it doesn't store the thesis objects in memory while still running a hold check on every row, but that would likely add complexity. How concerned are we about efficiency?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed that important use case. Let's leave it as is and if we notice performance issues we can revisit. We probably won't :)

My concern noted the performance concern, but was really rooted in trying to make this easier to follow... but accuracy is more important than simplicity for this for sure.

else
results[:updated_theses] += 1
end
logger.info("Thesis is #{thesis.inspect}")
rescue RuntimeError
e = "Multiple theses found for author #{user.name} for term #{grad_date}, requires Processor attention. CSV row ##{i}: #{row.inspect}"
Expand All @@ -55,11 +61,36 @@ def perform(registrar)
author.set_graduated_from_csv(row)
results[:processed] += 1
end

multiple_hold_users = collect_users_with_multiple_hold_theses(new_theses)

Rails.logger.info(results.to_s)
ReportMailer.registrar_import_email(registrar, results).deliver_later
ReportMailer.registrar_import_email(registrar, results, multiple_hold_users).deliver_later
results
end

def collect_users_with_multiple_hold_theses(new_theses)
multiple_hold_users = []

new_theses.each do |thesis|
other_theses_with_holds = thesis.other_theses_with_holds.includes(:users).to_a
thesis.users.each do |user|
user_with_other_theses_with_holds = other_theses_with_holds.select do |other_thesis|
other_thesis.users.any? { |u| u.id == user.id }
end
next if user_with_other_theses_with_holds.empty?

multiple_hold_users << {
user: user,
new_thesis: thesis,
other_theses_with_holds: user_with_other_theses_with_holds
}
end
end
Comment thread
Copilot marked this conversation as resolved.

multiple_hold_users
end
Comment on lines +72 to +92

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a tad overstated. We execute one DB query per new thesis to fetch related theses and eager-load users on those theses.


# The thesis model sets the day of the month to 1 if only supplied a month
# and a year during thesis creation, which means in practice we have to
# assume that the day is always 1 (because this will be true for any theses
Expand Down
21 changes: 11 additions & 10 deletions app/mailers/report_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
class ReportMailer < ApplicationMailer
def registrar_import_email(registrar, results)
def registrar_import_email(registrar, results, multiple_hold_users = [])
return unless ENV.fetch('DISABLE_ALL_EMAIL', 'true') == 'false' # allows PR builds to disable emails

@registrar = registrar
@results = results
mail(from: "MIT Libraries <#{ENV['ETD_APP_EMAIL']}>",
to: ENV['THESIS_ADMIN_EMAIL'],
cc: ENV['MAINTAINER_EMAIL'],
@multiple_hold_users = multiple_hold_users
mail(from: "MIT Libraries <#{ENV.fetch('ETD_APP_EMAIL', nil)}>",
to: ENV.fetch('THESIS_ADMIN_EMAIL', nil),
cc: ENV.fetch('MAINTAINER_EMAIL', nil),
Comment on lines +8 to +10

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts directly with rubocop guidance.

subject: 'Registrar data import summary')
end

def publication_results_email(results)
return unless ENV.fetch('DISABLE_ALL_EMAIL', 'true') == 'false' # allows PR builds to disable emails

@results = results
mail(from: "MIT Libraries <#{ENV['ETD_APP_EMAIL']}>",
to: ENV['THESIS_ADMIN_EMAIL'],
cc: ENV['MAINTAINER_EMAIL'],
mail(from: "MIT Libraries <#{ENV.fetch('ETD_APP_EMAIL', nil)}>",
to: ENV.fetch('THESIS_ADMIN_EMAIL', nil),
cc: ENV.fetch('MAINTAINER_EMAIL', nil),
Comment on lines +18 to +20

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts directly with rubocop guidance.

subject: 'DSpace publication results summary')
end

def preservation_results_email(results)
return unless ENV.fetch('DISABLE_ALL_EMAIL', 'true') == 'false' # allows PR builds to disable emails

@results = results
mail(from: "MIT Libraries <#{ENV['ETD_APP_EMAIL']}>",
to: ENV['THESIS_ADMIN_EMAIL'],
cc: ENV['MAINTAINER_EMAIL'],
mail(from: "MIT Libraries <#{ENV.fetch('ETD_APP_EMAIL', nil)}>",
to: ENV.fetch('THESIS_ADMIN_EMAIL', nil),
cc: ENV.fetch('MAINTAINER_EMAIL', nil),
Comment on lines +28 to +30

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts directly with rubocop guidance.

subject: 'Archivematica preservation submission results summary')
end
end
27 changes: 27 additions & 0 deletions app/models/thesis.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ def active_holds?
holds.map { |h| h.status.in? %w[active expired] }.any?
end

# Returns an ActiveRecord::Relation of other theses for the same user(s)
# that have active or expired holds.
# Used to alert processors about pre-existing holds prior to publication.
def other_theses_with_holds
user_ids = users.ids
return Thesis.none if user_ids.empty?

Thesis.joins(:users, :holds)
.where(users: { id: user_ids })
.merge(Hold.active_or_expired)
.where.not(id: id)
.distinct
.order(:id)
end

# Returns a true/false value if there are any associated advisors.
def advisors?
advisors.any?
Expand Down Expand Up @@ -204,6 +219,7 @@ def evaluate_status
metadata_complete?,
no_issues_found?,
no_active_holds?,
no_other_theses_with_holds?,
authors_graduated?,
departments_have_dspace_name?,
degrees_have_types?,
Expand Down Expand Up @@ -237,6 +253,17 @@ def no_active_holds?
!active_holds?
end

# Mirrors no_active_holds? but for other theses by the same user(s).
# Publication review is blocked when another thesis for the same user has
# an active or expired hold.
Comment thread
JPrevost marked this conversation as resolved.
#
# Releasing a Hold is the expected way for a Hold to be removed. Expiration signals to
# processors to review the Hold to see if it can be released, but expiration is not intended as a
# signal that the Thesis is publishable.
def no_other_theses_with_holds?
other_theses_with_holds.none?
end

# This inverts the issues_found field, so that the checks inside the
# update_status method below are all written the same way.
# The UI will still rely on the issues_found field directly, as its
Expand Down
25 changes: 25 additions & 0 deletions app/views/report_mailer/registrar_import_email.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@
<li>New degree periods: <%= @results[:new_degree_periods].count %></li>
</ul>

<% if @multiple_hold_users.any? %>
<p style="font-weight: bold;">WARNING: Users with active or expired holds on existing theses</p>

<p>The following users have active or expired holds on existing theses and are receiving new theses from this import.
Processors should review these cases to resolve holds during publication review:</p>

<ul>
<% @multiple_hold_users.each do |hold_case| %>
<li>
<strong><%= hold_case[:user].name %></strong> (ID: <%= hold_case[:user].id %>)
<ul>
<li>New thesis created: <%= link_to "Thesis ##{hold_case[:new_thesis].id}", thesis_url(hold_case[:new_thesis]) %></li>
<li>Other theses with active or expired holds:
<ul>
<% hold_case[:other_theses_with_holds].each do |other_thesis| %>
<li><%= link_to "Thesis ##{other_thesis.id}", thesis_url(other_thesis) %></li>
<% end %>
</ul>
</li>
</ul>
</li>
<% end %>
</ul>
<% end %>

<% if @results[:errors].any? %>
<p>The following errors require processor attention:</p>

Expand Down
14 changes: 14 additions & 0 deletions app/views/thesis/process_theses.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@
hint_html: { style: 'display: block' },
hint: link_to('See details in admin interface', admin_thesis_path(f.object), target: :_blank) %>
</li>
<li>
<%= f.input :no_other_theses_with_holds?, as: :string,
readonly: true,
label_html: { style: 'width: 50%' },
input_html: { class: 'disabled', style: 'width: 40%', value: @other_theses_with_holds.none? ? 'Yes' : 'No' },
label: 'Related holds resolved?' %>
<% if @other_theses_with_holds.any? %>
<ul style="margin: 0.5em 0 0.75em 0; padding-left: 1.25em;">
<% @other_theses_with_holds.each do |other_thesis| %>
<li><%= link_to "Thesis ##{other_thesis.id}", thesis_path(other_thesis) %></li>
<% end %>
</ul>
<% end %>
</li>
<li>
<%= f.input :accession_number?, as: :string,
readonly: true,
Expand Down
24 changes: 24 additions & 0 deletions test/controllers/thesis_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,30 @@ def attach_files_to_records(tr, th)
assert_response :success
end

test 'processing form shows links to other theses with active or expired holds' do
sign_in users(:processor)
Author.create!(user: users(:yo), thesis: theses(:downloaded), graduation_confirmed: true)

get thesis_process_path(theses(:one))

assert_response :success
assert_select 'label', text: 'Related holds resolved?'
assert_select 'input[readonly][value=?]', 'No'
assert_select "a[href='#{thesis_path(theses(:with_hold))}']", text: "Thesis ##{theses(:with_hold).id}"
assert_select "a[href='#{thesis_path(theses(:downloaded))}']", text: "Thesis ##{theses(:downloaded).id}"
end

test 'processing form does not show other-theses-with-holds section when there are no matches' do
sign_in users(:processor)

get thesis_process_path(theses(:released_hold))

assert_response :success
assert_select 'label', text: 'Related holds resolved?'
assert_select 'input[readonly][value=?]', 'Yes'
assert_select 'a', text: /Thesis #\d+/, count: 0
end

# ~~~~~~~~~~~~~~~~~~~ submitting thesis processing form ~~~~~~~~~~~~~~~~~~~~~
test 'anonymous users cannot submit thesis processing form' do
patch thesis_process_update_path(theses(:one)),
Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/authors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ seven:
proquest_allowed: false

review:
user: yo
user: third
thesis: publication_review
graduation_confirmed: true

eight:
user: basic
user: second
thesis: publication_review_except_hold
graduation_confirmed: true

Expand Down Expand Up @@ -100,7 +100,7 @@ fourteen:
graduation_confirmed: true

fifteen:
user: yo
user: admin
thesis: doctor
graduation_confirmed: true
proquest_allowed: false
Expand Down
9 changes: 9 additions & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,12 @@ thesis_admin:
given_name: 'Thesis A.'
surname: 'Robot'
display_name: 'Thesis A. Robot'

isolated:
uid: 'isolated'
email: 'isolated@example.com'
kerberos_id: 'isolated'
given_name: 'Isolated'
surname: 'Student'
display_name: 'Isolated Student'
preferred_name: 'Student, Isolated'
2 changes: 1 addition & 1 deletion test/integration/admin/admin_hold_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def teardown
patch admin_hold_path(hold), params: { hold: { status: 'released' } }
hold.reload
assert_equal 'released', hold.status
assert_equal Date.today.strftime('%Y-%m-%d'), Date.parse(hold.date_released).strftime('%Y-%m-%d')
assert_equal Date.current.strftime('%Y-%m-%d'), Date.parse(hold.date_released).strftime('%Y-%m-%d')
end

test 'hold release date is the most recent released status change' do
Expand Down
48 changes: 48 additions & 0 deletions test/jobs/registrar_import_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,52 @@ class RegistrarImportJobTest < ActiveJob::TestCase
assert_equal 'registrar', User.first.versions.first.whodunnit
assert_equal 'registrar', Thesis.last.versions.first.whodunnit
end

test 'collect_users_with_multiple_hold_theses returns empty array when no users have other held theses' do
job = RegistrarImportJob.new
user = User.create!(uid: 'test_fresh_user', email: 'fresh@example.com')
new_thesis = Thesis.new(
title: 'Fresh Thesis',
graduation_year: 2025,
graduation_month: 'September',
departments: [departments(:one)]
)
new_thesis.users << user
new_thesis.save!

result = job.collect_users_with_multiple_hold_theses([new_thesis])
assert_equal [], result
assert_equal Array, result.class
end

test 'collect_users_with_multiple_hold_theses flags a thesis for a single user with other held theses' do
job = RegistrarImportJob.new
new_thesis = theses(:one)

result = job.collect_users_with_multiple_hold_theses([new_thesis])

assert_equal 1, result.length
assert_equal users(:yo), result.first[:user]
assert_equal new_thesis, result.first[:new_thesis]
assert_equal Array, result.first[:other_theses_with_holds].class
assert_includes result.first[:other_theses_with_holds], theses(:with_hold)
end

test 'collect_users_with_multiple_hold_theses flags only co-authors with other held theses' do
job = RegistrarImportJob.new
new_thesis = theses(:one)

# Create a co-author with no other held theses to ensure per-user filtering works.
coauthor = User.create!(uid: 'coauthor_test_user', email: 'coauthor@example.com')
Author.create!(user: coauthor, thesis: new_thesis, graduation_confirmed: true)

result = job.collect_users_with_multiple_hold_theses([new_thesis])

assert_equal 1, result.length
assert_equal users(:yo), result.first[:user]
assert_equal new_thesis, result.first[:new_thesis]
assert_equal Array, result.first[:other_theses_with_holds].class
assert_includes result.first[:other_theses_with_holds], theses(:with_hold)
assert_not_includes result.map { |row| row[:user] }, coauthor
end
end
Loading