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

pulibrary / tigerdata-app / 54385a8c-ec76-421e-81fd-41bf4c9d5b76

21 Jul 2025 08:55PM UTC coverage: 66.374% (-5.3%) from 71.631%
54385a8c-ec76-421e-81fd-41bf4c9d5b76

push

circleci

web-flow
Upgrade mediaflux build to v0.7.0 (#1617)

* Upgrade mediaflux build to v0.7.0

* prettier

* Upgrade mflux version used by the test suite

* tagging integration tests

* tagging integration tests

4 of 18 branches covered (22.22%)

0 of 1 new or added line in 1 file covered. (0.0%)

222 existing lines in 24 files now uncovered.

2722 of 4101 relevant lines covered (66.37%)

295.38 hits per line

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

90.58
/app/models/project.rb
1
# frozen_string_literal: true
2
class Project < ApplicationRecord
1✔
3
  validates_with ProjectValidator
1✔
4
  has_many :provenance_events, dependent: :destroy
1✔
5
  before_save do |project|
1✔
6
    # Ensure that the metadata JSONB postgres field is persisted properly
7
    project.metadata = project.metadata_model
432✔
8
  end
9

10
  # Valid project status described in ADR 7
11
  # See `architecture-decisions/0007-valid-project-statuses.md`
12
  PENDING_STATUS = "pending"
1✔
13
  APPROVED_STATUS = "approved"
1✔
14
  ACTIVE_STATUS = "active"
1✔
15

16
  delegate :to_json, to: :metadata_json # field in the database
1✔
17

18
  def create!(initial_metadata:, user:)
1✔
19
    self.metadata_model = initial_metadata
16✔
20
    if self.valid?
16✔
21
      if initial_metadata.project_id == ProjectMetadata::DOI_NOT_MINTED
14✔
22
        self.draft_doi(user: user)
13✔
23
        self.save!
13✔
24
        ProvenanceEvent.generate_submission_events(project: self, user: user)
13✔
25
      else
26
        self.save!
1✔
27
      end
28
      # return doi
29
      self.metadata_model.project_id
14✔
30
    else
31
      nil
32
    end
33
  end
34

35
  def approve!(current_user:)
1✔
36
    request = Mediaflux::ProjectCreateServiceRequest.new(session_token: current_user.mediaflux_session, project: self)
37✔
37
    request.resolve
37✔
38

39
    self.mediaflux_id = request.mediaflux_id
37✔
40
    self.metadata_model.status = Project::APPROVED_STATUS if self.mediaflux_id != 0
37✔
41
    self.save!
37✔
42

43
    debug_output = if request.mediaflux_id == 0
36✔
44
                     "Error saving project #{self.id} to Mediaflux: #{request.response_error}. Debug output: #{request.debug_output}"
36✔
45
                   else
UNCOV
46
                     "#{request.debug_output}"
×
47
                   end
48
    Rails.logger.error debug_output
36✔
49

50
    # create provenance events:
51
    # - one for approving the project and
52
    # - another for changing the status of the project
53
    # - another with debug information from the create project service
54
    ProvenanceEvent.generate_approval_events(project: self, user: current_user, debug_output: debug_output)
36✔
55
  end
56

57
  def reload
1✔
58
    super
12✔
59
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
12✔
60
    self
12✔
61
  end
62

63
  def activate!(collection_id:, current_user:)
1✔
UNCOV
64
    response = Mediaflux::AssetMetadataRequest.new(session_token: current_user.mediaflux_session, id: collection_id)
×
UNCOV
65
    mediaflux_metadata = response.metadata # get the metadata of the collection from mediaflux
×
66

UNCOV
67
    return unless mediaflux_metadata[:collection] == true # If the collection id exists
×
68

69
    # check if the project doi in rails matches the project doi in mediaflux
UNCOV
70
    return unless mediaflux_metadata[:project_id] == self.metadata_model.project_id
×
71

72
    # activate a project by setting the status to 'active'
UNCOV
73
    self.metadata_model.status = Project::ACTIVE_STATUS
×
74

75
    # also read in the actual project directory
UNCOV
76
    self.metadata_model.project_directory = mediaflux_metadata[:project_directory]
×
UNCOV
77
    self.save!
×
78

UNCOV
79
    ProvenanceEvent.generate_active_events(project: self, user: current_user)
×
80
  end
81

82
  def draft_doi(user: nil)
1✔
83
    puldatacite = PULDatacite.new
15✔
84
    self.metadata_model.project_id = puldatacite.draft_doi
15✔
85
  end
86

87
  # Ideally this method should return a ProjectMetadata object (like `metadata_model` does)
88
  # but we'll keep them both while we are refactoring the code so that we don't break
89
  # everything at once since `metadata` is used everywhere.
90
  def metadata
1✔
91
    @metadata_hash = (metadata_json || {}).with_indifferent_access
630✔
92
  end
93

94
  def metadata_model
1✔
95
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
5,612✔
96
  end
97

98
  def metadata_model=(new_metadata_model)
1✔
99
    @metadata_model = new_metadata_model
371✔
100
  end
101

102
  def metadata=(metadata_model)
1✔
103
    # Convert our metadata to a hash so it can be saved on our JSONB field
104
    metadata_hash = JSON.parse(metadata_model.to_json)
923✔
105
    self.metadata_json = metadata_hash
923✔
106
  end
107

108
  def title
1✔
109
    self.metadata_model.title
368✔
110
  end
111

112
  def departments
1✔
113
    unsorted = metadata_model.departments || []
167✔
114
    unsorted.sort
167✔
115
  end
116

117
  def project_directory
1✔
118
    metadata_model.project_directory || ""
231✔
119
  end
120

121
  def project_directory_parent_path
1✔
122
    # The tigerdata.project.create expectes every project to be under "tigerdata"
123
    Mediaflux::Connection.root
33✔
124
  end
125

126
  def project_directory_short
1✔
127
    project_directory
54✔
128
  end
129

130
  def status
1✔
131
    metadata_model.status
756✔
132
  end
133

134
  def pending?
1✔
135
    status == PENDING_STATUS
388✔
136
  end
137

138
  def in_mediaflux?
1✔
139
    mediaflux_id.present?
110✔
140
  end
141

142
  def self.users_projects(user)
1✔
143
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
144
    # for information on the @> operator
145
    uid = user.uid
99✔
146
    query_ro = '{"data_user_read_only":["' + uid + '"]}'
99✔
147
    query_rw = '{"data_user_read_write":["' + uid + '"]}'
99✔
148
    query = "(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)"
99✔
149
    args = [query_ro, query_rw]
99✔
150
    if user.eligible_sponsor?
99✔
151
      query += "OR (metadata_json->>'data_sponsor' = ?)"
38✔
152
      args << uid
38✔
153
    end
154
    if user.eligible_manager?
99✔
155
      query += "OR (metadata_json->>'data_manager' = ?)"
18✔
156
      args << uid
18✔
157
    end
158
    Project.where( query, *args)
99✔
159
  end
160

161
  def self.sponsored_projects(sponsor)
1✔
162
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
163
  end
164

165
  def self.managed_projects(manager)
1✔
166
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
167
  end
168

169
  def self.pending_projects
1✔
170
    Project.where("mediaflux_id IS NULL")
95✔
171
  end
172

173
  def self.approved_projects
1✔
174
    Project.where("mediaflux_id IS NOT NULL")
95✔
175
  end
176

177
  def self.data_user_projects(user)
1✔
178
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
179
    # for information on the @> operator
180
    query_ro = '{"data_user_read_only":["' + user + '"]}'
1✔
181
    query_rw = '{"data_user_read_write":["' + user + '"]}'
1✔
182
    Project.where("(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)", query_ro, query_rw)
1✔
183
  end
184

185
  def user_has_access?(user:)
1✔
186
    return true if user.eligible_sysadmin?
76✔
187
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
61✔
188
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
189
  end
190

191
  def save_in_mediaflux(user:)
1✔
192
    ProjectMediaflux.save(project: self, user: user)
5✔
193
  end
194

195
  def created_by_user
1✔
196
    User.find_by(uid: metadata_model.created_by)
12✔
197
  end
198

199
  def to_xml
1✔
200
    ProjectMediaflux.xml_payload(project: self)
5✔
201
  end
202

203
  # @return [Nokogiri::XML::Document] the Mediaflux XML document for this project
204
  def mediaflux_document
1✔
205
    ProjectMediaflux.document(project: self)
1✔
206
  end
207

208
  # @return [Nokogiri::XML::Element] the <meta> element from the Mediaflux XML document
209
  def mediaflux_meta_element
1✔
210
    doc = mediaflux_document.clone
1✔
211
    # Remove the namespaces in order to simplify the XPath query
212
    doc.remove_namespaces!
1✔
213
    elements = doc.xpath("/request/service/args/meta")
1✔
214
    raise("Failed to extract the <meta> element found in the Mediaflux XML document for project #{self.id}") if elements.empty?
1✔
215

216
    elements.first
1✔
217
  end
218

219
  # @return [String] XML representation of the <meta> element
220
  def mediaflux_meta_xml
1✔
221
    mediaflux_meta_element.to_xml
1✔
222
  end
223

224
  def mediaflux_metadata(session_id:)
1✔
225
    @mediaflux_metadata ||= begin
183✔
226
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
57✔
227
      accum_req.metadata
57✔
228
    end
229
    @mediaflux_metadata
179✔
230
  end
231

232
  def asset_count(session_id:)
1✔
233
    values = mediaflux_metadata(session_id:)
24✔
234
    values.fetch(:total_file_count, 0)
24✔
235
  end
236

237
  def self.default_storage_unit
1✔
238
    "KB"
41✔
239
  end
240

241
  def self.default_storage_usage
1✔
242
    "0 #{default_storage_unit}"
40✔
243
  end
244

245
  def storage_usage(session_id:)
1✔
246
    values = mediaflux_metadata(session_id:)
43✔
247
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
39✔
248
  end
249

250
  def storage_usage_raw(session_id:)
1✔
UNCOV
251
    values = mediaflux_metadata(session_id:)
×
UNCOV
252
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
×
253
  end
254

255
  def self.default_storage_capacity
1✔
256
    "0 GB"
39✔
257
  end
258

259
  def storage_capacity(session_id:)
1✔
260
    values = mediaflux_metadata(session_id:)
39✔
261
    quota_value = values.fetch(:quota_allocation, '') #if quota does not exist, set value to an empty string
39✔
262
    if quota_value.blank?
39✔
263
      return self.class.default_storage_capacity
39✔
264
    else
UNCOV
265
      return quota_value
×
266
    end
267
  end
268

269
  def storage_capacity_raw(session_id:)
1✔
270
    values = mediaflux_metadata(session_id:)
75✔
271
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
75✔
272
    quota_value
75✔
273
  end
274

275
  # Fetches the first n files
276
  def file_list(session_id:, size: 10)
1✔
277
    return { files: [] } if mediaflux_id.nil?
24✔
278

279
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true, aql_query: "type!='application/arc-asset-collection'")
13✔
280
    iterator_id = query_req.result
13✔
281

282
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
13✔
283
    results = iterator_req.result
13✔
284

285
    # Destroy _after_ fetching the first set of results from iterator_req.
286
    # This call is required since it possible that we have read less assets than
287
    # what the collection has but we are done with the iterator.
288
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
13✔
289

290
    results
13✔
291
  end
292

293
  # Fetches the entire file list to a file
294
  def file_list_to_file(session_id:, filename:)
1✔
295
    return { files: [] } if mediaflux_id.nil?
11✔
296

297
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true,  aql_query: "type!='application/arc-asset-collection'")
10✔
298
    iterator_id = query_req.result
10✔
299

300
    start_time = Time.zone.now
10✔
301
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
10✔
302
    log_elapsed(start_time, prefix, "STARTED")
10✔
303

304
    File.open(filename, "w") do |file|
10✔
305
      page_number = 0
10✔
306
      # file header
307
      file.write("ID, PATH, NAME, COLLECTION?, LAST_MODIFIED, SIZE\r\n")
10✔
308
      loop do
10✔
309
        iterator_start_time = Time.zone.now
10✔
310
        page_number += 1
10✔
311
        iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: 1000)
10✔
312
        iterator_resp = iterator_req.result
10✔
313
        log_elapsed(iterator_start_time, prefix, "FETCHED page #{page_number} from iterator")
10✔
314
        lines = files_from_iterator(iterator_resp)
10✔
315
        file.write(lines.join("\r\n") + "\r\n")
10✔
316
        break if iterator_resp[:complete] || iterator_req.error?
10✔
317
      end
318
      log_elapsed(start_time, prefix, "ENDED")
10✔
319
    end
320

321
    # Destroy _after_ fetching the results from iterator_req
322
    # This call is technically not necessary since Mediaflux automatically deletes the iterator
323
    # once we have ran through it and by now we have. But it does not hurt either.
324
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
10✔
325
  end
326

327

328
  private
1✔
329

330
    def files_from_iterator(iterator_resp)
1✔
331
      lines = []
10✔
332
      iterator_resp[:files].each do |asset|
10✔
UNCOV
333
        lines << "#{asset.id}, #{asset.path_only}, #{asset.name}, #{asset.collection}, #{asset.last_modified}, #{asset.size}"
×
334
      end
335
      lines
10✔
336
    end
337

338
    def project_directory_pathname
1✔
339
      # allow the directory to be modified by changes in the metadata_json
UNCOV
340
      @project_directory_pathname = nil if @original_directory.present? && @original_directory != metadata_model.project_directory
×
341

UNCOV
342
      @project_directory_pathname ||= begin
×
UNCOV
343
        @original_directory = metadata_model.project_directory
×
UNCOV
344
        Pathname.new(@original_directory)
×
345
      end
346
    end
347

348
    # Ensure that the project directory is a valid path
349
    def safe_directory(directory)
1✔
UNCOV
350
      Project.safe_directory(directory)
×
351
    end
352

353
    def log_elapsed(start_time, prefix, message)
1✔
354
      elapsed_time = Time.zone.now - start_time
30✔
355
      timing_info = "#{format('%.2f', elapsed_time)} s"
30✔
356
      Rails.logger.info "#{prefix}: #{message}, #{timing_info}"
30✔
357
    end
358
end
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