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

pulibrary / pdc_describe / 0be1a6dd-919e-4669-b3a8-8b58eced6e6d

15 Apr 2025 08:08PM UTC coverage: 95.424% (+0.006%) from 95.418%
0be1a6dd-919e-4669-b3a8-8b58eced6e6d

Pull #2105

circleci

carolyncole
Allow the system to be identified in work activities
Pull Request #2105: Allow the system to be identified in work activities

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

7 existing lines in 1 file now uncovered.

3524 of 3693 relevant lines covered (95.42%)

394.3 hits per line

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

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

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

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

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

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

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

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

27
  def self.add_work_activity(work_id, message, user_id, activity_type:, created_at: nil)
2✔
28
    activity = WorkActivity.new(
926✔
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!
926✔
36
    activity.notify_users
926✔
37
    activity
926✔
38
  end
39

40
  def self.activities_for_work(work_id, activity_types)
2✔
41
    where(work_id:, activity_type: activity_types)
1,142✔
42
  end
43

44
  def self.messages_for_work(work_id)
2✔
45
    activities_for_work(work_id, MESSAGE_ACTIVITY_TYPES)
546✔
46
  end
47

48
  def self.changes_for_work(work_id)
2✔
49
    activities_for_work(work_id, CHANGE_LOG_ACTIVITY_TYPES)
550✔
50
  end
51

52
  # Log notifications for each of the users references on the activity
53
  def notify_users
2✔
54
    users_referenced.each do |uid|
938✔
55
      user_id = User.where(uid:).first&.id
480✔
56
      if user_id.nil?
480✔
57
        notify_group(uid)
208✔
58
      else
59
        WorkActivityNotification.create(work_activity_id: id, user_id:)
272✔
60
      end
61
    end
62
  end
63

64
  def notify_group(groupid)
2✔
65
    group = Group.where(code: groupid).first
208✔
66
    if group.nil?
208✔
67
      Rails.logger.info("Message #{id} for work #{work_id} referenced an non-existing user: #{groupid}")
6✔
68
    else
69
      group.administrators.each do |admin|
202✔
70
        WorkActivityNotification.create(work_activity_id: id, user_id: admin.id)
128✔
71
      end
72
    end
73
  end
74

75
  # Returns the `uid` of the users referenced on the activity (without the `@` symbol)
76
  def users_referenced
2✔
77
    message.scan(USER_REFERENCE).map { |at_uid| at_uid[1..-1] }
1,418✔
78
  end
79

80
  def created_by_user
2✔
81
    return nil unless created_by_user_id
472✔
82
    User.find(created_by_user_id)
462✔
83
  end
84

85
  def message_event_type?
2✔
UNCOV
86
    MESSAGE_ACTIVITY_TYPES.include? activity_type
×
87
  end
88

89
  def log_event_type?
2✔
UNCOV
90
    CHANGE_LOG_ACTIVITY_TYPES.include? activity_type
×
91
  end
92

93
  def renderer
2✔
94
    @renderer ||= begin
252✔
95
                    klass = if activity_type == CHANGES
250✔
96
                              MetadataChanges
64✔
97
                            elsif activity_type == FILE_CHANGES
186✔
98
                              FileChanges
20✔
99
                            elsif activity_type == MIGRATION_COMPLETE
166✔
100
                              Migration
2✔
101
                            elsif activity_type == PROVENANCE_NOTES
164✔
102
                              ProvenanceNote
10✔
103
                            elsif activity_type == SYSTEM
154✔
104
                              SystemEvent
76✔
105
                            elsif CHANGE_LOG_ACTIVITY_TYPES.include?(activity_type)
78✔
106
                              OtherLogEvent
2✔
107
                            else
108
                              Message
76✔
109
                            end
110
                    klass.new(self)
250✔
111

112
                  end
113
  end
114

115
  delegate :to_html, to: :renderer
2✔
116

117
  class Renderer
2✔
118
    def initialize(work_activity)
2✔
119
      @work_activity = work_activity
250✔
120
    end
121

122
    UNKNOWN_USER = "Unknown user outside the system"
2✔
123
    DATE_TIME_FORMAT = "%B %d, %Y %H:%M"
2✔
124
    DATE_FORMAT = "%B %d, %Y"
2✔
125
    SORTABLE_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M"
2✔
126

127
    def to_html
2✔
128
      title_html + "<span class='message-html'>#{body_html.chomp}</span>"
242✔
129
    end
130

131
    def created_by_user_html
2✔
132
      return UNKNOWN_USER unless @work_activity.created_by_user
98✔
133

134
      "#{@work_activity.created_by_user.given_name_safe} (@#{@work_activity.created_by_user.uid})"
90✔
135
    end
136

137
    def created_sortable_html
2✔
138
      @work_activity.created_at.time.strftime(SORTABLE_DATE_TIME_FORMAT)
10✔
139
    end
140

141
    def created_updated_html
2✔
142
      created = @work_activity.created_at.time.strftime(DATE_TIME_FORMAT)
242✔
143
      updated = @work_activity.updated_at.time.strftime(DATE_TIME_FORMAT)
242✔
144
      created_date = @work_activity.created_at.time.strftime(DATE_FORMAT)
242✔
145
      updated_date = @work_activity.updated_at.time.strftime(DATE_FORMAT)
242✔
146
      if created_date == updated_date
242✔
147
        created
218✔
148
      else
149
        "#{created} (backdated event created #{updated})"
24✔
150
      end
151
    end
152

153
    def title_html
2✔
154
      "<span class='activity-history-title'>#{created_updated_html} by #{created_by_user_html}</span>"
164✔
155
    end
156
  end
157

158
  class MetadataChanges < Renderer
2✔
159
    # Returns the message formatted to display _metadata_ changes that were logged as an activity
160
    def body_html
2✔
161
      message_json = JSON.parse(@work_activity.message)
64✔
162

163
      # Messages should consistently be Arrays of Hashes, but this might require a migration from legacy field records
164
      messages = if message_json.is_a?(Array)
64✔
UNCOV
165
                   message_json
×
166
                 else
167
                   Array.wrap(message_json)
64✔
168
                 end
169

170
      elements = messages.map do |message|
64✔
171
        markup = if message.is_a?(Hash)
64✔
172
                   message.keys.map do |field|
64✔
173
                     mapped = message[field].map { |value| change_value_html(value) }
348✔
174
                     "<details class='message-html'><summary class='show-changes'>#{field&.titleize}</summary>#{mapped.join}</details>"
174✔
175
                   end
176
                 else
177
                   # For handling cases where WorkActivity#message only contains Strings, or Arrays of Strings
178
                   [
UNCOV
179
                     "<details class='message-html'><summary class='show-changes'></summary>#{message}</details>"
×
180
                   ]
181
                 end
182
        markup.join
64✔
183
      end
184

185
      elements.flatten.join
64✔
186
    end
187

188
    def change_value_html(value)
2✔
189
      if value["action"] == "changed"
174✔
190
        DiffTools::SimpleDiff.new(value["from"], value["to"]).to_html
174✔
191
      else
UNCOV
192
        "old change"
×
193
      end
194
    end
195
  end
196

197
  class FileChanges < Renderer
2✔
198
    # Returns the message formatted to display _file_ changes that were logged as an activity
199
    def body_html
2✔
200
      changes = JSON.parse(@work_activity.message)
20✔
201
      if changes.is_a?(Hash)
20✔
202
        changes = [changes]
2✔
203
      end
204

205
      files_added = changes.select { |v| v["action"] == "added" }
60✔
206
      files_deleted = changes.select { |v| v["action"] == "removed" }
60✔
207
      files_replaced = changes.select { |v| v["action"] == "replaced" }
60✔
208

209
      changes_html = []
20✔
210
      unless files_added.empty?
20✔
211
        label = "Files Added: "
18✔
212
        label += files_added.length.to_s
18✔
213
        changes_html << "<tr><td>#{label}</td></tr>"
18✔
214
      end
215

216
      unless files_deleted.empty?
20✔
217
        label = "Files Deleted: "
4✔
218
        label += files_deleted.length.to_s
4✔
219
        changes_html << "<tr><td>#{label}</td></tr>"
4✔
220
      end
221

222
      unless files_replaced.empty?
20✔
223
        label = "Files Replaced: "
2✔
224
        label += files_replaced.length.to_s
2✔
225
        changes_html << "<tr><td>#{label}</td></tr>"
2✔
226
      end
227

228
      "<table>#{changes_html.join}</table>"
20✔
229
    end
230
  end
231

232
  class Migration < Renderer
2✔
233
    # Returns the message formatted to display _file_ changes that were logged as an activity
234
    def body_html
2✔
235
      changes = JSON.parse(@work_activity.message)
2✔
236
      "<p>#{changes['message']}</p>"
2✔
237
    end
238
  end
239

240
  class BaseMessage < Renderer
2✔
241
    def body_html
2✔
242
      text = user_refernces(@work_activity.message)
156✔
243
      mark_down_to_html(text)
156✔
244
    end
245

246
    def mark_down_to_html(text_in)
2✔
247
      # allow ``` for code blocks (Kramdown only supports ~~~)
248
      text = text_in.gsub("```", "~~~")
166✔
249
      Kramdown::Document.new(text).to_html
166✔
250
    end
251

252
    def user_refernces(text_in)
2✔
253
      # convert user references to user links
254
      text_in.gsub(USER_REFERENCE) do |at_uid|
166✔
255
        uid = at_uid[1..-1]
100✔
256

257
        if uid
100✔
258
          group = Group.find_by(code: uid)
100✔
259
          if group
100✔
260
            "<a class='message-user-link' title='#{group.title}' href='#{@work_activity.group_path(group)}'>#{group.title}</a>"
44✔
261
          else
262
            user = User.find_by(uid:)
56✔
263
            user_info = if user
56✔
264
                          user.given_name_safe
56✔
265
                        else
UNCOV
266
                          uid
×
267
                        end
268
            "<a class='message-user-link' title='#{user_info}' href='#{@work_activity.users_path}/#{uid}'>#{at_uid}</a>"
56✔
269
          end
270
        else
UNCOV
271
          Rails.logger.warn("Failed to extract the user ID from #{uid}")
×
272
          UNKNOWN_USER
×
273
        end
274
      end
275
    end
276
  end
277

278
  class OtherLogEvent < BaseMessage
2✔
279
  end
280

281
  class SystemEvent < BaseMessage
2✔
282
    def created_by_user_html
2✔
283
      "the system"
76✔
284
    end
285
  end
286

287
  class Message < BaseMessage
2✔
288
    # Override the default:
289
    def created_by_user_html
2✔
290
      return UNKNOWN_USER unless @work_activity.created_by_user
78✔
291

292
      user = @work_activity.created_by_user
76✔
293
      "#{user.given_name_safe} (@#{user.uid})"
76✔
294
    end
295

296
    def title_html
2✔
297
      "<span class='activity-history-title'>#{created_by_user_html} at #{created_updated_html}</span>"
78✔
298
    end
299
  end
300

301
  class ProvenanceNote < BaseMessage
2✔
302
    def body_html
2✔
303
      message_hash = JSON.parse(@work_activity.message)
10✔
304
      text = user_refernces(message_hash["note"])
10✔
305
      message = mark_down_to_html(text)
10✔
306
      change_label = message_hash["change_label"]&.titleize
10✔
307
      change_label ||= "Change"
10✔
308
      # TODO: Make this show the change label with the note under see changes
309
      "<details class='message-html'><summary class='show-changes'>#{change_label}</summary>#{message}</details>"
10✔
310
    end
311
  end
312
end
313
# 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