Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
7fc740e
update bundler
tmfrnz Jan 5, 2026
adfffc3
add public_api columns and update models, policies and serialisers ac…
tmfrnz Jan 5, 2026
ae1b425
remove obsolete tests
tmfrnz Jan 5, 2026
efaedad
add new tests, update factories
tmfrnz Jan 5, 2026
6e5e16c
tweak error messages
tmfrnz Jan 6, 2026
3d2ebfe
tweak error messages 2
tmfrnz Jan 6, 2026
0126e52
cleanup routes, make API-only application explicit
tmfrnz Jan 6, 2026
c601050
remove obsolete csrf protetion
tmfrnz Jan 6, 2026
8574541
remove obsolete layout and rendering
tmfrnz Jan 6, 2026
bf2ad27
disable active storage processing
tmfrnz Jan 6, 2026
6fe1f12
enable cookies
tmfrnz Jan 6, 2026
bb7b439
be specific about required frameworks
tmfrnz Jan 6, 2026
16ba837
remove obsolete channels
tmfrnz Jan 6, 2026
8f716f6
add API fields and endpoint for topics
tmfrnz Jan 6, 2026
78a67ab
afix public controller
tmfrnz Jan 6, 2026
b3b28a7
add API fields and endpoint for statements
tmfrnz Jan 6, 2026
6a61b6b
split controllers for statements and topics, add route
tmfrnz Jan 6, 2026
9d56d36
fix statement controller
tmfrnz Jan 6, 2026
1b8ee53
fix statement controller
tmfrnz Jan 6, 2026
4e12130
add public countries endpoint, improve statements/topics
tmfrnz Jan 7, 2026
55cc6b5
fix public topics controller
tmfrnz Jan 7, 2026
c5ed791
add public countries-statements endpoint
tmfrnz Jan 7, 2026
6671f35
fix public country-statement controller
tmfrnz Jan 7, 2026
c1c776c
add public topics field description_api
tmfrnz Jan 7, 2026
6681869
add return check to controller to fix doublerender issue
tmfrnz Jan 7, 2026
a994774
make sure groups are public
tmfrnz Jan 7, 2026
b76f9c2
also refresh statements api if related content changes
tmfrnz Jan 8, 2026
a13d1b7
fix: handle nil relationship_updated_at
tmfrnz Jan 8, 2026
a304c55
fix: pluck topic ids later
tmfrnz Jan 8, 2026
8b67ac4
fix: pluck topic ids later
tmfrnz Jan 8, 2026
3397558
fix: var name
tmfrnz Jan 8, 2026
d8b77ea
add debug
tmfrnz Jan 8, 2026
5a04f67
add debug (2)
tmfrnz Jan 8, 2026
84f4d9b
fix cache issue
tmfrnz Jan 8, 2026
9e7bcbb
improve caching
tmfrnz Jan 8, 2026
ebb0aea
add topics/code_api and statements/is_official
tmfrnz Jan 9, 2026
8abdabf
update rspec
tmfrnz Jan 9, 2026
e7e9f55
add event id to statements endpoint
tmfrnz Jan 11, 2026
52d8e31
fix event type id
tmfrnz Jan 11, 2026
502b5c3
fix parent association
tmfrnz Jan 11, 2026
b45011c
fix controller includes
tmfrnz Jan 11, 2026
21c1112
make sure actor-statement relationships are only included once - skip…
tmfrnz Jan 11, 2026
95305b3
make sure actor-statement relationships are only included once - skip…
tmfrnz Jan 11, 2026
af4c83e
make sure actor-statement relationships are only included once - skip…
tmfrnz Jan 11, 2026
1dc6a6f
merge re-add-smtp
tmfrnz Jan 11, 2026
1f3b560
allow redirects
tmfrnz Jan 11, 2026
2bae73a
also allow coordinators to set public_api
tmfrnz Jan 12, 2026
3c8e9c8
disable unused routes and indicator fields for now
tmfrnz Jan 12, 2026
e37e721
add parent id to indicators to support aggregate topics
tmfrnz Jan 16, 2026
f17680e
limit updating of published statements and relationships
tmfrnz Jan 20, 2026
bcb25bd
user edit: check outdated
tmfrnz Jan 22, 2026
5b0c69b
user edit: fix check outdated
tmfrnz Jan 22, 2026
2d96883
publish only statements with strong support
tmfrnz Apr 16, 2026
fac660b
add has_precendence field
tmfrnz Apr 17, 2026
d06c77a
run standard --fix
tmfrnz Apr 17, 2026
81a883e
delete duplicate method
tmfrnz Apr 17, 2026
c01bf3e
add has_precendence field (2)
tmfrnz Apr 17, 2026
7bf519f
revert only publish strong statements
tmfrnz Apr 17, 2026
031881f
add precedence info to API
tmfrnz Apr 17, 2026
aa0daaa
only show `supportive` as green
tmfrnz May 6, 2026
61ce134
Merge pull request #105 from dumparkltd/public-api-precendence
tmfrnz May 6, 2026
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
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ GEM
debug_inspector (>= 0.0.1)
brakeman (5.1.1)
builder (3.3.0)
bundler-audit (0.9.0.1)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
byebug (11.1.3)
capybara (3.37.1)
Expand Down Expand Up @@ -478,7 +478,7 @@ DEPENDENCIES
web-console (~> 4.1)

RUBY VERSION
ruby 3.4.7p58
ruby 3.4.7p58

BUNDLED WITH
2.3.22
4.0.3
6 changes: 0 additions & 6 deletions app/channels/application_cable/channel.rb

This file was deleted.

6 changes: 0 additions & 6 deletions app/channels/application_cable/connection.rb

This file was deleted.

2 changes: 2 additions & 0 deletions app/controllers/actor_measures_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class ActorMeasuresController < ApplicationController
skip_before_action :authorize!, only: [:create]

# GET /actor_measures/:id
def show
authorize @actor_measure
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/api/v1/countries_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Api
module V1
class CountriesController < ActionController::API
def index
countries = Actor.public_countries

last_updated = Actor.where(actortype_id: Actor::COUNTRY_TYPE_ID).maximum(:updated_at)

expires_in 0, public: true
fresh_when(
etag: last_updated,
last_modified: last_updated
)

return if performed?

cache_key = "public/v1/countries/#{last_updated.to_i}/#{countries.count}"
json = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
countries.order(:code).map do |country|
{
gpat_id: country.id,
code: country.code,
name: country.title,
updated_at: country.updated_at
}
end.to_json
end

render json: json
end
end
end
end
113 changes: 113 additions & 0 deletions app/controllers/api/v1/country_statements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true
module Api
module V1
class CountryStatementsController < ActionController::API
def index
countries = Actor.public_countries
statements = Measure.public_statements

# Get all relevant timestamps for cache invalidation
country_max = Actor.where(actortype_id: Actor::COUNTRY_TYPE_ID).maximum(:updated_at)
group_max = Actor.where(actortype_id: Actor::GROUP_TYPE_ID).maximum(:updated_at)
statement_max = Measure.where(measuretype_id: Measure::STATEMENT_TYPE_ID).maximum(:updated_at)
actor_measure_max = ActorMeasure.maximum(:updated_at)
membership_max = Membership.maximum(:updated_at)
country_relationship_max = countries.maximum(:relationship_updated_at) || Time.at(0)
group_relationship_max = Actor.where(actortype_id: Actor::GROUP_TYPE_ID).maximum(:relationship_updated_at) || Time.at(0)

last_updated = [
country_max,
group_max,
statement_max,
actor_measure_max,
membership_max,
country_relationship_max,
group_relationship_max
].compact.max

expires_in 0, public: true
fresh_when(
etag: last_updated,
last_modified: last_updated
)
return if performed?

cache_key = "public/v1/country_statements/#{last_updated.to_i}/#{statements.count}/#{countries.count}"
json = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
results = []

# 1. Direct country-statement relationships
direct_relationships = ActorMeasure
.joins("INNER JOIN actors ON actors.id = actor_measures.actor_id")
.joins("INNER JOIN measures ON measures.id = actor_measures.measure_id")
.where(actors: { id: countries.pluck(:id) })
.where(measures: { id: statements.pluck(:id) })
.select(
'actors.id as country_id',
'actors.code as country_code',
'measures.id as statement_id',
'measures.code as statement_code',
'measures.date_start as date_start'
)

direct_relationships.each do |rel|
results << {
country_id: rel.country_id,
country_code: rel.country_code,
statement_id: rel.statement_id,
statement_code: rel.statement_code,
date_start: rel.date_start
}
end

# 2. Indirect relationships via group memberships
group_relationships = ActorMeasure
.joins("INNER JOIN actors AS groups ON groups.id = actor_measures.actor_id")
.joins("INNER JOIN measures ON measures.id = actor_measures.measure_id")
.joins("INNER JOIN memberships ON memberships.memberof_id = groups.id")
.joins("INNER JOIN actors AS countries ON countries.id = memberships.member_id")
.where(groups: {
actortype_id: Actor::GROUP_TYPE_ID,
is_archive: false,
private: false,
draft: false
})
.where(countries: { id: countries.pluck(:id) })
.where(measures: { id: statements.pluck(:id) })
.where("NOT EXISTS (
SELECT 1 FROM actor_measures direct
WHERE direct.actor_id = countries.id
AND direct.measure_id = measures.id
)")
.select(
'countries.id as country_id',
'countries.code as country_code',
'measures.id as statement_id',
'measures.code as statement_code',
'measures.date_start as date_start',
'groups.id as group_id',
'groups.code as group_code'
)

group_relationships.each do |rel|
results << {
country_id: rel.country_id,
country_code: rel.country_code,
statement_id: rel.statement_id,
statement_code: rel.statement_code,
via_group_id: rel.group_id,
via_group_code: rel.group_code,
date_start: rel.date_start
}
end

results.sort_by! { |r| r[:date_start] }

results.to_json
end

render json: json
end
end
end
end
96 changes: 96 additions & 0 deletions app/controllers/api/v1/statements_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

module Api
module V1
class StatementsController < ActionController::API
def index
statements = Measure
.public_statements
.includes(
measure_indicators: :indicator,
parent_measure_measures: :other_measure
)

topics = Indicator.public_topics

# Combine all relevant timestamps for cache invalidation
statement_max = Measure.where(measuretype_id: Measure::STATEMENT_TYPE_ID).maximum(:updated_at)
relationship_max = statements.maximum(:relationship_updated_at) || Time.at(0)
topic_max = Indicator.maximum(:updated_at)
statement_topic_max = statements.joins(:measure_indicators).maximum("measure_indicators.updated_at")
event_max = Measure.where(measuretype_id: Measure::EVENT_TYPE_ID).maximum(:updated_at)
measure_measure_max = MeasureMeasure.maximum(:updated_at)

last_updated = [
statement_max,
relationship_max,
topic_max,
statement_topic_max,
event_max,
measure_measure_max
].compact.max

expires_in 0, public: true

fresh_when(
etag: last_updated,
last_modified: last_updated
)
return if performed?

cache_key = "public/v1/statements/#{last_updated.to_i}/#{statements.count}/#{topics.count}"
json = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
statements.order(:code).map do |statement|
parent_event = statement.parent_measures.find { |m| m.event? }

result = {
gpat_id: statement.id,
code: statement.code,
title: statement.title,
date: statement.date_start,
url: statement.url,
quote: statement.quote_api,
source: statement.source_api,
event_id: parent_event&.id,
updated_at: statement.updated_at,
has_precedence: statement.has_precedence || false
}

# Initialize ALL public topic positions as null
topics.each do |topic|
result["position_t#{topic.code_api.presence || topic.id}"] = nil
end

# Populate actual position values where they exist
statement.measure_indicators.each do |measure_indicator|
topic = topics.find { |t| t.id == measure_indicator.indicator_id }
next unless topic

position_value = map_supportlevel_to_position(measure_indicator.supportlevel_id)
topic_key = topic.code_api.presence || topic.id
result["position_t#{topic_key}"] = position_value
end

result
end.to_json
end

render json: json
end

private

def map_supportlevel_to_position(supportlevel_id)
case supportlevel_id
when 1 then 3 # "Supportive" > "Supportive"
when 2 then 0 # "Mostly supportive" > "Have not expressed support"
when 3 then 0 # "on the fence" > "Have not expressed support"
when 4 then 0 # "Mostly opposing" > "Have not expressed support*"
when 5 then 0 # "Opposing" > "Have not expressed support*"
when 99 then 0 # "no statement" > "Have not expressed support"
else 0 # "no support"
end
end
end
end
end
41 changes: 41 additions & 0 deletions app/controllers/api/v1/topics_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Api
module V1
class TopicsController < ActionController::API
def index
topics = Indicator.public_topics

last_updated = Indicator.maximum(:updated_at)
# Always validate during development (0 minutes cache)
expires_in 0, public: true

fresh_when(
etag: last_updated,
last_modified: last_updated
)
return if performed?

# Rails cache layer (stays fast even with 0 minute browser cache)
cache_key = "public/v1/topics/#{last_updated.to_i}/#{topics.count}"
json = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
topics.order(:code).map do |topic|
{
gpat_id: topic.id,
code: topic.code,
code_api: topic.code_api,
title: topic.title,
description: topic.description_api,
teaser: topic.teaser_api,
annotation: topic.annotation_api,
short_title: topic.short_api,
updated_at: topic.updated_at
}
end.to_json
end

render json: json
end
end
end
end
19 changes: 3 additions & 16 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
# frozen_string_literal: true

class ApplicationController < ActionController::Base
class ApplicationController < ActionController::API
include ActionController::Cookies
include DeviseTokenAuth::Concerns::SetUserByToken
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

protect_from_forgery with: :exception
protect_from_forgery with: :null_session, if: proc { |c| c.request.format == "application/json" }

layout :layout_by_resource

before_action :authenticate_user!, only: [:create, :update, :destroy], unless: :devise_controller?
before_action :authorize!
after_action :verify_authorized, except: [:index], unless: :devise_controller?
Expand All @@ -34,10 +30,6 @@ def serialize(target, serializer:)
serializer.new(target).serializable_hash.to_json
end

def layout_by_resource
devise_controller? ? "authentication" : "application"
end

rescue_from ActiveRecord::RecordNotFound do |e|
render json: {error: e.message}, status: :not_found
end
Expand All @@ -58,11 +50,6 @@ def configure_permitted_parameters
private

def user_not_authorized
if request.format == "application/json"
return render json: {error: "not authorized"}, status: 403
end

flash[:error] = "You are not authorized to perform this action."
redirect_to(root_path)
render json: {error: "not authorized"}, status: :forbidden
end
end
8 changes: 0 additions & 8 deletions app/controllers/dashboards_controller.rb

This file was deleted.

2 changes: 2 additions & 0 deletions app/controllers/measure_categories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class MeasureCategoriesController < ApplicationController
skip_before_action :authorize!, only: [:create]

# GET /measure_categories
def index
@measure_categories = policy_scope(base_object).order(created_at: :desc).page(params[:page])
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/measure_indicators_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class MeasureIndicatorsController < ApplicationController
skip_before_action :authorize!, only: [:create]

# GET /measure_indicators
def index
@measure_indicators = policy_scope(base_object).order(created_at: :desc).page(params[:page])
Expand Down
Loading
Loading