From 3a4f3bef58924c0e59a8041b8a6c60b56b284da0 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 13 Aug 2025 07:52:24 -0600 Subject: [PATCH 01/19] Stashing initial work on background job. --- app/models/site.rb | 13 +++++++++++++ app/sidekiq/site_document_audit_export_job.rb | 7 +++++++ config/routes.rb | 4 ++++ 3 files changed, 24 insertions(+) create mode 100644 app/sidekiq/site_document_audit_export_job.rb diff --git a/app/models/site.rb b/app/models/site.rb index 6f8c3948..a00ae6db 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -231,6 +231,19 @@ def process_archive_or_csv(file_path, is_archive) end end + def export_document_audit + report_name = "audit_export_#{site.name.downcase}" + # Tempfile.create(['large_data', '.csv']) do |temp_file| + # CSV.open(temp_file.path, 'wb') do |csv| + # csv << ['Header1', 'Header2', 'Header3'] # Add your CSV headers + # + # # Fetch data in batches to avoid loading everything into memory + # YourModel.find_each do |record| # Use find_each for large datasets + # csv << [record.attribute1, record.attribute2, record.attribute3] # Populate CSV rows + # end + # end + end + private def attributes_from(data) diff --git a/app/sidekiq/site_document_audit_export_job.rb b/app/sidekiq/site_document_audit_export_job.rb new file mode 100644 index 00000000..e40d604a --- /dev/null +++ b/app/sidekiq/site_document_audit_export_job.rb @@ -0,0 +1,7 @@ +class SiteDocumentAuditExportJob + include Sidekiq::Job + + def perform(*args) + Site.find(args[0]).export_document_audit + end +end diff --git a/config/routes.rb b/config/routes.rb index 37605b53..a9d9b00a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ +require "sidekiq/web" + Rails.application.routes.draw do devise_for :users, controllers: { sessions: "users/sessions", @@ -37,6 +39,8 @@ end end + mount Sidekiq::Web => "/sidekiq" + mount AsapPdf::API => "/api" get "api-docs", to: "api_docs#index" From 73c59796e434fa726addadd24d663909ea9d9c5b Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Fri, 22 Aug 2025 09:18:56 -0600 Subject: [PATCH 02/19] Stash progress on exporting. --- app/controllers/documents_controller.rb | 104 +++++++++++++++++- app/controllers/sites_controller.rb | 98 +---------------- app/models/site.rb | 30 +++-- app/services/aws_s3_manager.rb | 35 ++++++ app/sidekiq/site_document_audit_export_job.rb | 7 -- app/views/documents/audit_exports.html.erb | 35 ++++++ app/views/documents/index.html.erb | 5 +- .../{sites => documents}/insights.html.erb | 9 +- bin/setup-localstack | 13 ++- bin/setup_python_components | 16 --- config/database.yml | 1 + config/environments/development.rb | 2 + config/routes.rb | 5 +- 13 files changed, 216 insertions(+), 144 deletions(-) create mode 100644 app/services/aws_s3_manager.rb delete mode 100644 app/sidekiq/site_document_audit_export_job.rb create mode 100644 app/views/documents/audit_exports.html.erb rename app/views/{sites => documents}/insights.html.erb (93%) delete mode 100755 bin/setup_python_components diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 47f6a37a..d9ecb756 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -4,9 +4,9 @@ class DocumentsController < AuthenticatedController protect_from_forgery with: :exception skip_before_action :verify_authenticity_token, only: [:update_document_category, :update_accessibility_recommendation, :update_notes, :update_summary_inference, :update_recommendation_inference] - before_action :set_site, only: [:index, :modal_content, :batch_update] - before_action :set_document, except: [:index, :batch_update] - before_action :ensure_user_site_access, only: [:index, :modal_content, :batch_update] + before_action :set_site, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] + before_action :set_document, except: [:index, :insights, :audit_exports, :batch_update] + before_action :ensure_user_site_access, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] before_action :ensure_user_document_access, except: [:index, :modal_content, :batch_update] def modal_content @@ -29,6 +29,104 @@ def index @filters_for_sorts = query_params [:sort, :direction, :page] end + def insights + # Build document list. + @documents = @site.documents + .by_category(params[:category]) + .by_department(params[:department]) + # Create binned date data for visualization. + # First, gather all documents by year + year_groups = @documents.group_by(&:modification_year).map { |label, year_documents| [label, year_documents.size] } + # Extract and remove "Unknown" to handle separately + unknown_group = year_groups.find { |item| item[0] == "Unknown" } + year_groups = year_groups.reject { |item| item[0] == "Unknown" } + year_groups = year_groups.select do |item| + Integer(item[0]) + true + rescue + if unknown_group.nil? + unknown_group = ["Unknown", 0] + end + unknown_group[1] += 1 + false + end + # Convert to integers for sorting and calculations + year_groups = year_groups.map { |year, count| [Integer(year), count] } + # Create bins based on specific year ranges + binned_data = [] + bins = [ + ["< 2000", -Float::INFINITY..1999], + ["2000-2005", 2000..2005], + ["2006-2011", 2006..2011], + ["2012-2017", 2012..2017], + ["2018-2023", 2018..2023], + ["> 2023", 2024..Float::INFINITY] + ] + bins.each do |label, range| + count = year_groups.filter_map { |year, count| count if range.cover?(year) }.sum + binned_data << [label, count] + end + # Add the "Unknown" group if it exists (placing it at the end) + binned_data << unknown_group if unknown_group + @document_years = binned_data + # Create table data. + default_group = Document::DECISION_TYPES.keys.map { |status| [status, 0] }.to_h + @category_groups = {} + @documents.group([:document_category, :accessibility_recommendation]).count.each do |groups, group_count| + @category_groups[groups[0]] = default_group.clone if @category_groups[groups[0]].nil? + if Document::DECISION_TYPES.keys.exclude? groups[1] + parent = Document::DECISION_TYPES.keys.find do |key| + if Document::DECISION_TYPES[key]["children"].present? && Document::DECISION_TYPES[key]["children"].key?(groups[1]) + key + end + end + if parent.present? + groups[1] = parent + end + end + @category_groups[groups[0]][groups[1]] += group_count + end + @category_groups.each do |key, child_hash| + sum = child_hash.values.sum + child_hash["Total"] = sum + end + @category_groups = @category_groups.sort.to_h + # Work on document links. + @document_links = { + complexity: [ + {title: Document::SIMPLE_STATUS, params: query_params.merge({complexity: Document::SIMPLE_STATUS})}, + {title: Document::COMPLEX_STATUS, params: query_params.merge({complexity: Document::COMPLEX_STATUS})} + ], + years: bins.map do |label, range| + document_count = @document_years.find { |item| item[0] == label } + if document_count[1] == 0 + next + end + start_date = (range.begin == -Float::INFINITY) ? nil : "#{range.begin}-01-01" + end_date = (range.end == Float::INFINITY) ? nil : "#{range.end}-12-31" + { + title: label, + params: query_params.merge( + start_date: start_date, + end_date: end_date + ).compact + } + end.compact, + decision: @documents.pluck(:accessibility_recommendation).uniq.map do |decision| + { + title: decision, + params: query_params.merge( + accessibility_recommendation: decision + ) + } + end + } + end + + def audit_exports + + end + def serve_document_url response = HTTParty.get(@document.normalized_url) if response.success? diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index fde11e78..21836f8c 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -2,8 +2,8 @@ class SitesController < AuthenticatedController include Access include ParamsHelper - before_action :find_site, only: [:insights, :show, :edit, :update, :destroy] - before_action :ensure_user_site_access, only: [:insights, :show, :edit, :update, :destroy] + before_action :find_site, only: [:show, :edit, :update, :destroy] + before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy] def index @sites = if current_user.is_site_admin? @@ -13,100 +13,6 @@ def index end end - def insights - # Build document list. - @documents = @site.documents - .by_category(params[:category]) - .by_department(params[:department]) - # Create binned date data for visualization. - # First, gather all documents by year - year_groups = @documents.group_by(&:modification_year).map { |label, year_documents| [label, year_documents.size] } - # Extract and remove "Unknown" to handle separately - unknown_group = year_groups.find { |item| item[0] == "Unknown" } - year_groups = year_groups.reject { |item| item[0] == "Unknown" } - year_groups = year_groups.select do |item| - Integer(item[0]) - true - rescue - if unknown_group.nil? - unknown_group = ["Unknown", 0] - end - unknown_group[1] += 1 - false - end - # Convert to integers for sorting and calculations - year_groups = year_groups.map { |year, count| [Integer(year), count] } - # Create bins based on specific year ranges - binned_data = [] - bins = [ - ["< 2000", -Float::INFINITY..1999], - ["2000-2005", 2000..2005], - ["2006-2011", 2006..2011], - ["2012-2017", 2012..2017], - ["2018-2023", 2018..2023], - ["> 2023", 2024..Float::INFINITY] - ] - bins.each do |label, range| - count = year_groups.filter_map { |year, count| count if range.cover?(year) }.sum - binned_data << [label, count] - end - # Add the "Unknown" group if it exists (placing it at the end) - binned_data << unknown_group if unknown_group - @document_years = binned_data - # Create table data. - default_group = Document::DECISION_TYPES.keys.map { |status| [status, 0] }.to_h - @category_groups = {} - @documents.group([:document_category, :accessibility_recommendation]).count.each do |groups, group_count| - @category_groups[groups[0]] = default_group.clone if @category_groups[groups[0]].nil? - if Document::DECISION_TYPES.keys.exclude? groups[1] - parent = Document::DECISION_TYPES.keys.find do |key| - if Document::DECISION_TYPES[key]["children"].present? && Document::DECISION_TYPES[key]["children"].key?(groups[1]) - key - end - end - if parent.present? - groups[1] = parent - end - end - @category_groups[groups[0]][groups[1]] += group_count - end - @category_groups.each do |key, child_hash| - sum = child_hash.values.sum - child_hash["Total"] = sum - end - @category_groups = @category_groups.sort.to_h - # Work on document links. - @document_links = { - complexity: [ - {title: Document::SIMPLE_STATUS, params: query_params.merge({complexity: Document::SIMPLE_STATUS})}, - {title: Document::COMPLEX_STATUS, params: query_params.merge({complexity: Document::COMPLEX_STATUS})} - ], - years: bins.map do |label, range| - document_count = @document_years.find { |item| item[0] == label } - if document_count[1] == 0 - next - end - start_date = (range.begin == -Float::INFINITY) ? nil : "#{range.begin}-01-01" - end_date = (range.end == Float::INFINITY) ? nil : "#{range.end}-12-31" - { - title: label, - params: query_params.merge( - start_date: start_date, - end_date: end_date - ).compact - } - end.compact, - decision: @documents.pluck(:accessibility_recommendation).uniq.map do |decision| - { - title: decision, - params: query_params.merge( - accessibility_recommendation: decision - ) - } - end - } - end - def show @documents = @site.documents.order(created_at: :desc) end diff --git a/app/models/site.rb b/app/models/site.rb index 91e510d2..8181343f 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -232,16 +232,26 @@ def process_archive_or_csv(file_path, is_archive) end def export_document_audit - report_name = "audit_export_#{site.name.downcase}" - # Tempfile.create(['large_data', '.csv']) do |temp_file| - # CSV.open(temp_file.path, 'wb') do |csv| - # csv << ['Header1', 'Header2', 'Header3'] # Add your CSV headers - # - # # Fetch data in batches to avoid loading everything into memory - # YourModel.find_each do |record| # Use find_each for large datasets - # csv << [record.attribute1, record.attribute2, record.attribute3] # Populate CSV rows - # end - # end + s3_manager = AwsS3Manager.new + bucket_name = Rails.application.config.default_s3_bucket + machine_site_name = name.downcase.gsub(/\W+/, '_') + report_name = "audit_export_#{machine_site_name}_#{Time.now}" + Tempfile.create([report_name, '.csv']) do |temp_file| + CSV.open(temp_file.path, 'wb') do |csv| + csv << Document.column_names + documents.find_each do |record| + csv << record.attributes.values + end + end + s3_manager.write_file(bucket_name, "/reports/#{machine_site_name}/#{report_name}.csv", temp_file.path, "text/csv") + end + end + + def get_document_audit_exports + s3_manager = AwsS3Manager.new + bucket_name = Rails.application.config.default_s3_bucket + machine_site_name = name.downcase.gsub(/\W+/, '_') + s3_manager.get_files(bucket_name, machine_site_name) end private diff --git a/app/services/aws_s3_manager.rb b/app/services/aws_s3_manager.rb new file mode 100644 index 00000000..2048226a --- /dev/null +++ b/app/services/aws_s3_manager.rb @@ -0,0 +1,35 @@ +class AwsS3Manager + def initialize + if Rails.env.development? + @s3_client = Aws::S3::Client.new( + endpoint: "http://localhost:4566", + account_id: "none", + access_key_id: "none", + secret_access_key: "none", + region: "us-east-1", + force_path_style: true, + stub_responses: false + ) + else + @s3_client = Aws::S3::Client.new + end + end + + def write_file(bucket_name, key, file_path, content_type = nil) + File.open(file_path, 'rb') do |file| + @s3_client.put_object( + bucket: bucket_name, + key: key, + body: file, + content_type: content_type || 'application/octet-stream' + ) + end + end + + def get_files(bucket_name, key) + prefix = key.end_with?('/') ? key : "#{key}/" + bucket = @s3_client.bucket(bucket_name) + objects = bucket.objects(prefix: prefix) + objects.map(&:key) + end +end \ No newline at end of file diff --git a/app/sidekiq/site_document_audit_export_job.rb b/app/sidekiq/site_document_audit_export_job.rb deleted file mode 100644 index e40d604a..00000000 --- a/app/sidekiq/site_document_audit_export_job.rb +++ /dev/null @@ -1,7 +0,0 @@ -class SiteDocumentAuditExportJob - include Sidekiq::Job - - def perform(*args) - Site.find(args[0]).export_document_audit - end -end diff --git a/app/views/documents/audit_exports.html.erb b/app/views/documents/audit_exports.html.erb new file mode 100644 index 00000000..14cca2c5 --- /dev/null +++ b/app/views/documents/audit_exports.html.erb @@ -0,0 +1,35 @@ +<% content_for :head do %> + <%= tag.meta name: "site-id", content: @site.id %> +<% end %> +
+
+
+ +
+
+
+

<%= @site.location %>: <%= @site.name %>

+
+ <%= link_to site_documents_path(@site), role: "tab", class: "tab" do %> + Documents + <% end %> + <%= link_to insights_site_documents_path(@site), role: "tab", class: "tab" do %> + Insights + <% end %> + <%= link_to audit_exports_site_documents_path(@site), role: "tab", class: "tab tab-active" do %> + Audit Exports + <% end %> +
+
+
+
+
+

Audit Exports

+
+
+
+
+
+
diff --git a/app/views/documents/index.html.erb b/app/views/documents/index.html.erb index 474a4088..58640ccc 100644 --- a/app/views/documents/index.html.erb +++ b/app/views/documents/index.html.erb @@ -16,9 +16,12 @@ <%= link_to site_documents_path(@site), role: "tab", class: "tab tab-active" do %> Documents <% end %> - <%= link_to insights_site_path(@site), role: "tab", class: "tab" do %> + <%= link_to insights_site_documents_path(@site), role: "tab", class: "tab" do %> Insights <% end %> + <%= link_to audit_exports_site_documents_path(@site), role: "tab", class: "tab" do %> + Audit Exports + <% end %> diff --git a/app/views/sites/insights.html.erb b/app/views/documents/insights.html.erb similarity index 93% rename from app/views/sites/insights.html.erb rename to app/views/documents/insights.html.erb index 2aa98476..80b7bf04 100644 --- a/app/views/sites/insights.html.erb +++ b/app/views/documents/insights.html.erb @@ -11,7 +11,7 @@

Filter View

- <%= form_tag insights_site_path(@site), method: :get, class: "px-4 py-4", data: { action: "submit->filter#submitForm" } do %> + <%= form_tag insights_site_documents_path(@site), method: :get, class: "px-4 py-4", data: { action: "submit->filter#submitForm" } do %>
diff --git a/bin/setup-localstack b/bin/setup-localstack index 45f7d223..fa93c3e1 100755 --- a/bin/setup-localstack +++ b/bin/setup-localstack @@ -2,23 +2,26 @@ # Wait for LocalStack to be ready echo "Waiting for LocalStack to be ready..." -while ! curl -s "http://localhost:4566/_localstack/health" | grep -q '"s3": "running"'; do - sleep 1 +while ! curl -s "http://localstack:4566/_localstack/health" | grep -q '"s3".*"available"'; do + echo "S3 not ready yet, waiting..." + sleep 2 done +echo "LocalStack S3 is ready!" # Create the bucket if it doesn't exist +# Must match value in config/environments/development.rb, config.default_s3_bucket. echo "Creating S3 bucket..." -aws --endpoint-url=http://localhost:4566 s3 mb s3://cfa-aistudio-asap-pdf +aws --endpoint-url=http://localstack:4566 s3 mb s3://cfa-aistudio-asap-pdf # Enable versioning on the bucket echo "Enabling bucket versioning..." -aws --endpoint-url=http://localhost:4566 s3api put-bucket-versioning \ +aws --endpoint-url=http://localstack:4566 s3api put-bucket-versioning \ --bucket cfa-aistudio-asap-pdf \ --versioning-configuration Status=Enabled # Set bucket policy to allow public access (for development only) echo "Setting bucket policy..." -aws --endpoint-url=http://localhost:4566 s3api put-bucket-policy \ +aws --endpoint-url=http://localstack:4566 s3api put-bucket-policy \ --bucket cfa-aistudio-asap-pdf \ --policy '{ "Version": "2012-10-17", diff --git a/bin/setup_python_components b/bin/setup_python_components deleted file mode 100755 index 926e48e3..00000000 --- a/bin/setup_python_components +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -declare -a arr=("GEMINI_KEY" "ANTHROPIC_KEY") - -for item in "${arr[@]}"; do - read -p "Enter value for $item: " key - docker exec asap_setup aws secretsmanager create-secret \ - --endpoint-url=http://localstack:4566 \ - --name $item \ - --description "LocalStack secret $key" \ - --secret-string $key \ - --region 'us-east-1' - echo "Set secret value for $item" -done - -echo "Python components should be ready to run!" \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index a064c8dc..5584a509 100644 --- a/config/database.yml +++ b/config/database.yml @@ -4,6 +4,7 @@ default: &default prepared_statements: false pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + development: <<: *default host: localhost diff --git a/config/environments/development.rb b/config/environments/development.rb index 0cb5b88b..d2049934 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -38,4 +38,6 @@ config.action_controller.raise_on_missing_callback_actions = true config.hosts = nil + + config.default_s3_bucket = "cfa-aistudio-asap-pdf" end diff --git a/config/routes.rb b/config/routes.rb index a9d9b00a..0ccf3218 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,9 +14,6 @@ get "up" => "rails/health#show", :as => :rails_health_check resources :sites do - member do - get :insights - end resources :documents do member do patch :update_status @@ -24,6 +21,8 @@ end collection do patch :batch_update + get :insights + get :audit_exports end end end From c911b5bee8090386e2d07edfe47c955761210458 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Fri, 22 Aug 2025 17:01:42 -0600 Subject: [PATCH 03/19] Mostly function audit export page. --- app/controllers/documents_controller.rb | 4 -- app/controllers/sites_controller.rb | 27 +++++++++++- .../controllers/audit_export_controller.js | 44 +++++++++++++++++++ app/javascript/controllers/index.js | 3 ++ app/models/site.rb | 7 ++- app/services/aws_s3_manager.rb | 15 +++++-- .../documents/_audit_export_list.html.erb | 13 ++++++ app/views/documents/audit_exports.html.erb | 26 ++++++++++- config/routes.rb | 4 ++ 9 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 app/javascript/controllers/audit_export_controller.js create mode 100644 app/views/documents/_audit_export_list.html.erb diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index d9ecb756..2d9e687e 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -123,10 +123,6 @@ def insights } end - def audit_exports - - end - def serve_document_url response = HTTParty.get(@document.normalized_url) if response.success? diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 21836f8c..55d75dd5 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -2,8 +2,8 @@ class SitesController < AuthenticatedController include Access include ParamsHelper - before_action :find_site, only: [:show, :edit, :update, :destroy] - before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy] + before_action :find_site, only: [:show, :edit, :update, :destroy, :create_workflow_audit_report] + before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy, :workflow_audit_report] def index @sites = if current_user.is_site_admin? @@ -47,6 +47,29 @@ def destroy redirect_to sites_path, notice: "Site was successfully deleted.", status: :see_other end + def create_workflow_audit_report + @site.export_document_audit + render json: {html: render_to_string(partial: "documents/audit_export_list", formats: [:html], locals: {site: @site})} + end + + def workflow_audit_report + s3_manager = AwsS3Manager.new + begin + key = params[:key] + key = key.start_with?("/") ? key : "/#{key}" + response = s3_manager.get_object(params[:bucket_name], key) + send_data response[:body].read, + filename: File.basename(key), + type: response[:content_type] || 'application/octet-stream', + disposition: 'inline' + rescue Aws::S3::Errors::NoSuchKey + render plain: 'File not found', status: 404 + rescue => e + Rails.logger.error "S3 error: #{e.message}" + render plain: 'Error retrieving file', status: 500 + end + end + private def site_params diff --git a/app/javascript/controllers/audit_export_controller.js b/app/javascript/controllers/audit_export_controller.js new file mode 100644 index 00000000..4d1e4160 --- /dev/null +++ b/app/javascript/controllers/audit_export_controller.js @@ -0,0 +1,44 @@ +import {Controller} from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button", "preloader", "documentList"] + + static values = { + siteId: Number, + } + + connect() { + super.connect(); + console.log(this.buttonTarget); + console.log(this.preloaderTarget); + console.log(this.documentListTarget); + console.log(this.siteIdValue); + } + + async createReport() { + try { + this.buttonTarget.classList.add("hidden"); + this.preloaderTarget.classList.remove("hidden"); + const response = await fetch(`/sites/${this.siteIdValue}/create_workflow_audit_report`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content, + "Accept": "application/json" + }, + }) + if (response.ok) { + const jsonSummary = await response.json() + this.documentListTarget.outerHTML = jsonSummary.html; + this.preloaderTarget.classList.add('hidden') + this.buttonTarget.classList.remove("hidden"); + } else { + this.displayTarget.textContent = 'An error occurred summarizing this document. Please try again later.'; + throw new Error("Response was not OK") + } + } catch (error) { + console.error("Error summarizing document:", error) + this.preloaderTarget.classList.add('hidden') + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index d04fedad..4330b39f 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -4,6 +4,9 @@ import { application } from "./application" +import AuditExportController from "./audit_export_controller" +application.register("audit-export", AuditExportController) + import BulkEditController from "./bulk_edit_controller" application.register("bulk-edit", BulkEditController) diff --git a/app/models/site.rb b/app/models/site.rb index 8181343f..b9819e10 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -235,7 +235,7 @@ def export_document_audit s3_manager = AwsS3Manager.new bucket_name = Rails.application.config.default_s3_bucket machine_site_name = name.downcase.gsub(/\W+/, '_') - report_name = "audit_export_#{machine_site_name}_#{Time.now}" + report_name = "audit_export_#{machine_site_name}_#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}" Tempfile.create([report_name, '.csv']) do |temp_file| CSV.open(temp_file.path, 'wb') do |csv| csv << Document.column_names @@ -251,7 +251,10 @@ def get_document_audit_exports s3_manager = AwsS3Manager.new bucket_name = Rails.application.config.default_s3_bucket machine_site_name = name.downcase.gsub(/\W+/, '_') - s3_manager.get_files(bucket_name, machine_site_name) + { + "bucket_name": bucket_name, + "files": s3_manager.get_files(bucket_name, "/reports/#{machine_site_name}") + } end private diff --git a/app/services/aws_s3_manager.rb b/app/services/aws_s3_manager.rb index 2048226a..e26166f7 100644 --- a/app/services/aws_s3_manager.rb +++ b/app/services/aws_s3_manager.rb @@ -13,6 +13,7 @@ def initialize else @s3_client = Aws::S3::Client.new end + end def write_file(bucket_name, key, file_path, content_type = nil) @@ -28,8 +29,16 @@ def write_file(bucket_name, key, file_path, content_type = nil) def get_files(bucket_name, key) prefix = key.end_with?('/') ? key : "#{key}/" - bucket = @s3_client.bucket(bucket_name) - objects = bucket.objects(prefix: prefix) - objects.map(&:key) + params = { + bucket: bucket_name, + prefix: prefix, + max_keys: 100 + } + response = @s3_client.list_objects_v2(params) + response.contents.sort_by(&:last_modified).reverse + end + + def get_object(bucket_name, key) + @s3_client.get_object(bucket: bucket_name, key: key) end end \ No newline at end of file diff --git a/app/views/documents/_audit_export_list.html.erb b/app/views/documents/_audit_export_list.html.erb new file mode 100644 index 00000000..6b9fe276 --- /dev/null +++ b/app/views/documents/_audit_export_list.html.erb @@ -0,0 +1,13 @@ +
    + <% report_data = site.get_document_audit_exports %> + <% if report_data[:files].present? %> + <% report_data[:files].each do |s3_object| %> +
  • + <%= link_to workflow_audit_report_site_path(site, report_data[:bucket_name], s3_object.key), class: "text-primary" do %> + + <%= File.basename(s3_object.key) %> + <% end %> +
  • + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/views/documents/audit_exports.html.erb b/app/views/documents/audit_exports.html.erb index 14cca2c5..99c4cc3e 100644 --- a/app/views/documents/audit_exports.html.erb +++ b/app/views/documents/audit_exports.html.erb @@ -24,9 +24,33 @@ -
+

Audit Exports

+
+

Create a downloadable CSV export of your audit progress.

+ + + <%= render partial: "documents/audit_export_list", locals: { site: @site } %> +
+
+

Use the API

+

Use the following RESTful endpoint to get your audit history.

+
+
curl -X GET \
+  -H "Accept: application/json" \
+  -H "Content-Type: application/json" \
+  -u "username:password" \
+  "https://api.example.com/api/sites/1/documents"
+
+

Read the full API documentation.

+
diff --git a/config/routes.rb b/config/routes.rb index 0ccf3218..074d840d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,6 +14,10 @@ get "up" => "rails/health#show", :as => :rails_health_check resources :sites do + member do + get "workflow_audit_report/:bucket_name/*key", to: "sites#workflow_audit_report", format: false, as: :workflow_audit_report + post :create_workflow_audit_report + end resources :documents do member do patch :update_status From 7d0c8087f5ac8703a9422f1800745182b650e668 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Mon, 25 Aug 2025 11:25:12 -0600 Subject: [PATCH 04/19] Clean up and make localstack erros more obvious. Move secret names to config. --- app/controllers/configurations_controller.rb | 21 ++++---- app/controllers/documents_controller.rb | 14 +++++- app/controllers/sites_controller.rb | 23 +++++---- .../controllers/audit_export_controller.js | 9 ---- app/models/site.rb | 48 ++++++++++++++----- app/services/aws_s3_manager.rb | 31 +++++++----- .../documents/_audit_export_list.html.erb | 41 +++++++++++----- app/views/documents/audit_exports.html.erb | 4 +- config/credentials/production.yml.enc | 1 + config/environments/development.rb | 9 ++++ config/environments/production.rb | 2 + config/environments/staging.rb | 2 + config/environments/test.rb | 11 +++++ 13 files changed, 151 insertions(+), 65 deletions(-) create mode 100644 config/credentials/production.yml.enc diff --git a/app/controllers/configurations_controller.rb b/app/controllers/configurations_controller.rb index b7b60453..a6798ff3 100644 --- a/app/controllers/configurations_controller.rb +++ b/app/controllers/configurations_controller.rb @@ -15,31 +15,32 @@ class ConfigurationsController < AuthenticatedController def initialize super @secret_manager = AwsLocalSecretManager.new + @secret_names = Rails.configuration.local_secret_names end def edit @config = { localstack_not_reachable: false } - response = @secret_manager.get_secret!(GOOGLE_API_SECRET_NAME) + response = @secret_manager.get_secret!(@secret_names[:google_api]) @config["google_ai_api_key"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(ANTHROPIC_API_SECRET_NAME) + response = @secret_manager.get_secret!(@secret_names[:anthropic_api]) @config["anthropic_api_key"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS) + response = @secret_manager.get_secret!(@secret_names[:google_eval_service_account]) @config["google_evaluation_service_account_credentials"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(GOOGLE_EVAL_SHEET_ID) + response = @secret_manager.get_secret!(@secret_names[:google_eval_sheet_id]) @config["google_evaluation_sheet_id"] = response.secret_string if response.present? rescue Seahorse::Client::NetworkingError @config["localstack_not_reachable"] = true end def update - @secret_manager.set_secret!(GOOGLE_API_SECRET_NAME, params[:config][:google_ai_api_key]) - @secret_manager.set_secret!(ANTHROPIC_API_SECRET_NAME, params[:config][:anthropic_api_key]) - @secret_manager.set_secret!(ASAP_API_USER, Rails.application.credentials.config[:api_user]) - @secret_manager.set_secret!(ASAP_API_PASSWORD, Rails.application.credentials.config[:api_password]) - @secret_manager.set_secret!(GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS, params[:config][:google_evaluation_service_account_credentials]) - @secret_manager.set_secret!(GOOGLE_EVAL_SHEET_ID, params[:config][:google_evaluation_sheet_id]) + @secret_manager.set_secret!(@secret_names[:google_api], params.dig(:config, :google_ai_api_key)) + @secret_manager.set_secret!(@secret_names[:anthropic_api], params.dig(:config, :anthropic_api_key)) + @secret_manager.set_secret!(@secret_names[:asap_api_user], Rails.application.credentials.config[:api_user]) + @secret_manager.set_secret!(@secret_names[:asap_api_password], Rails.application.credentials.config[:api_password]) + @secret_manager.set_secret!(@secret_names[:google_eval_service_account], params.dig(:config, :google_evaluation_service_account_credentials)) + @secret_manager.set_secret!(@secret_names[:google_eval_sheet_id], params.dig(:config, :google_evaluation_sheet_id)) redirect_to edit_configuration_path, notice: "Configuration updated successfully. API user set to Rails config values." rescue => e redirect_to edit_configuration_path, alert: "Error updating configuration: #{e.message}" diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 2d9e687e..ff2da846 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -32,8 +32,8 @@ def index def insights # Build document list. @documents = @site.documents - .by_category(params[:category]) - .by_department(params[:department]) + .by_category(params[:category]) + .by_department(params[:department]) # Create binned date data for visualization. # First, gather all documents by year year_groups = @documents.group_by(&:modification_year).map { |label, year_documents| [label, year_documents.size] } @@ -123,6 +123,16 @@ def insights } end + def audit_exports + @export_links = [] + @error_message = nil + begin + @export_links = @site.get_document_audit_link_hashes! + rescue => e + @error_message = e.message + end + end + def serve_document_url response = HTTParty.get(@document.normalized_url) if response.success? diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 55d75dd5..bc7aaa01 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -48,8 +48,15 @@ def destroy end def create_workflow_audit_report - @site.export_document_audit - render json: {html: render_to_string(partial: "documents/audit_export_list", formats: [:html], locals: {site: @site})} + export_links = [] + error_message = nil + begin + @site.export_document_audit!(current_user) + export_links = @site.get_document_audit_link_hashes! + rescue => e + error_message = e.message + end + render json: {html: render_to_string(partial: "documents/audit_export_list", formats: [:html], locals: {export_links: export_links, error: error_message})} end def workflow_audit_report @@ -57,16 +64,16 @@ def workflow_audit_report begin key = params[:key] key = key.start_with?("/") ? key : "/#{key}" - response = s3_manager.get_object(params[:bucket_name], key) + response = s3_manager.get_object!(params[:bucket_name], key) send_data response[:body].read, - filename: File.basename(key), - type: response[:content_type] || 'application/octet-stream', - disposition: 'inline' + filename: File.basename(key), + type: response[:content_type] || "application/octet-stream", + disposition: "inline" rescue Aws::S3::Errors::NoSuchKey - render plain: 'File not found', status: 404 + render plain: "File not found", status: 404 rescue => e Rails.logger.error "S3 error: #{e.message}" - render plain: 'Error retrieving file', status: 500 + render plain: "Error retrieving file", status: 500 end end diff --git a/app/javascript/controllers/audit_export_controller.js b/app/javascript/controllers/audit_export_controller.js index 4d1e4160..6f405f48 100644 --- a/app/javascript/controllers/audit_export_controller.js +++ b/app/javascript/controllers/audit_export_controller.js @@ -6,15 +6,6 @@ export default class extends Controller { static values = { siteId: Number, } - - connect() { - super.connect(); - console.log(this.buttonTarget); - console.log(this.preloaderTarget); - console.log(this.documentListTarget); - console.log(this.siteIdValue); - } - async createReport() { try { this.buttonTarget.classList.add("hidden"); diff --git a/app/models/site.rb b/app/models/site.rb index b9819e10..ba08cbe8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -54,6 +54,14 @@ class Site < ApplicationRecord validates :primary_url, presence: true, uniqueness: true validate :ensure_safe_url + after_initialize :after_initialize + + def after_initialize + @s3_manager = AwsS3Manager.new + rescue + @s3_manager = nil + end + def has_departments? documents.where.not(department: [nil, ""]).any? end @@ -231,32 +239,50 @@ def process_archive_or_csv(file_path, is_archive) end end - def export_document_audit - s3_manager = AwsS3Manager.new + def export_document_audit!(current_user) bucket_name = Rails.application.config.default_s3_bucket - machine_site_name = name.downcase.gsub(/\W+/, '_') + machine_site_name = name.downcase.gsub(/\W+/, "_") report_name = "audit_export_#{machine_site_name}_#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}" - Tempfile.create([report_name, '.csv']) do |temp_file| - CSV.open(temp_file.path, 'wb') do |csv| + Tempfile.create([report_name, ".csv"]) do |temp_file| + CSV.open(temp_file.path, "wb") do |csv| csv << Document.column_names documents.find_each do |record| csv << record.attributes.values end end - s3_manager.write_file(bucket_name, "/reports/#{machine_site_name}/#{report_name}.csv", temp_file.path, "text/csv") + metadata = { + "created" => Time.now.iso8601, + "author" => current_user.email + } + @s3_manager.write_file!(bucket_name, "/reports/#{machine_site_name}/#{report_name}.csv", temp_file.path, "text/csv", metadata) end end - def get_document_audit_exports - s3_manager = AwsS3Manager.new + def get_document_audit_exports! bucket_name = Rails.application.config.default_s3_bucket - machine_site_name = name.downcase.gsub(/\W+/, '_') + machine_site_name = name.downcase.gsub(/\W+/, "_") { - "bucket_name": bucket_name, - "files": s3_manager.get_files(bucket_name, "/reports/#{machine_site_name}") + bucket_name: bucket_name, + files: @s3_manager.get_files!(bucket_name, "/reports/#{machine_site_name}") } end + def get_document_audit_link_hashes! + export_links = [] + report_data = get_document_audit_exports! + if report_data[:files].present? + report_data[:files].each do |s3_object| + metadata = @s3_manager.get_metadata!(report_data[:bucket_name], s3_object.key) + export_links << { + url: Rails.application.routes.url_helpers.workflow_audit_report_site_path(self, report_data[:bucket_name], s3_object.key), + created: metadata.has_key?("created") ? metadata["created"] : "", + author: metadata.has_key?("author") ? metadata["author"] : "" + } + end + end + export_links + end + private def attributes_from(data) diff --git a/app/services/aws_s3_manager.rb b/app/services/aws_s3_manager.rb index e26166f7..ef01d830 100644 --- a/app/services/aws_s3_manager.rb +++ b/app/services/aws_s3_manager.rb @@ -1,7 +1,7 @@ class AwsS3Manager def initialize - if Rails.env.development? - @s3_client = Aws::S3::Client.new( + @s3_client = if Rails.env.development? + Aws::S3::Client.new( endpoint: "http://localhost:4566", account_id: "none", access_key_id: "none", @@ -11,24 +11,24 @@ def initialize stub_responses: false ) else - @s3_client = Aws::S3::Client.new + Aws::S3::Client.new end - end - def write_file(bucket_name, key, file_path, content_type = nil) - File.open(file_path, 'rb') do |file| + def write_file!(bucket_name, key, file_path, content_type = nil, metadata = {}) + File.open(file_path, "rb") do |file| @s3_client.put_object( bucket: bucket_name, key: key, body: file, - content_type: content_type || 'application/octet-stream' + content_type: content_type || "application/octet-stream", + metadata: metadata ) end end - def get_files(bucket_name, key) - prefix = key.end_with?('/') ? key : "#{key}/" + def get_files!(bucket_name, key) + prefix = key.end_with?("/") ? key : "#{key}/" params = { bucket: bucket_name, prefix: prefix, @@ -38,7 +38,16 @@ def get_files(bucket_name, key) response.contents.sort_by(&:last_modified).reverse end - def get_object(bucket_name, key) + def get_metadata!(bucket_name, key) + head_response = @s3_client.head_object( + bucket: bucket_name, + key: key + ) + return {} unless head_response.metadata.present? + head_response.metadata + end + + def get_object!(bucket_name, key) @s3_client.get_object(bucket: bucket_name, key: key) end -end \ No newline at end of file +end diff --git a/app/views/documents/_audit_export_list.html.erb b/app/views/documents/_audit_export_list.html.erb index 6b9fe276..63db1b92 100644 --- a/app/views/documents/_audit_export_list.html.erb +++ b/app/views/documents/_audit_export_list.html.erb @@ -1,13 +1,30 @@ -
    - <% report_data = site.get_document_audit_exports %> - <% if report_data[:files].present? %> - <% report_data[:files].each do |s3_object| %> -
  • - <%= link_to workflow_audit_report_site_path(site, report_data[:bucket_name], s3_object.key), class: "text-primary" do %> - - <%= File.basename(s3_object.key) %> - <% end %> -
  • +<% if error %> +
    + There was an error retrieving your audit exports. Here are the details:
    <%= error %> +
    +<% else %> + + + + + + + + + + <% export_links.each do |link| %> + + + + + + <% end %> - <% end %> - \ No newline at end of file + +
    Link to ExportDate CreatedAuthor
    + + + Download Report + + <%= link[:created] %><%= link[:author] %>
    +<% end %> \ No newline at end of file diff --git a/app/views/documents/audit_exports.html.erb b/app/views/documents/audit_exports.html.erb index 99c4cc3e..ec0067fe 100644 --- a/app/views/documents/audit_exports.html.erb +++ b/app/views/documents/audit_exports.html.erb @@ -37,12 +37,12 @@ Creating Audit Export... - <%= render partial: "documents/audit_export_list", locals: { site: @site } %> + <%= render partial: "documents/audit_export_list", locals: { export_links: @export_links, error: @error_message } %>

    Use the API

    Use the following RESTful endpoint to get your audit history.

    -
    +
    curl -X GET \
       -H "Accept: application/json" \
       -H "Content-Type: application/json" \
    diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc
    new file mode 100644
    index 00000000..2450c765
    --- /dev/null
    +++ b/config/credentials/production.yml.enc
    @@ -0,0 +1 @@
    +t+sp+zLTSKdE0nQaoq74+EZ2GZghyXnVDC9iZKXFjJzdCpzRBouHaHYe2hcTvK88xhrE2mMEvRC72q3fB9ON6Gu+jwPAaR/kyKZLJuMvjaer1Kr57GAEyHmALOqtCHKTRVt9i1SU+YeiXrZ5vrUF6ZXqfISsP5W8OeILAXUv0h3KGq7jenDQAclQTNIrhHG6WgxYdnyrzuFgBWZtYkZUVaBMgX8BAtwN8QX4qXDG31zk3TOt893+3Da/IwiTqMMVphvl1i9qAtQrdwS03sibXTfUVTluv75cU2XDxMvPro7Ws4vgKmwFjiUJKx7IXaZQxBARJD0EOzkhFjiQOfdW2s694Or/uJGtO/nnWjOUrP+8+NWigzOxatjueikuNEAJ//lkTwMX4ni3AZrbC5KrShMWWNrfLAkYpjCthOc9tuPAgRJlrhW/j7x1cGNE3+YOSz4ze/WcpR9SwZfu9SosBJOp1MhJFGww5wXCc66bYGifamnHD+Qr7bx8--4EiPuW5dKUPW0HGF--wVQ1fNnNfCAxI6nvxUne5w==
    \ No newline at end of file
    diff --git a/config/environments/development.rb b/config/environments/development.rb
    index d2049934..bb9a341e 100644
    --- a/config/environments/development.rb
    +++ b/config/environments/development.rb
    @@ -40,4 +40,13 @@
       config.hosts = nil
     
       config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +
    +  config.local_secret_names = {
    +    asap_api_user: "asap-pdf/staging/RAILS_API_USER",
    +    asap_api_password: "asap-pdf/staging/RAILS_API_PASSWORD",
    +    google_api: "asap-pdf/staging/GOOGLE_AI_KEY",
    +    anthropic_api: "asap-pdf/staging/ANTHROPIC_KEY",
    +    google_eval_service_account: "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT",
    +    google_eval_sheet_id: "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION"
    +  }
     end
    diff --git a/config/environments/production.rb b/config/environments/production.rb
    index 1d825d3e..f9b4aab0 100644
    --- a/config/environments/production.rb
    +++ b/config/environments/production.rb
    @@ -43,4 +43,6 @@
       config.i18n.fallbacks = true
       config.active_record.dump_schema_after_migration = false
       config.active_record.attributes_for_inspect = [:id]
    +
    +  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
     end
    diff --git a/config/environments/staging.rb b/config/environments/staging.rb
    index 83af0209..482f9489 100644
    --- a/config/environments/staging.rb
    +++ b/config/environments/staging.rb
    @@ -43,4 +43,6 @@
       config.i18n.fallbacks = true
       config.active_record.dump_schema_after_migration = false
       config.active_record.attributes_for_inspect = [:id]
    +
    +  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
     end
    diff --git a/config/environments/test.rb b/config/environments/test.rb
    index 1446aa10..aa9c5697 100644
    --- a/config/environments/test.rb
    +++ b/config/environments/test.rb
    @@ -17,4 +17,15 @@
       config.active_support.deprecation = :stderr
     
       config.action_controller.raise_on_missing_callback_actions = true
    +
    +  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +
    +  config.local_secret_names = {
    +    asap_api_user: "asap-pdf/staging/RAILS_API_USER",
    +    asap_api_password: "asap-pdf/staging/RAILS_API_PASSWORD",
    +    google_api: "asap-pdf/staging/GOOGLE_AI_KEY",
    +    anthropic_api: "asap-pdf/staging/ANTHROPIC_KEY",
    +    google_eval_service_account: "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT",
    +    google_eval_sheet_id: "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION"
    +  }
     end
    
    From fdd69bbd611615bf223197f8e5061417a93de9cd Mon Sep 17 00:00:00 2001
    From: Leo Kacenjar 
    Date: Mon, 25 Aug 2025 11:25:55 -0600
    Subject: [PATCH 05/19] Remove secret name constants.
    
    ---
     app/controllers/configurations_controller.rb | 9 ---------
     1 file changed, 9 deletions(-)
    
    diff --git a/app/controllers/configurations_controller.rb b/app/controllers/configurations_controller.rb
    index a6798ff3..0e4b11fa 100644
    --- a/app/controllers/configurations_controller.rb
    +++ b/app/controllers/configurations_controller.rb
    @@ -3,15 +3,6 @@ class ConfigurationsController < AuthenticatedController
     
       before_action :ensure_user_site_admin
     
    -  # This form is only for local development.
    -  # Python components expect to use staging keys for local development.
    -  ASAP_API_USER = "asap-pdf/staging/RAILS_API_USER"
    -  ASAP_API_PASSWORD = "asap-pdf/staging/RAILS_API_PASSWORD"
    -  GOOGLE_API_SECRET_NAME = "asap-pdf/staging/GOOGLE_AI_KEY"
    -  ANTHROPIC_API_SECRET_NAME = "asap-pdf/staging/ANTHROPIC_KEY"
    -  GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS = "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT"
    -  GOOGLE_EVAL_SHEET_ID = "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION"
    -
       def initialize
         super
         @secret_manager = AwsLocalSecretManager.new
    
    From 46230628733063b333b2ad3b5ead0718483c3dcb Mon Sep 17 00:00:00 2001
    From: Leo Kacenjar 
    Date: Mon, 25 Aug 2025 11:29:42 -0600
    Subject: [PATCH 06/19] Use actual bucket name.
    
    ---
     config/environments/development.rb | 2 +-
     config/environments/production.rb  | 2 +-
     config/environments/staging.rb     | 2 +-
     config/environments/test.rb        | 2 +-
     4 files changed, 4 insertions(+), 4 deletions(-)
    
    diff --git a/config/environments/development.rb b/config/environments/development.rb
    index bb9a341e..66b897f8 100644
    --- a/config/environments/development.rb
    +++ b/config/environments/development.rb
    @@ -39,7 +39,7 @@
     
       config.hosts = nil
     
    -  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +  config.default_s3_bucket = "asap-pdf-staging-documents"
     
       config.local_secret_names = {
         asap_api_user: "asap-pdf/staging/RAILS_API_USER",
    diff --git a/config/environments/production.rb b/config/environments/production.rb
    index f9b4aab0..b559ef3d 100644
    --- a/config/environments/production.rb
    +++ b/config/environments/production.rb
    @@ -44,5 +44,5 @@
       config.active_record.dump_schema_after_migration = false
       config.active_record.attributes_for_inspect = [:id]
     
    -  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +  config.default_s3_bucket = "asap-pdf-prod-documents"
     end
    diff --git a/config/environments/staging.rb b/config/environments/staging.rb
    index 482f9489..f7afdaa1 100644
    --- a/config/environments/staging.rb
    +++ b/config/environments/staging.rb
    @@ -44,5 +44,5 @@
       config.active_record.dump_schema_after_migration = false
       config.active_record.attributes_for_inspect = [:id]
     
    -  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +  config.default_s3_bucket = "asap-pdf-staging-documents"
     end
    diff --git a/config/environments/test.rb b/config/environments/test.rb
    index aa9c5697..615b4cd3 100644
    --- a/config/environments/test.rb
    +++ b/config/environments/test.rb
    @@ -18,7 +18,7 @@
     
       config.action_controller.raise_on_missing_callback_actions = true
     
    -  config.default_s3_bucket = "cfa-aistudio-asap-pdf"
    +  config.default_s3_bucket = "asap-pdf-staging-documents"
     
       config.local_secret_names = {
         asap_api_user: "asap-pdf/staging/RAILS_API_USER",
    
    From e25fc7692210583fecdf52861545692a1b9500fb Mon Sep 17 00:00:00 2001
    From: Leo Kacenjar 
    Date: Mon, 25 Aug 2025 12:35:54 -0600
    Subject: [PATCH 07/19] Move S3 permissions to the correct role.
    
    ---
     terraform/modules/ecs/main.tf | 4 ++--
     1 file changed, 2 insertions(+), 2 deletions(-)
    
    diff --git a/terraform/modules/ecs/main.tf b/terraform/modules/ecs/main.tf
    index 9fb36c67..df5f9833 100644
    --- a/terraform/modules/ecs/main.tf
    +++ b/terraform/modules/ecs/main.tf
    @@ -16,8 +16,8 @@ module "fargate_service" {
       memory          = 2048
       container_port  = 3000
     
    -  execution_policies = [aws_iam_policy.ecs_task_secrets_policy.arn, aws_iam_policy.ecs_s3_access.arn]
    -  task_policies = [aws_iam_policy.ecs_task_lambda_invoke_policy.arn]
    +  execution_policies = [aws_iam_policy.ecs_task_secrets_policy.arn]
    +  task_policies = [aws_iam_policy.ecs_task_lambda_invoke_policy.arn, aws_iam_policy.ecs_s3_access.arn]
     
       enable_execute_command   = true
       create_version_parameter = true
    
    From 2d9e0d474dbfe268203a9086e050f42c1390d070 Mon Sep 17 00:00:00 2001
    From: Leo Kacenjar 
    Date: Mon, 25 Aug 2025 12:40:57 -0600
    Subject: [PATCH 08/19] Allow backend to handle errors.
    
    ---
     app/javascript/controllers/audit_export_controller.js | 2 +-
     1 file changed, 1 insertion(+), 1 deletion(-)
    
    diff --git a/app/javascript/controllers/audit_export_controller.js b/app/javascript/controllers/audit_export_controller.js
    index 6f405f48..a6a8719c 100644
    --- a/app/javascript/controllers/audit_export_controller.js
    +++ b/app/javascript/controllers/audit_export_controller.js
    @@ -6,6 +6,7 @@ export default class extends Controller {
         static values = {
             siteId: Number,
         }
    +
         async createReport() {
             try {
                 this.buttonTarget.classList.add("hidden");
    @@ -24,7 +25,6 @@ export default class extends Controller {
                     this.preloaderTarget.classList.add('hidden')
                     this.buttonTarget.classList.remove("hidden");
                 } else {
    -                this.displayTarget.textContent = 'An error occurred summarizing this document. Please try again later.';
                     throw new Error("Response was not OK")
                 }
             } catch (error) {
    
    From 07faab69372c99d8b286c4d1e684ff38e16a755e Mon Sep 17 00:00:00 2001
    From: Leo Kacenjar 
    Date: Tue, 26 Aug 2025 15:47:11 -0600
    Subject: [PATCH 09/19] Refactor and simplify API.
    
    ---
     app/api/asap_pdf/api.rb            | 228 ++++++++++++++---------------
     app/views/layouts/swagger.html.erb |  43 ------
     2 files changed, 107 insertions(+), 164 deletions(-)
     delete mode 100644 app/views/layouts/swagger.html.erb
    
    diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb
    index d7c41912..c96dd9be 100644
    --- a/app/api/asap_pdf/api.rb
    +++ b/app/api/asap_pdf/api.rb
    @@ -6,152 +6,138 @@ class API < Grape::API::Instance
         format :json
     
         http_basic do |email, password|
    -      user = User.find_by(email: email)
    -      user&.valid_password?(password)
    +      @user = User.find_by(email: email)
    +      @user&.valid_password?(password)
         end
     
         rescue_from ActiveRecord::RecordNotFound do |e|
    -      error!({error: e.message}, 404)
    +      error!({ error: e.message }, 404)
         end
     
    -    desc "Return list of sites" do
    -      detail "Returns a list of all sites in the system"
    -      tags ["Sites"]
    -      produces ["application/json"]
    -      failure [[401, "Unauthorized"], [403, "Forbidden"]]
    -    end
    -    get "/sites" do
    -      Site.all
    -    end
    -
    -    desc "Return a specific site" do
    -      detail "Returns detailed information about a specific site"
    -      tags ["Sites"]
    -      produces ["application/json"]
    -      failure [
    -        [401, "Unauthorized"],
    -        [403, "Forbidden"],
    -        [404, "Site not found"]
    -      ]
    -    end
    -    params do
    -      requires :id, type: Integer, desc: "Site ID"
    -    end
    -    get "/sites/:id" do
    -      Site.find(params[:id])
    +    resource :sites do
    +      desc "Return list of sites" do
    +        detail "Returns a list of all sites the user has access to."
    +        tags ["Sites"]
    +        produces ["application/json"]
    +        failure [[401, "Unauthorized"], [403, "Forbidden"]]
    +        security [{ basic_auth: [] }]
    +      end
    +      get do
    +        @user.is_site_admin ? Site.all : [@user.site]
    +      end
         end
     
    -    desc "Discover documents for a site" do
    -      detail "Creates or updates documents for a specific site based on the provided URLs and timestamps"
    -      tags ["Documents"]
    -      produces ["application/json"]
    -      consumes ["application/json"]
    -      failure [
    -        [400, "Bad Request - Invalid parameters"],
    -        [401, "Unauthorized"],
    -        [403, "Forbidden"],
    -        [404, "Site not found"]
    -      ]
    -      named "Create Documents"
    -    end
    -    params do
    -      requires :id, type: Integer, desc: "Site ID"
    -      requires :documents, type: Array do
    -        requires :url, type: String, desc: "Document URL"
    -        requires :modification_date, type: DateTime, desc: "Document's last modified timestamp"
    +    resource :documents do
    +      desc "List documents related to site." do
    +        detail "A paginated list of documents related to a site."
    +        tags ["Documents"]
    +        produces ["application/json"]
    +        consumes ["application/json"]
    +        failure [
    +                  [400, "Bad Request - Invalid parameters"],
    +                  [401, "Unauthorized"],
    +                  [403, "Forbidden"],
    +                  [404, "Site not found"]
    +                ]
    +        named "List documents"
    +      end
    +      params do
    +        requires :id, type: Integer, desc: "Site ID"
    +        optional :page, type: Integer, desc: 'Page number for pagination', default: 0
    +        optional :items_per_page, type: Integer, desc: 'Items per page', default: 25
    +      end
    +      get "/sites/:id/documents" do
    +        items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i
    +        page = params[:page].nil? ? 0 : params[:page].to_i
    +        unless @user.is_site_admin || params[:id] == @user.site_id
    +          { documents: [] }
    +        end
    +        site = Site.find(params[:id])
    +        { documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc) }
           end
         end
    -    post "/sites/:id/documents" do
    -      site = Site.find(params[:id])
    -      documents = site.discover_documents!(params[:documents], true)
     
    -      status 201
    -      {documents: documents.map { |doc|
    -        {
    -          id: doc.id,
    -          url: doc.url,
    -          document_status: doc.document_status,
    -          s3_path: doc.s3_path
    -        }
    -      }}
    -    end
    +    resource :document_inferences do
    +      desc "List document inferences for a document." do
    +        detail "List LLM document inferences (summary or exception check) for a document."
    +        tags ["Document Inferences"]
    +        produces ["application/json"]
    +        consumes ["application/json"]
    +        failure [
    +                  [400, "Bad Request - Invalid parameters"],
    +                  [401, "Unauthorized"],
    +                  [403, "Forbidden"],
    +                  [404, "Site not found"]
    +                ]
    +      end
    +      params do
    +        requires :id, type: Integer, desc: "Document ID"
    +      end
    +      get "/documents/:id/inference" do
    +        status 201
    +        document = Document.find(params[:id])
    +        { document_inferences: document.document_inferences.order(id: :asc) }
    +      end
     
    -    desc "Adds or updates a document inference with type" do
    -      detail "Adds or updates a document inference with the provided type and document id."
    -      tags ["Document Inferences"]
    -      produces ["application/json"]
    -      consumes ["application/json"]
    -      failure [
    -        [400, "Bad Request - Invalid parameters"],
    -        [401, "Unauthorized"],
    -        [403, "Forbidden"],
    -        [404, "Site not found"]
    -      ]
    -    end
    -    params do
    -      requires :id, type: Integer, desc: "Document ID"
    -      requires :inference_type, type: String, desc: "Document inference type", values: ["summary", "exception"]
    -      requires :result, type: Hash, desc: "Value of document inference"
    -    end
    -    post "/documents/:id/inference" do
    -      status 201
    -      if params[:inference_type] == "summary"
    -        inference = DocumentInference.create(document_id: params[:id], inference_type: "summary")
    -        inference.inference_value = params[:result]["summary"]
    -        inference.is_active = true
    -        inference.save!
    +      desc "Adds or updates a document inference with type" do
    +        detail "Adds or updates a document inference with the provided type and document id."
    +        tags ["Document Inferences"]
    +        produces ["application/json"]
    +        consumes ["application/json"]
    +        failure [
    +                  [400, "Bad Request - Invalid parameters"],
    +                  [401, "Unauthorized"],
    +                  [403, "Forbidden"],
    +                  [404, "Site not found"]
    +                ]
           end
    -      if params[:inference_type] == "exception"
    -        ["individualized", "archival", "application", "third_party"].each do |type|
    -          result_boolean = "is_#{type}"
    -          unless params[:result][result_boolean].nil?
    -            inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}")
    -            inference.inference_value = params[:result][result_boolean] ? "True" : "False"
    -            inference.inference_confidence = params[:result]["#{result_boolean}_confidence"]
    -            inference.inference_reason = params[:result]["why_#{type}"]
    -            inference.is_active = true
    -            inference.save!
    +      params do
    +        requires :id, type: Integer, desc: "Document ID"
    +        requires :inference_type, type: String, desc: "Document inference type", values: ["summary", "exception"]
    +        optional :result, type: Hash, desc: "Value of document inference" do
    +          optional "summary", type: String, desc: "For inference_type summary, generated summary"
    +          optional "is_", type: String, desc: "For inference_type exception, the exception type"
    +          optional "why_", type: String, desc: "For inference_type exception, the LLM explanation"
    +        end
    +      end
    +      post "/documents/:id/inference" do
    +        status 201
    +        if params[:inference_type] == "summary"
    +          inference = DocumentInference.create(document_id: params[:id], inference_type: "summary")
    +          inference.inference_value = params[:result]["summary"]
    +          inference.is_active = true
    +          inference.save!
    +        end
    +        if params[:inference_type] == "exception"
    +          ["individualized", "archival", "application", "third_party"].each do |type|
    +            result_boolean = "is_#{type}"
    +            unless params[:result][result_boolean].nil?
    +              inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}")
    +              inference.inference_value = params[:result][result_boolean] ? "True" : "False"
    +              inference.inference_confidence = params[:result]["#{result_boolean}_confidence"]
    +              inference.inference_reason = params[:result]["why_#{type}"]
    +              inference.is_active = true
    +              inference.save!
    +            end
               end
             end
           end
         end
     
         add_swagger_documentation(
    +      doc_version: "1.0.0",
           mount_path: "/swagger_doc",
    -      openapi_version: "3.0.1",
           info: {
             title: "ASAP PDF API",
    -        description: "API for managing ASAP PDF resources and document processing",
    +        description: "API for managing ASAP PDF resources and document processing. Note: Basic authentication is required for all endpoints.",
             version: "1.0.0"
           },
           tags: [
    -        {name: "Sites", description: "Site management operations"},
    -        {name: "Documents", description: "Document processing operations"}
    +        { name: "Sites", description: "Site operations" },
    +        { name: "Documents", description: "Document operations" },
    +        { name: "Document Inferences", description: "Document Inference operations" }
           ],
    -      components: {
    -        schemas: {
    -          Site: {
    -            type: "object",
    -            properties: {
    -              id: {type: "integer", description: "Site ID"},
    -              name: {type: "string", description: "Site name"},
    -              location: {type: "string", description: "Site location"},
    -              primary_url: {type: "string", description: "Primary URL of the site"}
    -            },
    -            required: ["id", "name", "location", "primary_url"]
    -          },
    -          Document: {
    -            type: "object",
    -            properties: {
    -              id: {type: "integer", description: "Document ID"},
    -              url: {type: "string", description: "Document URL"},
    -              document_status: {type: "string", description: "Current status of the document"},
    -              s3_path: {type: "string", description: "S3 storage path"}
    -            },
    -            required: ["id", "url", "document_status", "s3_path"]
    -          }
    -        }
    -      }
    +      models: [],
         )
       end
     end
    diff --git a/app/views/layouts/swagger.html.erb b/app/views/layouts/swagger.html.erb
    deleted file mode 100644
    index d0653682..00000000
    --- a/app/views/layouts/swagger.html.erb
    +++ /dev/null
    @@ -1,43 +0,0 @@
    -
    -
    -  
    -    
    -    ASAP PDF API Documentation
    -    
    -    
    -  
    -  
    -    
    - - - - - From 3aad551b82a8ed40ade4d96a72c35760b90b5e6e Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Tue, 26 Aug 2025 15:47:42 -0600 Subject: [PATCH 10/19] Add swagger UI to the app. --- app/controllers/swagger_controller.rb | 5 +++++ app/javascript/swagger.js | 20 +++++++++++++++++ app/views/swagger/ui.html.erb | 32 +++++++++++++++++++++++++++ config/routes.rb | 3 ++- package.json | 1 + yarn.lock | 12 ++++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 app/controllers/swagger_controller.rb create mode 100644 app/javascript/swagger.js create mode 100644 app/views/swagger/ui.html.erb diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb new file mode 100644 index 00000000..d9f65487 --- /dev/null +++ b/app/controllers/swagger_controller.rb @@ -0,0 +1,5 @@ +class SwaggerController < ApplicationController + layout false + def ui + end +end diff --git a/app/javascript/swagger.js b/app/javascript/swagger.js new file mode 100644 index 00000000..d3ce8279 --- /dev/null +++ b/app/javascript/swagger.js @@ -0,0 +1,20 @@ +import SwaggerUI from "swagger-ui-dist/swagger-ui-bundle" +import "swagger-ui-dist/swagger-ui.css" + + +const ui = SwaggerUI({ + url: "/api/swagger_doc", + dom_id: "#swagger-ui", + deepLinking: true, + docExpansion: "list", + defaultModelsExpandDepth: 1, + defaultModelExpandDepth: 1, + displayRequestDuration: true, + showExtensions: true, + showCommonExtensions: true, + tryItOutEnabled: false, + persistAuthorization: true, + supportedSubmitMethods: [] +}) + +window.ui = ui; \ No newline at end of file diff --git a/app/views/swagger/ui.html.erb b/app/views/swagger/ui.html.erb new file mode 100644 index 00000000..defcaf07 --- /dev/null +++ b/app/views/swagger/ui.html.erb @@ -0,0 +1,32 @@ + + + + + ASAP PDF API Documentation + <%= javascript_include_tag "swagger", "data-turbo-track": "reload", defer: true %> + <%= stylesheet_link_tag "swagger", "data-turbo-track": "reload" %> + + + +
    + + diff --git a/config/routes.rb b/config/routes.rb index 074d840d..9b5a83e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -45,7 +45,8 @@ mount Sidekiq::Web => "/sidekiq" mount AsapPdf::API => "/api" - get "api-docs", to: "api_docs#index" + + get "/api-docs", to: "swagger#ui" resource :configuration, only: [:edit, :update] diff --git a/package.json b/package.json index 643db994..5e578adb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "chartkick": "^5.0.1", "daisyui": "^4.12.23", "postcss": "^8.5.1", + "swagger-ui-dist": "^5.27.1", "tailwindcss": "^3.4.1" } } diff --git a/yarn.lock b/yarn.lock index bc21c61b..3a2266a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -235,6 +235,11 @@ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.201.tgz#bfb3da01b3e2462f5a18f372c52dedd7de76037f" integrity sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw== +"@scarf/scarf@=1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.4.0.tgz#3bbb984085dbd6d982494538b523be1ce6562972" + integrity sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -945,6 +950,13 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-ui-dist@^5.27.1: + version "5.27.1" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.27.1.tgz#556e77c659752e99621ac61ad5ef6cb0832279e7" + integrity sha512-oGtpYO3lnoaqyGtlJalvryl7TwzgRuxpOVWqEHx8af0YXI+Kt+4jMpLdgMtMcmWmuQ0QTCHLKExwrBFMSxvAUA== + dependencies: + "@scarf/scarf" "=1.4.0" + tailwindcss@^3.4.1: version "3.4.17" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63" From 58974a96fb105712b4fd0def2b11e42c5912c96c Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Tue, 26 Aug 2025 16:11:09 -0600 Subject: [PATCH 11/19] Fix API paths and example. --- app/api/asap_pdf/api.rb | 188 ++++++++++----------- app/views/documents/audit_exports.html.erb | 11 +- 2 files changed, 96 insertions(+), 103 deletions(-) diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index c96dd9be..a4f29872 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -14,111 +14,105 @@ class API < Grape::API::Instance error!({ error: e.message }, 404) end - resource :sites do - desc "Return list of sites" do - detail "Returns a list of all sites the user has access to." - tags ["Sites"] - produces ["application/json"] - failure [[401, "Unauthorized"], [403, "Forbidden"]] - security [{ basic_auth: [] }] - end - get do - @user.is_site_admin ? Site.all : [@user.site] - end + desc "Return list of sites" do + detail "Returns a list of all sites the user has access to." + tags ["Sites"] + produces ["application/json"] + failure [[401, "Unauthorized"], [403, "Forbidden"]] + security [{ basic_auth: [] }] + end + get "/sites" do + @user.is_site_admin ? Site.all : [@user.site] end - resource :documents do - desc "List documents related to site." do - detail "A paginated list of documents related to a site." - tags ["Documents"] - produces ["application/json"] - consumes ["application/json"] - failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] - named "List documents" - end - params do - requires :id, type: Integer, desc: "Site ID" - optional :page, type: Integer, desc: 'Page number for pagination', default: 0 - optional :items_per_page, type: Integer, desc: 'Items per page', default: 25 - end - get "/sites/:id/documents" do - items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i - page = params[:page].nil? ? 0 : params[:page].to_i - unless @user.is_site_admin || params[:id] == @user.site_id - { documents: [] } - end - site = Site.find(params[:id]) - { documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc) } + desc "List documents related to site." do + detail "A paginated list of documents related to a site." + tags ["Documents"] + produces ["application/json"] + consumes ["application/json"] + failure [ + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] + named "List documents" + end + params do + requires :id, type: Integer, desc: "Site ID" + optional :page, type: Integer, desc: 'Page number for pagination', default: 0 + optional :items_per_page, type: Integer, desc: 'Items per page', default: 25 + end + get "/sites/:id/documents" do + items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i + page = params[:page].nil? ? 0 : params[:page].to_i + unless @user.is_site_admin || params[:id] == @user.site_id + { documents: [] } end + site = Site.find(params[:id]) + { documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc) } end - resource :document_inferences do - desc "List document inferences for a document." do - detail "List LLM document inferences (summary or exception check) for a document." - tags ["Document Inferences"] - produces ["application/json"] - consumes ["application/json"] - failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] - end - params do - requires :id, type: Integer, desc: "Document ID" - end - get "/documents/:id/inference" do - status 201 - document = Document.find(params[:id]) - { document_inferences: document.document_inferences.order(id: :asc) } - end + desc "List document inferences for a document." do + detail "List LLM document inferences (summary or exception check) for a document." + tags ["Document Inferences"] + produces ["application/json"] + consumes ["application/json"] + failure [ + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] + end + params do + requires :id, type: Integer, desc: "Document ID" + end + get "/documents/:id/inference" do + status 201 + document = Document.find(params[:id]) + { document_inferences: document.document_inferences.order(id: :asc) } + end - desc "Adds or updates a document inference with type" do - detail "Adds or updates a document inference with the provided type and document id." - tags ["Document Inferences"] - produces ["application/json"] - consumes ["application/json"] - failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] + desc "Adds or updates a document inference with type" do + detail "Adds or updates a document inference with the provided type and document id." + tags ["Document Inferences"] + produces ["application/json"] + consumes ["application/json"] + failure [ + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] + end + params do + requires :id, type: Integer, desc: "Document ID" + requires :inference_type, type: String, desc: "Document inference type", values: ["summary", "exception"] + optional :result, type: Hash, desc: "Value of document inference" do + optional "summary", type: String, desc: "For inference_type summary, generated summary" + optional "is_", type: String, desc: "For inference_type exception, the exception type" + optional "why_", type: String, desc: "For inference_type exception, the LLM explanation" end - params do - requires :id, type: Integer, desc: "Document ID" - requires :inference_type, type: String, desc: "Document inference type", values: ["summary", "exception"] - optional :result, type: Hash, desc: "Value of document inference" do - optional "summary", type: String, desc: "For inference_type summary, generated summary" - optional "is_", type: String, desc: "For inference_type exception, the exception type" - optional "why_", type: String, desc: "For inference_type exception, the LLM explanation" - end + end + post "/documents/:id/inference" do + status 201 + if params[:inference_type] == "summary" + inference = DocumentInference.create(document_id: params[:id], inference_type: "summary") + inference.inference_value = params[:result]["summary"] + inference.is_active = true + inference.save! end - post "/documents/:id/inference" do - status 201 - if params[:inference_type] == "summary" - inference = DocumentInference.create(document_id: params[:id], inference_type: "summary") - inference.inference_value = params[:result]["summary"] - inference.is_active = true - inference.save! - end - if params[:inference_type] == "exception" - ["individualized", "archival", "application", "third_party"].each do |type| - result_boolean = "is_#{type}" - unless params[:result][result_boolean].nil? - inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}") - inference.inference_value = params[:result][result_boolean] ? "True" : "False" - inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] - inference.inference_reason = params[:result]["why_#{type}"] - inference.is_active = true - inference.save! - end + if params[:inference_type] == "exception" + ["individualized", "archival", "application", "third_party"].each do |type| + result_boolean = "is_#{type}" + unless params[:result][result_boolean].nil? + inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}") + inference.inference_value = params[:result][result_boolean] ? "True" : "False" + inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] + inference.inference_reason = params[:result]["why_#{type}"] + inference.is_active = true + inference.save! end end end diff --git a/app/views/documents/audit_exports.html.erb b/app/views/documents/audit_exports.html.erb index ec0067fe..4c9b3c7a 100644 --- a/app/views/documents/audit_exports.html.erb +++ b/app/views/documents/audit_exports.html.erb @@ -43,13 +43,12 @@

    Use the API

    Use the following RESTful endpoint to get your audit history.

    -
    curl -X GET \
    -  -H "Accept: application/json" \
    -  -H "Content-Type: application/json" \
    -  -u "username:password" \
    -  "https://api.example.com/api/sites/1/documents"
    +
    curl -X 'GET' \
    +          '<%= request.base_url %>/api/sites/1/documents?page=0&items_per_page=100' \
    +          -H 'accept: application/json' \
    +          -H 'authorization: Basic [Your base64 encoded credentials]'
    -

    Read the full API documentation.

    +

    Read the <%= link_to api_docs_path, class: "text-primary" do %>full API documentation.<% end %>

    From 9dabed352c6e64cd3a74ead654355cc4818d9fc3 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Tue, 26 Aug 2025 16:20:15 -0600 Subject: [PATCH 12/19] Handle errors more gracefully. --- app/models/site.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/models/site.rb b/app/models/site.rb index ba08cbe8..7d68d34c 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -240,6 +240,7 @@ def process_archive_or_csv(file_path, is_archive) end def export_document_audit!(current_user) + assert_s3_manager bucket_name = Rails.application.config.default_s3_bucket machine_site_name = name.downcase.gsub(/\W+/, "_") report_name = "audit_export_#{machine_site_name}_#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}" @@ -259,6 +260,7 @@ def export_document_audit!(current_user) end def get_document_audit_exports! + assert_s3_manager bucket_name = Rails.application.config.default_s3_bucket machine_site_name = name.downcase.gsub(/\W+/, "_") { @@ -268,6 +270,7 @@ def get_document_audit_exports! end def get_document_audit_link_hashes! + assert_s3_manager export_links = [] report_data = get_document_audit_exports! if report_data[:files].present? @@ -285,6 +288,12 @@ def get_document_audit_link_hashes! private + def assert_s3_manager + if @s3_manager.nil? + raise StandardError.new("Failed to connect to AWS environment (AwsS3Manager failed to initialize).") + end + end + def attributes_from(data) { document_category: data[:predicted_category] || data[:document_category], From f92d1ed9475f97007bc660aa996fbd78417cd81d Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Tue, 26 Aug 2025 16:20:31 -0600 Subject: [PATCH 13/19] Use staging bucket name (default). --- bin/setup-localstack | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/setup-localstack b/bin/setup-localstack index fa93c3e1..1c602a12 100755 --- a/bin/setup-localstack +++ b/bin/setup-localstack @@ -11,18 +11,18 @@ echo "LocalStack S3 is ready!" # Create the bucket if it doesn't exist # Must match value in config/environments/development.rb, config.default_s3_bucket. echo "Creating S3 bucket..." -aws --endpoint-url=http://localstack:4566 s3 mb s3://cfa-aistudio-asap-pdf +aws --endpoint-url=http://localstack:4566 s3 mb s3://asap-pdf-staging-documents # Enable versioning on the bucket echo "Enabling bucket versioning..." aws --endpoint-url=http://localstack:4566 s3api put-bucket-versioning \ - --bucket cfa-aistudio-asap-pdf \ + --bucket asap-pdf-staging-documents \ --versioning-configuration Status=Enabled # Set bucket policy to allow public access (for development only) echo "Setting bucket policy..." aws --endpoint-url=http://localstack:4566 s3api put-bucket-policy \ - --bucket cfa-aistudio-asap-pdf \ + --bucket asap-pdf-staging-documents \ --policy '{ "Version": "2012-10-17", "Statement": [ @@ -31,7 +31,7 @@ aws --endpoint-url=http://localstack:4566 s3api put-bucket-policy \ "Effect": "Allow", "Principal": "*", "Action": "s3:*", - "Resource": "arn:aws:s3:::cfa-aistudio-asap-pdf/*" + "Resource": "arn:aws:s3:::asap-pdf-staging-documents/*" } ] }' From d548be58af78f2fbe092cd1f6b475bacca3687c1 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 08:52:34 -0600 Subject: [PATCH 14/19] Improve access and responses as revealed by testing. --- app/api/asap_pdf/api.rb | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index a4f29872..2d19c897 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -22,7 +22,11 @@ class API < Grape::API::Instance security [{ basic_auth: [] }] end get "/sites" do - @user.is_site_admin ? Site.all : [@user.site] + if @user.is_site_admin + Site.all + else + @user.site.present? ? [@user.site] : [] + end end desc "List documents related to site." do @@ -47,7 +51,7 @@ class API < Grape::API::Instance items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i page = params[:page].nil? ? 0 : params[:page].to_i unless @user.is_site_admin || params[:id] == @user.site_id - { documents: [] } + error!("Unauthorized", 401) end site = Site.find(params[:id]) { documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc) } @@ -68,9 +72,11 @@ class API < Grape::API::Instance params do requires :id, type: Integer, desc: "Document ID" end - get "/documents/:id/inference" do - status 201 + get "/documents/:id/document_inference" do document = Document.find(params[:id]) + unless @user.is_site_admin || document.site_id == @user.site_id + error!('Unauthorized', 401) + end { document_inferences: document.document_inferences.order(id: :asc) } end @@ -97,8 +103,12 @@ class API < Grape::API::Instance end post "/documents/:id/inference" do status 201 + document = Document.find(params[:id]) + unless @user.is_site_admin || document.site_id == @user.site_id + error!('Unauthorized', 401) + end if params[:inference_type] == "summary" - inference = DocumentInference.create(document_id: params[:id], inference_type: "summary") + inference = document.document_inferences.create(inference_type: "summary") inference.inference_value = params[:result]["summary"] inference.is_active = true inference.save! @@ -107,7 +117,7 @@ class API < Grape::API::Instance ["individualized", "archival", "application", "third_party"].each do |type| result_boolean = "is_#{type}" unless params[:result][result_boolean].nil? - inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}") + inference = document.document_inferences.create(inference_type: "exception:#{result_boolean}") inference.inference_value = params[:result][result_boolean] ? "True" : "False" inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] inference.inference_reason = params[:result]["why_#{type}"] From 6d446d4cbf031a8912e8f35ace2181859cf58de5 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 08:53:00 -0600 Subject: [PATCH 15/19] Refactor tests for new endpoints. --- spec/factories/document_inferences.rb | 13 ++ spec/requests/api/sites_spec.rb | 213 +++++++++++--------------- 2 files changed, 101 insertions(+), 125 deletions(-) create mode 100644 spec/factories/document_inferences.rb diff --git a/spec/factories/document_inferences.rb b/spec/factories/document_inferences.rb new file mode 100644 index 00000000..12fb0ee0 --- /dev/null +++ b/spec/factories/document_inferences.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :document_inference do + creation_date { Time.current } + inference_type { "exception:is_application" } + inference_value { "True" } + inference_confidence { 0.85 } + inference_reason { "This is an event flyer for a for a croquet party." } + is_active { true } + inference_model_name { "gemini-2.0-flash" } + token_details { "input_tokens: 150, output_tokens: 25" } + document + end +end \ No newline at end of file diff --git a/spec/requests/api/sites_spec.rb b/spec/requests/api/sites_spec.rb index de9737c0..7860b1b9 100644 --- a/spec/requests/api/sites_spec.rb +++ b/spec/requests/api/sites_spec.rb @@ -7,13 +7,13 @@ def app AsapPdf::API end - def auth_headers - user = User.last + def auth_headers user encoded_credentials = ActionController::HttpAuthentication::Basic.encode_credentials(user.email, "password") - {"HTTP_AUTHORIZATION" => encoded_credentials} + { "HTTP_AUTHORIZATION" => encoded_credentials } end - let!(:user) { create(:user, :site_admin) } + let!(:admin_user) { create(:user, :site_admin) } + let!(:user) { create(:user) } describe "GET /sites" do let!(:sites) { create_list(:site, 3) } @@ -22,157 +22,109 @@ def auth_headers expect(last_response.status).to eq(401) end - it "returns all sites" do - get "/sites", {}, auth_headers + it "returns all accessible sites" do + get "/sites", {}, auth_headers(admin_user) expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body).length).to eq(3) + + get "/sites", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body).length).to eq(0) + + user.site = sites[0] + user.save! + + get "/sites", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body).length).to eq(1) end it "returns sites with correct structure" do - get "/sites", {}, auth_headers + get "/sites", {}, auth_headers(admin_user) json_response = JSON.parse(last_response.body) first_site = json_response.first expect(first_site).to include( - "id", - "name", - "location", - "primary_url" - ) + "id", + "name", + "location", + "primary_url" + ) end end - describe "GET /sites/:id" do + describe "GET /sites/:id/documents" do let!(:site) { create(:site) } - context "when the site exists" do - it "returns the requested site" do - get "/sites/#{site.id}", {}, auth_headers - expect(last_response.status).to eq(200) - - json_response = JSON.parse(last_response.body) - expect(json_response["id"]).to eq(site.id) - expect(json_response["name"]).to eq(site.name) - expect(json_response["location"]).to eq(site.location) - expect(json_response["primary_url"]).to eq(site.primary_url) - end - end + let!(:document) { create_list(:document, 10, site: site) } - context "when the site does not exist" do - it "returns 404 not found" do - get "/sites/0", {}, auth_headers - expect(last_response.status).to eq(404) - end - end - end - - describe "POST /sites/:id/documents" do - let!(:site) { create(:site) } - let(:timestamp) { Time.current } - let(:valid_documents) do - [ - {url: "https://example.com/doc1.pdf", modification_date: timestamp, document_category: "Brochure"}, - {url: "https://example.com/doc2.pdf", modification_date: timestamp, document_category: "Brochure"} - ] + it "blocks access to anonymous users" do + get "/sites/#{site.id}/documents" + expect(last_response.status).to eq(401) end - context "when the site exists" do - it "blocks access to anonymous users" do - post "/sites/#{site.id}/documents", {documents: valid_documents} - expect(last_response.status).to eq(401) - end - - it "creates new documents for new URLs" do - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(2) - - expect(last_response.status).to eq(201) - - json_response = JSON.parse(last_response.body) - expect(json_response["documents"].length).to eq(2) - - first_doc = json_response["documents"].first - expect(first_doc).to include( - "id", - "url", - "document_status", - "s3_path" - ) - expect(first_doc["url"]).to eq(valid_documents.first[:url]) - expect(first_doc["document_status"]).to eq("discovered") - expect(first_doc["s3_path"]).to include(site.s3_endpoint_prefix) - end - - it "updates existing documents when modification_date changes" do - existing_doc = site.documents.create!( - url: valid_documents.first[:url], - modification_date: 1.day.ago, - file_name: "doc1.pdf", - document_status: "discovered", - document_category: "Brochure" - ) - - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(1) # Only creates one new document + it "returns all accessible documents" do + get "/sites/#{site.id}/documents", {}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(10) - expect(last_response.status).to eq(201) + get "/sites/#{site.id}/documents", {}, auth_headers(user) + expect(last_response.status).to eq(401) - existing_doc.reload - expect(existing_doc.document_status).to eq("discovered") - expect(existing_doc.modification_date).to be_within(1.second).of(timestamp) - end + user.site = site + user.save! - it "doesn't modify existing documents when modification_date hasn't changed" do - existing_doc = site.documents.create!( - url: valid_documents.first[:url], - modification_date: timestamp, - file_name: "doc1.pdf", - document_status: "discovered", - document_category: "Brochure" - ) + get "/sites/#{site.id}/documents", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(10) + end - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(1) # Only creates one new document + it "paginates" do + get "/sites/#{site.id}/documents", { "page": 0, "items_per_page": 2 }, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(2) + get "/sites/#{site.id}/documents", { "page": 4, "items_per_page": 2 }, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(2) + get "/sites/#{site.id}/documents", { "page": 5, "items_per_page": 2 }, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(0) + end + end - expect(last_response.status).to eq(201) + describe "GET /documents/:id/document_inference" do + let!(:site) { create(:site) } + let!(:document) { create(:document, site: site) } + let!(:document_inference) { create_list(:document_inference, 4, document: document) } - existing_doc.reload - expect(existing_doc.document_status).to eq("discovered") - end + it "blocks access to anonymous users" do + get "/documents/#{document.id}/document_inference" + expect(last_response.status).to eq(401) end - context "when the site does not exist" do - it "returns 404 not found" do - post "/sites/0/documents", {documents: valid_documents}, auth_headers - expect(last_response.status).to eq(404) - end - end + it "returns all accessible document inferences" do + get "/documents/#{document.id}/document_inference", {}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["document_inferences"].length).to eq(4) - context "with invalid parameters" do - it "returns 400 bad request when documents is missing" do - post "/sites/#{site.id}/documents", {}, auth_headers - expect(last_response.status).to eq(400) - end + get "/documents/#{document.id}/document_inference", {}, auth_headers(user) + expect(last_response.status).to eq(401) - it "returns 400 bad request when documents is not an array" do - post "/sites/#{site.id}/documents", {documents: "not_an_array"}, auth_headers - expect(last_response.status).to eq(400) - end + user.site = site + user.save! - it "returns 400 bad request when document is missing required fields" do - post "/sites/#{site.id}/documents", {documents: [{url: "https://example.com/doc.pdf"}]}, auth_headers - expect(last_response.status).to eq(400) - end + get "/documents/#{document.id}/document_inference", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["document_inferences"].length).to eq(4) end + end describe "POST /documents/:id/inference" do let(:timestamp) { Time.current } - let!(:document) { create(:document) } - let(:inference) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section."}} } - let(:inference_update) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section.", is_application: "True", why_application: "Test 123"}} } + let!(:site) { create(:site) } + let!(:document) { create(:document, site: site) } + let(:inference) { { inference_type: "exception", result: { is_archival: "True", why_archival: "This document is in a special archival section." } } } + let(:inference_update) { { inference_type: "exception", result: { is_archival: "True", why_archival: "This document is in a special archival section.", is_application: "True", why_application: "Test 123" } } } context "when the document receives inferences" do it "blocks access to anonymous users" do @@ -181,13 +133,24 @@ def auth_headers end it "creates new inferences" do expect { - post "/documents/#{document.id}/inference", inference, auth_headers + post "/documents/#{document.id}/inference", inference, auth_headers(admin_user) }.to change(DocumentInference, :count).by(1) expect(document.document_inferences.count).to eq(1) expect { - post "/documents/#{document.id}/inference", inference_update, auth_headers + post "/documents/#{document.id}/inference", inference_update, auth_headers(admin_user) }.to change(DocumentInference, :count).by(2) expect(document.document_inferences.count).to eq(3) + + post "/documents/#{document.id}/inference", inference_update, auth_headers(user) + expect(last_response.status).to eq(401) + expect(document.document_inferences.count).to eq(3) + + user.site = site + user.save! + + post "/documents/#{document.id}/inference", inference_update, auth_headers(user) + expect(last_response.status).to eq(201) + expect(document.document_inferences.count).to eq(5) end end end From 3e0e59d3357bc3cf83b99d5197dc3b008205e0d1 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 08:58:32 -0600 Subject: [PATCH 16/19] Fix up linting issues. --- app/api/asap_pdf/api.rb | 54 +++++++++++++-------------- spec/factories/document_inferences.rb | 2 +- spec/requests/api/sites_spec.rb | 23 ++++++------ 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index 2d19c897..6094bbf3 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -11,7 +11,7 @@ class API < Grape::API::Instance end rescue_from ActiveRecord::RecordNotFound do |e| - error!({ error: e.message }, 404) + error!({error: e.message}, 404) end desc "Return list of sites" do @@ -19,7 +19,7 @@ class API < Grape::API::Instance tags ["Sites"] produces ["application/json"] failure [[401, "Unauthorized"], [403, "Forbidden"]] - security [{ basic_auth: [] }] + security [{basic_auth: []}] end get "/sites" do if @user.is_site_admin @@ -35,17 +35,17 @@ class API < Grape::API::Instance produces ["application/json"] consumes ["application/json"] failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] named "List documents" end params do requires :id, type: Integer, desc: "Site ID" - optional :page, type: Integer, desc: 'Page number for pagination', default: 0 - optional :items_per_page, type: Integer, desc: 'Items per page', default: 25 + optional :page, type: Integer, desc: "Page number for pagination", default: 0 + optional :items_per_page, type: Integer, desc: "Items per page", default: 25 end get "/sites/:id/documents" do items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i @@ -54,7 +54,7 @@ class API < Grape::API::Instance error!("Unauthorized", 401) end site = Site.find(params[:id]) - { documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc) } + {documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc)} end desc "List document inferences for a document." do @@ -63,11 +63,11 @@ class API < Grape::API::Instance produces ["application/json"] consumes ["application/json"] failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] end params do requires :id, type: Integer, desc: "Document ID" @@ -75,9 +75,9 @@ class API < Grape::API::Instance get "/documents/:id/document_inference" do document = Document.find(params[:id]) unless @user.is_site_admin || document.site_id == @user.site_id - error!('Unauthorized', 401) + error!("Unauthorized", 401) end - { document_inferences: document.document_inferences.order(id: :asc) } + {document_inferences: document.document_inferences.order(id: :asc)} end desc "Adds or updates a document inference with type" do @@ -86,11 +86,11 @@ class API < Grape::API::Instance produces ["application/json"] consumes ["application/json"] failure [ - [400, "Bad Request - Invalid parameters"], - [401, "Unauthorized"], - [403, "Forbidden"], - [404, "Site not found"] - ] + [400, "Bad Request - Invalid parameters"], + [401, "Unauthorized"], + [403, "Forbidden"], + [404, "Site not found"] + ] end params do requires :id, type: Integer, desc: "Document ID" @@ -105,7 +105,7 @@ class API < Grape::API::Instance status 201 document = Document.find(params[:id]) unless @user.is_site_admin || document.site_id == @user.site_id - error!('Unauthorized', 401) + error!("Unauthorized", 401) end if params[:inference_type] == "summary" inference = document.document_inferences.create(inference_type: "summary") @@ -137,11 +137,11 @@ class API < Grape::API::Instance version: "1.0.0" }, tags: [ - { name: "Sites", description: "Site operations" }, - { name: "Documents", description: "Document operations" }, - { name: "Document Inferences", description: "Document Inference operations" } + {name: "Sites", description: "Site operations"}, + {name: "Documents", description: "Document operations"}, + {name: "Document Inferences", description: "Document Inference operations"} ], - models: [], + models: [] ) end end diff --git a/spec/factories/document_inferences.rb b/spec/factories/document_inferences.rb index 12fb0ee0..2ffd8b8a 100644 --- a/spec/factories/document_inferences.rb +++ b/spec/factories/document_inferences.rb @@ -10,4 +10,4 @@ token_details { "input_tokens: 150, output_tokens: 25" } document end -end \ No newline at end of file +end diff --git a/spec/requests/api/sites_spec.rb b/spec/requests/api/sites_spec.rb index 7860b1b9..96e14bd8 100644 --- a/spec/requests/api/sites_spec.rb +++ b/spec/requests/api/sites_spec.rb @@ -9,7 +9,7 @@ def app def auth_headers user encoded_credentials = ActionController::HttpAuthentication::Basic.encode_credentials(user.email, "password") - { "HTTP_AUTHORIZATION" => encoded_credentials } + {"HTTP_AUTHORIZATION" => encoded_credentials} end let!(:admin_user) { create(:user, :site_admin) } @@ -45,11 +45,11 @@ def auth_headers user first_site = json_response.first expect(first_site).to include( - "id", - "name", - "location", - "primary_url" - ) + "id", + "name", + "location", + "primary_url" + ) end end @@ -79,13 +79,13 @@ def auth_headers user end it "paginates" do - get "/sites/#{site.id}/documents", { "page": 0, "items_per_page": 2 }, auth_headers(admin_user) + get "/sites/#{site.id}/documents", {page: 0, items_per_page: 2}, auth_headers(admin_user) expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)["documents"].length).to eq(2) - get "/sites/#{site.id}/documents", { "page": 4, "items_per_page": 2 }, auth_headers(admin_user) + get "/sites/#{site.id}/documents", {page: 4, items_per_page: 2}, auth_headers(admin_user) expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)["documents"].length).to eq(2) - get "/sites/#{site.id}/documents", { "page": 5, "items_per_page": 2 }, auth_headers(admin_user) + get "/sites/#{site.id}/documents", {page: 5, items_per_page: 2}, auth_headers(admin_user) expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)["documents"].length).to eq(0) end @@ -116,15 +116,14 @@ def auth_headers user expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body)["document_inferences"].length).to eq(4) end - end describe "POST /documents/:id/inference" do let(:timestamp) { Time.current } let!(:site) { create(:site) } let!(:document) { create(:document, site: site) } - let(:inference) { { inference_type: "exception", result: { is_archival: "True", why_archival: "This document is in a special archival section." } } } - let(:inference_update) { { inference_type: "exception", result: { is_archival: "True", why_archival: "This document is in a special archival section.", is_application: "True", why_application: "Test 123" } } } + let(:inference) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section."}} } + let(:inference_update) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section.", is_application: "True", why_application: "Test 123"}} } context "when the document receives inferences" do it "blocks access to anonymous users" do From 4d15e99733c330138e2c5cb4c63d1d0462b84670 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 09:05:11 -0600 Subject: [PATCH 17/19] Remove future fields from factory. --- spec/factories/document_inferences.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/factories/document_inferences.rb b/spec/factories/document_inferences.rb index 2ffd8b8a..d652d90e 100644 --- a/spec/factories/document_inferences.rb +++ b/spec/factories/document_inferences.rb @@ -6,8 +6,6 @@ inference_confidence { 0.85 } inference_reason { "This is an event flyer for a for a croquet party." } is_active { true } - inference_model_name { "gemini-2.0-flash" } - token_details { "input_tokens: 150, output_tokens: 25" } document end end From 10af3388252567ef508c3e8fd8082a1c19d18a2b Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 09:27:37 -0600 Subject: [PATCH 18/19] Fix tests by matching new routes and using required fields. --- app/api/asap_pdf/api.rb | 4 ++-- app/controllers/documents_controller.rb | 2 +- spec/features/document_spec.rb | 22 +++++++++++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index 6094bbf3..0e3850ed 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -108,7 +108,7 @@ class API < Grape::API::Instance error!("Unauthorized", 401) end if params[:inference_type] == "summary" - inference = document.document_inferences.create(inference_type: "summary") + inference = document.document_inferences.new(inference_type: "summary") inference.inference_value = params[:result]["summary"] inference.is_active = true inference.save! @@ -117,7 +117,7 @@ class API < Grape::API::Instance ["individualized", "archival", "application", "third_party"].each do |type| result_boolean = "is_#{type}" unless params[:result][result_boolean].nil? - inference = document.document_inferences.create(inference_type: "exception:#{result_boolean}") + inference = document.document_inferences.new(inference_type: "exception:#{result_boolean}") inference.inference_value = params[:result][result_boolean] ? "True" : "False" inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] inference.inference_reason = params[:result]["why_#{type}"] diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 8651f78b..67ba1251 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -7,7 +7,7 @@ class DocumentsController < AuthenticatedController before_action :set_site, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] before_action :set_document, except: [:index, :insights, :audit_exports, :batch_update] before_action :ensure_user_site_access, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] - before_action :ensure_user_document_access, except: [:index, :modal_content, :batch_update] + before_action :ensure_user_document_access, except: [:index, :insights, :audit_exports, :modal_content, :batch_update] def modal_content render partial: "modal_content", locals: {document: @document} diff --git a/spec/features/document_spec.rb b/spec/features/document_spec.rb index fb8df1a7..bbbd233c 100644 --- a/spec/features/document_spec.rb +++ b/spec/features/document_spec.rb @@ -485,7 +485,7 @@ click_button "Apply Filters" end sleep(1) - assert_match "sites/#{site.id}/insights?category=Agreement&department=Public+Transportation", current_url + assert_match "sites/#{site.id}/documents/insights?category=Agreement&department=Public+Transportation", current_url # Look for general content. within("#insights") do expect(page).to have_content "\nTYPE NEEDS DECISION IN REVIEW DONE TOTAL\nAgreement 1 0 0 1" @@ -505,7 +505,7 @@ teahouse_doc.save market_doc.accessibility_recommendation = Document::ARCHIVE_DECISION market_doc.save - visit "/sites/#{site.id}/insights?category=Agreement&department=Public+Transportation&accessibility_decision=Remediate" + visit "/sites/#{site.id}/documents/insights?category=Agreement&department=Public+Transportation&accessibility_decision=Remediate" within("#insights #chart-modification-year") do find(".dropdown .btn").click click_link "2018-2023" @@ -513,7 +513,7 @@ sleep(1) assert_match "sites/#{site.id}/documents?accessibility_decision=Remediate&category=Agreement&department=Public+Transportation&end_date=2023-12-31&start_date=2018-01-01", current_url - visit "/sites/#{site.id}/insights?category=Notice&department=Parks+and+Recreation&status=Archive" + visit "/sites/#{site.id}/documents/insights?category=Notice&department=Parks+and+Recreation&status=Archive" within("#insights #chart-decision") do find(".dropdown .btn").click click_link "Archive" @@ -588,8 +588,20 @@ expect(page).to have_no_content "Feedback on AI Response:" end DocumentInference.new(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.").save - DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id) - DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id) + DocumentInference.create( + inference_type: "exception:is_application", + inference_value: "True", + inference_reason: "This is not used as an application or means of participation in government services.", + document_id: doc.id, + is_active: true + ) + DocumentInference.create( + inference_type: "exception:is_archival", + inference_value: "True", + inference_reason: "This thing was made in 1988 and hasn't been opened since then.", + document_id: doc.id, + is_active: true + ) visit "/" click_link "City of Denver" within("#document-list") do From 8ee3313a79270e05d0e2e69d74635e47cb439492 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 27 Aug 2025 09:32:36 -0600 Subject: [PATCH 19/19] Add a light test for exports. --- spec/features/document_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/features/document_spec.rb b/spec/features/document_spec.rb index bbbd233c..ef2581a9 100644 --- a/spec/features/document_spec.rb +++ b/spec/features/document_spec.rb @@ -666,4 +666,21 @@ expect(FeedbackItem.count).to eq 5 end end + + it "exports documents" do + site = Site.create(name: "City of Denver", location: "Colorado", primary_url: "https://denvergov.org") + @current_user.site = site + @current_user.save! + Document.create(url: "http://denvergov.org/docs/ex.ample.pdf", file_name: "ex.ample.pdf", document_category: "Agenda", site_id: site.id) + visit "/" + click_link "City of Denver" + within("#document-list #document-tabs") do + click_link "Audit Exports" + sleep(1) + end + assert_match "sites/#{site.id}/documents/audit_exports", current_url + expect(page).to have_content "Create Audit Export" + expect(page).to have_content "Use the following RESTful endpoint to get your audit history." + expect(page).to have_content "authorization: Basic [Your base64 encoded credentials]" + end end