Add GBIF to our scientific names, so that our crops can associate creative commons photos (#3559)

* Add GBIF cient

* Add lookup

* Add autocomplete for GBIF lookup

* Add extra detail to scientific names

* Autocomplete

* Add routes

* Rmeove mapping

* Add autocomplete

* Update GBIF data on save

* db/schema

* Style

* Extract service

* Add concern

* Add concern

* Save photos

* Initial coverage

* Coverage

* Add coverage

* Shut up, codeclimate

* Shut up, codeclimate

* Unused

* Shut up, codeclimate

* Apply suggestions from code review

* Remove localhost

* Fix rubocop

* Fix rubocop

* Add UI links

* Add rake

* Indent

* Update Gemfile.lock

* Update lib/tasks/gbif.rake

* Update app/views/crops/_scientific_names.html.haml

* Rubocop

* Expand edit photo form

* Fix error

* Add model validations

* Skip photos without backlinks

* Fix tests

* Add photo words

* Allow blank

* Rubocop and handle invalid legacy data

* Apply suggestions from code review

* Update lib/tasks/gbif.rake
This commit is contained in:
Daniel O'Connor
2024-01-21 13:22:25 +10:30
committed by GitHub
parent 1840e36d77
commit 1f0cfa9b6c
31 changed files with 1059 additions and 19 deletions

View File

@@ -128,6 +128,9 @@ gem 'faraday_middleware'
gem 'rack-cors'
# External APIs for data
gem "gbifrb"
group :production do
gem 'bonsai-elasticsearch-rails' # Integration with Bonsa-Elasticsearch on heroku
gem 'dalli'
@@ -153,6 +156,7 @@ group :development, :test do
gem 'factory_bot_rails' # for creating test data
gem 'faker'
gem 'haml-rails' # HTML templating language
gem 'pry'
gem 'query_diet'
gem 'rspec-activemodel-mocks'
gem 'rspec-rails' # unit testing framework
@@ -176,6 +180,7 @@ group :test do
gem 'rails-controller-testing'
gem 'selenium-webdriver'
gem 'timecop'
gem 'vcr'
end
group :travis do

View File

@@ -163,6 +163,7 @@ GEM
chartkick (5.0.5)
codeclimate-test-reporter (1.0.9)
simplecov (<= 0.13)
coderay (1.1.3)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
railties (>= 5.2.0)
@@ -254,6 +255,7 @@ GEM
sassc (>= 1.11)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
gbifrb (0.2.0)
geocoder (1.8.2)
gibbon (1.2.1)
httparty
@@ -412,6 +414,9 @@ GEM
moneta (~> 1.0.0)
rate_throttle_client (~> 0.1.0)
popper_js (1.16.1)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (5.0.4)
puma (6.4.2)
nio4r (~> 2.0)
@@ -614,6 +619,7 @@ GEM
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
vcr (6.2.0)
warden (1.2.9)
rack (>= 2.0.9)
webrat (0.7.3)
@@ -673,6 +679,7 @@ DEPENDENCIES
flickraw
font-awesome-sass
friendly_id
gbifrb
geocoder
gibbon (~> 1.2.0)
gravatar-ultimate
@@ -701,6 +708,7 @@ DEPENDENCIES
percy-capybara (~> 5.0.0)
pg
platform-api
pry
puma
query_diet
rack-cors
@@ -731,6 +739,7 @@ DEPENDENCIES
uglifier
unicorn
validate_url
vcr
webrat
will_paginate
will_paginate-bootstrap-style

View File

@@ -5,7 +5,7 @@ jQuery ->
$(".remove-altname-row").css("display", "inline-block")
-$ ->
sci_template = "<div id='sci_template[INDEX]' class='template col-md-12'><div class='col-md-2'><label>Scientific name INDEX:</label></div><div class='col-md-8'><input name='sci_name[INDEX]' class='form-control', id='sci_name[INDEX]')'></input><span class='help-block'>Scientific name of crop.</span></div><div class='col-md-2'></div></div>"
sci_template = "<div id='sci_template[INDEX]' class='template col-md-12'><div class='col-md-2'><label>Scientific name INDEX:</label></div><div class='col-md-8'><input name='sci_name[INDEX]' class='scientific-name-auto-suggest form-control' id='sci_name[INDEX]' data-source-url='/scientific_names/gbif_suggest')'></input><span class='help-block'>Scientific name of crop</span><input type='text' id='sci_gbif_key[INDEX]' class=''></div><div class='col-md-2'></div></div>"
sci_index = $('#scientific_names .template').length + 1
@@ -21,7 +21,7 @@ jQuery ->
element = document.getElementById(tmp)
element.remove()
alt_template = "<div id='alt_template[INDEX]' class='template col-md-12'><div class='col-md-2'><label>Alternate name INDEX:</label></div><div class='col-md-8'><input name='alt_name[INDEX]' class='form-control', id='alt_name[INDEX]')'></input><span class='help-block'>Alternate name of crop.</span></div><div class='col-md-2'></div></div>"
alt_template = "<div id='alt_template[INDEX]' class='template col-md-12'><div class='col-md-2'><label>Alternate name INDEX:</label></div><div class='col-md-8'><input name='alt_name[INDEX]' class='form-control' id='alt_name[INDEX]')'></input><span class='help-block'>Alternate name of crop.</span></div><div class='col-md-2'></div></div>"
alt_index = $('#alternate_names .template').length + 1

View File

@@ -0,0 +1,31 @@
# TODO: This assumes one autocomplete per page.
# Needs to be a function so that when we append one of these, it gets uniquely associated with the hidden controls.
jQuery ->
if el = $( '.scientific-name-auto-suggest' )
id = $( '.scientific-name-auto-suggest-id' )
el.autocomplete
minLength: 3,
source: el.attr( 'data-source-url' ),
focus: ( event, ui ) ->
el.val( ui.item.canonicalName )
id.val( ui.item.nameKey )
false
select: ( event, ui ) ->
el.val( ui.item.canonicalName )
id.val( ui.item.nameKey )
false
response: ( event, ui ) ->
id.val( "" )
for item in ui.content
if item.name == el.val()
id.val( item.nameKey )
if el.data( 'uiAutocomplete' )
el.data( 'uiAutocomplete' )._renderItem = ( ul, item ) ->
$( '<li class="list-group-item"></li>' )
.data( 'item.autocomplete', item )
.append( "<a>#{item.canonicalName} (#{item.scientificName}) - #{item.rank}</a>" )
.appendTo( ul )

View File

@@ -46,6 +46,12 @@ class CropsController < ApplicationController
respond_with @crop, location: @crop
end
def gbif
@crop = Crop.find(params[:crop_slug])
@crop.update_gbif_data!
respond_with @crop, location: @crop
end
def hierarchy
@crops = Crop.toplevel.order(:name)
respond_with @crops
@@ -120,6 +126,7 @@ class CropsController < ApplicationController
if @crop.approval_status_changed?(from: "pending", to: "approved")
notifier.deliver_now!
@crop.update_openfarm_data!
@crop.update_gbif_data!
end
else
@crop.approval_status = @crop.approval_status_was

View File

@@ -63,7 +63,7 @@ class PhotosController < ApplicationController
def photo_params
params.require(:photo).permit(:source_id, :source, :title, :license_name,
:license_url, :thumbnail_url, :fullsize_url, :link_url)
:license_url, :thumbnail_url, :fullsize_url, :link_url, :date_taken)
end
# Item with photos attached

View File

@@ -1,8 +1,8 @@
# frozen_string_literal: true
class ScientificNamesController < ApplicationController
before_action :authenticate_member!, except: %i(index show)
load_and_authorize_resource
before_action :authenticate_member!, except: %i(index show gbif_suggest)
load_and_authorize_resource except: [:gbif_suggest]
respond_to :html, :json
responders :flash
@@ -35,7 +35,7 @@ class ScientificNamesController < ApplicationController
def create
@scientific_name = ScientificName.new(scientific_name_params)
@scientific_name.creator = current_member
gbif_sync!(@scientific_name)
@scientific_name.save
respond_with(@scientific_name.crop)
end
@@ -43,7 +43,9 @@ class ScientificNamesController < ApplicationController
# PUT /scientific_names/1
# PUT /scientific_names/1.json
def update
@scientific_name.update(scientific_name_params)
@scientific_name.assign_attributes(scientific_name_params)
gbif_sync!(@scientific_name)
@scientific_name.save
respond_with(@scientific_name.crop)
end
@@ -56,9 +58,26 @@ class ScientificNamesController < ApplicationController
respond_with(@crop)
end
def gbif_suggest
render json: gbif_service.suggest(params[:term])
end
private
def gbif_sync!(model)
return unless model.gbif_key
result = gbif_service.fetch(model.gbif_key)
model.gbif_rank = result["rank"]
model.gbif_status = result["status"]
end
def scientific_name_params
params.require(:scientific_name).permit(:crop_id, :name)
params.require(:scientific_name).permit(:crop_id, :name, :gbif_key)
end
def gbif_service
GbifService.new
end
end

View File

@@ -79,6 +79,7 @@ class Ability
can :manage, ScientificName
can :manage, AlternateName
can :openfarm, Crop
can :gbif, Crop
end
# any member can create a crop provisionally

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
module GbifData
extend ActiveSupport::Concern
included do
def update_gbif_data!
GbifService.new.update_crop(self)
end
end
end

View File

@@ -4,6 +4,7 @@ class Crop < ApplicationRecord
extend FriendlyId
include PhotoCapable
include OpenFarmData
include GbifData
include SearchCrops
friendly_id :name, use: %i(slugged finders)

View File

@@ -16,8 +16,13 @@ class Photo < ApplicationRecord
Crop.distinct.joins(:photo_associations).where(photo_associations: { photo: self })
end
validates :fullsize_url, url: true
validates :thumbnail_url, url: true
validates :fullsize_url, url: true, presence: true
validates :thumbnail_url, url: true, presence: true
validates :link_url, url: true, presence: true
validates :owner, presence: true
validates :title, presence: true
validates :license_name, presence: true # Should assert this is one of CC-BY, CC-BY-NC, etc
validates :license_url, url: true, allow_blank: true
# creates a relationship for each assignee type
PHOTO_CAPABLE.each do |type|

View File

@@ -0,0 +1,193 @@
# frozen_string_literal: true
require 'English'
class GbifService
def initialize
@cropbot = Member.find_by(login_name: 'cropbot')
@species = Gbif::Species
end
def suggest(term)
# Query the GBIF name autocomplete and discover the scientific name.
# [
# {
# "key": 2932942,
# "nameKey": 1970347,
# "kingdom": "Plantae",
# "phylum": "Tracheophyta",
# "order": "Solanales",
# "family": "Solanaceae",
# "genus": "Capsicum",
# "species": "Capsicum chinense",
# "kingdomKey": 6,
# "phylumKey": 7707728,
# "classKey": 220,
# "orderKey": 1176,
# "familyKey": 7717,
# "genusKey": 2932937,
# "speciesKey": 2932942,
# "parent": "Capsicum",
# "parentKey": 2932937,
# "nubKey": 2932942,
# "scientificName": "Capsicum chinense Jacq.",
# "canonicalName": "Capsicum chinense",
# "rank": "SPECIES",
# "status": "ACCEPTED",
# "synonym": false,
# "higherClassificationMap": {
# "6": "Plantae",
# "220": "Magnoliopsida",
# "1176": "Solanales",
# "7717": "Solanaceae",
# "2932937": "Capsicum",
# "7707728": "Tracheophyta"
# },
# "class": "Magnoliopsida"
# },
# {
# "key": 12079498,
# "nameKey": 81778754,
# "kingdom": "Plantae",
# "phylum": "Tracheophyta",
# "order": "Solanales",
# "family": "Solanaceae",
# "genus": "Capsicum",
# "species": "Capsicum chinense",
# "kingdomKey": 6,
# "phylumKey": 7707728,
# "classKey": 220,
# "orderKey": 1176,
# "familyKey": 7717,
# "genusKey": 2932937,
# "speciesKey": 2932942,
# "parent": "Capsicum",
# "parentKey": 2932937,
# "nubKey": 12079498,
# "scientificName": "Capsicum annuum var. chinense (Jacq.) Alef.",
# "canonicalName": "Capsicum annuum chinense",
# "rank": "VARIETY",
# "status": "SYNONYM",
# "synonym": true,
# "higherClassificationMap": {
# "6": "Plantae",
# "220": "Magnoliopsida",
# "1176": "Solanales",
# "7717": "Solanaceae",
# "2932937": "Capsicum",
# "2932942": "Capsicum chinense",
# "7707728": "Tracheophyta"
# },
# "class": "Magnoliopsida"
# }
# ]
@species.name_suggest(q: term)
end
def import!
Crop.order(updated_at: :desc).each do |crop|
Rails.logger.debug { "#{crop.id}, #{crop.name}" }
update_crop(crop) if crop.valid?
rescue ActiveRecord::RecordInvalid
Rails.logger.error($ERROR_INFO.message)
end
end
def update_crop(crop)
# Attempt to resolve the scientific names via /species/match.
gbif_usage_key = crop.scientific_names.detect { |sn| sn.gbif_key.present? }&.gbif_key
unless gbif_usage_key
crop.scientific_names.each do |sn|
result = @species.name_backbone(name: sn.name) # , higherTaxonKey: 6, nameType: 'SCIENTIFIC')
next unless result["confidence"] > 95 && result["matchType"] == "EXACT"
sn.gbif_key = result["usageKey"]
sn.gbif_rank = result["rank"]
sn.gbif_status = result["status"]
sn.save!
end
gbif_usage_key = crop.scientific_names.detect { |sn| sn.gbif_key.present? }&.gbif_key
end
# No match? Fall back to common names
unless gbif_usage_key
query_results = @species.name_lookup(q: crop.name, higherTaxonKey: 6)
# We only want one result, otherwise it needs human.
return unless query_results["results"].length == 1
query_result = query_results["results"].first
gbif_usage_key = query_result["key"]
crop.scientific_names.create!(gbif_key: gbif_usage_key, name: query_result["canonicalName"], creator: @cropbot)
end
gbif_record = fetch(gbif_usage_key)
if gbif_record.present?
# crop.update! openfarm_data: gbif_record.fetch('data', false)
# save_companions(crop, gbif_record)
save_photos(crop, gbif_usage_key)
else
Rails.logger.debug "\tcrop not found on GBIF"
# crop.update!(openfarm_data: false)
end
end
def save_photos(crop, key)
# https://api.gbif.org/v1/occurrence/search?taxon_key=3084850
occurrences = Gbif::Occurrences.search(taxonKey: key, mediatype: 'StillImage', limit: 3, hasCoordinate: true)
occurrences["results"].each do |result|
next unless result["media"]
media = result["media"].first
next unless media["identifier"]
# Example: "https://inaturalist-open-data.s3.amazonaws.com/photos/250226497/original.jpg"
url = media["identifier"]
md5 = Digest::MD5.hexdigest(url)
width = 200
thumbnail = "https://api.gbif.org/v1/image/cache/#{width}x/occurrence/#{result['key']}/media/#{md5}"
next unless url.start_with? 'http'
next if Photo.find_by(source_id: result["key"], source: 'gbif')
next if media["references"].blank?
photo = Photo.new(
# This is for the overall observation which may technically have multiple media. However, we're only taking the first.
source_id: result["key"],
source: 'gbif',
owner: @cropbot,
thumbnail_url: thumbnail,
fullsize_url: url,
title: "Photo by #{media['creator']} via #{media['publisher']} (Copyright #{media['rightsHolder']})",
license_name: case media["license"]
when "http://creativecommons.org/licenses/by/4.0/"
"CC BY 4.0"
when "http://creativecommons.org/licenses/by-nc/4.0/"
"CC BY-NC 4.0"
else
media["license"]
end,
license_url: media["license"],
link_url: media["references"]
)
photo.date_taken = DateTime.parse(media["created"]) if media["created"]
if photo.valid?
Photo.transaction do
photo.save
PhotoAssociation.find_or_create_by! photo:, photographable: crop
end
Rails.logger.debug { "\t saved photo #{photo.id} #{photo.source_id}" }
else
Rails.logger.warn "Photo not valid"
end
end
end
def fetch(key)
Gbif::Request.new("species/#{key}", nil, nil, nil).perform
end
end

View File

@@ -65,8 +65,12 @@
.col-2
= label_tag :scientific_names, "Scientific name #{index + 1}:", class: 'control-label'
.col-8
= text_field_tag "sci_name[#{index + 1}]", sci.name, id: "sci_name[#{index + 1}]", class: 'form-control'
%span.help-block Scientific name of crop.
= text_field_tag "sci_name[#{index + 1}]", sci.name, id: "sci_name[#{index + 1}]",
class: 'scientific-name-auto-suggest form-control',
data: { source_url: gbif_suggest_scientific_names_path }
%span.help-block Searches GBIF to determine scientific name of crop.
= hidden_field_tag "sci_gbif_key[#{index + 1}]", sci.gbif_key, id: "sci_gbif_key[#{index + 1}]",
class: 'scientific-name-auto-suggest-id'
%h2 Alternate names
= button_tag "+", class: "add-altname-row", type: "button"
= button_tag "-", class: "remove-altname-row", type: "button"

View File

@@ -16,7 +16,13 @@
= delete_icon
= t('.delete')
- else
.badge= sn.name
- if sn.gbif_key
= link_to sn.name, "https://www.gbif.org/species/#{sn.gbif_key}",
class: 'card-link',
target: "_blank",
rel: "noopener noreferrer"
- else
.badge= sn.name
%p.text-right
- if can? :edit, crop

View File

@@ -14,6 +14,10 @@
= icon 'far', 'update'
Fetch data from OpenFarm
= link_to crop_gbif_path(crop), method: :post, class: 'dropdown-item' do
= icon 'far', 'update'
Fetch data from GBIF
- if can? :destroy, crop
.dropdown-divider
= delete_button(crop, classes: 'dropdown-item text-danger')

View File

@@ -5,7 +5,8 @@
%h5.ellipsis
= photo_icon
= link_to photo.title, photo_path(id: photo.id)
%i by #{link_to photo.owner_login_name, member_path(slug: photo.owner_slug)}
- if photo.owner_slug
%i by #{link_to photo.owner_login_name, member_path(slug: photo.owner_slug)}
- if photo.date_taken.present?
%small.text-muted
%time{datetime: photo.date_taken}= I18n.l(photo.date_taken.to_date)

View File

@@ -5,6 +5,31 @@
= form_for(@photo) do |f|
.form-group
= f.label :title
= f.text_field :title, placeholder: "title"
= f.text_field :title, placeholder: "title", required: true
.form-group
= f.label :thumbnail_url
= f.url_field :thumbnail_url
.form-group
= f.label :fullsize_url
= f.url_field :fullsize_url
.form-group
= f.label :link_url
= f.url_field :link_url
.form-group
= f.label :license_name
= f.text_field :license_name
.form-group
= f.label :license_url
= f.text_field :license_url
.form-group
= f.label :date_taken
= f.datetime_field :date_taken
.form-group
.form-actions= f.submit 'Save', class: 'btn'

View File

@@ -1,7 +1,7 @@
- content_for :title, "New Photo"
%h1 New Photo
%h2 Choose photo for #{link_to @item, @item}
%h2 Choose photo for #{link_to @item, @item} from Flickr, or contribute to unique crops to <a href="https://inaturalist.org/" target="_blank">iNaturalist</a> or <a href="https://identify.plantnet.org/" target="_blank">Pl@ntNet</a> via the app.
- if @please_reconnect_flickr
%h2.alert Please reconnect your flickr account

View File

@@ -10,5 +10,5 @@
- else
%p No photos.
- if can?(:edit, planting) && can?(:create, Photo)
%p Add a photo to visually track growth of this planting
%p Add a photo to visually track growth of this planting, to Flickr, iNaturalist or Pl@ntNet
= add_photo_button(planting)

View File

@@ -53,7 +53,11 @@ Rails.application.routes.draw do
get 'author/:author' => 'posts#index', as: 'by_author', on: :collection
end
resources :scientific_names
resources :scientific_names do
collection do
get :gbif_suggest
end
end
resources :alternate_names
resources :plant_parts
resources :photos
@@ -73,6 +77,7 @@ Rails.application.routes.draw do
get 'planted_from' => 'charts/crops#planted_from', constraints: { format: 'json' }
get 'harvested_for' => 'charts/crops#harvested_for', constraints: { format: 'json' }
post :openfarm
post :gbif
collection do
get 'requested'

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddGbif < ActiveRecord::Migration[7.0]
def change
add_column :scientific_names, :gbif_key, :int
add_column :scientific_names, :gbif_rank, :string
add_column :scientific_names, :gbif_status, :string
add_column :scientific_names, :wikidata_id, :string
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_03_13_015323) do
ActiveRecord::Schema[7.0].define(version: 2024_01_14_045751) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -543,6 +543,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_13_015323) do
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.integer "creator_id"
t.integer "gbif_key"
t.string "gbif_rank"
t.string "gbif_status"
t.string "wikidata_id"
end
create_table "seeds", id: :serial, force: :cascade do |t|
@@ -567,6 +571,26 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_13_015323) do
t.index ["slug"], name: "index_seeds_on_slug", unique: true
end
create_table "weather_observations", force: :cascade do |t|
t.string "source"
t.datetime "observation_at"
t.integer "solar_uv_index"
t.decimal "wind_speed_kmh"
t.decimal "wind_gust_speed_kmh"
t.string "wind_direction"
t.decimal "air_temperature_centigrade"
t.decimal "relative_humidity"
t.decimal "precipitation_probability"
t.decimal "dew_point_temperature_centigrade"
t.decimal "pressure"
t.integer "visibility_distance_metres"
t.string "weather_type"
t.bigint "owner_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["owner_id"], name: "index_weather_observations_on_owner_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "harvests", "plantings"

10
lib/tasks/gbif.rake Normal file
View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
namespace :gbif do
desc "Retrieve crop info from GBIF"
task import: :environment do
Rails.logger = Logger.new(STDOUT)
GbifService.new.import!
end
end

View File

@@ -0,0 +1,57 @@
---
http_interactions:
- request:
method: get
uri: https://api.gbif.org/v1/species/2930137
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday/v1.10.3 Gbif/v0.2.0
X-USER-AGENT:
- Faraday/v1.10.3 Gbif/v0.2.0
response:
status:
code: 200
message: OK
headers:
vary:
- Origin, Access-Control-Request-Method, Access-Control-Request-Headers
x-content-type-options:
- nosniff
x-xss-protection:
- 1; mode=block
pragma:
- no-cache
expires:
- '0'
x-frame-options:
- DENY
content-type:
- application/json
date:
- Sun, 14 Jan 2024 10:03:58 GMT
cache-control:
- public, max-age=3601
x-varnish:
- 952863014 979042621
age:
- '126'
via:
- 1.1 varnish (Varnish/6.0)
accept-ranges:
- bytes
content-length:
- '938'
connection:
- keep-alive
body:
encoding: UTF-8
string: '{"key":2930137,"nubKey":2930137,"nameKey":10463714,"taxonID":"gbif:2930137","kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"datasetKey":"d7dddbf4-2cf0-4f39-9b2a-bb099caae36c","constituentKey":"7ddf754f-d193-4cc9-b351-99906754a03b","parentKey":2928997,"parent":"Solanum","scientificName":"Solanum
lycopersicum L.","canonicalName":"Solanum lycopersicum","vernacularName":"Garden
tomato","authorship":"L.","nameType":"SCIENTIFIC","rank":"SPECIES","origin":"SOURCE","taxonomicStatus":"ACCEPTED","nomenclaturalStatus":[],"remarks":"","publishedIn":"L.
(1753). In: Sp. Pl. 185.","numDescendants":23,"lastCrawled":"2023-08-22T23:20:59.545+00:00","lastInterpreted":"2023-08-22T23:12:05.487+00:00","issues":[],"class":"Magnoliopsida"}'
recorded_at: Sun, 14 Jan 2024 10:06:05 GMT
recorded_with: VCR 6.2.0

View File

@@ -0,0 +1,103 @@
---
http_interactions:
- request:
method: get
uri: https://api.gbif.org/v1/species/suggest?limit=100&q=Solanum+lycopersicum
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday/v1.10.3 Gbif/v0.2.0
X-USER-AGENT:
- Faraday/v1.10.3 Gbif/v0.2.0
response:
status:
code: 200
message: OK
headers:
vary:
- Origin, Access-Control-Request-Method, Access-Control-Request-Headers
x-content-type-options:
- nosniff
x-xss-protection:
- 1; mode=block
pragma:
- no-cache
expires:
- '0'
x-frame-options:
- DENY
content-type:
- application/json
date:
- Sun, 14 Jan 2024 10:06:04 GMT
cache-control:
- public, max-age=3601
x-varnish:
- '993984559'
age:
- '0'
via:
- 1.1 varnish (Varnish/6.0)
accept-ranges:
- bytes
content-length:
- '10730'
connection:
- keep-alive
body:
encoding: UTF-8
string: '[{"key":2930137,"nameKey":10463714,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":2930137,"scientificName":"Solanum
lycopersicum L.","canonicalName":"Solanum lycopersicum","rank":"SPECIES","status":"ACCEPTED","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum"},"class":"Magnoliopsida"},{"key":7815295,"nameKey":31973001,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":7815295,"parent":"Solanum","parentKey":2928997,"nubKey":7815295,"scientificName":"Solanum
lycopersicum Blanco, 1837","canonicalName":"Solanum lycopersicum","rank":"SPECIES","status":"DOUBTFUL","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum"},"class":"Magnoliopsida"},{"key":8586238,"nameKey":6531517,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Lycopersicum
solanum-lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":8586238,"parent":"Solanum","parentKey":2928997,"nubKey":8586238,"scientificName":"Lycopersicum
solanum-lycopersicum Hill","canonicalName":"Lycopersicum solanum-lycopersicum","rank":"SPECIES","status":"DOUBTFUL","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum"},"class":"Magnoliopsida"},{"key":8640337,"nameKey":6531515,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Lycopersicum
solanum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":8640337,"parent":"Solanum","parentKey":2928997,"nubKey":8640337,"scientificName":"Lycopersicum
solanum Medik.","canonicalName":"Lycopersicum solanum","rank":"SPECIES","status":"DOUBTFUL","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum"},"class":"Magnoliopsida"},{"key":7608359,"nameKey":6531353,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":7608359,"scientificName":"Lycopersicon
solanum-lycopersicum Hill","canonicalName":"Lycopersicon solanum-lycopersicum","rank":"SPECIES","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":11519041,"nameKey":97469846,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum
lycopersicum","parentKey":2930137,"nubKey":11519041,"scientificName":"Solanum
lycopersicum subsp. lycopersicum","canonicalName":"Solanum lycopersicum lycopersicum","rank":"SUBSPECIES","status":"ACCEPTED","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":11760453,"nameKey":97469847,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum
lycopersicum","parentKey":2930137,"nubKey":11760453,"scientificName":"Solanum
lycopersicum subsp. cerasiforme (Alef.) Voss","canonicalName":"Solanum lycopersicum
cerasiforme","rank":"SUBSPECIES","status":"ACCEPTED","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":7904703,"nameKey":10463761,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum
lycopersicum","parentKey":2930137,"nubKey":7904703,"scientificName":"Solanum
lycopersicum var. cerasiforme (Alef.) Voss","canonicalName":"Solanum lycopersicum
cerasiforme","rank":"VARIETY","status":"ACCEPTED","synonym":false,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":11014233,"nameKey":36721070,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":11014233,"scientificName":"Solanum
lycopersicum var. piriforme (Alef.) Voss","canonicalName":"Solanum lycopersicum
piriforme","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":10798260,"nameKey":36720428,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":10798260,"scientificName":"Solanum
lycopersicum var. ribisiodes Voss","canonicalName":"Solanum lycopersicum ribisiodes","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":2930169,"nameKey":10463787,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":2930169,"scientificName":"Solanum
lycopersicum var. esculentum (Mill.) Voss","canonicalName":"Solanum lycopersicum
esculentum","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":4274699,"nameKey":10463795,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":4274699,"scientificName":"Solanum
lycopersicum var. lycopersicum","canonicalName":"Solanum lycopersicum lycopersicum","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":8118741,"nameKey":10463759,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":8118741,"scientificName":"Solanum
lycopersicum var. cerasiforme (Alef.) Fosberg","canonicalName":"Solanum lycopersicum
cerasiforme","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":2930179,"nameKey":10463798,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":2930179,"scientificName":"Solanum
lycopersicum var. oviforme Voss","canonicalName":"Solanum lycopersicum oviforme","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"},{"key":2930165,"nameKey":10463770,"kingdom":"Plantae","phylum":"Tracheophyta","order":"Solanales","family":"Solanaceae","genus":"Solanum","species":"Solanum
lycopersicum","kingdomKey":6,"phylumKey":7707728,"classKey":220,"orderKey":1176,"familyKey":7717,"genusKey":2928997,"speciesKey":2930137,"parent":"Solanum","parentKey":2928997,"nubKey":2930165,"scientificName":"Solanum
lycopersicum var. cerasiforme (Dunal) D.M.Spooner, G.J.Anderson & R.K.Jansen","canonicalName":"Solanum
lycopersicum cerasiforme","rank":"VARIETY","status":"SYNONYM","synonym":true,"higherClassificationMap":{"6":"Plantae","7707728":"Tracheophyta","220":"Magnoliopsida","1176":"Solanales","7717":"Solanaceae","2928997":"Solanum","2930137":"Solanum
lycopersicum"},"class":"Magnoliopsida"}]'
recorded_at: Sun, 14 Jan 2024 10:06:04 GMT
recorded_with: VCR 6.2.0

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -114,6 +114,8 @@ RSpec.configure do |config|
# Prevent Poltergeist from fetching external URLs during feature tests
config.before(:each, :js) do
# TODO: Why are we setting this page size then straight afterwards, maximising?
width = 1280
height = 1280
Capybara.current_session.driver.browser.manage.window.resize_to(width, height)

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'rails_helper'
describe GbifService, :vcr, type: :service do
let(:scientific_name_tomato) { create(:solanum_lycopersicum) }
let(:tomato) { scientific_name_tomato.crop }
let(:gbif_service) { described_class.new }
# TODO: Find places where we should just use dependency injection to insert the cropbot user.
before do
# don't use 'let' for this -- we need to actually create it,
# regardless of whether it's used.
@cropbot = FactoryBot.create(:cropbot)
end
describe "#fetch" do
it "fetches a given key" do
result = gbif_service.fetch(2_930_137)
expect(result["key"]).to eq 2_930_137
expect(result["family"]).to eq "Solanaceae"
end
end
describe "#suggest" do
it "matches" do
results = gbif_service.suggest(scientific_name_tomato.name)
expect(results[0]["key"]).to eq 2_930_137
expect(results[0]["family"]).to eq "Solanaceae"
end
end
describe "#import!"
describe "#update_crop" do
it "resolves scientific names" do
gbif_service.update_crop(tomato)
scientific_name_tomato.reload
expect(scientific_name_tomato.gbif_key).to eq 2_930_137
end
it "resolves common names" do
crop = create(:crop, name: "Habanero")
gbif_service.update_crop(crop)
crop.reload
expect(crop.scientific_names.first.name).to eq "Capsicum chinense"
end
it "gets photos" do
scientific_name_tomato.update(gbif_key: "2930137")
gbif_service.update_crop(tomato)
tomato.reload
expect(tomato.photos.count).to eq 3
photo = tomato.photos.order(:id)[0]
expect(photo.fullsize_url).to eq "https://inaturalist-open-data.s3.amazonaws.com/photos/343874350/original.jpeg"
expect(photo.thumbnail_url).to eq "https://api.gbif.org/v1/image/cache/200x/occurrence/4507688130/media/7bc2c1b87c7110b785674bfc198d891c"
expect(photo.title).to eq "Photo by Ingeborg van Leeuwen via iNaturalist (Copyright Ingeborg van Leeuwen)"
expect(photo.license_name).to eq "CC BY-NC 4.0"
expect(photo.license_url).to eq "http://creativecommons.org/licenses/by-nc/4.0/"
expect(photo.link_url).to eq "https://www.inaturalist.org/photos/343874350"
expect(photo.source_id).to eq("4507688130")
expect(photo.source).to eq("gbif")
expect(photo.date_taken).to eq("2024-01-01T11:07:00.000+00:00")
end
end
end

View File

@@ -18,6 +18,14 @@
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
require 'simplecov'
require 'percy/capybara'
require 'vcr'
VCR.configure do |c|
c.ignore_host "elasticsearch", "localhost"
c.cassette_library_dir = 'spec/cassettes'
c.hook_into :faraday
c.configure_rspec_metadata!
end
SimpleCov.start