diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb index b1aee288dd..724c7658d7 100644 --- a/app/controllers/api/v1/annual_reports_controller.rb +++ b/app/controllers/api/v1/annual_reports_controller.rb @@ -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 diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb index 035dc4cde7..6da9ecae14 100644 --- a/app/lib/annual_report.rb +++ b/app/lib/annual_report.rb @@ -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 diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 36ddfae328..75d3acfea5 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -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? diff --git a/app/workers/generate_annual_report_worker.rb b/app/workers/generate_annual_report_worker.rb index 7094c1ab9c..07e7298e6f 100644 --- a/app/workers/generate_annual_report_worker.rb +++ b/app/workers/generate_annual_report_worker.rb @@ -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 diff --git a/config/routes/api.rb b/config/routes/api.rb index 32685d791f..48e960af44 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/spec/requests/api/v1/annual_reports_spec.rb b/spec/requests/api/v1/annual_reports_spec.rb index 88a7dbdd82..482e91736c 100644 --- a/spec/requests/api/v1/annual_reports_spec.rb +++ b/spec/requests/api/v1/annual_reports_spec.rb @@ -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' } diff --git a/spec/requests/api/v2/instance_spec.rb b/spec/requests/api/v2/instance_spec.rb index 92a9744e41..50798612e8 100644 --- a/spec/requests/api/v2/instance_spec.rb +++ b/spec/requests/api/v2/instance_spec.rb @@ -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(