Skip to content

Commit

Permalink
Merge commit '7a1f087659204e9d0cbba2de37e45b1921cefe20' into glitch-s…
Browse files Browse the repository at this point in the history
…oc/merge-upstream
  • Loading branch information
ClearlyClaire committed Jan 25, 2024
2 parents adde803 + 7a1f087 commit 70de52c
Show file tree
Hide file tree
Showing 45 changed files with 605 additions and 28 deletions.
3 changes: 0 additions & 3 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,6 @@ Rails/WhereExists:
- 'app/lib/delivery_failure_tracker.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/suspicious_sign_in_detector.rb'
- 'app/models/poll.rb'
- 'app/models/session_activation.rb'
- 'app/models/status.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/rest/announcement_serializer.rb'
- 'app/workers/move_worker.rb'
Expand Down
30 changes: 30 additions & 0 deletions app/controllers/api/v1/annual_reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 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
before_action :require_user!
before_action :set_annual_report, except: :index

def index
with_read_replica do
@presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending)
@relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
end

render json: @presenter,
serializer: REST::AnnualReportsSerializer,
relationships: @relationships
end

def read
@annual_report.view!
render_empty
end

private

def set_annual_report
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
end
end
43 changes: 43 additions & 0 deletions app/lib/annual_report.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

class AnnualReport
include DatabaseHelper

SOURCES = [
AnnualReport::Archetype,
AnnualReport::TypeDistribution,
AnnualReport::TopStatuses,
AnnualReport::MostUsedApps,
AnnualReport::CommonlyInteractedWithAccounts,
AnnualReport::TimeSeries,
AnnualReport::TopHashtags,
AnnualReport::MostRebloggedAccounts,
AnnualReport::Percentiles,
].freeze

SCHEMA = 1

def initialize(account, year)
@account = account
@year = year
end

def generate
return if GeneratedAnnualReport.exists?(account: @account, year: @year)

GeneratedAnnualReport.create(
account: @account,
year: @year,
schema_version: SCHEMA,
data: data
)
end

private

def data
with_read_replica do
SOURCES.each_with_object({}) { |klass, hsh| hsh.merge!(klass.new(@account, @year).generate) }
end
end
end
49 changes: 49 additions & 0 deletions app/lib/annual_report/archetype.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

class AnnualReport::Archetype < AnnualReport::Source
# Average number of posts (including replies and reblogs) made by
# each active user in a single year (2023)
AVERAGE_PER_YEAR = 113

def generate
{
archetype: archetype,
}
end

private

def archetype
if (standalone_count + replies_count + reblogs_count) < AVERAGE_PER_YEAR
:lurker
elsif reblogs_count > (standalone_count * 2)
:booster
elsif polls_count > (standalone_count * 0.1) # standalone_count includes posts with polls
:pollster
elsif replies_count > (standalone_count * 2)
:replier
else
:oracle
end
end

def polls_count
@polls_count ||= base_scope.where.not(poll_id: nil).count
end

def reblogs_count
@reblogs_count ||= base_scope.where.not(reblog_of_id: nil).count
end

def replies_count
@replies_count ||= base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
end

def standalone_count
@standalone_count ||= base_scope.without_replies.without_reblogs.count
end

def base_scope
@account.statuses.where(id: year_as_snowflake_range)
end
end
22 changes: 22 additions & 0 deletions app/lib/annual_report/commonly_interacted_with_accounts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
SET_SIZE = 40

def generate
{
commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
{
account_id: account_id,
count: count,
}
end,
}
end

private

def commonly_interacted_with_accounts
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('in_reply_to_account_id, count(*) AS total'))
end
end
22 changes: 22 additions & 0 deletions app/lib/annual_report/most_reblogged_accounts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
SET_SIZE = 10

def generate
{
most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
{
account_id: account_id,
count: count,
}
end,
}
end

private

def most_reblogged_accounts
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(reblog_of_id: nil).joins(reblog: :account).group('accounts.id').having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('accounts.id, count(*) as total'))
end
end
22 changes: 22 additions & 0 deletions app/lib/annual_report/most_used_apps.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class AnnualReport::MostUsedApps < AnnualReport::Source
SET_SIZE = 10

def generate
{
most_used_apps: most_used_apps.map do |(name, count)|
{
name: name,
count: count,
}
end,
}
end

private

def most_used_apps
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).joins(:application).group('oauth_applications.name').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('oauth_applications.name, count(*) as total'))
end
end
62 changes: 62 additions & 0 deletions app/lib/annual_report/percentiles.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

class AnnualReport::Percentiles < AnnualReport::Source
def generate
{
percentiles: {
followers: (total_with_fewer_followers / (total_with_any_followers + 1.0)) * 100,
statuses: (total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100,
},
}
end

private

def followers_gained
@followers_gained ||= @account.passive_relationships.where("date_part('year', follows.created_at) = ?", @year).count
end

def statuses_created
@statuses_created ||= @account.statuses.where(id: year_as_snowflake_range).count
end

def total_with_fewer_followers
@total_with_fewer_followers ||= Follow.find_by_sql([<<~SQL.squish, { year: @year, comparison: followers_gained }]).first.total
WITH tmp0 AS (
SELECT follows.target_account_id
FROM follows
INNER JOIN accounts ON accounts.id = follows.target_account_id
WHERE date_part('year', follows.created_at) = :year
AND accounts.domain IS NULL
GROUP BY follows.target_account_id
HAVING COUNT(*) < :comparison
)
SELECT count(*) AS total
FROM tmp0
SQL
end

def total_with_fewer_statuses
@total_with_fewer_statuses ||= Status.find_by_sql([<<~SQL.squish, { comparison: statuses_created, min_id: year_as_snowflake_range.first, max_id: year_as_snowflake_range.last }]).first.total
WITH tmp0 AS (
SELECT statuses.account_id
FROM statuses
INNER JOIN accounts ON accounts.id = statuses.account_id
WHERE statuses.id BETWEEN :min_id AND :max_id
AND accounts.domain IS NULL
GROUP BY statuses.account_id
HAVING count(*) < :comparison
)
SELECT count(*) AS total
FROM tmp0
SQL
end

def total_with_any_followers
@total_with_any_followers ||= Follow.where("date_part('year', follows.created_at) = ?", @year).joins(:target_account).merge(Account.local).count('distinct follows.target_account_id')
end

def total_with_any_statuses
@total_with_any_statuses ||= Status.where(id: year_as_snowflake_range).joins(:account).merge(Account.local).count('distinct statuses.account_id')
end
end
16 changes: 16 additions & 0 deletions app/lib/annual_report/source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class AnnualReport::Source
attr_reader :account, :year

def initialize(account, year)
@account = account
@year = year
end

protected

def year_as_snowflake_range
(Mastodon::Snowflake.id_at(DateTime.new(year, 1, 1))..Mastodon::Snowflake.id_at(DateTime.new(year, 12, 31)))
end
end
30 changes: 30 additions & 0 deletions app/lib/annual_report/time_series.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

class AnnualReport::TimeSeries < AnnualReport::Source
def generate
{
time_series: (1..12).map do |month|
{
month: month,
statuses: statuses_per_month[month] || 0,
following: following_per_month[month] || 0,
followers: followers_per_month[month] || 0,
}
end,
}
end

private

def statuses_per_month
@statuses_per_month ||= @account.statuses.reorder(nil).where(id: year_as_snowflake_range).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end

def following_per_month
@following_per_month ||= @account.active_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end

def followers_per_month
@followers_per_month ||= @account.passive_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
end
end
22 changes: 22 additions & 0 deletions app/lib/annual_report/top_hashtags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class AnnualReport::TopHashtags < AnnualReport::Source
SET_SIZE = 40

def generate
{
top_hashtags: top_hashtags.map do |(name, count)|
{
name: name,
count: count,
}
end,
}
end

private

def top_hashtags
Tag.joins(:statuses).where(statuses: { id: @account.statuses.where(id: year_as_snowflake_range).reorder(nil).select(:id) }).group(:id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('COALESCE(tags.display_name, tags.name), count(*) AS total'))
end
end
21 changes: 21 additions & 0 deletions app/lib/annual_report/top_statuses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

class AnnualReport::TopStatuses < AnnualReport::Source
def generate
top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id

{
top_statuses: {
by_reblogs: top_reblogs,
by_favourites: top_favourites,
by_replies: top_replies,
},
}
end

def base_scope
@account.statuses.with_public_visibility.joins(:status_stat).where(id: year_as_snowflake_range).reorder(nil)
end
end
20 changes: 20 additions & 0 deletions app/lib/annual_report/type_distribution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

class AnnualReport::TypeDistribution < AnnualReport::Source
def generate
{
type_distribution: {
total: base_scope.count,
reblogs: base_scope.where.not(reblog_of_id: nil).count,
replies: base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
standalone: base_scope.without_replies.without_reblogs.count,
},
}
end

private

def base_scope
@account.statuses.where(id: year_as_snowflake_range)
end
end
Loading

0 comments on commit 70de52c

Please sign in to comment.