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

pulibrary / tigerdata-app / e2866cab-4bfa-4335-af16-e15744c71e89

21 Aug 2025 07:00PM UTC coverage: 87.048% (+0.007%) from 87.041%
e2866cab-4bfa-4335-af16-e15744c71e89

Pull #1767

circleci

JaymeeH
Trigger the CI-test pipeline when TigerData-Config is deployed
Pull Request #1767: Trigger the CI-test pipeline when TigerData-Config is deployed

2574 of 2957 relevant lines covered (87.05%)

311.87 hits per line

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

97.02
/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
603✔
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
7✔
21
    if self.valid?
7✔
22
      if initial_metadata.project_id == ProjectMetadata::DOI_NOT_MINTED
5✔
23
        self.draft_doi(user: user)
3✔
24
        self.save!
3✔
25
        ProvenanceEvent.generate_submission_events(project: self, user: user)
3✔
26
      else
27
        self.save!
2✔
28
      end
29
      # return doi
30
      self.metadata_model.project_id
5✔
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)
42
    create_project_operation = ProjectCreate.new
71✔
43
    result = create_project_operation.call(request: nil, approver: current_user, project: self)
71✔
44
    if result.success?
71✔
45
       self.mediaflux_id
69✔
46
    else
47
      raise ProjectCreate::ProjectCreateError, result.failure
2✔
48
    end
49
  end
50

51
  def reload
1✔
52
    super
9✔
53
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
9✔
54
    self
9✔
55
  end
56

57
  def draft_doi(user: nil)
1✔
58
    puldatacite = PULDatacite.new
13✔
59
    self.metadata_model.project_id = puldatacite.draft_doi
13✔
60
  end
61

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

69
  def metadata_model
1✔
70
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
7,591✔
71
  end
72

73
  def metadata_model=(new_metadata_model)
1✔
74
    @metadata_model = new_metadata_model
392✔
75
  end
76

77
  def metadata=(metadata_model)
1✔
78
    # Convert our metadata to a hash so it can be saved on our JSONB field
79
    metadata_hash = JSON.parse(metadata_model.to_json)
1,489✔
80
    self.metadata_json = metadata_hash
1,489✔
81
  end
82

83
  def title
1✔
84
    self.metadata_model.title
367✔
85
  end
86

87
  def departments
1✔
88
    unsorted = metadata_model.departments || []
82✔
89
    unsorted.sort
82✔
90
  end
91

92
  def project_directory
1✔
93
    metadata_model.project_directory || ""
208✔
94
  end
95

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

101
  def project_directory_short
1✔
102
    project_directory
21✔
103
  end
104

105
  def status
1✔
106
    metadata_model.status
689✔
107
  end
108

109
  def pending?
1✔
110
    status == PENDING_STATUS
344✔
111
  end
112

113
  def in_mediaflux?
1✔
114
    mediaflux_id.present?
73✔
115
  end
116

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

136
  def self.sponsored_projects(sponsor)
1✔
137
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
138
  end
139

140
  def self.managed_projects(manager)
1✔
141
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
142
  end
143

144
  def self.pending_projects
1✔
145
    Project.where("mediaflux_id IS NULL")
66✔
146
  end
147

148
  def self.approved_projects
1✔
149
    Project.where("mediaflux_id IS NOT NULL")
66✔
150
  end
151

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

160
  def user_has_access?(user:)
1✔
161
    return true if user.eligible_sysadmin?
82✔
162
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
66✔
163
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
164
  end
165

166
  def created_by_user
1✔
167
    User.find_by(uid: metadata_model.created_by)
12✔
168
  end
169

170
  def to_xml
1✔
171
    ProjectShowPresenter.new(self).to_xml
5✔
172
  end
173

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

180
  def mediaflux_metadata(session_id:)
1✔
181
    @mediaflux_metadata ||= begin
344✔
182
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
62✔
183
      accum_req.metadata
62✔
184
    end
185
    @mediaflux_metadata
340✔
186
  end
187

188
  def asset_count(session_id:)
1✔
189
    values = mediaflux_metadata(session_id:)
35✔
190
    values.fetch(:total_file_count, 0)
35✔
191
  end
192

193
  def self.default_storage_unit
1✔
194
    "KB"
74✔
195
  end
196

197
  def self.default_storage_usage
1✔
198
    "0 #{default_storage_unit}"
73✔
199
  end
200

201
  def storage_usage(session_id:)
1✔
202
    values = mediaflux_metadata(session_id:)
76✔
203
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
72✔
204
  end
205

206
  def storage_usage_raw(session_id:)
1✔
207
    values = mediaflux_metadata(session_id:)
43✔
208
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
43✔
209
  end
210

211
  def self.default_storage_capacity
1✔
212
    "0 GB"
28✔
213
  end
214

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

225
  def storage_capacity_raw(session_id:)
1✔
226
    values = mediaflux_metadata(session_id:)
116✔
227
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
116✔
228
    quota_value
116✔
229
  end
230

231
  # Fetches the first n files
232
  def file_list(session_id:, size: 10)
1✔
233
    return { files: [] } if mediaflux_id.nil?
38✔
234

235
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true, aql_query: "type!='application/arc-asset-collection'")
29✔
236
    iterator_id = query_req.result
29✔
237

238
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
29✔
239
    results = iterator_req.result
29✔
240

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

246
    results
29✔
247
  end
248

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

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

256
    start_time = Time.zone.now
10✔
257
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
10✔
258
    log_elapsed(start_time, prefix, "STARTED")
10✔
259

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

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

283

284
  private
1✔
285

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

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

298
      @project_directory_pathname ||= begin
×
299
        @original_directory = metadata_model.project_directory
×
300
        Pathname.new(@original_directory)
×
301
      end
302
    end
303

304
    # Ensure that the project directory is a valid path
305
    def safe_directory(directory)
1✔
306
      Project.safe_directory(directory)
×
307
    end
308

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