Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inat Import Stimulus Status #2586

Merged
merged 40 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
bb90e1b
Update InatImportJobTracker show with Stimulus 1st draft
JoeCohen Dec 14, 2024
e5303b3
job_controller gets status as a string
JoeCohen Dec 15, 2024
488b26b
Add turbo response to trackers controller
nimmolo Dec 17, 2024
a007b3a
Split controller to inat_imports/job_trackers
nimmolo Dec 17, 2024
7e7c71e
Update the "endpoint" data sent to Stimulus, for the new controller r…
nimmolo Dec 17, 2024
8097a5f
Update stimulus contoller to update its internal `status` property af…
nimmolo Dec 17, 2024
b3bde52
Merge branch 'main' into jdc-2319-stimulus-inat-import
JoeCohen Dec 18, 2024
9cb8bae
Reconfigure tests according to controller namespacing
nimmolo Dec 18, 2024
10a715d
Fix namespacing of PageParser
nimmolo Dec 18, 2024
7325ce6
Resolve namespacing in a hopefully easier way
nimmolo Dec 18, 2024
e6e804d
::RestClient::Request
nimmolo Dec 18, 2024
b056e65
Add :INAT_IMPORTS translation
JoeCohen Dec 18, 2024
ac563ac
Fix translation string
JoeCohen Dec 18, 2024
2b2f21d
update redirect_uri env variable
JoeCohen Dec 18, 2024
0911352
enhance comment
JoeCohen Dec 18, 2024
c6ca984
Move method after constant definitions
JoeCohen Dec 19, 2024
ab25353
Reorder inat_imports routes
JoeCohen Dec 19, 2024
e10607c
Update documentation comment
JoeCohen Dec 20, 2024
135b5ac
Revert "Reorder inat_imports routes"
JoeCohen Dec 20, 2024
ccaa918
Merge branch 'jdc-2319-stimulus-inat-import' of https://github.com/Mu…
JoeCohen Dec 20, 2024
bbd60db
Reapply "Reorder inat_imports routes"
JoeCohen Dec 20, 2024
42d552a
Revert "Update documentation comment"
JoeCohen Dec 20, 2024
7cd4b5b
Refactor span
JoeCohen Dec 21, 2024
d42d7f5
Update routes.rb
nimmolo Dec 21, 2024
6ee8e40
fix indentation
JoeCohen Dec 22, 2024
2a03242
Edit comment summarizing entire process
JoeCohen Dec 22, 2024
e6cb05e
Update Trackers comment
JoeCohen Dec 22, 2024
140c83f
Fix params passed to Import#show and Tracker#show
JoeCohen Dec 22, 2024
e823c2e
Add newly required param to test expectation
JoeCohen Dec 22, 2024
ce04f8a
Fix test param to match new Import#show requirement
JoeCohen Dec 22, 2024
6ecd082
Local vars instead of ivars in authorization_response
JoeCohen Dec 22, 2024
1498a76
Forgot to switch 2 ivars to locals
JoeCohen Dec 23, 2024
db69d0e
Merge branch 'main' into jdc-2319-stimulus-inat-import
JoeCohen Dec 31, 2024
9dff9e3
Switch one more ivar to local
JoeCohen Dec 31, 2024
2544074
Reformat template to make data attrs more visible
JoeCohen Jan 13, 2025
591284b
Move Stimulus-controlled stuff to partial
JoeCohen Jan 14, 2025
4b8869d
Remove manually update status line from view
JoeCohen Jan 14, 2025
7824fee
Rename partial updated by Stimulus
JoeCohen Jan 14, 2025
696c9c8
Refresh button uses js instead of Rails request
JoeCohen Jan 14, 2025
c224b36
Fix indentation & comment.
JoeCohen Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/classes/inat/page_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

class Inat
class PageParser
attr_accessor :last_import_id

# The iNat API
API_BASE = InatImportsController::API_BASE
# limit results iNat API requests, with Protozoa as a proxy for slime molds
ICONIC_TAXA = "Fungi,Protozoa"

def initialize(importer, ids, restricted_user_login)
@importer = importer
@last_import_id = 0
@ids = ids
@restricted_user_login = restricted_user_login
end

# Get one page of observations (up to 200)
# This is where we actually hit the iNat API
# https://api.inaturalist.org/v1/docs/#!/Observations/get_observations
# https://stackoverflow.com/a/11251654/3357635
# NOTE: The `ids` parameter may be a comma-separated list of iNat obs
# ids - that needs to be URL encoded to a string when passed as an arg here
# because URI.encode_www_form deals with arrays by passing the same key
# multiple times.
def next_page
result = next_request(id: @ids, id_above: @last_import_id,
user_login: @restricted_user_login)
return nil if response_bad?(result)

JSON.parse(result)
end

private

def response_bad?(response)
response.is_a?(::RestClient::RequestFailed) ||
response.instance_of?(::RestClient::Response) && response.code != 200 ||
# RestClient was happy, but the user wasn't authorized
response.is_a?(Hash) && response[:status] == 401
end

def next_request(**args)
query_args = {
id: nil, id_above: nil, only_id: false, per_page: 200,
order: "asc", order_by: "id",
# obss of only the iNat user with iNat login @inat_import.inat_username
user_login: nil,
iconic_taxa: ICONIC_TAXA
}.merge(args)
query = URI.encode_www_form(query_args)

# ::Inat.new(operation: query, token: @inat_import.token).body
# Nimmo 2024-06-19 jdc. Moving the request from the inat class to here.
# RestClient::Request.execute wasn't available in the class
headers = { authorization: "Bearer #{@importer.token}", accept: :json }
::RestClient::Request.execute(
method: :get, url: "#{API_BASE}/observations?#{query}", headers: headers
)
rescue ::RestClient::ExceptionWithResponse => e
@importer.add_response_error(e.response)
e.response
end
end
end
64 changes: 0 additions & 64 deletions app/classes/inat_page_parser.rb

This file was deleted.

10 changes: 0 additions & 10 deletions app/controllers/inat_import_job_trackers_controller.rb

This file was deleted.

22 changes: 22 additions & 0 deletions app/controllers/inat_imports/job_trackers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module InatImports
class JobTrackersController < ApplicationController
before_action :login_required

# This is only a Turbo endpoint updating the display of the status of a job.
def show
return unless (@tracker = InatImportJobTracker.find(params[:id]))

respond_to do |format|
format.turbo_stream do
render(turbo_stream: turbo_stream.update(
:status, # id of element to change
partial: "inat_imports/job_trackers/updates",
locals: { tracker: @tracker }
))
end
end
end
end
end
143 changes: 143 additions & 0 deletions app/controllers/inat_imports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# frozen_string_literal: true

# import iNaturalist Observations as MO Observations
# Actions
# -------
# new (get)
# create (post)
# authorization_response (get)
#
# Work flow:
# 1. User calls `new`, fills out form
# 2. create
# saves some user data in a InatImport instance
# attributes include: user, inat_ids, token, state
# passes things off (redirects) to iNat at the inat_authorization_url
# 3. iNat
# checks if MO is authorized to access iNat user's confidential data
# if not, asks iNat user for authorization
# iNat calls the MO redirect_url (authorization_response) with a code param
# 4. MO continues in the authorization_response action
# Reads the saved InatImport instance
# Updates the InatImport instance with the code received from iNat
# Instantiates an InatImportJobTracker, passing in the InatImport instance
# Enqueues an InatImportJob, passing in the InatImport instance
# Redirects to InatImport.show (for that InatImport instance)
# ---------------------------------
# InatImport.show view:
# Includes a `#status` element which:
# Instantiates a Stimulus controller (inat-import-job_controller)
# with an endpoint of InatImportJobTracker.show
# is updated by a TurboStream response from the endpoint
# ---------------------------------
# Stimulus controller (inat-import-job_controller):
# Makes a request every second to the InatImportJobTracker.show endpoint
# ---------------------------------
# The InatImportJobTracker.show endpoint action:
# returns the status of the InatImport as a TurboStream response
# ---------------------------------
# 5. The InatImportJob:
# Uses the `code` to obtain an oauth access_token
# Trades the oauth token for a JWT api_token
# Checks if the MO user is trying to import some else's obss
# Makes an authenticated iNat API request for the desired observations
# For each iNat obs in the results,
# creates an Inat::Obs
# adds an MO Observation, mapping Inat::Obs details to the MO Obs
# adds Inat photos to the MO Observation via the MO API
# maps iNat sequences to MO Sequences
# adds an MO Comment with a snapshot of the imported data
# updates the iNat obs with a Mushroom Observer URL Observation Field
# updates the iNat obs Notes
# updates the InatImport instance attributes:
# state, importables, imported_count, response_errors
#
class InatImportsController < ApplicationController
include Validators

before_action :login_required
before_action :pass_query_params

# Site for authorization and authentication requests
SITE = "https://www.inaturalist.org"
# iNat calls this after iNat user authorizes MO to access their data.
REDIRECT_URI = Rails.configuration.redirect_uri
# iNat's id for the MO application
APP_ID = Rails.application.credentials.inat.id
# The iNat API. Not called here, but referenced in tests and ActiveJob
API_BASE = "https://api.inaturalist.org/v1"

def show
@tracker = InatImportJobTracker.find(params[:tracker_id])
@inat_import = InatImport.find(@tracker.inat_import)
end

def new; end

def create
return reload_form unless params_valid?

@inat_import = InatImport.find_or_create_by(user: User.current)
@inat_import.update(state: "Authorizing",
import_all: params[:all],
importables: 0, imported_count: 0,
inat_ids: params[:inat_ids],
inat_username: params[:inat_username].strip,
response_errors: "", token: "", log: [])

request_inat_user_authorization
end

# ---------------------------------

private

def reload_form
@inat_ids = params[:inat_ids]
@inat_username = params[:inat_username]
render(:new)
end

def request_inat_user_authorization
redirect_to(inat_authorization_url, allow_other_host: true)
end

def inat_authorization_url
"#{SITE}/oauth/authorize" \
"?client_id=#{APP_ID}" \
"&redirect_uri=#{REDIRECT_URI}" \
"&response_type=code"
end

# ---------------------------------

public

# iNat redirects here after user completes iNat authorization
def authorization_response
auth_code = params[:code]
return not_authorized if auth_code.blank?

inat_import = InatImport.find_or_create_by(user: User.current)
inat_import.update(token: auth_code, state: "Authenticating")
tracker = InatImportJobTracker.create(inat_import: inat_import.id)

Rails.logger.info(
"Enqueuing InatImportJob for InatImport id: #{inat_import.id}"
)
# InatImportJob.perform_now(inat_import) # uncomment for manual testing
InatImportJob.perform_later(inat_import) # uncomment for production

redirect_to(inat_import_path(inat_import,
params: { tracker_id: tracker.id }))
end

# ---------------------------------

private

def not_authorized
flash_error(:inat_no_authorization.l)
redirect_to(observations_path)
end
end
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

module Observations::InatImportsControllerValidators
module InatImportsController::Validators
private

SITE = "https://www.inaturalist.org"
Expand Down
Loading
Loading