From 1688ff75e99f4423a5d3ea244c416d199fa8f07e Mon Sep 17 00:00:00 2001 From: denisahearn Date: Wed, 4 Mar 2026 13:59:49 -0600 Subject: [PATCH 1/3] Add Ruby example for creating a ground scouting feature set --- api/create_ground_scouting_feature_set.rb | 412 ++++++++++++++++++++++ api/upsert_images.rb | 16 +- utils/upload.rb | 23 +- 3 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 api/create_ground_scouting_feature_set.rb diff --git a/api/create_ground_scouting_feature_set.rb b/api/create_ground_scouting_feature_set.rb new file mode 100644 index 0000000..4c2ebbb --- /dev/null +++ b/api/create_ground_scouting_feature_set.rb @@ -0,0 +1,412 @@ +#!/usr/bin/env ruby + +# frozen_string_literal: true + +# ================================================================== +# A Ruby example that demonstrates the workflow for uploading a +# feature set with annotations to Sentera's FieldAgent platform +# using the upsert_feature_set GraphQL mutation. +# +# Contact devops@sentera.com with any questions. +# ================================================================== + +require '../utils/utils' +verify_ruby_version + +require 'net/http' +require 'json' +require 'digest' +require '../utils/parallel' +require '../utils/upload' + +SOME = 'some'.freeze +ALL = 'all'.freeze +NONE = 'none'.freeze +SOME_ALL = [SOME, ALL].freeze + + +# If you want to debug this script, run the following gem install +# commands. Then uncomment the require statements below, and put +# debugger statements in the code to trace the code execution. +# +# > gem install pry +# > gem install pry-byebug +# +# require 'pry' +# require 'pry-byebug' + + +# Retrieves field information for a given survey. +# +# @param survey_sentera_id [String] The Sentera ID of the survey +# @return [Hash] Survey data including field sentera_id and bounding box +# +def get_field_by_survey(survey_sentera_id) + puts 'Get field by survey' + + gql = <<~GQL + query GetFieldBySurvey( + $survey_sentera_id: ID! + ) { + survey( + sentera_id: $survey_sentera_id + ) { + field { + sentera_id + bbox + } + } + } + GQL + + variables = { + survey_sentera_id: survey_sentera_id + } + + response = make_graphql_request(gql, variables) + json = JSON.parse(response.body) + json.dig('data', 'survey') +end + +# Creates a GeoJSON feature collection with randomly distributed point features. +# The features can optionally include sample notes and attachment placeholders +# depending on the with_notes and with_attachments parameters. +# +# @param num_locations [Integer] Number of location features to generate +# @param bbox [Array] Bounding box coordinates [min_lon, min_lat, max_lon, max_lat] +# @param with_notes [String] Whether to add sample notes to each feature (NONE, SOME, ALL) +# @param with_attachments [String] Whether to prepare attachment placeholders (NONE, SOME, ALL) +# @return [Hash] GeoJSON FeatureCollection with generated features +# +def create_ground_scouting_geojson( + num_locations:, + bbox:, + with_notes:, + with_attachments: +) + puts 'Create ground scouting GeoJSON' + + geojson = { type: 'FeatureCollection' } + + geojson[:features] = (1..num_locations).map do |location_num| + longitude = rand(bbox[0]..bbox[2]) + latitude = rand(bbox[1]..bbox[3]) + + has_note = with_notes == ALL || (with_notes == SOME && location_num.odd?) + has_attachment = with_attachments == ALL || (with_attachments == SOME && location_num.even?) + + properties = {} + properties[:notes] = "Sample note #{location_num}" if has_note + properties[:attachments] = [] if has_attachment # Placeholder for attachments which will be added later + + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [longitude, latitude] + }, + properties: properties + } + end + + geojson +end + +# Creates file upload records and obtains pre-signed S3 URLs for file uploads. +# +# @param file_props [Array] Array of file property hashes, each containing: +# - :filename [String] The name of the file +# - :byte_size [Integer] The size of the file in bytes +# - :checksum [String] The checksum of the file +# - :content_type [String] The MIME type of the file +# @param survey_sentera_id [String] The Sentera ID of the parent survey +# @return [Array] Array of file upload records with S3 URLs and headers +# +def create_file_uploads(file_props, survey_sentera_id) + puts 'Create file uploads' + + files = file_props.map do |props| + { + filename: props[:filename], + byte_size: props[:byte_size], + checksum: props[:checksum], + content_type: props[:content_type], + file_type: 'IMAGE' # Set this as needed based on your file types + } + end + + gql = <<~GQL + mutation CreateFileUploads( + $file_upload_owner: FileUploadOwnerInput, + $files: [FileUploadInput!]! + ) { + create_file_uploads( + create_files: true + file_upload_owner: $file_upload_owner + files: $files + ) { + id + owner_sentera_id + headers + s3_url + upload_url + } + } + GQL + + variables = { + file_upload_owner: { + owner_type: 'FEATURE_SET', + parent_sentera_id: survey_sentera_id + }, + files: files + } + + response = make_graphql_request(gql, variables) + json = JSON.parse(response.body) + json.dig('data', 'create_file_uploads') +end + +# Uploads attachment files to S3 and associates them with GeoJSON features. +# +# @param survey_sentera_id [String] The Sentera ID of the parent survey +# @param attachment_props [Array] An array of attachment property hashes, each containing: +# - :path [String] The file path to upload as an attachment +# - :filename [String] The name of the file +# - :byte_size [Integer] The size of the file in bytes +# - :checksum [String] The MD5 checksum of the file +# - :content_type [String] The MIME type of the file +# @param num_attachments_per_feature [Integer] The number of attachments to add to each feature +# @param with_attachment_names [Boolean] Whether to include a "name" property in the attachment properties +# @param with_attachment_name_keys [Boolean] Whether to include a "name-key" property in the attachment properties +# @param geojson [Hash] GeoJSON FeatureCollection to which attachments will be added +# @return [String] The owner Sentera ID (feature set ID) from the file uploads +# +def upload_attachments( + survey_sentera_id:, + attachment_props:, + num_attachments_per_feature:, + with_attachment_names:, + with_attachment_name_keys:, + geojson: +) + puts 'Upload attachments' + + file_uploads = create_file_uploads(attachment_props, survey_sentera_id) + if file_uploads.nil? || file_uploads.empty? + raise 'Failed to create file uploads' + end + + attachment_file_paths = attachment_props.map { |prop| prop[:path] } + upload_files(file_uploads, attachment_file_paths) + + geojson[:features].each_with_index do |feature, index| + next unless feature[:properties].key?(:attachments) # Only add attachments to features that were designated to have them + + attachments = feature[:properties][:attachments] + + (0...num_attachments_per_feature).each do |offset| + attachment_index = (index + offset) % attachment_props.length + attachments << build_attachment(attachment_index, attachment_props, file_uploads, + with_attachment_names, with_attachment_name_keys, + offset) + end + end + + file_uploads.first['owner_sentera_id'] +end + +# Builds an attachment hash based on the file upload information and attachment properties. +# +# @param attachment_index [Integer] The index of the attachment in the attachment_props and file_uploads arrays +# @param attachment_props [Array] An array of attachment property hashes +# @param file_uploads [Array] An array of file upload records with S3 URLs +# @param with_attachment_names [Boolean] Whether to include a "name" property in the attachment properties +# @param with_attachment_name_keys [Boolean] Whether to include a "name-key" property in the attachment properties +# @param attachment_offset [Integer] The offset used for naming attachments when multiple attachments are added per feature +# @return [Hash] An attachment hash to be included in the GeoJSON feature's properties +# +def build_attachment( + attachment_index, + attachment_props, + file_uploads, + with_attachment_names, + with_attachment_name_keys, + attachment_offset +) + attachment_prop = attachment_props[attachment_index] + file_upload = file_uploads[attachment_index] + + attachment = { + md5: attachment_prop[:checksum], + mime: attachment_prop[:content_type], + size: attachment_prop[:byte_size], + s3_url: file_upload['s3_url'] + } + attachment['name-key'] = "name_key_#{attachment_offset}" if with_attachment_name_keys + attachment[:name] = "Name #{attachment_offset + 1}" if with_attachment_names + + return attachment +end + +# Creates or updates a ground scouting feature set under a survey. +# +# @param geojson [Hash] GeoJSON FeatureCollection containing the features +# @param feature_set_sentera_id [String, nil] Optional existing feature set ID for upserting +# @param survey_sentera_id [String] The Sentera ID of the parent survey +# @return [Hash] Mutation result with succeeded and failed records +# +def upsert_ground_scouting_feature_set( + geojson:, + feature_set_sentera_id:, + survey_sentera_id:, + feature_set_name: +) + puts 'Upsert ground scouting feature set' + + gql = <<~GQL + mutation UpsertFeatureSet( + $feature_set: FeatureSetImport! + $owner: FeatureSetOwnerInput! + ) { + upsert_feature_set( + feature_set: $feature_set + owner: $owner + ) { + succeeded { + ... on FeatureSet { + sentera_id + name + type + status + released + } + } + failed { + attributes { + key + details + attribute + } + } + } + } + GQL + + variables = { + owner: { + owner_type: 'SURVEY', + sentera_id: survey_sentera_id + }, + feature_set: { + name: feature_set_name, + type: "GROUND_SCOUTING", + geometry: geojson, + released: true + } + } + + # Only include sentera_id in the mutation if it's not nil. It's an optional field used for upserting. + variables[:feature_set][:sentera_id] = feature_set_sentera_id if feature_set_sentera_id + + response = make_graphql_request(gql, variables) + json = JSON.parse(response.body) + json.dig('data', 'upsert_feature_set') +end + + +# MAIN + +# ************************************************** +# Set these variables based on the files you want +# to upload and the survey within FieldAgent to +# which you want to attach a feature set +feature_set_name = ENV.fetch('FEATURE_SET_NAME', nil) # The name of the feature set to create +num_locations = ENV.fetch('NUM_LOCATIONS', 5).to_i # The number of scouted locations (e.g.features) to create in the feature set +num_attachments_per_feature = ENV.fetch('NUM_ATTACHMENTS_PER_FEATURE', 1).to_i # The number of attachments to add per feature if WITH_ATTACHMENTS is some or all +with_notes = ENV.fetch('WITH_NOTES', NONE).downcase # Whether all, some or no features have notes" +with_attachments = ENV.fetch('WITH_ATTACHMENTS', NONE).downcase # Whether all, some or no features have attachments +with_attachment_names= ENV.fetch('WITH_ATTACHMENT_NAMES', false) # Whether to include a "name" property in the attachment properties +with_attachment_name_keys= ENV.fetch('WITH_ATTACHMENT_NAME_KEYS', false) # Whether to include a "name-key" property in the attachment properties +attachments_path = ENV.fetch('ATTACHMENTS_PATH', nil) # The path to the attachments to upload. Required if WITH_ATTACHMENTS is some or all. +attachments_ext = ENV.fetch('ATTACHMENTS_EXT', nil) # Your file extension for the attachments to upload. Required if WITH_ATTACHMENTS is some or all. +survey_sentera_id = ENV.fetch('SURVEY_SENTERA_ID', nil) # Existing survey under which the ground scouting feature set will be created. Required to be provided. +# ************************************************** + +# Validate input variables +if survey_sentera_id.nil? + raise 'SURVEY_SENTERA_ID environment variable must be specified' +end +if num_locations <= 0 + raise 'NUM_LOCATIONS environment variable must be greater than 0' +end +if SOME_ALL.include?(with_attachments) && attachments_path.nil? + raise 'ATTACHMENTS_PATH environment variable must be specified if WITH_ATTACHMENTS is some or all' +end +if SOME_ALL.include?(with_attachments) && attachments_ext.nil? + raise 'ATTACHMENTS_EXT environment variable must be specified if WITH_ATTACHMENTS is some or all' +end + +attachment_props = [] +if SOME_ALL.include?(with_attachments) + attachment_props = read_file_props(attachments_path, attachments_ext) + if attachment_props.empty? + raise "No files found in ATTACHMENTS_PATH #{attachments_path} with extension #{attachments_ext}" + end +end + +# Step 1: Retrieve the survey's field's bounding box (e.g bbox) +survey = get_field_by_survey(survey_sentera_id) +if survey.nil? || survey['field'].nil? + raise "Failed to retrieve field information for survey #{survey_sentera_id}" +end +field = survey['field'] +bbox = field['bbox'] + +# Step 2: Create the ground scouting GeoJSON +geojson = create_ground_scouting_geojson( + num_locations: num_locations, + bbox: bbox, + with_notes: with_notes, + with_attachments: with_attachments +) + +# Step 3: Upload attachments if WITH_ATTACHMENTS is some or all +feature_set_sentera_id = nil +if ['some', 'all'].include?(with_attachments) + feature_set_sentera_id = upload_attachments( + survey_sentera_id: survey_sentera_id, + attachment_props: attachment_props, + num_attachments_per_feature: num_attachments_per_feature, + with_attachment_names: with_attachment_names, + with_attachment_name_keys: with_attachment_name_keys, + geojson: geojson + ) + if feature_set_sentera_id.nil? + raise 'Failed to upload attachments and create feature set' + end +end + +# Step 4: Update the ground scouting feature set with the GeoJSON +details = { + num_locations: num_locations, + with_attachments: with_attachments, + attachment_ext: attachments_ext, + num_attachments_per_feature: num_attachments_per_feature, + with_attachment_names: with_attachment_names, + with_attachment_name_keys: with_attachment_name_keys, + with_notes: with_notes +} +feature_set_name = "Ground Scouting Feature Set - #{details}" +results = upsert_ground_scouting_feature_set( + geojson: geojson, + feature_set_sentera_id: feature_set_sentera_id, + survey_sentera_id: survey_sentera_id, + feature_set_name: feature_set_name +) +if results && results['succeeded'].any? + feature_set = results['succeeded'][0] + puts "Done! Ground scouting feature set #{feature_set['sentera_id']} was created with #{num_locations} locations." +else + puts "Failed due to error: #{results['failed'].inspect}" +end diff --git a/api/upsert_images.rb b/api/upsert_images.rb index ce695b3..d73685d 100644 --- a/api/upsert_images.rb +++ b/api/upsert_images.rb @@ -94,11 +94,13 @@ def create_image_uploads(image_props, survey_sentera_id, sensor_type) # objects created by the # create_image_uploads mutation # @param [string] sensor_type The type of sensor that captured the images +# @param [string] calculated_index The calculated index value for the images +# @param [string] color_applied The color applied value for the images # @param [Array[Hash]] image_props Array of image properties # # @return [Hash] Hash containing results of the GraphQL request # -def upsert_images(survey_sentera_id, image_uploads, sensor_type, image_props) +def upsert_images(survey_sentera_id, image_uploads, sensor_type, calculated_index, color_applied, image_props) puts 'Upsert images' gql = <<~GQL @@ -139,9 +141,9 @@ def upsert_images(survey_sentera_id, image_uploads, sensor_type, image_props) # Note the use of the file_key attribute to reference # the previously uploaded image. altitude: 0, - calculated_index: 'UNKNOWN', + calculated_index: calculated_index, captured_at: Time.now.utc.iso8601, - color_applied: 'UNKNOWN', + color_applied: color_applied, filename: filename, key: image_upload['id'], gps_carrier_phase_status: 'STANDARD', @@ -169,6 +171,8 @@ def upsert_images(survey_sentera_id, image_uploads, sensor_type, image_props) images_path = ENV.fetch('IMAGES_PATH', '.') # Your fully qualified path to a folder containing the images to upload file_ext = ENV.fetch('FILE_EXT', '*.*') # Your image file extension sensor_type = ENV.fetch('SENSOR_TYPE', 'UNKNOWN') # Your sensor type for the images being uploaded +calculated_index = ENV.fetch('CALCULATED_INDEX', 'UNKNOWN') # Your calculated index for the images being uploaded +colored_applied = ENV.fetch('COLOR_APPLIED', 'UNKNOWN') # Your color applied value for the images being uploaded survey_sentera_id = ENV.fetch('SURVEY_SENTERA_ID', nil) # Your existing survey Sentera ID # ************************************************** @@ -193,7 +197,11 @@ def upsert_images(survey_sentera_id, image_uploads, sensor_type, image_props) upload_files(image_uploads, image_paths) # Step 3: Create images in FieldAgent using the uploaded images -results = upsert_images(survey_sentera_id, image_uploads, sensor_type, image_props) +results = upsert_images( + survey_sentera_id, image_uploads, + sensor_type, calculated_index, colored_applied, + image_props +) if results && results['succeeded'].any? puts "Done! Images for #{survey_sentera_id} were created in FieldAgent." diff --git a/utils/upload.rb b/utils/upload.rb index abf12c6..5f13af5 100644 --- a/utils/upload.rb +++ b/utils/upload.rb @@ -19,6 +19,27 @@ '.kmz' => 'application/vnd.google-earth.kmz' }.freeze +# Returns the MIME content type for a file based on its extension. +# +# Looks up the file extension in the CONTENT_TYPES hash and returns the +# corresponding MIME type. If the extension is not found, returns a default +# of 'application/octet-stream'. +# +# @param file_path [String] the path to the file +# @return [String] the MIME content type for the file +# +# @example +# get_content_type('/path/to/image.jpg') +# # => "image/jpeg" +# +# get_content_type('/path/to/unknown.xyz') +# # => "application/octet-stream" +# +def get_content_type(file_path) + ext = File.extname(file_path).downcase + CONTENT_TYPES[ext] || 'application/octet-stream' +end + # # Reads the files at a path for a specified extension # @@ -52,7 +73,7 @@ def read_file_props(files_path, file_ext) read_file_paths(files_path, file_ext).map do |file_path| ext = File.extname(file_path).downcase - content_type = CONTENT_TYPES[ext] || 'application/octet-stream' + content_type = get_content_type(file_path) { path: file_path, From 8f2781594b6bf4a3dd5c678e489d752a43069f8a Mon Sep 17 00:00:00 2001 From: denisahearn Date: Wed, 4 Mar 2026 14:16:40 -0600 Subject: [PATCH 2/3] Update README.md with create_ground_scouting_feature example --- api/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/api/README.md b/api/README.md index b39ea9d..40809c0 100644 --- a/api/README.md +++ b/api/README.md @@ -33,3 +33,4 @@ $ FIELDAGENT_SERVER=https://apistaging.sentera.com ruby upsert_feature_set.rb | Ruby | `$ ruby upsert_files.rb` | `FIELDAGENT_ACCESS_TOKEN=PAmnCNUyosKShN9K1AEflLOw6T7bA2fRTWTg-vL3P5Y FIELDAGENT_SERVER=https://api.sentera.com FILE_PATH="../test_files/test.geojson" CONTENT_TYPE="application/json" FIELD_SENTERA_ID=agwmnou_AS_lk07AcmeOrg_CV_deve_773b47acb_240514_160730 ORGANIZATION_SENTERA_ID="jiqn6qi_OR_5qytAcmeOrg_CV_deve_0f569249e_250206_162717" ruby upsert_files.rb` | | Ruby | `$ ruby upsert_images.rb` | `FIELDAGENT_ACCESS_TOKEN=PAmnCNUyosKShN9K1AEflLOw6T7bA2fRTWTg-vL3P5Y FIELDAGENT_SERVER=https://api.sentera.com IMAGES_PATH="../test_files" SURVEY_SENTERA_ID=mjlmmrw_CO_lk07AcmeOrg_CV_deve_773b47acb_240514_160730 FILE_EXT="*.jpeg" SENSOR_TYPE="RGB" ruby upsert_images.rb` | | Ruby | `$ ruby upsert_mosaics.rb` | `FIELDAGENT_ACCESS_TOKEN=PAmnCNUyosKShN9K1AEflLOw6T7bA2fRTWTg-vL3P5Y FIELDAGENT_SERVER=https://api.sentera.com FILE_PATH="../test_files/test.tif" SURVEY_SENTERA_ID=mjlmmrw_CO_lk07AcmeOrg_CV_deve_773b47acb_240514_160730 ruby upsert_mosaics.rb` | +| Ruby | `$ ruby create_ground_scouting_feature_set.rb` | `FIELDAGENT_ACCESS_TOKEN="PAmnCNUyosKShN9K1AEflLOw6T7bA2fRTWTg-vL3P5Y" FIELDAGENT_SERVER="https://api.sentera.com" SURVEY_SENTERA_ID="mjlmmrw_CO_lk07AcmeOrg_CV_deve_773b47acb_240514_160730" NUM_LOCATIONS=20 NUM_ATTACHMENTS_PER_FEATURE=2 WITH_ATTACHMENTS="none,some,all" ATTACHMENTS_PATH="../test_files" ATTACHMENTS_EXT="*.*" WITH_ATTACHMENT_NAME_KEYS=false,true WITH_ATTACHMENT_NAMES=false,true WITH_NOTES="none,some,all" FEATURE_SET_NAME="" ruby create_ground_scouting_feature_set.rb` | From 1034544fb5a49d3cb1ae4ccafb4d80aeab76ec18 Mon Sep 17 00:00:00 2001 From: denisahearn Date: Thu, 5 Mar 2026 13:49:55 -0600 Subject: [PATCH 3/3] Use FEATURE_SET_NAME environment name when provided --- api/create_ground_scouting_feature_set.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/create_ground_scouting_feature_set.rb b/api/create_ground_scouting_feature_set.rb index 4c2ebbb..60b2487 100644 --- a/api/create_ground_scouting_feature_set.rb +++ b/api/create_ground_scouting_feature_set.rb @@ -397,7 +397,7 @@ def upsert_ground_scouting_feature_set( with_attachment_name_keys: with_attachment_name_keys, with_notes: with_notes } -feature_set_name = "Ground Scouting Feature Set - #{details}" +feature_set_name ||= "Ground Scouting Feature Set - #{details}" results = upsert_ground_scouting_feature_set( geojson: geojson, feature_set_sentera_id: feature_set_sentera_id,