Add API for on-demand generation of annual reports (#37055)
This commit is contained in:
parent
9aec6936e5
commit
f8422e1fa4
7 changed files with 236 additions and 5 deletions
|
|
@ -1,10 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::AnnualReportsController < Api::BaseController
|
class Api::V1::AnnualReportsController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
include AsyncRefreshesConcern
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, except: [:read, :generate]
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:read, :generate]
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
before_action :set_annual_report, except: :index
|
before_action :set_annual_report, only: [:show, :read]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
with_read_replica do
|
with_read_replica do
|
||||||
|
|
@ -28,14 +30,59 @@ class Api::V1::AnnualReportsController < Api::BaseController
|
||||||
relationships: @relationships
|
relationships: @relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def state
|
||||||
|
render json: { state: report_state }
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate
|
||||||
|
return render_empty unless year == AnnualReport.current_campaign
|
||||||
|
return render_empty if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year)
|
||||||
|
|
||||||
|
async_refresh = AsyncRefresh.new(refresh_key)
|
||||||
|
|
||||||
|
if async_refresh.running?
|
||||||
|
add_async_refresh_header(async_refresh, retry_seconds: 2)
|
||||||
|
return head 202
|
||||||
|
end
|
||||||
|
|
||||||
|
add_async_refresh_header(AsyncRefresh.create(refresh_key), retry_seconds: 2)
|
||||||
|
|
||||||
|
GenerateAnnualReportWorker.perform_async(current_account.id, year)
|
||||||
|
|
||||||
|
head 202
|
||||||
|
end
|
||||||
|
|
||||||
def read
|
def read
|
||||||
@annual_report.view!
|
@annual_report.view!
|
||||||
render_empty
|
render_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def refresh_key
|
||||||
|
"wrapstodon:#{current_account.id}:#{year}"
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def report_state
|
||||||
|
return 'available' if GeneratedAnnualReport.exists?(account_id: current_account.id, year: year)
|
||||||
|
|
||||||
|
async_refresh = AsyncRefresh.new(refresh_key)
|
||||||
|
|
||||||
|
if async_refresh.running?
|
||||||
|
add_async_refresh_header(async_refresh, retry_seconds: 2)
|
||||||
|
'generating'
|
||||||
|
elsif AnnualReport.current_campaign == year && AnnualReport.new(current_account, year).eligible?
|
||||||
|
'eligible'
|
||||||
|
else
|
||||||
|
'ineligible'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def year
|
||||||
|
params[:id]&.to_i
|
||||||
|
end
|
||||||
|
|
||||||
def set_annual_report
|
def set_annual_report
|
||||||
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
|
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: year)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ class AnnualReport
|
||||||
'annual_report_'
|
'annual_report_'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.current_campaign
|
||||||
|
return unless Mastodon::Feature.wrapstodon_enabled?
|
||||||
|
|
||||||
|
datetime = Time.now.utc
|
||||||
|
datetime.year if datetime.month == 12 && (10..31).cover?(datetime.day)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(account, year)
|
def initialize(account, year)
|
||||||
@account = account
|
@account = account
|
||||||
@year = year
|
@year = year
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
attributes :domain, :title, :version, :source_url, :description,
|
attributes :domain, :title, :version, :source_url, :description,
|
||||||
:usage, :thumbnail, :icon, :languages, :configuration,
|
:usage, :thumbnail, :icon, :languages, :configuration,
|
||||||
:registrations, :api_versions
|
:registrations, :api_versions, :wrapstodon
|
||||||
|
|
||||||
has_one :contact, serializer: ContactSerializer
|
has_one :contact, serializer: ContactSerializer
|
||||||
has_many :rules, serializer: REST::RuleSerializer
|
has_many :rules, serializer: REST::RuleSerializer
|
||||||
|
|
@ -134,6 +134,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||||
Mastodon::Version.api_versions
|
Mastodon::Version.api_versions
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def wrapstodon
|
||||||
|
AnnualReport.current_campaign
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def registrations_enabled?
|
def registrations_enabled?
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ class GenerateAnnualReportWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
def perform(account_id, year)
|
def perform(account_id, year)
|
||||||
|
async_refresh = AsyncRefresh.new("wrapstodon:#{account_id}:#{year}}")
|
||||||
|
|
||||||
AnnualReport.new(Account.find(account_id), year).generate
|
AnnualReport.new(Account.find(account_id), year).generate
|
||||||
|
|
||||||
|
async_refresh&.finish!
|
||||||
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ namespace :api, format: false do
|
||||||
resources :annual_reports, only: [:index, :show] do
|
resources :annual_reports, only: [:index, :show] do
|
||||||
member do
|
member do
|
||||||
post :read
|
post :read
|
||||||
|
post :generate
|
||||||
|
get :state
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,153 @@ RSpec.describe 'API V1 Annual Reports' do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/annual_reports/:year/state' do
|
||||||
|
context 'when not authorized' do
|
||||||
|
it 'returns http unauthorized' do
|
||||||
|
get '/api/v1/annual_reports/2025/state'
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(401)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with wrong scope' do
|
||||||
|
before do
|
||||||
|
get '/api/v1/annual_reports/2025/state', headers: headers
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'forbidden for wrong scope', 'write write:accounts'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with correct scope' do
|
||||||
|
let(:scopes) { 'read:accounts' }
|
||||||
|
|
||||||
|
context 'when a report is already generated' do
|
||||||
|
before do
|
||||||
|
Fabricate(:generated_annual_report, account: user.account, year: 2025)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success and available status' do
|
||||||
|
get '/api/v1/annual_reports/2025/state', headers: headers
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to be_present
|
||||||
|
.and include(state: 'available')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the feature is not enabled' do
|
||||||
|
it 'returns http success and ineligible status' do
|
||||||
|
get '/api/v1/annual_reports/2025/state', headers: headers
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to be_present
|
||||||
|
.and include(state: 'ineligible')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the feature is enabled and time is within window', feature: :wrapstodon do
|
||||||
|
before do
|
||||||
|
travel_to Time.utc(2025, 12, 20)
|
||||||
|
|
||||||
|
status = Fabricate(:status, visibility: :public, account: user.account)
|
||||||
|
status.tags << Fabricate(:tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success and eligible status' do
|
||||||
|
get '/api/v1/annual_reports/2025/state', headers: headers
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to be_present
|
||||||
|
.and include(state: 'eligible')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the feature is enabled but we are out of the time window', feature: :wrapstodon do
|
||||||
|
before do
|
||||||
|
travel_to Time.utc(2025, 6, 20)
|
||||||
|
|
||||||
|
status = Fabricate(:status, visibility: :public, account: user.account)
|
||||||
|
status.tags << Fabricate(:tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success and ineligible status' do
|
||||||
|
get '/api/v1/annual_reports/2025/state', headers: headers
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to be_present
|
||||||
|
.and include(state: 'ineligible')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /api/v1/annual_reports/:id/generate' do
|
||||||
|
context 'when not authorized' do
|
||||||
|
it 'returns http unauthorized' do
|
||||||
|
post '/api/v1/annual_reports/2025/generate'
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(401)
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with wrong scope' do
|
||||||
|
before do
|
||||||
|
post '/api/v1/annual_reports/2025/generate', headers: headers
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'forbidden for wrong scope', 'read read:accounts'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with correct scope' do
|
||||||
|
let(:scopes) { 'write:accounts' }
|
||||||
|
|
||||||
|
context 'when the feature is enabled and time is within window', feature: :wrapstodon do
|
||||||
|
before do
|
||||||
|
travel_to Time.utc(2025, 12, 20)
|
||||||
|
|
||||||
|
status = Fabricate(:status, visibility: :public, account: user.account)
|
||||||
|
status.tags << Fabricate(:tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http accepted, create an async job and schedules a job' do
|
||||||
|
expect { post '/api/v1/annual_reports/2025/generate', headers: headers }
|
||||||
|
.to enqueue_sidekiq_job(GenerateAnnualReportWorker).with(user.account_id, 2025)
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(202)
|
||||||
|
|
||||||
|
expect(response.headers['Mastodon-Async-Refresh']).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'POST /api/v1/annual_reports/:id/read' do
|
describe 'POST /api/v1/annual_reports/:id/read' do
|
||||||
context 'with correct scope' do
|
context 'with correct scope' do
|
||||||
let(:scopes) { 'write:accounts' }
|
let(:scopes) { 'write:accounts' }
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,26 @@ RSpec.describe 'Instances' do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when wrapstodon is enabled', feature: :wrapstodon do
|
||||||
|
before do
|
||||||
|
travel_to Time.utc(2025, 12, 20)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success and the wrapstodon year' do
|
||||||
|
get api_v2_instance_path
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
|
||||||
|
expect(response.content_type)
|
||||||
|
.to start_with('application/json')
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to be_present
|
||||||
|
.and include(wrapstodon: 2025)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def include_configuration_limits
|
def include_configuration_limits
|
||||||
include(
|
include(
|
||||||
configuration: include(
|
configuration: include(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue