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

pulibrary / tigerdata-app / 8d70f2ab-acc5-4aab-b64b-743d66ddd2eb

29 Aug 2025 06:22PM UTC coverage: 87.983% (-0.1%) from 88.118%
8d70f2ab-acc5-4aab-b64b-743d66ddd2eb

Pull #1801

circleci

JaymeeH
Merge branch '1586-request-mailer' of https://github.com/pulibrary/tiger-data-app into 1586-request-mailer
Pull Request #1801: 1586 request mailer

10 of 10 new or added lines in 2 files covered. (100.0%)

1173 existing lines in 56 files now uncovered.

2482 of 2821 relevant lines covered (87.98%)

317.98 hits per line

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

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

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

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

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

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

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

50
  def activate(current_user:)
2✔
51
    raise StandardError.new("Only approved projects can be activated") if self.status != Project::APPROVED_STATUS
72✔
52
    metadata_request = Mediaflux::AssetMetadataRequest.new(session_token: current_user.mediaflux_session, id: self.mediaflux_id)
72✔
53
    metadata_request.resolve
72✔
54
    raise metadata_request.response_error if metadata_request.error?
72✔
55
    if self.title == metadata_request.metadata[:title]
72✔
56
      self.metadata_model.status = Project::ACTIVE_STATUS
72✔
57
      self.save!
72✔
58
    else
59
      raise StandardError.new("Title mismatch: #{title} != #{metadata_request.title}")
×
60
    end
61
  end
62

63
  def reload
2✔
64
    super
3✔
65
    @metadata_model = ProjectMetadata.new_from_hash(self.metadata)
3✔
66
    self
3✔
67
  end
68

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

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

81
  def metadata_model
2✔
82
    @metadata_model ||= ProjectMetadata.new_from_hash(self.metadata)
6,832✔
83
  end
84

85
  def metadata_model=(new_metadata_model)
2✔
86
    @metadata_model = new_metadata_model
373✔
87
  end
88

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

95
  def title
2✔
96
    self.metadata_model.title
384✔
97
  end
98

99
  def departments
2✔
100
    unsorted = metadata_model.departments || []
19✔
101
    unsorted.sort
19✔
102
  end
103

104
  def project_directory
2✔
105
    metadata_model.project_directory || ""
189✔
106
  end
107

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

113
  def project_directory_short
2✔
UNCOV
114
    project_directory
10✔
115
  end
116

117
  def status
2✔
118
    metadata_model.status
378✔
119
  end
120

121
  def in_mediaflux?
2✔
UNCOV
122
    mediaflux_id.present?
26✔
123
  end
124

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

144
  def self.sponsored_projects(sponsor)
2✔
UNCOV
145
    Project.where("metadata_json->>'data_sponsor' = ?", sponsor)
1✔
146
  end
147

148
  def self.managed_projects(manager)
2✔
UNCOV
149
    Project.where("metadata_json->>'data_manager' = ?", manager)
1✔
150
  end
151

152
  def self.all_projects
2✔
153
    Project.all
4✔
154
  end
155

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

164
  def user_has_access?(user:)
2✔
165
    return true if user.eligible_sysadmin?
54✔
166
    metadata_model.data_sponsor == user.uid || metadata_model.data_manager == user.uid ||
46✔
167
    metadata_model.data_user_read_only.include?(user.uid) || metadata_model.data_user_read_write.include?(user.uid)
168
  end
169

170
  def created_by_user
2✔
171
    User.find_by(uid: metadata_model.created_by)
12✔
172
  end
173

174
  def to_xml
2✔
175
    ProjectShowPresenter.new(self).to_xml
5✔
176
  end
177

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

184
  def mediaflux_metadata(session_id:)
2✔
185
    @mediaflux_metadata ||= begin
591✔
186
      accum_req = Mediaflux::AssetMetadataRequest.new(session_token: session_id, id: mediaflux_id)
103✔
187
      accum_req.metadata
103✔
188
    end
189
    @mediaflux_metadata
587✔
190
  end
191

192
  def asset_count(session_id:)
2✔
193
    values = mediaflux_metadata(session_id:)
29✔
194
    values.fetch(:total_file_count, 0)
29✔
195
  end
196

197
  def self.default_storage_unit
2✔
198
    "KB"
179✔
199
  end
200

201
  def self.default_storage_usage
2✔
202
    "0 #{default_storage_unit}"
178✔
203
  end
204

205
  def storage_usage(session_id:)
2✔
206
    values = mediaflux_metadata(session_id:)
181✔
207
    values.fetch(:quota_used, self.class.default_storage_usage) # if the storage is empty use the default
177✔
208
  end
209

210
  def storage_usage_raw(session_id:)
2✔
UNCOV
211
    values = mediaflux_metadata(session_id:)
43✔
UNCOV
212
    values.fetch(:quota_used_raw, 0) # if the storage raw is empty use zero
43✔
213
  end
214

215
  def self.default_storage_capacity
2✔
216
    "0 GB"
133✔
217
  end
218

219
  def storage_capacity(session_id:)
2✔
220
    values = mediaflux_metadata(session_id:)
177✔
221
    quota_value = values.fetch(:quota_allocation, '') #if quota does not exist, set value to an empty string
177✔
222
    if quota_value.blank?
177✔
223
      return self.class.default_storage_capacity
133✔
224
    else
UNCOV
225
      return quota_value
44✔
226
    end
227
  end
228

229
  def storage_capacity_raw(session_id:)
2✔
230
    values = mediaflux_metadata(session_id:)
159✔
231
    quota_value = values.fetch(:quota_allocation_raw, 0) #if quota does not exist, set value to 0
159✔
232
    quota_value
159✔
233
  end
234

235
  # Fetches the first n files
236
  def file_list(session_id:, size: 10)
2✔
237
    return { files: [] } if mediaflux_id.nil?
32✔
238

UNCOV
239
    query_req = Mediaflux::QueryRequest.new(session_token: session_id, collection: mediaflux_id, deep_search: true, aql_query: "type!='application/arc-asset-collection'")
26✔
UNCOV
240
    iterator_id = query_req.result
26✔
241

UNCOV
242
    iterator_req = Mediaflux::IteratorRequest.new(session_token: session_id, iterator: iterator_id, size: size)
26✔
UNCOV
243
    results = iterator_req.result
26✔
244

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

UNCOV
250
    results
26✔
251
  end
252

253
  # Fetches the entire file list to a file
254
  def file_list_to_file(session_id:, filename:)
2✔
255
    return { files: [] } if mediaflux_id.nil?
11✔
256

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

UNCOV
260
    start_time = Time.zone.now
10✔
UNCOV
261
    prefix = "file_list_to_file #{session_id[0..7]} #{self.metadata_model.project_id}"
10✔
UNCOV
262
    log_elapsed(start_time, prefix, "STARTED")
10✔
263

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

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

287

288
  private
2✔
289

290
    def files_from_iterator(iterator_resp)
2✔
UNCOV
291
      lines = []
10✔
UNCOV
292
      iterator_resp[:files].each do |asset|
10✔
UNCOV
293
        lines << "#{asset.id}, #{asset.path_only}, #{asset.name}, #{asset.collection}, #{asset.last_modified}, #{asset.size}"
16✔
294
      end
UNCOV
295
      lines
10✔
296
    end
297

298
    def project_directory_pathname
2✔
299
      # allow the directory to be modified by changes in the metadata_json
300
      @project_directory_pathname = nil if @original_directory.present? && @original_directory != metadata_model.project_directory
×
301

302
      @project_directory_pathname ||= begin
×
303
        @original_directory = metadata_model.project_directory
×
304
        Pathname.new(@original_directory)
×
305
      end
306
    end
307

308
    # Ensure that the project directory is a valid path
309
    def safe_directory(directory)
2✔
310
      Project.safe_directory(directory)
×
311
    end
312

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