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

MushroomObserver / mushroom-observer / 30493767616

29 Jul 2026 09:48PM UTC coverage: 99.238% (-0.006%) from 99.244%
30493767616

Pull #4944

github

AlanRockefeller
Derive API2 external_link url from external_id

The JSON and XML serializers read the raw url attribute, which the model
deliberately nils whenever external_id is set (the #4565 XOR invariant),
so every materialized link serialized as an empty url. Use link_url, the
documented source of truth, and expose external_id alongside it.
Pull Request #4944: Derive API2 `external_link` url from `external_id`

53543 of 53954 relevant lines covered (99.24%)

850.08 hits per line

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

99.34
/app/controllers/observations_controller/shared_form_methods.rb
1
# frozen_string_literal: true
2

3
#  :section: Shared form private methods
4
#
5
#    permitted_observation_args
6
#    update_permitted_observation_attributes
7
#    permitted_observation_params
8
#    notes_to_sym_and_compact
9
#    notes_param_present?
10
#
11
#    init_license_var
12
#    init_new_image_var
13
#    init_specimen_vars
14
#    init_specimen_vars_for_reload
15
#    init_project_vars
16
#    init_project_vars_for_reload
17
#    init_list_vars
18
#    init_list_vars_for_reload
19
#    save_observation
20
#
21
#    create_image_objects_and_update_bad_images
22
#    try_to_save_image
23
#    update_good_images
24
#    attach_good_images
25
#    strip_images!
26
#
27
#    update_projects
28
#    update_species_lists
29
#
30
module ObservationsController::SharedFormMethods
1✔
31
  private
1✔
32

33
  # NOTE: potential gotcha... Any nested attributes must come last.
34
  def permitted_observation_args
1✔
35
    [:lat, :lng, :alt, :gps_hidden, :place_name, :where, :location_id,
154✔
36
     :is_collection_location, :when, "when(1i)", "when(2i)", "when(3i)",
37
     :collector, :collector_user_id, :notes, :specimen, :thumb_image_id]
38
  end
39

40
  def update_permitted_observation_attributes
1✔
41
    # Must be set before `attributes=` below: `place_name=` (see
42
    # HasPlaceName) reads `current_user` to decide postal vs
43
    # scientific parsing.
44
    @observation.current_user = @user
41✔
45
    @observation.attributes = permitted_observation_params || {}
41✔
46
  end
47

48
  # NOTE: call `to_h` on the permitted params if problems with nested params.
49
  # As of rails 5, params are an ActionController::Parameters object,
50
  # not a hash.
51
  def permitted_observation_params
1✔
52
    return unless params[:observation]
41✔
53

54
    params[:observation].permit(permitted_observation_args).to_h
41✔
55
  end
56

57
  # Symbolize keys and drop blank values -- except a blank on a key some
58
  # occurrence sibling holds, which is a deliberate suppression of the
59
  # otherwise-inherited value on the primary's show page
60
  # (Occurrence#merged_notes) and so must be stored, not compacted.
61
  # Also avoids param permitting issues.
62
  def notes_to_sym_and_compact
1✔
63
    return Observation.no_notes unless notes_param_present?
154✔
64

65
    symbolized = params[:observation][:notes].to_unsafe_h.symbolize_keys
22✔
66
    # Collector has its own column; never let it live in notes (#4211).
67
    symbolized.delete(:Collector)
22✔
68
    suppressible = suppressible_notes_keys
22✔
69
    symbolized.reject! do |key, value|
22✔
70
      value.blank? && suppressible.exclude?(key)
29✔
71
    end
72
    symbolized
22✔
73
  end
74

75
  # Keys where a blank value means "suppress the inherited value" rather
76
  # than "delete" -- the sibling keys of a primary in a multi-member
77
  # occurrence. Empty for creates and non-primary/non-occurrence edits.
78
  def suppressible_notes_keys
1✔
79
    return Set.new unless @observation&.shows_merged_notes?
22✔
80

81
    @observation.occurrence.sibling_note_keys.to_set
2✔
82
  end
83

84
  def notes_param_present?
1✔
85
    params.dig(:observation, :notes).present?
154✔
86
  end
87

88
  def init_license_var
1✔
89
    @licenses = License.available_names_and_ids(@user.license)
200✔
90
  end
91

92
  # Initialize image for the dynamic image form at the bottom.
93
  def init_new_image_var(default_date)
1✔
94
    @new_image = Image.new(when: default_date, license: @user.license,
200✔
95
                           copyright_holder: @user.legal_name)
96
  end
97

98
  def init_specimen_vars
1✔
99
    # The form submits the collector nested (params[:observation]
100
    # [:collector]); the field-slip redirect to `new` carries it
101
    # top-level. Check nested first so a failed create re-render keeps
102
    # the user-entered collector instead of falling back to legal_name.
103
    @collectors_name   = params.dig(:observation, :collector).presence ||
52✔
104
                         params[:collector].presence || @user.legal_name
105
    @collectors_number = ""
52✔
106
    @herbarium_name    = @user.preferred_herbarium_name
52✔
107
    @herbarium_id      = @user.preferred_herbarium&.id
52✔
108
    @accession_number  = ""
52✔
109
  end
110

111
  def init_specimen_vars_for_reload
1✔
112
    init_specimen_vars
27✔
113
    col_params = collection_number_params
27✔
114
    if col_params
27✔
115
      @collectors_name   = col_params[:name]
18✔
116
      @collectors_number = col_params[:number]
18✔
117
    end
118
    herb_params = herbarium_record_params
27✔
119
    return unless herb_params
27✔
120

121
    @herbarium_name   = herb_params[:herbarium_name]
18✔
122
    @herbarium_id     = herb_params[:herbarium_id]
18✔
123
    @accession_number = herb_params[:accession_number]
18✔
124
  end
125

126
  def init_project_vars
1✔
127
    @projects = @user.projects_member(order: :title,
88✔
128
                                      include: :user_group)
129
  end
130

131
  # Failure-reload path: capture the user's just-submitted project_ids
132
  # for the form to render checked. We CAN'T set
133
  # `@observation.project_ids = submitted` — that would instantly
134
  # commit join-table changes on a persisted record (Rails
135
  # `*_ids=` setter is INSERT/DELETE on the join table immediately),
136
  # even though the save itself failed.
137
  def init_project_vars_for_reload
1✔
138
    @observation.projects.each do |proj|
42✔
139
      @projects << proj unless @projects.include?(proj)
2✔
140
    end
141
    @submitted_project_ids = params.dig(:observation, :project_ids)
42✔
142
  end
143

144
  def init_list_vars
1✔
145
    @lists = @user.all_editable_species_lists.sort_by(&:title)
88✔
146
  end
147

148
  def init_list_vars_for_reload
1✔
149
    init_list_vars
42✔
150
    @lists = @lists.union(@observation.species_lists)
42✔
151
    @submitted_list_ids = params.dig(:observation, :species_list_ids)
42✔
152
  end
153

154
  # Save observation now that everything is created successfully.
155
  def save_observation
1✔
156
    return true if @observation.save
101✔
157

158
    flash_error(:runtime_no_save_observation.t)
2✔
159
    flash_object_errors(@observation)
2✔
160
    false
2✔
161
  end
162

163
  ##############################################################################
164

165
  # Attempt to upload any images.  We will attach them to the observation
166
  # later, assuming we can create it.  Problem is if anything goes wrong, we
167
  # cannot repopulate the image forms (security issue associated with giving
168
  # file upload fields default values).  So we need to do this immediately,
169
  # even if observation creation fails.  Keep a list of images we've uploaded
170
  # successfully in @good_images (stored in hidden form field).
171
  #
172
  # INPUT: params[:observation][:image], observation, good_images (and @user)
173
  # OUTPUT: list of images we couldn't create
174
  #
175
  def create_image_objects_and_update_bad_images
1✔
176
    @bad_images = []
154✔
177
    # can't do each_with_index here because it's ActionController::Parameters
178
    params.dig(:observation, :image)&.each do |idx, args|
154✔
179
      next if (upload = args[:image]).blank?
6✔
180

181
      if upload.respond_to?(:original_filename)
6✔
182
        name = upload.original_filename.force_encoding("utf-8")
6✔
183
      end
184
      image = Image.new(args.permit(permitted_image_args))
6✔
185
      image.created_at = Time.zone.now
6✔
186
      image.updated_at = image.created_at
6✔
187
      # If image.when is 1950 it means user never saw the form
188
      # field, so we should use default instead.
189
      image.when = @observation.when if image.when.year == 1950
6✔
190
      image.user = @user
6✔
191
      try_to_save_image(idx.to_i, image, name)
6✔
192
    end
193
    @observation.thumb_image_id = nil if @observation.thumb_image_id&.<= 0
154✔
194
    @bad_images
154✔
195
  end
196

197
  # Try to save a single image.  If successful, add it to good_images.
198
  def try_to_save_image(idx, image, name)
1✔
199
    if !image.save
6✔
200
      @bad_images.push(image)
1✔
201
      flash_object_errors(image)
1✔
202
    elsif !image.process_image(strip: @observation.gps_hidden)
5✔
203
      name_str = name ? "'#{name}'" : "##{image.id}"
1✔
204
      flash_notice(:runtime_no_upload_image.t(name: name_str))
1✔
205
      @bad_images.push(image)
1✔
206
      flash_object_errors(image)
1✔
207
    else
208
      name = image.original_name
4✔
209
      name = "##{image.id}" if name.empty?
4✔
210
      flash_notice(:runtime_image_uploaded.t(name: name))
4✔
211
      @good_images.push(image)
4✔
212
      if @observation.thumb_image_id == -idx
4✔
213
        @observation.thumb_image_id = image.id
2✔
214
      end
215
    end
216
  end
217

218
  # List of images that we've successfully uploaded, but which haven't been
219
  # attached to the observation yet.  Also supports some mininal editing.
220
  # INPUT: params[:observation][:good_image_ids],
221
  #        params[:observation][:good_image]
222
  # OUTPUT: list of images
223

224
  def update_good_images
1✔
225
    # Get list of images first.
226
    good_image_ids = params.dig(:observation, :good_image_ids) || ""
154✔
227
    @good_images = good_image_ids.split.filter_map do |id|
154✔
228
      Image.safe_find(id.to_i)
22✔
229
    end
230

231
    # Now check for edits.
232
    @good_images.map do |image|
154✔
233
      next unless permission?(image)
22✔
234

235
      args = params.dig(:observation, :good_image, image.id.to_s)
18✔
236
      next unless args
18✔
237

238
      image.current_user = @user
3✔
239
      image.attributes = args.permit(permitted_image_args)
3✔
240
      next unless image.when_changed? ||
3✔
241
                  image.notes_changed? ||
242
                  image.copyright_holder_changed? ||
243
                  image.license_id_changed? ||
244
                  image.original_name_changed?
245

246
      image.updated_at = Time.zone.now
3✔
247
      if image.save
3✔
248
        flash_notice(:runtime_image_updated_notes.t(id: image.id))
3✔
249
      else
250
        flash_object_errors(image)
×
251
      end
252
    end
253
  end
254

255
  # For now, this has to read the exif off the actual file on the server.
256
  # This is because the exif data is not stored on the Image record.
257
  def get_exif_data(images)
1✔
258
    data = {}
149✔
259
    images.each do |image|
149✔
260
      # Don't hide GPS for the owner viewing their own edit form
261
      exif_data = image&.read_exif_geocode(hide_gps: false)
28✔
262
      # If no EXIF data (no GPS), provide basic info from database
263
      if exif_data.nil?
28✔
264
        exif_data = {
27✔
265
          lat: nil,
266
          lng: nil,
267
          alt: nil,
268
          date: image.when&.strftime("%d-%B-%Y"),
269
          file_name: image.original_name,
270
          file_size: nil # Could calculate from file system if needed
271
        }
272
      else
273
        # EXIF data exists, but ensure file_name is set from database
274
        exif_data[:file_name] ||= image.original_name
1✔
275
      end
276
      data[image.id] = exif_data
28✔
277
    end
278
    data
149✔
279
  end
280

281
  # Now that the observation has been successfully created, we can attach
282
  # any images that were uploaded earlier
283
  def attach_good_images
1✔
284
    return unless @good_images
127✔
285

286
    @good_images.each do |image|
127✔
287
      next if @observation.image_ids.include?(image.id)
25✔
288

289
      @observation.add_image(image)
9✔
290
      image.current_user = @user
9✔
291
      image.log_create_for(@observation)
9✔
292
    end
293
  end
294

295
  def strip_images!
1✔
296
    @observation.images.each do |img|
3✔
297
      error = img.strip_gps!
5✔
298
      flash_error(:runtime_failed_to_strip_gps.t(msg: error)) if error
5✔
299
    end
300
  end
301

302
  ##############################################################################
303

304
  # `submitted_ids` is `observation[project_ids][]` — the projects
305
  # the user wants the obs attached to. Only `@user.projects_member`
306
  # projects are toggled — non-member projects the obs belongs to are
307
  # preserved by omission (disabled checkboxes don't submit, and the
308
  # iteration excludes them anyway).
309
  def update_projects
1✔
310
    submitted_ids = params.dig(:observation, :project_ids)
112✔
311
    return unless submitted_ids
112✔
312

313
    desired = submitted_ids.compact_blank.map(&:to_i)
11✔
314
    @user.projects_member(include: :observations).each do |project|
11✔
315
      before = @observation.projects.include?(project)
105✔
316
      after = desired.include?(project.id)
105✔
317
      next unless before != after
105✔
318

319
      if after
9✔
320
        project.add_observation(@observation)
8✔
321
        name_flash_for_project(@observation.name, project)
8✔
322
      else
323
        project.remove_observation(@observation)
1✔
324
        flash_notice(:removed_from_project.t(object: :observation,
1✔
325
                                             project: project.title))
326
      end
327
    end
328
  end
329

330
  def update_species_lists
1✔
331
    submitted_ids = params.dig(:observation, :species_list_ids)
112✔
332
    return unless submitted_ids
112✔
333

334
    desired = submitted_ids.compact_blank.map(&:to_i)
7✔
335
    @user.all_editable_species_lists.includes(:observations).
7✔
336
      find_each do |list|
337
      before = @observation.species_lists.include?(list)
19✔
338
      after = desired.include?(list.id)
19✔
339
      next unless before != after
19✔
340

341
      if after
2✔
342
        list.add_observation(@observation)
1✔
343
        flash_notice(:added_to_list.t(list: list.title))
1✔
344
      else
345
        list.remove_observation(@observation)
1✔
346
        flash_notice(:removed_from_list.t(list: list.title))
1✔
347
      end
348
    end
349
  end
350
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc