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

pulibrary / tigerdata-app / 8dd85b68-d5d5-48b3-a55f-ee0d93ed9238

21 Aug 2025 04:09PM UTC coverage: 86.981% (+0.09%) from 86.887%
8dd85b68-d5d5-48b3-a55f-ee0d93ed9238

Pull #1760

circleci

web-flow
Merge branch 'main' into 1726-project-active
Pull Request #1760: Adds step to activate a project

29 of 31 new or added lines in 3 files covered. (93.55%)

3 existing lines in 1 file now uncovered.

2599 of 2988 relevant lines covered (86.98%)

342.89 hits per line

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

97.16
/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
703✔
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 activate(current_user:)
1✔
52
    raise StandardError.new("Only approved projects can be activated") if self.status != Project::APPROVED_STATUS
75✔
53
    metadata_request = Mediaflux::ProjectMetadataGetRequest.new(session_token: current_user.mediaflux_session, id: self.mediaflux_id)
75✔
54
    metadata_request.resolve
75✔
55
    raise metadata_request.response_error if metadata_request.error?
75✔
56
    raise StandardError.new("Title mismatch: #{title} != #{metadata_request.title}") if self.title != metadata_request.title
75✔
57

58
    self.metadata_model.status = Project::ACTIVE_STATUS
75✔
59
    self.save!
75✔
60
  end
61

62
  def reload
1✔
63
    super
9✔
64
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
9✔
65
    self
9✔
66
  end
67

68
  def draft_doi(user: nil)
1✔
69
    puldatacite = PULDatacite.new
13✔
70
    self.metadata_model.project_id = puldatacite.draft_doi
13✔
71
  end
72

73
  # Ideally this method should return a ProjectMetadata object (like `metadata_model` does)
74
  # but we'll keep them both while we are refactoring the code so that we don't break
75
  # everything at once since `metadata` is used everywhere.
76
  def metadata
1✔
77
    @metadata_hash = (metadata_json || {}).with_indifferent_access
714✔
78
  end
79

80
  def metadata_model
1✔
81
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
8,581✔
82
  end
83

84
  def metadata_model=(new_metadata_model)
1✔
85
    @metadata_model = new_metadata_model
394✔
86
  end
87

88
  def metadata=(metadata_model)
1✔
89
    # Convert our metadata to a hash so it can be saved on our JSONB field
90
    metadata_hash = JSON.parse(metadata_model.to_json)
1,729✔
91
    self.metadata_json = metadata_hash
1,729✔
92
  end
93

94
  def title
1✔
95
    self.metadata_model.title
448✔
96
  end
97

98
  def departments
1✔
99
    unsorted = metadata_model.departments || []
94✔
100
    unsorted.sort
94✔
101
  end
102

103
  def project_directory
1✔
104
    metadata_model.project_directory || ""
212✔
105
  end
106

107
  def project_directory_parent_path
1✔
108
    # The tigerdata.project.create expectes every project to be under "tigerdata"
109
    Mediaflux::Connection.root
13✔
110
  end
111

112
  def project_directory_short
1✔
113
    project_directory
23✔
114
  end
115

116
  def status
1✔
117
    metadata_model.status
773✔
118
  end
119

120
  def pending?
1✔
121
    status == PENDING_STATUS
349✔
122
  end
123

124
  def in_mediaflux?
1✔
125
    mediaflux_id.present?
81✔
126
  end
127

128
  def self.users_projects(user)
1✔
129
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
130
    # for information on the @> operator
131
    uid = user.uid
70✔
132
    query_ro = '{"data_user_read_only":["' + uid + '"]}'
70✔
133
    query_rw = '{"data_user_read_write":["' + uid + '"]}'
70✔
134
    query = "(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)"
70✔
135
    args = [query_ro, query_rw]
70✔
136
    if user.eligible_sponsor?
70✔
137
      query += "OR (metadata_json->>'data_sponsor' = ?)"
19✔
138
      args << uid
19✔
139
    end
140
    if user.eligible_manager?
70✔
141
      query += "OR (metadata_json->>'data_manager' = ?)"
13✔
142
      args << uid
13✔
143
    end
144
    Project.where( query, *args)
70✔
145
  end
146

147
  def self.sponsored_projects(sponsor)
1✔
148
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
149
  end
150

151
  def self.managed_projects(manager)
1✔
152
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
153
  end
154

155
  def self.pending_projects
1✔
156
    Project.where("mediaflux_id IS NULL")
66✔
157
  end
158

159
  def self.approved_projects
1✔
160
    Project.where("mediaflux_id IS NOT NULL")
66✔
161
  end
162

163
  def self.data_user_projects(user)
1✔
164
    # See https://scalegrid.io/blog/using-jsonb-in-postgresql-how-to-effectively-store-index-json-data-in-postgresql/
165
    # for information on the @> operator
166
    query_ro = '{"data_user_read_only":["' + user + '"]}'
1✔
167
    query_rw = '{"data_user_read_write":["' + user + '"]}'
1✔
168
    Project.where("(metadata_json @> ? :: jsonb) OR (metadata_json @> ? :: jsonb)", query_ro, query_rw)
1✔
169
  end
170

171
  def user_has_access?(user:)
1✔
172
    return true if user.eligible_sysadmin?
85✔
173
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
69✔
174
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
175
  end
176

177
  def created_by_user
1✔
178
    User.find_by(uid: metadata_model.created_by)
12✔
179
  end
180

181
  def to_xml
1✔
182
    ProjectShowPresenter.new(self).to_xml
5✔
183
  end
184

185
  # @return [String] XML representation of the <meta> element
186
  def mediaflux_meta_xml(user:)
1✔
187
    doc = ProjectMediaflux.document(project: self, user: user)
2✔
188
    doc.xpath("/response/reply/result/asset/meta").to_s
2✔
189
  end
190

191
  def mediaflux_metadata(session_id:)
1✔
192
    @mediaflux_metadata ||= begin
348✔
193
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
63✔
194
      accum_req.metadata
63✔
195
    end
196
    @mediaflux_metadata
344✔
197
  end
198

199
  def asset_count(session_id:)
1✔
200
    values = mediaflux_metadata(session_id:)
34✔
201
    values.fetch(:total_file_count, 0)
34✔
202
  end
203

204
  def self.default_storage_unit
1✔
205
    "KB"
74✔
206
  end
207

208
  def self.default_storage_usage
1✔
209
    "0 #{default_storage_unit}"
73✔
210
  end
211

212
  def storage_usage(session_id:)
1✔
213
    values = mediaflux_metadata(session_id:)
76✔
214
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
72✔
215
  end
216

217
  def storage_usage_raw(session_id:)
1✔
218
    values = mediaflux_metadata(session_id:)
45✔
219
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
45✔
220
  end
221

222
  def self.default_storage_capacity
1✔
223
    "0 GB"
26✔
224
  end
225

226
  def storage_capacity(session_id:)
1✔
227
    values = mediaflux_metadata(session_id:)
72✔
228
    quota_value = values.fetch(:quota_allocation, '') #if quota does not exist, set value to an empty string
72✔
229
    if quota_value.blank?
72✔
230
      return self.class.default_storage_capacity
26✔
231
    else
232
      return quota_value
46✔
233
    end
234
  end
235

236
  def storage_capacity_raw(session_id:)
1✔
237
    values = mediaflux_metadata(session_id:)
119✔
238
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
119✔
239
    quota_value
119✔
240
  end
241

242
  # Fetches the first n files
243
  def file_list(session_id:, size: 10)
1✔
244
    return { files: [] } if mediaflux_id.nil?
37✔
245

246
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true, aql_query: "type!='application/arc-asset-collection'")
28✔
247
    iterator_id = query_req.result
28✔
248

249
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
28✔
250
    results = iterator_req.result
28✔
251

252
    # Destroy _after_ fetching the first set of results from iterator_req.
253
    # This call is required since it possible that we have read less assets than
254
    # what the collection has but we are done with the iterator.
255
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
28✔
256

257
    results
28✔
258
  end
259

260
  # Fetches the entire file list to a file
261
  def file_list_to_file(session_id:, filename:)
1✔
262
    return { files: [] } if mediaflux_id.nil?
11✔
263

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

267
    start_time = Time.zone.now
10✔
268
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
10✔
269
    log_elapsed(start_time, prefix, "STARTED")
10✔
270

271
    File.open(filename, "w") do |file|
10✔
272
      page_number = 0
10✔
273
      # file header
274
      file.write("ID, PATH, NAME, COLLECTION?, LAST_MODIFIED, SIZE\r\n")
10✔
275
      loop do
10✔
276
        iterator_start_time = Time.zone.now
10✔
277
        page_number += 1
10✔
278
        iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: 1000)
10✔
279
        iterator_resp = iterator_req.result
10✔
280
        log_elapsed(iterator_start_time, prefix, "FETCHED page #{page_number} from iterator")
10✔
281
        lines = files_from_iterator(iterator_resp)
10✔
282
        file.write(lines.join("\r\n") + "\r\n")
10✔
283
        break if iterator_resp[:complete] || iterator_req.error?
10✔
284
      end
285
      log_elapsed(start_time, prefix, "ENDED")
10✔
286
    end
287

288
    # Destroy _after_ fetching the results from iterator_req
289
    # This call is technically not necessary since Mediaflux automatically deletes the iterator
290
    # once we have ran through it and by now we have. But it does not hurt either.
291
    Mediaflux::IteratorDestroyRequest.new(session_token: session_id, iterator: iterator_id).resolve
10✔
292
  end
293

294

295
  private
1✔
296

297
    def files_from_iterator(iterator_resp)
1✔
298
      lines = []
10✔
299
      iterator_resp[:files].each do |asset|
10✔
300
        lines << "#{asset.id}, #{asset.path_only}, #{asset.name}, #{asset.collection}, #{asset.last_modified}, #{asset.size}"
16✔
301
      end
302
      lines
10✔
303
    end
304

305
    def project_directory_pathname
1✔
306
      # allow the directory to be modified by changes in the metadata_json
UNCOV
307
      @project_directory_pathname = nil if @original_directory.present? && @original_directory != metadata_model.project_directory
×
308

309
      @project_directory_pathname ||= begin
×
UNCOV
310
        @original_directory = metadata_model.project_directory
×
311
        Pathname.new(@original_directory)
×
312
      end
313
    end
314

315
    # Ensure that the project directory is a valid path
316
    def safe_directory(directory)
1✔
UNCOV
317
      Project.safe_directory(directory)
×
318
    end
319

320
    def log_elapsed(start_time, prefix, message)
1✔
321
      elapsed_time = Time.zone.now - start_time
30✔
322
      timing_info = "#{format('%.2f', elapsed_time)} s"
30✔
323
      Rails.logger.info "#{prefix}: #{message}, #{timing_info}"
30✔
324
    end
325
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