asxpio/lib/bingo_admin.rb
Sergei Poljanski e725395825
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 52s
Build and Deploy to Production / deploy (push) Successful in 38s
bingo: add, delete and reorder datacenters from admin
The card was fixed to the 25 seeded rows, so a new site such as
IXcellerate MOS7 (34 MW, under construction) could only be shoehorned in
by overwriting an existing row. Add create and delete (cascading to
incidents), make position an insert that shifts the rest, and keep
positions dense. More than 25 rows are allowed: the card shows the first
25 and the rest wait in reserve. "Sort by MW" re-ranks by the parsed
power figure, unknown last.

Assisted-by: Claude:opus-5
2026-10-10 06:00:46 +04:00

203 lines
7.2 KiB
Ruby

require 'uri'
# /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb.
# Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply.
module BingoAdmin
DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status
photo_credit photo_source_url info_url].freeze
module Helpers
def bingo_public_url
ENV['BINGO_HOST'] && "https://#{ENV['BINGO_HOST']}/ru"
end
def http_url?(s)
s.empty? || s.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https]))
end
def position_param
params[:position].to_s =~ /\A\d+\z/ ? params[:position].to_i : nil
end
# Copies the edit form onto dc (position is handled by Bingo.place!).
def assign_dc(dc)
DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) }
dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
dc.status = 'operational' unless %w[operational building].include?(dc.status)
errs = []
errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?)
errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) }
errs
end
def blank_nil(s)
s = s.to_s.strip
s.empty? ? nil : s
end
end
def self.registered(app)
app.helpers Helpers
app.get '/admin/bingo' do
@page_title = 'Bingo · admin'
@noindex = true
@dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
@incidents = Bingo::Incident.order(Sequel.desc(:occurred_on), Sequel.desc(:id)).eager(:datacenter).all
@flash = session.delete(:bingo_flash)
erb :'admin/bingo/index'
end
app.get '/admin/bingo/dc/new' do
@dc = Bingo::Datacenter.new(status: 'operational')
@page_title = 'New datacenter · bingo admin'
@noindex = true
erb :'admin/bingo/edit'
end
app.post '/admin/bingo/dc' do
@dc = Bingo::Datacenter.new
@dc.slug = params[:slug].to_s.strip.downcase
errs = assign_dc(@dc)
errs.unshift('Slug: lowercase letters, digits and dashes') unless @dc.slug.match?(/\A[a-z0-9]+(-[a-z0-9]+)*\z/)
errs.unshift('Slug already taken') if Bingo::Datacenter.where(slug: @dc.slug).any?
if errs.any?
@flash = errs.join('; ')
@page_title = 'New datacenter · bingo admin'
@noindex = true
status 422
return erb :'admin/bingo/edit'
end
@dc.position = Bingo::Datacenter.count + 1
@dc.save
Bingo.place!(@dc, position_param)
session[:bingo_flash] = "Added #{@dc.name_ru} at ##{@dc.refresh.position}."
redirect "/admin/bingo/dc/#{@dc.id}"
end
app.get '/admin/bingo/dc/:id' do
@dc = Bingo::Datacenter[params[:id].to_i] or halt 404
@page_title = "#{@dc.name_ru} · bingo admin"
@noindex = true
@flash = session.delete(:bingo_flash)
erb :'admin/bingo/edit'
end
app.post '/admin/bingo/dc/:id' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
errs = assign_dc(dc)
if errs.empty?
dc.save_changes
Bingo.place!(dc, position_param) if position_param && position_param != dc.position
session[:bingo_flash] = 'Saved.'
else
session[:bingo_flash] = errs.join('; ')
end
redirect "/admin/bingo/dc/#{dc.id}"
end
# Deletes the row and (FK cascade) its incidents; the photo object stays
# in the bucket.
app.post '/admin/bingo/dc/:id/delete' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
n = dc.incidents.size
DB.connection.transaction do
dc.destroy
Bingo.renumber!
end
session[:bingo_flash] = "Deleted #{dc.name_ru}#{" and #{n} incident(s)" if n.positive?}."
redirect '/admin/bingo'
end
app.post '/admin/bingo/sort' do
Bingo.sort_by_mw!
session[:bingo_flash] = 'Sorted by MW (unknown last).'
redirect '/admin/bingo'
end
# Either an uploaded file or "import from URL" (defaults to photo_source_url).
app.post '/admin/bingo/dc/:id/photo' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
begin
bytes =
if params[:photo].is_a?(Hash) && params[:photo][:tempfile]
params[:photo][:tempfile].read(Bingo::Photo::MAX_BYTES + 1)
else
url = blank_nil(params[:url]) || dc.photo_source_url or raise Bingo::Photo::Error, 'no file and no URL'
dc.photo_source_url = url
Bingo::Photo.fetch(url)
end
dc.photo_key = Bingo::Photo.store(dc.slug, bytes)
dc.save_changes
session[:bingo_flash] = 'Photo stored.'
rescue Bingo::Photo::Error => e
session[:bingo_flash] = "Photo failed: #{e.message}"
end
redirect "/admin/bingo/dc/#{dc.id}"
end
app.post '/admin/bingo/dc/:id/photo/delete' do
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
dc.update(photo_key: nil)
session[:bingo_flash] = 'Photo removed from card (object kept in bucket).'
redirect "/admin/bingo/dc/#{dc.id}"
end
# Bulk: import every missing photo from its photo_source_url, or with
# all=1 re-import every photo that has a source URL.
app.post '/admin/bingo/photos/import' do
ok, failed = 0, []
scope = Bingo::Datacenter.exclude(photo_source_url: nil)
scope = scope.where(photo_key: nil) unless params[:all] == '1'
scope.each do |dc|
dc.update(photo_key: Bingo::Photo.store(dc.slug, Bingo::Photo.fetch(dc.photo_source_url)))
ok += 1
rescue Bingo::Photo::Error => e
failed << "#{dc.slug}: #{e.message}"
end
session[:bingo_flash] = (["Imported #{ok}."] + failed).join(' | ')
redirect '/admin/bingo'
end
app.get '/admin/bingo/p/:file' do
bytes, type = Bingo::Photo.read(params[:file])
content_type type
bytes
rescue Bingo::Photo::Error, Aws::S3::Errors::NoSuchKey, Aws::S3::Errors::NotFound
halt 404
end
app.post '/admin/bingo/incidents' do
dc = Bingo::Datacenter[params[:datacenter_id].to_i]
date = begin
Date.iso8601(params[:occurred_on].to_s)
rescue Date::Error
nil
end
url = params[:url].to_s.strip
errs = []
errs << 'Pick a datacenter' unless dc
errs << 'Valid date required' unless date
errs << 'Unknown kind' unless Bingo::KINDS.include?(params[:kind])
errs << 'Title (ru) required' if params[:title_ru].to_s.strip.empty?
errs << 'Source URL must be http(s)' unless http_url?(url)
if errs.empty?
Bingo::Incident.create(
datacenter_id: dc.id, occurred_on: date, kind: params[:kind],
title_ru: params[:title_ru].strip, title_en: blank_nil(params[:title_en]),
title_uk: blank_nil(params[:title_uk]), url: blank_nil(url)
)
session[:bingo_flash] = "Incident recorded for #{dc.name_ru}."
else
session[:bingo_flash] = errs.join('; ')
end
redirect '/admin/bingo'
end
app.post '/admin/bingo/incidents/:id/delete' do
Bingo::Incident.where(id: params[:id].to_i).delete
session[:bingo_flash] = 'Incident deleted.'
redirect '/admin/bingo'
end
end
end