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

pulibrary / tigerdata-app / 1a6204eb-6bb3-4684-869d-4f999da08a47

31 Jul 2025 03:56PM UTC coverage: 73.992% (-1.8%) from 75.821%
1a6204eb-6bb3-4684-869d-4f999da08a47

Pull #1657

circleci

web-flow
Merge branch 'main' into i1632-save-data-users
Pull Request #1657: Add data users to a project after it is created in mediaflux

0 of 74 new or added lines in 3 files covered. (0.0%)

1 existing line in 1 file now uncovered.

2239 of 3026 relevant lines covered (73.99%)

309.69 hits per line

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

74.18
/app/models/project.rb
1
# frozen_string_literal: true
2
class Project < ApplicationRecord
1✔
3

4
  class ProjectCreateError < StandardError; end
1✔
5

6
  validates_with ProjectValidator
1✔
7
  has_many :provenance_events, dependent: :destroy
1✔
8
  before_save do |project|
1✔
9
    # Ensure that the metadata JSONB postgres field is persisted properly
10
    project.metadata = project.metadata_model
332✔
11
  end
12

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

19
  delegate :to_json, to: :metadata_json # field in the database
1✔
20

21
  def create!(initial_metadata:, user:)
1✔
22
    self.metadata_model = initial_metadata
4✔
23
    if self.valid?
4✔
24
      if initial_metadata.project_id == ProjectMetadata::DOI_NOT_MINTED
3✔
25
        self.draft_doi(user: user)
2✔
26
        self.save!
2✔
27
        ProvenanceEvent.generate_submission_events(project: self, user: user)
2✔
28
      else
29
        self.save!
1✔
30
      end
31
      # return doi
32
      self.metadata_model.project_id
3✔
33
    else
34
      nil
35
    end
36
  end
37

38
  def approve!(current_user:)
1✔
39
    request = Mediaflux::ProjectCreateServiceRequest.new(session_token: current_user.mediaflux_session, project: self)
1✔
40
    request.resolve
1✔
41

42
    if request.mediaflux_id.to_i == 0
1✔
43
      raise ProjectCreateError.new("Error saving project #{self.id} to Mediaflux: #{request.response_error}. Debug output: #{request.debug_output}")
1✔
44
    end
45

46
    self.mediaflux_id = request.mediaflux_id
×
47
    self.metadata_model.status = Project::APPROVED_STATUS
×
48
    self.save!
×
49

NEW
50
    debug_output = if request.mediaflux_id == 0
×
NEW
51
                     "Error saving project #{self.id} to Mediaflux: #{request.response_error}. Debug output: #{request.debug_output}"
×
52
                   else
NEW
53
                     "#{request.debug_output}"
×
54
                   end
NEW
55
    Rails.logger.error debug_output
×
56

NEW
57
    add_users_request = Mediaflux::ProjectUserAddRequest.new(session_token: current_user.mediaflux_session, project: self)
×
NEW
58
    add_users_request.resolve
×
59

NEW
60
    user_debug = "#{add_users_request.debug_output}"
×
NEW
61
    Rails.logger.error "Project #{self.id} users have been added to MediaFlux: #{user_debug}"
×
62

63
    # create provenance events:
64
    # - one for approving the project and
65
    # - another for changing the status of the project
66
    # - another with debug information from the create project service
67
    ProvenanceEvent.generate_approval_events(project: self, user: current_user, debug_output: debug_output)
×
68
  end
69

70
  def reload
1✔
71
    super
7✔
72
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
7✔
73
    self
7✔
74
  end
75

76
  def draft_doi(user: nil)
1✔
77
    puldatacite = PULDatacite.new
3✔
78
    self.metadata_model.project_id = puldatacite.draft_doi
3✔
79
  end
80

81
  # Ideally this method should return a ProjectMetadata object (like `metadata_model` does)
82
  # but we'll keep them both while we are refactoring the code so that we don't break
83
  # everything at once since `metadata` is used everywhere.
84
  def metadata
1✔
85
    @metadata_hash = (metadata_json || {}).with_indifferent_access
432✔
86
  end
87

88
  def metadata_model
1✔
89
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
3,871✔
90
  end
91

92
  def metadata_model=(new_metadata_model)
1✔
93
    @metadata_model = new_metadata_model
312✔
94
  end
95

96
  def metadata=(metadata_model)
1✔
97
    # Convert our metadata to a hash so it can be saved on our JSONB field
98
    metadata_hash = JSON.parse(metadata_model.to_json)
708✔
99
    self.metadata_json = metadata_hash
708✔
100
  end
101

102
  def title
1✔
103
    self.metadata_model.title
265✔
104
  end
105

106
  def departments
1✔
107
    unsorted = metadata_model.departments || []
77✔
108
    unsorted.sort
77✔
109
  end
110

111
  def project_directory
1✔
112
    metadata_model.project_directory || ""
173✔
113
  end
114

115
  def project_directory_parent_path
1✔
116
    # The tigerdata.project.create expectes every project to be under "tigerdata"
117
    Mediaflux::Connection.root
11✔
118
  end
119

120
  def project_directory_short
1✔
121
    project_directory
15✔
122
  end
123

124
  def status
1✔
125
    metadata_model.status
561✔
126
  end
127

128
  def pending?
1✔
129
    status == PENDING_STATUS
272✔
130
  end
131

132
  def in_mediaflux?
1✔
133
    mediaflux_id.present?
61✔
134
  end
135

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

155
  def self.sponsored_projects(sponsor)
1✔
156
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
157
  end
158

159
  def self.managed_projects(manager)
1✔
160
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
161
  end
162

163
  def self.pending_projects
1✔
164
    Project.where("mediaflux_id IS NULL")
58✔
165
  end
166

167
  def self.approved_projects
1✔
168
    Project.where("mediaflux_id IS NOT NULL")
58✔
169
  end
170

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

179
  def user_has_access?(user:)
1✔
180
    return true if user.eligible_sysadmin?
46✔
181
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
36✔
182
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
183
  end
184

185
  def save_in_mediaflux(user:)
1✔
186
    ProjectMediaflux.save(project: self, user: user)
×
187
  end
188

189
  def created_by_user
1✔
190
    User.find_by(uid: metadata_model.created_by)
12✔
191
  end
192

193
  def to_xml
1✔
194
    ProjectShowPresenter.new(self).to_xml
4✔
195
  end
196

197
  # @return [String] XML representation of the <meta> element
198
  def mediaflux_meta_xml(user:)
1✔
199
    doc = ProjectMediaflux.document(project: self, user: user)
1✔
200
    doc.xpath("/response/reply/result/asset/meta").to_s
1✔
201
  end
202

203
  def mediaflux_metadata(session_id:)
1✔
204
    @mediaflux_metadata ||= begin
109✔
205
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
29✔
206
      accum_req.metadata
29✔
207
    end
208
    @mediaflux_metadata
109✔
209
  end
210

211
  def asset_count(session_id:)
1✔
212
    values = mediaflux_metadata(session_id:)
11✔
213
    values.fetch(:total_file_count, 0)
11✔
214
  end
215

216
  def self.default_storage_unit
1✔
217
    "KB"
26✔
218
  end
219

220
  def self.default_storage_usage
1✔
221
    "0 #{default_storage_unit}"
25✔
222
  end
223

224
  def storage_usage(session_id:)
1✔
225
    values = mediaflux_metadata(session_id:)
24✔
226
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
24✔
227
  end
228

229
  def storage_usage_raw(session_id:)
1✔
230
    values = mediaflux_metadata(session_id:)
×
231
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
×
232
  end
233

234
  def self.default_storage_capacity
1✔
235
    "0 GB"
24✔
236
  end
237

238
  def storage_capacity(session_id:)
1✔
239
    values = mediaflux_metadata(session_id:)
24✔
240
    quota_value = values.fetch(:quota_allocation, '') #if quota does not exist, set value to an empty string
24✔
241
    if quota_value.blank?
24✔
242
      return self.class.default_storage_capacity
24✔
243
    else
244
      return quota_value
×
245
    end
246
  end
247

248
  def storage_capacity_raw(session_id:)
1✔
249
    values = mediaflux_metadata(session_id:)
48✔
250
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
48✔
251
    quota_value
48✔
252
  end
253

254
  # Fetches the first n files
255
  def file_list(session_id:, size: 10)
1✔
256
    return { files: [] } if mediaflux_id.nil?
11✔
257

258
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true, aql_query: "type!='application/arc-asset-collection'")
4✔
259
    iterator_id = query_req.result
4✔
260

261
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
4✔
262
    results = iterator_req.result
4✔
263

264
    # Destroy _after_ fetching the first set of results from iterator_req.
265
    # This call is required since it possible that we have read less assets than
266
    # what the collection has but we are done with the iterator.
267
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
4✔
268

269
    results
4✔
270
  end
271

272
  # Fetches the entire file list to a file
273
  def file_list_to_file(session_id:, filename:)
1✔
274
    return { files: [] } if mediaflux_id.nil?
1✔
275

276
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true,  aql_query: "type!='application/arc-asset-collection'")
×
277
    iterator_id = query_req.result
×
278

279
    start_time = Time.zone.now
×
280
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
×
281
    log_elapsed(start_time, prefix, "STARTED")
×
282

283
    File.open(filename, "w") do |file|
×
284
      page_number = 0
×
285
      # file header
286
      file.write("ID, PATH, NAME, COLLECTION?, LAST_MODIFIED, SIZE\r\n")
×
287
      loop do
×
288
        iterator_start_time = Time.zone.now
×
289
        page_number += 1
×
290
        iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: 1000)
×
291
        iterator_resp = iterator_req.result
×
292
        log_elapsed(iterator_start_time, prefix, "FETCHED page #{page_number} from iterator")
×
293
        lines = files_from_iterator(iterator_resp)
×
294
        file.write(lines.join("\r\n") + "\r\n")
×
295
        break if iterator_resp[:complete] || iterator_req.error?
×
296
      end
297
      log_elapsed(start_time, prefix, "ENDED")
×
298
    end
299

300
    # Destroy _after_ fetching the results from iterator_req
301
    # This call is technically not necessary since Mediaflux automatically deletes the iterator
302
    # once we have ran through it and by now we have. But it does not hurt either.
303
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
×
304
  end
305

306

307
  private
1✔
308

309
    def files_from_iterator(iterator_resp)
1✔
310
      lines = []
×
311
      iterator_resp[:files].each do |asset|
×
312
        lines << "#{asset.id}, #{asset.path_only}, #{asset.name}, #{asset.collection}, #{asset.last_modified}, #{asset.size}"
×
313
      end
314
      lines
×
315
    end
316

317
    def project_directory_pathname
1✔
318
      # allow the directory to be modified by changes in the metadata_json
319
      @project_directory_pathname = nil if @original_directory.present? && @original_directory != metadata_model.project_directory
×
320

321
      @project_directory_pathname ||= begin
×
322
        @original_directory = metadata_model.project_directory
×
323
        Pathname.new(@original_directory)
×
324
      end
325
    end
326

327
    # Ensure that the project directory is a valid path
328
    def safe_directory(directory)
1✔
329
      Project.safe_directory(directory)
×
330
    end
331

332
    def log_elapsed(start_time, prefix, message)
1✔
333
      elapsed_time = Time.zone.now - start_time
×
334
      timing_info = "#{format('%.2f', elapsed_time)} s"
×
335
      Rails.logger.info "#{prefix}: #{message}, #{timing_info}"
×
336
    end
337
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