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

pulibrary / tigerdata-app / 8c709eb2-45e2-46e5-8e26-9122d36a006c

08 Aug 2025 05:27PM UTC coverage: 76.512% (+0.3%) from 76.229%
8c709eb2-45e2-46e5-8e26-9122d36a006c

push

circleci

web-flow
Improve exception handling for project creation (#1708)

* Handle all exceptions in our exception handling code and remove duplicate approve code

* Rubocop nitpicking

* Minor tweaks

3 of 10 new or added lines in 4 files covered. (30.0%)

2 existing lines in 2 files now uncovered.

2264 of 2959 relevant lines covered (76.51%)

176.7 hits per line

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

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

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

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

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

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

36
  # TODO: Remove this method https://github.com/pulibrary/tigerdata-app/issues/1707 has been completed
37
  def approve!(current_user:)
1✔
38
    # This code is duplicated with Request.approve() and it should
39
    # be removed. We keep it for now since we have way too many tests
40
    # wired to it already. The goal is that projects won't be approved,
41
    # instead Request are approved (and that process creates the project)
NEW
42
    create_project_operation = ProjectCreate.new
×
NEW
43
    result = create_project_operation.call(request: nil, approver: current_user, project: self)
×
NEW
44
    result = result.flatten while result.class != Project
×
UNCOV
45
    self.mediaflux_id
×
46
  end
47

48
  def reload
1✔
49
    super
7✔
50
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
7✔
51
    self
7✔
52
  end
53

54
  def draft_doi(user: nil)
1✔
55
    puldatacite = PULDatacite.new
3✔
56
    self.metadata_model.project_id = puldatacite.draft_doi
3✔
57
  end
58

59
  # Ideally this method should return a ProjectMetadata object (like `metadata_model` does)
60
  # but we'll keep them both while we are refactoring the code so that we don't break
61
  # everything at once since `metadata` is used everywhere.
62
  def metadata
1✔
63
    @metadata_hash = (metadata_json || {}).with_indifferent_access
430✔
64
  end
65

66
  def metadata_model
1✔
67
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
3,891✔
68
  end
69

70
  def metadata_model=(new_metadata_model)
1✔
71
    @metadata_model = new_metadata_model
314✔
72
  end
73

74
  def metadata=(metadata_model)
1✔
75
    # Convert our metadata to a hash so it can be saved on our JSONB field
76
    metadata_hash = JSON.parse(metadata_model.to_json)
712✔
77
    self.metadata_json = metadata_hash
712✔
78
  end
79

80
  def title
1✔
81
    self.metadata_model.title
263✔
82
  end
83

84
  def departments
1✔
85
    unsorted = metadata_model.departments || []
77✔
86
    unsorted.sort
77✔
87
  end
88

89
  def project_directory
1✔
90
    metadata_model.project_directory || ""
173✔
91
  end
92

93
  def project_directory_parent_path
1✔
94
    # The tigerdata.project.create expectes every project to be under "tigerdata"
95
    Mediaflux::Connection.root
11✔
96
  end
97

98
  def project_directory_short
1✔
99
    project_directory
15✔
100
  end
101

102
  def status
1✔
103
    metadata_model.status
555✔
104
  end
105

106
  def pending?
1✔
107
    status == PENDING_STATUS
269✔
108
  end
109

110
  def in_mediaflux?
1✔
111
    mediaflux_id.present?
61✔
112
  end
113

114
  def self.users_projects(user)
1✔
115
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
116
    # for information on the @> operator
117
    uid = user.uid
62✔
118
    query_ro = '{"data_user_read_only":["' + uid + '"]}'
62✔
119
    query_rw = '{"data_user_read_write":["' + uid + '"]}'
62✔
120
    query = "(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)"
62✔
121
    args = [query_ro, query_rw]
62✔
122
    if user.eligible_sponsor?
62✔
123
      query += "OR (metadata_json->>'data_sponsor' = ?)"
17✔
124
      args << uid
17✔
125
    end
126
    if user.eligible_manager?
62✔
127
      query += "OR (metadata_json->>'data_manager' = ?)"
12✔
128
      args << uid
12✔
129
    end
130
    Project.where( query, *args)
62✔
131
  end
132

133
  def self.sponsored_projects(sponsor)
1✔
134
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
135
  end
136

137
  def self.managed_projects(manager)
1✔
138
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
139
  end
140

141
  def self.pending_projects
1✔
142
    Project.where("mediaflux_id IS NULL")
58✔
143
  end
144

145
  def self.approved_projects
1✔
146
    Project.where("mediaflux_id IS NOT NULL")
58✔
147
  end
148

149
  def self.data_user_projects(user)
1✔
150
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
151
    # for information on the @> operator
152
    query_ro = '{"data_user_read_only":["' + user + '"]}'
1✔
153
    query_rw = '{"data_user_read_write":["' + user + '"]}'
1✔
154
    Project.where("(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)", query_ro, query_rw)
1✔
155
  end
156

157
  def user_has_access?(user:)
1✔
158
    return true if user.eligible_sysadmin?
46✔
159
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
36✔
160
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
161
  end
162

163
  def created_by_user
1✔
164
    User.find_by(uid: metadata_model.created_by)
12✔
165
  end
166

167
  def to_xml
1✔
168
    ProjectShowPresenter.new(self).to_xml
4✔
169
  end
170

171
  # @return [String] XML representation of the <meta> element
172
  def mediaflux_meta_xml(user:)
1✔
173
    doc = ProjectMediaflux.document(project: self, user: user)
1✔
174
    doc.xpath("/response/reply/result/asset/meta").to_s
1✔
175
  end
176

177
  def mediaflux_metadata(session_id:)
1✔
178
    @mediaflux_metadata ||= begin
109✔
179
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
29✔
180
      accum_req.metadata
29✔
181
    end
182
    @mediaflux_metadata
109✔
183
  end
184

185
  def asset_count(session_id:)
1✔
186
    values = mediaflux_metadata(session_id:)
11✔
187
    values.fetch(:total_file_count, 0)
11✔
188
  end
189

190
  def self.default_storage_unit
1✔
191
    "KB"
26✔
192
  end
193

194
  def self.default_storage_usage
1✔
195
    "0 #{default_storage_unit}"
25✔
196
  end
197

198
  def storage_usage(session_id:)
1✔
199
    values = mediaflux_metadata(session_id:)
24✔
200
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
24✔
201
  end
202

203
  def storage_usage_raw(session_id:)
1✔
204
    values = mediaflux_metadata(session_id:)
×
205
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
×
206
  end
207

208
  def self.default_storage_capacity
1✔
209
    "0 GB"
24✔
210
  end
211

212
  def storage_capacity(session_id:)
1✔
213
    values = mediaflux_metadata(session_id:)
24✔
214
    quota_value = values.fetch(:quota_allocation, '') #if quota does not exist, set value to an empty string
24✔
215
    if quota_value.blank?
24✔
216
      return self.class.default_storage_capacity
24✔
217
    else
218
      return quota_value
×
219
    end
220
  end
221

222
  def storage_capacity_raw(session_id:)
1✔
223
    values = mediaflux_metadata(session_id:)
48✔
224
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
48✔
225
    quota_value
48✔
226
  end
227

228
  # Fetches the first n files
229
  def file_list(session_id:, size: 10)
1✔
230
    return { files: [] } if mediaflux_id.nil?
11✔
231

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

235
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
4✔
236
    results = iterator_req.result
4✔
237

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

243
    results
4✔
244
  end
245

246
  # Fetches the entire file list to a file
247
  def file_list_to_file(session_id:, filename:)
1✔
248
    return { files: [] } if mediaflux_id.nil?
1✔
249

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

253
    start_time = Time.zone.now
×
254
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
×
255
    log_elapsed(start_time, prefix, "STARTED")
×
256

257
    File.open(filename, "w") do |file|
×
258
      page_number = 0
×
259
      # file header
260
      file.write("ID, PATH, NAME, COLLECTION?, LAST_MODIFIED, SIZE\r\n")
×
261
      loop do
×
262
        iterator_start_time = Time.zone.now
×
263
        page_number += 1
×
264
        iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: 1000)
×
265
        iterator_resp = iterator_req.result
×
266
        log_elapsed(iterator_start_time, prefix, "FETCHED page #{page_number} from iterator")
×
267
        lines = files_from_iterator(iterator_resp)
×
268
        file.write(lines.join("\r\n") + "\r\n")
×
269
        break if iterator_resp[:complete] || iterator_req.error?
×
270
      end
271
      log_elapsed(start_time, prefix, "ENDED")
×
272
    end
273

274
    # Destroy _after_ fetching the results from iterator_req
275
    # This call is technically not necessary since Mediaflux automatically deletes the iterator
276
    # once we have ran through it and by now we have. But it does not hurt either.
277
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
×
278
  end
279

280

281
  private
1✔
282

283
    def files_from_iterator(iterator_resp)
1✔
284
      lines = []
×
285
      iterator_resp[:files].each do |asset|
×
286
        lines << "#{asset.id}, #{asset.path_only}, #{asset.name}, #{asset.collection}, #{asset.last_modified}, #{asset.size}"
×
287
      end
288
      lines
×
289
    end
290

291
    def project_directory_pathname
1✔
292
      # allow the directory to be modified by changes in the metadata_json
293
      @project_directory_pathname = nil if @original_directory.present? && @original_directory != metadata_model.project_directory
×
294

295
      @project_directory_pathname ||= begin
×
296
        @original_directory = metadata_model.project_directory
×
297
        Pathname.new(@original_directory)
×
298
      end
299
    end
300

301
    # Ensure that the project directory is a valid path
302
    def safe_directory(directory)
1✔
303
      Project.safe_directory(directory)
×
304
    end
305

306
    def log_elapsed(start_time, prefix, message)
1✔
307
      elapsed_time = Time.zone.now - start_time
×
308
      timing_info = "#{format('%.2f', elapsed_time)} s"
×
309
      Rails.logger.info "#{prefix}: #{message}, #{timing_info}"
×
310
    end
311
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