• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / pdc_describe / 858fe02b-c87a-499f-b042-1a6083291eee

16 Apr 2025 05:29PM UTC coverage: 95.451% (+0.03%) from 95.418%
858fe02b-c87a-499f-b042-1a6083291eee

Pull #2106

circleci

JaymeeH
restoring tool versions
Pull Request #2106: notifying creators for all message events

11 of 11 new or added lines in 1 file covered. (100.0%)

7 existing lines in 1 file now uncovered.

3525 of 3693 relevant lines covered (95.45%)

206.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.98
/app/models/work_activity.rb
1
# frozen_string_literal: true
2

3
require_relative "../lib/diff_tools"
1✔
4

5
# rubocop:disable Metrics/ClassLength
6
class WorkActivity < ApplicationRecord
1✔
7
  MESSAGE = "COMMENT" # TODO: Migrate existing records to "MESSAGE"; then close #825.
1✔
8
  NOTIFICATION = "NOTIFICATION"
1✔
9
  MESSAGE_ACTIVITY_TYPES = [MESSAGE, NOTIFICATION].freeze
1✔
10

11
  CHANGES = "CHANGES"
1✔
12
  DATACITE_ERROR = "DATACITE-ERROR"
1✔
13
  FILE_CHANGES = "FILE-CHANGES"
1✔
14
  MIGRATION_START = "MIGRATION_START"
1✔
15
  MIGRATION_COMPLETE = "MIGRATION_COMPLETE"
1✔
16
  PROVENANCE_NOTES = "PROVENANCE-NOTES"
1✔
17
  SYSTEM = "SYSTEM"
1✔
18
  CHANGE_LOG_ACTIVITY_TYPES = [CHANGES, FILE_CHANGES, PROVENANCE_NOTES, SYSTEM, DATACITE_ERROR, MIGRATION_COMPLETE].freeze
1✔
19

20
  USER_REFERENCE = /@[\w]*/ # e.g. @xy123
1✔
21

22
  include Rails.application.routes.url_helpers
1✔
23

24
  belongs_to :work
1✔
25
  has_many :work_activity_notifications, dependent: :destroy
1✔
26

27
  def self.add_work_activity(work_id, message, user_id, activity_type:, created_at: nil)
1✔
28
    activity = WorkActivity.new(
477✔
29
      work_id:,
30
      activity_type:,
31
      message:,
32
      created_by_user_id: user_id,
33
      created_at: # If nil, will be set by activerecord at save.
34
    )
35
    activity.save!
477✔
36
    activity.notify_users
477✔
37

38
    if activity.message_event_type?
477✔
39
      activity.notify_creator
137✔
40
    end
41

42
    activity
477✔
43
  end
44

45
  def self.activities_for_work(work_id, activity_types)
1✔
46
    where(work_id:, activity_type: activity_types)
609✔
47
  end
48

49
  def self.messages_for_work(work_id)
1✔
50
    activities_for_work(work_id, MESSAGE_ACTIVITY_TYPES)
292✔
51
  end
52

53
  def self.changes_for_work(work_id)
1✔
54
    activities_for_work(work_id, CHANGE_LOG_ACTIVITY_TYPES)
294✔
55
  end
56

57
  # notify the creator of the work whenever a message activity type is created
58
  def notify_creator
1✔
59
    # Don't notify the creator if they are already referenced in the message
60
    return if users_referenced.include?(created_by_user_id) || created_by_user_id.nil?
137✔
61
    WorkActivityNotification.create(work_activity_id: id, user_id: created_by_user_id)
136✔
62
  end
63

64
  # Log notifications for each of the users references on the activity
65
  def notify_users
1✔
66
    users_referenced.each do |uid|
483✔
67
      user_id = User.where(uid:).first&.id
252✔
68
      if user_id.nil?
252✔
69
        notify_group(uid)
106✔
70
      else
71
        WorkActivityNotification.create(work_activity_id: id, user_id:)
146✔
72
      end
73
    end
74
  end
75

76
  def notify_group(groupid)
1✔
77
    group = Group.where(code: groupid).first
106✔
78
    if group.nil?
106✔
79
      Rails.logger.info("Message #{id} for work #{work_id} referenced an non-existing user: #{groupid}")
3✔
80
    else
81
      group.administrators.each do |admin|
103✔
82
        WorkActivityNotification.create(work_activity_id: id, user_id: admin.id)
66✔
83
      end
84
    end
85
  end
86

87
  # Returns the `uid` of the users referenced on the activity (without the `@` symbol)
88
  def users_referenced
1✔
89
    message.scan(USER_REFERENCE).map { |at_uid| at_uid[1..-1] }
1,088✔
90
  end
91

92
  def created_by_user
1✔
93
    return nil unless created_by_user_id
362✔
94
    User.find(created_by_user_id)
357✔
95
  end
96

97
  def message_event_type?
1✔
98
    MESSAGE_ACTIVITY_TYPES.include? activity_type
477✔
99
  end
100

101
  def log_event_type?
1✔
UNCOV
102
    CHANGE_LOG_ACTIVITY_TYPES.include? activity_type
×
103
  end
104

105
  def renderer
1✔
106
    @renderer ||= begin
133✔
107
                    klass = if activity_type == CHANGES
132✔
108
                              MetadataChanges
32✔
109
                            elsif activity_type == FILE_CHANGES
100✔
110
                              FileChanges
9✔
111
                            elsif activity_type == MIGRATION_COMPLETE
91✔
112
                              Migration
1✔
113
                            elsif activity_type == PROVENANCE_NOTES
90✔
114
                              ProvenanceNote
5✔
115
                            elsif CHANGE_LOG_ACTIVITY_TYPES.include?(activity_type)
85✔
116
                              OtherLogEvent
38✔
117
                            else
118
                              Message
47✔
119
                            end
120
                    klass.new(self)
132✔
121

122
                  end
123
  end
124

125
  delegate :to_html, to: :renderer
1✔
126

127
  class Renderer
1✔
128
    def initialize(work_activity)
1✔
129
      @work_activity = work_activity
132✔
130
    end
131

132
    UNKNOWN_USER = "Unknown user outside the system"
1✔
133
    DATE_TIME_FORMAT = "%B %d, %Y %H:%M"
1✔
134
    DATE_FORMAT = "%B %d, %Y"
1✔
135
    SORTABLE_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M"
1✔
136

137
    def to_html
1✔
138
      title_html + "<span class='message-html'>#{body_html.chomp}</span>"
128✔
139
    end
140

141
    def created_by_user_html
1✔
142
      return UNKNOWN_USER unless @work_activity.created_by_user
85✔
143

144
      "#{@work_activity.created_by_user.given_name_safe} (@#{@work_activity.created_by_user.uid})"
81✔
145
    end
146

147
    def created_sortable_html
1✔
148
      @work_activity.created_at.time.strftime(SORTABLE_DATE_TIME_FORMAT)
5✔
149
    end
150

151
    def created_updated_html
1✔
152
      created = @work_activity.created_at.time.strftime(DATE_TIME_FORMAT)
128✔
153
      updated = @work_activity.updated_at.time.strftime(DATE_TIME_FORMAT)
128✔
154
      created_date = @work_activity.created_at.time.strftime(DATE_FORMAT)
128✔
155
      updated_date = @work_activity.updated_at.time.strftime(DATE_FORMAT)
128✔
156
      if created_date == updated_date
128✔
157
        created
116✔
158
      else
159
        "#{created} (backdated event created #{updated})"
12✔
160
      end
161
    end
162

163
    def title_html
1✔
164
      "<span class='activity-history-title'>#{created_updated_html} by #{created_by_user_html}</span>"
80✔
165
    end
166
  end
167

168
  class MetadataChanges < Renderer
1✔
169
    # Returns the message formatted to display _metadata_ changes that were logged as an activity
170
    def body_html
1✔
171
      message_json = JSON.parse(@work_activity.message)
32✔
172

173
      # Messages should consistently be Arrays of Hashes, but this might require a migration from legacy field records
174
      messages = if message_json.is_a?(Array)
32✔
UNCOV
175
                   message_json
×
176
                 else
177
                   Array.wrap(message_json)
32✔
178
                 end
179

180
      elements = messages.map do |message|
32✔
181
        markup = if message.is_a?(Hash)
32✔
182
                   message.keys.map do |field|
32✔
183
                     mapped = message[field].map { |value| change_value_html(value) }
174✔
184
                     "<details class='message-html'><summary class='show-changes'>#{field&.titleize}</summary>#{mapped.join}</details>"
87✔
185
                   end
186
                 else
187
                   # For handling cases where WorkActivity#message only contains Strings, or Arrays of Strings
188
                   [
UNCOV
189
                     "<details class='message-html'><summary class='show-changes'></summary>#{message}</details>"
×
190
                   ]
191
                 end
192
        markup.join
32✔
193
      end
194

195
      elements.flatten.join
32✔
196
    end
197

198
    def change_value_html(value)
1✔
199
      if value["action"] == "changed"
87✔
200
        DiffTools::SimpleDiff.new(value["from"], value["to"]).to_html
87✔
201
      else
UNCOV
202
        "old change"
×
203
      end
204
    end
205
  end
206

207
  class FileChanges < Renderer
1✔
208
    # Returns the message formatted to display _file_ changes that were logged as an activity
209
    def body_html
1✔
210
      changes = JSON.parse(@work_activity.message)
9✔
211
      if changes.is_a?(Hash)
9✔
212
        changes = [changes]
1✔
213
      end
214

215
      files_added = changes.select { |v| v["action"] == "added" }
27✔
216
      files_deleted = changes.select { |v| v["action"] == "removed" }
27✔
217
      files_replaced = changes.select { |v| v["action"] == "replaced" }
27✔
218

219
      changes_html = []
9✔
220
      unless files_added.empty?
9✔
221
        label = "Files Added: "
8✔
222
        label += files_added.length.to_s
8✔
223
        changes_html << "<tr><td>#{label}</td></tr>"
8✔
224
      end
225

226
      unless files_deleted.empty?
9✔
227
        label = "Files Deleted: "
2✔
228
        label += files_deleted.length.to_s
2✔
229
        changes_html << "<tr><td>#{label}</td></tr>"
2✔
230
      end
231

232
      unless files_replaced.empty?
9✔
233
        label = "Files Replaced: "
1✔
234
        label += files_replaced.length.to_s
1✔
235
        changes_html << "<tr><td>#{label}</td></tr>"
1✔
236
      end
237

238
      "<table>#{changes_html.join}</table>"
9✔
239
    end
240
  end
241

242
  class Migration < Renderer
1✔
243
    # Returns the message formatted to display _file_ changes that were logged as an activity
244
    def body_html
1✔
245
      changes = JSON.parse(@work_activity.message)
1✔
246
      "<p>#{changes['message']}</p>"
1✔
247
    end
248
  end
249

250
  class BaseMessage < Renderer
1✔
251
    def body_html
1✔
252
      text = user_refernces(@work_activity.message)
86✔
253
      mark_down_to_html(text)
86✔
254
    end
255

256
    def mark_down_to_html(text_in)
1✔
257
      # allow ``` for code blocks (Kramdown only supports ~~~)
258
      text = text_in.gsub("```", "~~~")
91✔
259
      Kramdown::Document.new(text).to_html
91✔
260
    end
261

262
    def user_refernces(text_in)
1✔
263
      # convert user references to user links
264
      text_in.gsub(USER_REFERENCE) do |at_uid|
91✔
265
        uid = at_uid[1..-1]
60✔
266

267
        if uid
60✔
268
          group = Group.find_by(code: uid)
60✔
269
          if group
60✔
270
            "<a class='message-user-link' title='#{group.title}' href='#{@work_activity.group_path(group)}'>#{group.title}</a>"
23✔
271
          else
272
            user = User.find_by(uid:)
37✔
273
            user_info = if user
37✔
274
                          user.given_name_safe
37✔
275
                        else
UNCOV
276
                          uid
×
277
                        end
278
            "<a class='message-user-link' title='#{user_info}' href='#{@work_activity.users_path}/#{uid}'>#{at_uid}</a>"
37✔
279
          end
280
        else
UNCOV
281
          Rails.logger.warn("Failed to extract the user ID from #{uid}")
×
UNCOV
282
          UNKNOWN_USER
×
283
        end
284
      end
285
    end
286
  end
287

288
  class OtherLogEvent < BaseMessage
1✔
289
  end
290

291
  class Message < BaseMessage
1✔
292
    # Override the default:
293
    def created_by_user_html
1✔
294
      return UNKNOWN_USER unless @work_activity.created_by_user
48✔
295

296
      user = @work_activity.created_by_user
47✔
297
      "#{user.given_name_safe} (@#{user.uid})"
47✔
298
    end
299

300
    def title_html
1✔
301
      "<span class='activity-history-title'>#{created_by_user_html} at #{created_updated_html}</span>"
48✔
302
    end
303
  end
304

305
  class ProvenanceNote < BaseMessage
1✔
306
    def body_html
1✔
307
      message_hash = JSON.parse(@work_activity.message)
5✔
308
      text = user_refernces(message_hash["note"])
5✔
309
      message = mark_down_to_html(text)
5✔
310
      change_label = message_hash["change_label"]&.titleize
5✔
311
      change_label ||= "Change"
5✔
312
      # TODO: Make this show the change label with the note under see changes
313
      "<details class='message-html'><summary class='show-changes'>#{change_label}</summary>#{message}</details>"
5✔
314
    end
315
  end
316
end
317
# rubocop:enable Metrics/ClassLength
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc