Election results in the London Borough of Sutton.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

268 lines
7.2 KiB

  1. #!/usr/bin/env ruby
  2. # Generate a static site
  3. # https://blog.dnsimple.com/2018/03/elapsed-time-with-ruby-the-right-way/
  4. t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  5. require 'logger'
  6. require 'haml'
  7. require_relative '../models'
  8. require_relative '../lib/helpers'
  9. OUTPUT_DIR = '_site'
  10. VIEWS_DIR = File.join('..', 'views')
  11. LAYOUT_FN = File.join(VIEWS_DIR, 'layout.haml')
  12. def write_page(path_items, template, locals = {})
  13. dir = File.join(path_items)
  14. FileUtils.mkdir_p(dir)
  15. @log.debug dir
  16. fn = File.join(dir, 'index.html')
  17. # https://stackoverflow.com/questions/6125265/using-layouts-in-haml-files-independently-of-rails
  18. html = Haml::Engine.new(File.read(LAYOUT_FN)).render do
  19. Haml::Engine.new(File.read(File.join(VIEWS_DIR, "#{template}.haml"))).render(Object.new, locals)
  20. end
  21. File.write(fn, html)
  22. @log.info fn
  23. @pages += 1
  24. # TODO - add page to sitemap.xml or sitemap.txt
  25. # https://support.google.com/webmasters/answer/183668?hl=en&ref_topic=4581190
  26. end
  27. def gen_info_pages
  28. write_page('about', 'about')
  29. write_page('guides', 'guides')
  30. write_page(%w(guides how-the-parliament-election-works), 'parliament')
  31. write_page(%w(guides how-the-council-election-works), 'election')
  32. end
  33. def gen_bodies_pages
  34. # Bodies index
  35. dir = 'bodies'
  36. FileUtils.mkdir_p(dir)
  37. @log.debug dir
  38. fn = File.join(dir, 'index.html')
  39. FileUtils.touch(fn) # empty file
  40. @log.info fn
  41. # Body detail pages
  42. Body.each do |b|
  43. locals = {
  44. body: b,
  45. districts: District.all(:body => b, :order => [:name])
  46. }
  47. locals['elections'] = repository(:default).adapter.select("
  48. SELECT
  49. e.id,
  50. e.kind,
  51. e.d,
  52. SUM(p.ballot_papers_issued)::float / SUM(p.electorate) * 100 AS turnout_percent
  53. FROM elections e
  54. LEFT JOIN polls p
  55. ON e.id = p.election_id
  56. WHERE e.body_id = ?
  57. GROUP BY p.election_id, e.id
  58. ORDER BY e.d DESC
  59. ", b.id)
  60. write_page(['bodies', b.slug], 'body', locals)
  61. # Districts for this body
  62. b.districts.each do |d|
  63. locals = {
  64. district: d,
  65. body: b
  66. }
  67. write_page(['bodies', b.slug, b.districts_name, d.slug], 'district', locals)
  68. end
  69. end
  70. end
  71. def gen_candidates_pages
  72. # Candidate index
  73. locals = { candidates: Candidate.all(:order => [ :surname, :forenames ]) }
  74. write_page('candidates', 'candidates', locals)
  75. # Candidate pages
  76. # FIXME: What do we do about deleted candidates/redirects?
  77. Candidate.each do |c|
  78. locals = {
  79. candidate: c
  80. }
  81. locals['candidacies'] = repository(:default).adapter.select("
  82. SELECT
  83. e.d,
  84. c.*,
  85. p.name AS party_name,
  86. p.colour AS party_colour,
  87. b.name AS body_name,
  88. b.slug AS body_slug,
  89. b.districts_name AS districts_name,
  90. d.name AS district_name,
  91. d.slug AS district_slug
  92. FROM candidacies c
  93. INNER JOIN elections e
  94. ON c.election_id = e.id
  95. INNER JOIN parties p
  96. ON c.party_id = p.id
  97. INNER JOIN bodies b
  98. ON e.body_id = b.id
  99. INNER JOIN districts d
  100. ON c.district_id = d.id
  101. WHERE c.candidate_id = ?
  102. ORDER BY d
  103. ", c.id)
  104. write_page(['candidates', c.id.to_s], 'candidate', locals)
  105. end
  106. end
  107. def gen_elections_pages
  108. # Election pages
  109. Election.each do |e|
  110. locals = {
  111. body: Body.first(:slug => e.body.slug),
  112. election: Election.first(:body => e.body, :d => e.d),
  113. elections_for_this_body: Election.all(:body => e.body, :order => [:d]),
  114. total_seats: Candidacy.sum(:seats, :election => e),
  115. total_votes: Candidacy.sum(:votes, :election => e)
  116. }
  117. # There's got to be a better way to do this, either with SQL or Datamapper
  118. locals['total_districts'] = repository(:default).adapter.select("
  119. SELECT district_id
  120. FROM candidacies
  121. WHERE election_id = ?
  122. GROUP BY district_id
  123. ORDER BY district_id
  124. ", e.id).count
  125. locals['results_by_party'] = repository(:default).adapter.select("
  126. SELECT
  127. p.colour,
  128. p.name,
  129. SUM(c.votes) AS votez,
  130. SUM(c.seats) AS seatz,
  131. COUNT(*) AS cands
  132. FROM candidacies c
  133. LEFT JOIN parties p ON p.id = c.party_id
  134. WHERE c.election_id = ?
  135. GROUP BY c.party_id, p.colour, p.name
  136. ORDER BY seatz DESC, votez DESC
  137. ", e.id)
  138. write_page(['bodies', e.body.slug, 'elections', e.d.to_s], 'electionsummary', locals)
  139. # District results for this election (resultsdistrict)
  140. # Loop through all districts in this election
  141. e.candidacies.districts.each do |d|
  142. total_seats = Candidacy.sum(:seats, :district => d, :election => e)
  143. total_votes = Candidacy.sum(:votes, :district => d, :election => e)
  144. poll = Poll.get(d.id, e.id)
  145. locals = {
  146. district: d,
  147. body: d.body,
  148. election: e,
  149. candidacies: Candidacy.all(:district => d, :election => e, :order => [:position]),
  150. total_votes: total_votes,
  151. total_candidates: Candidacy.count(:district => d, :election => e),
  152. total_seats: total_seats,
  153. districts_in_this_election: e.candidacies.districts,
  154. poll: poll
  155. }
  156. locals['share_message'] = nil
  157. if total_seats == 1
  158. locals['share_denominator'] = total_votes
  159. elsif poll && poll.valid_ballot_papers
  160. locals['share_denominator'] = poll.valid_ballot_papers
  161. else
  162. locals['share_denominator'] = total_votes / total_seats
  163. locals['share_message'] = "The vote share percentages have been estimated as we don't have data for the number of valid ballot papers in this poll."
  164. end
  165. # Postgres: All the columns selected when using GROUP BY must either be aggregate functions or appear in the GROUP BY clause
  166. locals['results_by_party'] = repository(:default).adapter.select("
  167. SELECT
  168. p.name AS party_name,
  169. p.colour AS party_colour,
  170. COUNT(c.id) AS num_candidates,
  171. SUM(c.seats) AS num_seats,
  172. SUM(c.votes) AS total_votes
  173. FROM candidacies c
  174. LEFT JOIN parties p
  175. ON c.party_id = p.id
  176. WHERE c.district_id = ?
  177. AND c.election_id = ?
  178. GROUP BY p.name, p.colour
  179. ORDER BY total_votes DESC
  180. ", d.id, e.id)
  181. write_page(['bodies', e.body.slug, 'elections', e.d.to_s, e.body.districts_name, d.slug], 'resultsdistrict', locals)
  182. end
  183. end
  184. end
  185. def gen_homepage
  186. locals = {
  187. future_elections: Election.future,
  188. past_elections: Election.past
  189. }
  190. write_page('.', 'index', locals)
  191. end
  192. def create_output_dir
  193. working_dir = File.join(Dir.pwd, OUTPUT_DIR)
  194. # Recursively delete working directory to ensure no redundant files are left behind from previous builds.
  195. FileUtils.rm_rf(working_dir)
  196. Dir.mkdir(working_dir) unless File.directory?(working_dir)
  197. Dir.chdir(working_dir)
  198. # Copy `public` dir to output dir
  199. FileUtils.copy_entry(File.join('..', 'public'), '.')
  200. end
  201. @log = Logger.new($stdout)
  202. @log.level = Logger::INFO
  203. @log.info "Build starts."
  204. @log.info "Output directory is: #{OUTPUT_DIR}"
  205. @pages = 0 # count the number of pages generated
  206. create_output_dir
  207. gen_homepage
  208. gen_elections_pages
  209. gen_bodies_pages
  210. gen_info_pages
  211. gen_candidates_pages
  212. @log.info "Build complete. %d pages generated in %0.2f seconds." % [ @pages, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start ]