Add API for on-demand generation of annual reports (#37055)

This commit is contained in:
Claire
2025-12-02 14:37:05 +01:00
committed by GitHub
parent 9aec6936e5
commit f8422e1fa4
7 changed files with 236 additions and 5 deletions

View File

@@ -1,10 +1,12 @@
# frozen_string_literal: true
class Api::V1::AnnualReportsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
include AsyncRefreshesConcern
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 :set_annual_report, except: :index
before_action :set_annual_report, only: [:show, :read]
def index
with_read_replica do
@@ -28,14 +30,59 @@ class Api::V1::AnnualReportsController < Api::BaseController
relationships: @relationships
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
@annual_report.view!
render_empty
end
def refresh_key
"wrapstodon:#{current_account.id}:#{year}"
end
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
@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

View File

@@ -18,6 +18,13 @@ class AnnualReport
'annual_report_'
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)
@account = account
@year = year

View File

@@ -12,7 +12,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :icon, :languages, :configuration,
:registrations, :api_versions
:registrations, :api_versions, :wrapstodon
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer
@@ -134,6 +134,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
Mastodon::Version.api_versions
end
def wrapstodon
AnnualReport.current_campaign
end
private
def registrations_enabled?

View File

@@ -4,7 +4,11 @@ class GenerateAnnualReportWorker
include Sidekiq::Worker
def perform(account_id, year)
async_refresh = AsyncRefresh.new("wrapstodon:#{account_id}:#{year}}")
AnnualReport.new(Account.find(account_id), year).generate
async_refresh&.finish!
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
true
end

View File

@@ -72,6 +72,8 @@ namespace :api, format: false do
resources :annual_reports, only: [:index, :show] do
member do
post :read
post :generate
get :state
end
end

View File

@@ -42,6 +42,153 @@ RSpec.describe 'API V1 Annual Reports' do
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
context 'with correct scope' do
let(:scopes) { 'write:accounts' }

View File

@@ -42,6 +42,26 @@ RSpec.describe 'Instances' do
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
include(
configuration: include(