Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ The GA4 integration lives in `app/lib/ga_response_builder.rb`. Each metric secti

### DPLA API

The DPLA API (`api.dp.la/v2/`) is used only to resolve contributor names for item IDs not yet in the S3 cache (`ItemDataProviders`). Hub and contributor item counts, and the contributor lists used throughout the dashboard, come from `hub_stats.json` in S3 (see below).
The DPLA API (`api.dp.la/v2/`) is used for two things: resolving contributor names for item IDs not yet in the S3 cache (`ItemDataProviders`), and live lookups of which DPLA exhibitions and primary source sets hold an institution's items. The index stamps `exhibitions` and `primarySourceSets` slugs onto items (added to the ingestion pipeline in August 2026); the dashboard reads them two ways:

- `DplaApiResponseBuilder#curated_breakdown` facets per institution (`facets=exhibitions&provider.name="..."&page_size=0`). The data menu uses it to disable "Exhibition views" and "Primary source set views" when nothing is there. Results are memoized per kind, hub, and contributor, so the menu's two checks cost one call each. 3-second timeout, no retries (to avoid excessive API calls).
- `DplaApiResponseBuilder#curated_memberships_for_items` resolves the items on each page of the exhibition and source set views tables (`id=a OR b ...&fields=id,exhibitions`). Each row shows which exhibition or set holds the item, linked by slug. The API caps each field parameter at 200 characters, so the OR list holds five IDs per request (ten requests for a full 50-row table page).

Hub and contributor item counts, and the contributor lists used throughout the dashboard, come from `hub_stats.json` in S3 (see below).

The source of truth for which hubs and contributors exist, and their Wikidata IDs, is a separate JSON file maintained by the ingestion team: [`institutions_v2.json`](https://raw.githubusercontent.com/dpla/ingestion3/main/src/main/resources/wiki/institutions_v2.json). This file is used by the Wikimedia cache builder but not directly by the API-based item count queries.

Expand Down
15 changes: 15 additions & 0 deletions app/assets/stylesheets/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ nav .divider {
background-color: #d7ebf4
}

.data-menu span.disabled {
color: #a3a5a6;
border-bottom: 1px solid #e9dec8;
display: block;
padding: 10px;
cursor: default;
}

.item-table .curated-membership {
font-size: 0.85em;
color: #767879;
margin-top: 2px;
padding-left: 4px;
}

.alert-banner {
clear: both;
background-color: #fef9ec;
Expand Down
43 changes: 43 additions & 0 deletions app/helpers/curated_content_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module CuratedContentHelper

CURATED_PAGE_URLS = {
exhibitions: "https://dp.la/exhibitions",
primary_source_sets: "https://dp.la/primary-source-sets",
}.freeze

##
# Which exhibitions or primary source sets hold the institution's items,
# as { slug => item count }
# nil when the API call failed
#
# @param target [Hub|Contributor]
# @param kind [Symbol] :exhibitions or :primary_source_sets
#
def curated_breakdown_for(target, kind)
hub, contributor = target.is_a?(Hub) ?
[target.name, nil] : [target.hub.name, target.name]

@curated_breakdowns ||= {}
key = [kind, hub, contributor]
@curated_breakdowns.fetch(key) do
@curated_breakdowns[key] =
DplaApiResponseBuilder.new.curated_breakdown(kind, hub, contributor)
end
end

# Fails open: an API failure counts as participation, so links never
# wrongly disable.
def curated_participant?(target, kind)
breakdown = curated_breakdown_for(target, kind)
breakdown.nil? || breakdown.any?
end

def curated_page_url(kind, slug)
"#{CURATED_PAGE_URLS.fetch(kind)}/#{slug}"
end

def curated_noun(kind, count)
noun = kind == :exhibitions ? "exhibition" : "primary source set"
count == 1 ? noun : noun.pluralize
end
end
64 changes: 22 additions & 42 deletions app/helpers/data_menu_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,6 @@ def render_overview_link(target)
link_to("Overview", path, html_opts(path))
end

def render_website_timelines_link(target)
path = target.is_a?(Hub) ?
hub_timeline_path(route_id(target.name), 'website', date_opts) :
hub_contributor_timeline_path(route_id(target.hub.name), route_id(target.name), 'website', date_opts)

link_to("DPLA website use timelines", path, html_opts(path))
end

def render_api_timelines_link(target)
path = target.is_a?(Hub) ?
hub_timeline_path(route_id(target.name), 'api', date_opts) :
hub_contributor_timeline_path(route_id(target.hub.name), route_id(target.name), 'api', date_opts)

link_to("API use timelines", path, html_opts(path))
end

def render_locations_link(target)
path = target.is_a?(Hub) ?
hub_locations_path(route_id(target.name), date_opts) :
hub_contributor_locations_path(route_id(target.hub.name), route_id(target.name), date_opts)

link_to("DPLA website user locations", path, html_opts(path))
end

def render_view_item_link(target)
path = target.is_a?(Hub) ?
hub_event_path(route_id(target.name), 'view_item', date_opts) :
Expand All @@ -41,6 +17,10 @@ def render_view_item_link(target)
end

def render_view_exhibit_link(target)
unless curated_participant?(target, :exhibitions)
return disabled_menu_item("Exhibition views", "No items in DPLA exhibitions")
end

path = target.is_a?(Hub) ?
hub_event_path(route_id(target.name), 'view_exhibit', date_opts) :
hub_contributor_event_path(route_id(target.hub.name), route_id(target.name), 'view_exhibit', date_opts)
Expand All @@ -49,6 +29,10 @@ def render_view_exhibit_link(target)
end

def render_view_pss_link(target)
unless curated_participant?(target, :primary_source_sets)
return disabled_menu_item("Primary source set views", "No items in DPLA primary source sets")
end

path = target.is_a?(Hub) ?
hub_event_path(route_id(target.name), 'view_pss', date_opts) :
hub_contributor_event_path(route_id(target.hub.name), route_id(target.name), 'view_pss', date_opts)
Expand All @@ -64,32 +48,28 @@ def render_click_through_link(target)
link_to("DPLA website click throughs", path, html_opts(path))
end

def render_view_api_link(target)
path = target.is_a?(Hub) ?
hub_event_path(route_id(target.name), 'view_api', date_opts) :
hub_contributor_event_path(route_id(target.hub.name), route_id(target.name), 'view_api', date_opts)

link_to("API item views", path, html_opts(path))
end

def render_website_terms_link
path = search_term_path('website', date_opts)
link_to("Website", path, html_opts(path))
end

def render_api_terms_link
path = search_term_path('api', date_opts)
link_to("API", path, html_opts(path))
end

def render_wikimedia_readiness_link(target)
unless wikimedia_participant?(target)
return disabled_menu_item("Wikimedia readiness", "Not a Wikimedia pipeline participant")
end

path = target.is_a?(Hub) ?
hub_wikimedia_preparations_path(route_id(target.name), date_opts) :
hub_contributor_wikimedia_preparations_path(route_id(target.hub.name), route_id(target.name), date_opts)

link_to("Wikimedia readiness", path, html_opts(path))
end

def wikimedia_participant?(target)
target.is_a?(Hub) ?
WikimediaParticipant.hub_participant?(target.name) :
WikimediaParticipant.participant?(target.hub.name, target.name)
end

def disabled_menu_item(label, title)
content_tag(:span, label, class: "disabled", title: title)
end

##
# Set HTML class to selected if the given path matches the current request
# path. Parameters (e.g. start_date and end_date) are ignored.
Expand Down
30 changes: 28 additions & 2 deletions app/helpers/tooltips_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ def view_metadata_record_tooltip
end

def view_exhibition_tooltip
"One of your items was viewed within a curated DPLA exhibition.
"A user viewed one of your items in a DPLA exhibition.
Comparable to \"pageview\" in Google Analytics."
end

def view_primary_source_set_tooltip
"One of your items was viewed within a curated DPLA primary source set.
"A user viewed one of your items in a DPLA primary source set.
Comparable to \"pageview\" in Google Analytics."
end

Expand Down Expand Up @@ -139,6 +139,32 @@ def wikimedia_views_total_tooltip
"All-time total page views of Wikimedia pages featuring files uploaded from your collection."
end

# Fuller copy for the table pages; the short forms above stay as tooltips.

def view_exhibition_description
"Views of your items on DPLA exhibition pages. Each row is one of your
items; the count is how many times visitors viewed it in an exhibition. Comparable to \"pageview\" in
Google Analytics."
end

def view_primary_source_set_description
"Views of your items on DPLA primary source set pages. Each source in a
set has its own page featuring one item; the count is how many times
visitors viewed your item's page.
Comparable to \"pageview\" in Google Analytics."
end

def find_event_page_description(key)
case key
when "view_exhibit"
view_exhibition_description
when "view_pss"
view_primary_source_set_description
else
find_tooltip(key)
end
end

def find_tooltip(key)
case key
when "view_item"
Expand Down
83 changes: 83 additions & 0 deletions app/lib/dpla_api_response_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,89 @@ def data_providers_for_items(ids)
result
end

# Fields the DPLA index stamps onto items in curated content.
CURATED_FIELDS = {
exhibitions: 'exhibitions',
primary_source_sets: 'primarySourceSets',
}.freeze

# Covers all 32 exhibitions and 142 source sets.
CURATED_FACET_SIZE = 200

# The API caps each parameter at 200 characters,
# which fits five 32-character IDs.
CURATED_ID_BATCH = 5

# Runs during page render, so one short attempt and no retries.
CURATED_TIMEOUT_SECONDS = 3

##
# Which exhibitions or primary source sets hold an institution's items.
#
# @param kind [Symbol] :exhibitions or :primary_source_sets
# @param hub [String] provider name
# @param contributor [String, nil] dataProvider name
# @return [Hash<String, Integer>, nil] { slug => item count }; {} when
# there are none, nil when the request failed so callers can fail open.
#
def curated_breakdown(kind, hub, contributor = nil)
field = CURATED_FIELDS.fetch(kind)
query = {
'facets' => field,
'facet_size' => CURATED_FACET_SIZE,
'provider.name' => %("#{hub.delete('"')}"),
'page_size' => 0,
'api_key' => api_key,
}
query['dataProvider.name'] = %("#{contributor.delete('"')}") if contributor

res = self.class.get('/items', query: query, timeout: CURATED_TIMEOUT_SECONDS)
return nil unless res.code == 200

terms = JSON.parse(res.body).dig('facets', field, 'terms') || []
terms.to_h { |term| [term['term'], term['count'].to_i] }
rescue StandardError => e
Rails.logger.warn("DplaApiResponseBuilder#curated_breakdown: #{e.class}: #{e.message}")
nil
end

##
# Which curated content holds each of the given items. Uses the search
# endpoint rather than the multi-ID path endpoint, which errors on some
# mixes of IDs no longer in the index.
#
# @param kind [Symbol] :exhibitions or :primary_source_sets
# @param ids [Array<String>] DPLA item hex IDs
# @return [Hash<String, Array<String>>] { item_id => [slug, ...] }; items
# with no membership are omitted, and {} on failure — the annotation is
# optional, so callers carry on without it.
#
def curated_memberships_for_items(kind, ids)
result = {}
field = CURATED_FIELDS.fetch(kind)

ids.filter_map { |id| id&.strip.presence }.uniq.each_slice(CURATED_ID_BATCH) do |batch|
query = {
'id' => batch.join(' OR '),
'fields' => "id,#{field}",
'page_size' => batch.size,
'api_key' => api_key,
}
res = self.class.get('/items', query: query, timeout: CURATED_TIMEOUT_SECONDS)
next unless res.code == 200

(JSON.parse(res.body)['docs'] || []).each do |doc|
# `fields` returns a lone membership as a bare string, not an array.
slugs = Array(doc[field]).map(&:to_s).reject(&:empty?)
result[doc['id']] = slugs if doc['id'] && slugs.any?
end
end
result
rescue StandardError => e
Rails.logger.warn("DplaApiResponseBuilder#curated_memberships_for_items: #{e.class}: #{e.message}")
result
end

private

def api_key
Expand Down
7 changes: 7 additions & 0 deletions app/lib/ga_response_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,11 @@ def id(row)
def title(row)
event_label(row)&.split(" : ", 2)&.last&.strip
end

# Overridden where a table shows curated content.
def membership_kind; end

def memberships(_row)
[]
end
end
26 changes: 26 additions & 0 deletions app/lib/website_events_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

class WebsiteEventsPresenter < GaResponsePresenter

MEMBERSHIP_KINDS = {
"View Exhibition Item" => :exhibitions,
"View Primary Source" => :primary_source_sets,
}.freeze

def label
dict = {
"View Item" => "Digital library catalog views",
Expand All @@ -26,6 +31,21 @@ def count(row)
row[columns.index("ga:totalEvents")]
end

# nil unless this table shows curated content.
def membership_kind
MEMBERSHIP_KINDS[@ga_response.event_name]
end

##
# Curated-content slugs for the row's item, e.g. ["erie-canal"].
# Empty for other tables, which never hit the API.
#
def memberships(row)
return [] unless membership_kind

membership_lookup[id(row)] || []
end

##
# Generate CSV of all events
# @return [CSV]
Expand Down Expand Up @@ -72,4 +92,10 @@ def data_providers
def item_contributor_lookup
@item_contributor_lookup ||= contributor_lookup(rows)
end

# One lookup for the whole displayed page.
def membership_lookup
@membership_lookup ||= DplaApiResponseBuilder.new.curated_memberships_for_items(
membership_kind, rows.filter_map { |row| id(row) })
end
end
Loading