Phase 2: move the 8 catalog models into the WarpEngine engine

- Software, Release, ReleaseAsset, ExternalLink, PlatformLink, Image,
  SoftwareImage, Download now live in the engine under WarpEngine::, on top
  of WarpEngine::ApplicationRecord; table names unchanged (empty prefix)
- each model runs an ActiveSupport load hook (:warp_engine_<model>) as a
  host extension point
- WarpEngine::Image reads its upload path from WarpEngine.config
- host references fully qualified (services, serializers, admin, specs);
  admin registrations renamed with as: so /admin URLs and route helpers are
  byte-identical; factories pinned to the namespaced classes
- Member#image now class_name: "WarpEngine::Image"

Verified: full suite green, /api/software and /api/builds byte-identical to
the phase-0 baselines, admin routes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:51:12 +02:00
co-authored by Claude Fable 5
parent 2f6cc4cdc2
commit 92f10197c1
47 changed files with 314 additions and 278 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
ActiveAdmin.register Download do
ActiveAdmin.register WarpEngine::Download, as: "Download" do
actions :index, :show
menu priority: 7, label: "📊 Download Stats"
@@ -15,21 +15,21 @@ ActiveAdmin.register Download do
sidebar "Summary", only: :index do
dl style: "display:grid;grid-template-columns:1fr auto;gap:6px 12px;margin:0;" do
dt { strong "Total" }
dd { b Download.count.to_s }
dd { b WarpEngine::Download.count.to_s }
dt { strong "Today" }
dd { b Download.where("created_at >= ?", Date.current.beginning_of_day).count.to_s }
dd { b WarpEngine::Download.where("created_at >= ?", Date.current.beginning_of_day).count.to_s }
dt { strong "This week" }
dd { b Download.where("created_at >= ?", 1.week.ago).count.to_s }
dd { b WarpEngine::Download.where("created_at >= ?", 1.week.ago).count.to_s }
dt { strong "This month" }
dd { b Download.where("created_at >= ?", 1.month.ago).count.to_s }
dd { b WarpEngine::Download.where("created_at >= ?", 1.month.ago).count.to_s }
end
end
sidebar "Top 10 Files", only: :index do
table_for Download.group(:file_path)
table_for WarpEngine::Download.group(:file_path)
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
.order("dl_count DESC")
.limit(10) do
@@ -39,7 +39,7 @@ ActiveAdmin.register Download do
end
sidebar "Top 5 Software", only: :index do
rows = Download.joins(release: :software)
rows = WarpEngine::Download.joins(release: :software)
.group("softwares.title")
.order("count_all DESC")
.limit(5)
+1 -1
View File
@@ -1,4 +1,4 @@
ActiveAdmin.register ExternalLink do
ActiveAdmin.register WarpEngine::ExternalLink, as: "External Link" do
permit_params :software_id, :label, :url
menu false
+1 -1
View File
@@ -61,7 +61,7 @@ ActiveAdmin.register_page "Files" do
# Pre-fetch download counts for files in current directory
file_entries = entries.select { |e| e[:type] != :directory }
dl_counts = if file_entries.any?
Download.where(file_path: file_entries.map { |e| e[:path] })
WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] })
.group(:file_path)
.count
else
+3 -3
View File
@@ -1,9 +1,9 @@
ActiveAdmin.register Image do
ActiveAdmin.register WarpEngine::Image, as: "Image" do
permit_params :file_upload
menu priority: 5, label: "🖼️ Images"
used_ids = -> { SoftwareImage.distinct.pluck(:image_id) + ImageUsage.used_image_ids }
used_ids = -> { WarpEngine::SoftwareImage.distinct.pluck(:image_id) + ImageUsage.used_image_ids }
scope :all, default: true
scope("In use") { |scope| scope.where(id: used_ids.call) }
@@ -11,7 +11,7 @@ ActiveAdmin.register Image do
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
orphan_ids = ids.map(&:to_i) - used_ids.call
Image.where(id: orphan_ids).find_each do |img|
WarpEngine::Image.where(id: orphan_ids).find_each do |img|
File.delete(img.file_path) if File.exist?(img.file_path)
img.destroy
end
+1 -1
View File
@@ -41,7 +41,7 @@ ActiveAdmin.register Member do
f.template.image_tag("/api/image/#{resource.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
end
f.input :image_id, as: :select, label: "Image",
collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
include_blank: "— no image (use avatar_filename) —",
hint: img_hint
end
+1 -1
View File
@@ -1,4 +1,4 @@
ActiveAdmin.register PlatformLink do
ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
permit_params :name, :url, :platform, :position
menu priority: 5, label: "🔗 Platform Links"
+2 -2
View File
@@ -1,4 +1,4 @@
ActiveAdmin.register Release do
ActiveAdmin.register WarpEngine::Release, as: "Release" do
actions :index, :show
menu false
@@ -38,7 +38,7 @@ ActiveAdmin.register Release do
end
panel "Downloads by File" do
file_stats = Download.where(release_id: resource.id)
file_stats = WarpEngine::Download.where(release_id: resource.id)
.group(:file_path)
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
.order("dl_count DESC")
+5 -5
View File
@@ -1,4 +1,4 @@
ActiveAdmin.register Software do
ActiveAdmin.register WarpEngine::Software, as: "Software" do
permit_params :name, :title, :author, :desc, :story,
:license, :platform, :status, :highlighted,
software_images_attributes: [ :id, :image_id, :is_default, :position, :file_upload, :_destroy ],
@@ -56,7 +56,7 @@ ActiveAdmin.register Software do
sidebar "Download Statistics", only: :show do
sw = resource
total = Download.joins(:release).where(releases: { software_id: sw.id }).count
total = WarpEngine::Download.joins(:release).where(releases: { software_id: sw.id }).count
div style: "margin-bottom:12px;" do
strong "Total downloads: "
@@ -64,7 +64,7 @@ ActiveAdmin.register Software do
end
sw.releases.each do |rel|
file_stats = Download.where(release_id: rel.id)
file_stats = WarpEngine::Download.where(release_id: rel.id)
.group(:file_path)
.select("file_path, COUNT(*) as dl_count, MAX(created_at) as last_dl")
.order("dl_count DESC")
@@ -108,7 +108,7 @@ ActiveAdmin.register Software do
si.template.image_tag("/api/image/#{si.object.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
end
si.input :image_id, as: :select, label: "Image",
collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
include_blank: "— select —",
hint: img_hint
si.input :file_upload, as: :file, label: "Upload new image"
@@ -128,7 +128,7 @@ ActiveAdmin.register Software do
f.has_many :releases, allow_destroy: true, new_record: true do |r|
r.input :version
r.has_many :release_assets, allow_destroy: true, new_record: true do |ra|
ra.input :kind, as: :select, collection: ReleaseAsset::KINDS, include_blank: false
ra.input :kind, as: :select, collection: WarpEngine::ReleaseAsset::KINDS, include_blank: false
ra.input :path
end
end
-15
View File
@@ -1,15 +0,0 @@
class Download < ApplicationRecord
belongs_to :release, optional: true
validates :file_path, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at file_path id ip_address referer release_id updated_at user_agent]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
end
-18
View File
@@ -1,18 +0,0 @@
class ExternalLink < ApplicationRecord
self.table_name = "external_links"
belongs_to :software
validates :label, presence: true
validates :url, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id label software_id updated_at url]
end
def self.ransackable_associations(auth_object = nil)
%w[software]
end
end
-33
View File
@@ -1,33 +0,0 @@
class Image < ApplicationRecord
# Híváskor kiértékelve, hogy a path ne class-load-kor rögzüljön.
def self.upload_path
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
end
has_many :software_images, dependent: :restrict_with_error
default_scope { where(deleted_at: nil) }
attr_accessor :file_upload
before_save :process_upload, if: -> { file_upload.present? }
def self.ransackable_attributes(auth_object = nil)
%w[content_type created_at deleted_at filename id original_filename updated_at]
end
def file_path
File.join(self.class.upload_path, filename.to_s)
end
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
IO.copy_stream(file_upload.to_io, file_path)
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Member < ApplicationRecord
belongs_to :image, optional: true
belongs_to :image, class_name: "WarpEngine::Image", optional: true
has_one :admin_user
validates :nick, presence: true, uniqueness: true
-23
View File
@@ -1,23 +0,0 @@
class PlatformLink < ApplicationRecord
self.table_name = "platform_links"
SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
validates :name, presence: true
validates :url, presence: true
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
default_scope { where(deleted_at: nil).order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id name platform position updated_at url]
end
def self.ransackable_associations(auth_object = nil)
[]
end
def self.for_platform(platform)
where(platform: platform).to_a
end
end
-33
View File
@@ -1,33 +0,0 @@
class Release < ApplicationRecord
self.table_name = "releases"
belongs_to :software
has_many :downloads
has_many :release_assets
accepts_nested_attributes_for :release_assets, allow_destroy: true
validates :version, presence: true
validates :version, uniqueness: { scope: :software_id }
default_scope { where(deleted_at: nil) }
validate :c64_cannot_be_web_playable
def c64_cannot_be_web_playable
return unless software&.platform == "c64"
has_html = release_assets.reject(&:marked_for_destruction?).any? { |a| a.kind == "html" }
if has_html
errors.add(:base, "C64 releases cannot be web playable (download only)")
end
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id software_id updated_at version]
end
def self.ransackable_associations(auth_object = nil)
%w[downloads release_assets software]
end
end
-22
View File
@@ -1,22 +0,0 @@
class ReleaseAsset < ApplicationRecord
KINDS = %w[cartridge source html docs
win_x86 win_x64 linux_x86 linux_x64
mac_x64 mac_arm64 mac_universal].freeze
belongs_to :release
enum :kind, KINDS.index_by(&:itself)
validates :path, presence: true
validates :kind, uniqueness: { scope: :release_id }
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id kind path release_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[release]
end
end
-27
View File
@@ -1,27 +0,0 @@
class Software < ApplicationRecord
self.table_name = "softwares"
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases
has_many :external_links, foreign_key: :software_id
accepts_nested_attributes_for :software_images, allow_destroy: true
accepts_nested_attributes_for :external_links, allow_destroy: true
accepts_nested_attributes_for :releases, allow_destroy: true
validates :name, presence: true, uniqueness: true
validates :title, presence: true
validates :platform, presence: true
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[releases external_links software_images images]
end
end
-41
View File
@@ -1,41 +0,0 @@
class SoftwareImage < ApplicationRecord
belongs_to :software
belongs_to :image, optional: true
attr_accessor :file_upload
before_validation :create_image_from_upload, if: -> { file_upload.present? }
validates :image_id, presence: true
validates :image_id, uniqueness: { scope: :software_id }
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
before_save :unset_other_defaults, if: -> { is_default? && is_default_changed? }
default_scope { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at id image_id is_default position software_id updated_at]
end
def self.ransackable_associations(auth_object = nil)
%w[image software]
end
private
def create_image_from_upload
img = Image.new(file_upload: file_upload)
if img.save
self.image = img
else
errors.add(:file_upload, img.errors.full_messages.join(", "))
end
end
def unset_other_defaults
SoftwareImage.where(software_id: software_id, is_default: true)
.where.not(id: id)
.update_all(is_default: false)
end
end
@@ -15,7 +15,7 @@ class SoftwareSerializer < Blueprinter::Base
field :status
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(PlatformLink.for_platform(sw.platform)) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
field(:imageUrl) { |sw|
si = sw.software_images.detect(&:is_default?) || sw.software_images.first
si ? "/api/image/#{si.image_id}" : nil
+3 -3
View File
@@ -1,6 +1,6 @@
class BuildsService
def index
platforms = PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
service_class = "SoftwareUpdater::#{platform.camelize}Service".constantize
hash[platform] = {
label: service_class.label,
@@ -8,13 +8,13 @@ class BuildsService
}
end
all_kinds = ReleaseAsset::KINDS
all_kinds = WarpEngine::ReleaseAsset::KINDS
{ platforms: platforms, allKinds: all_kinds }
end
def show(name)
software = Software.find_by!(name: name)
software = WarpEngine::Software.find_by!(name: name)
service_class = "SoftwareUpdater::#{software.platform.camelize}Service".constantize
expected = service_class.expected_kinds
@@ -3,7 +3,7 @@ module SoftwareUpdater
private
def update_or_create_software(attrs)
software = Software.unscoped.find_or_initialize_by(name: attrs[:name])
software = WarpEngine::Software.unscoped.find_or_initialize_by(name: attrs[:name])
software.assign_attributes(attrs.except(:name))
software.deleted_at = nil
software.save!
@@ -11,22 +11,22 @@ module SoftwareUpdater
end
def upsert_external_link(software_id, label, url)
link = ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
link = WarpEngine::ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
link.url = url
link.deleted_at = nil
link.save!
end
def create_release_if_not_exists(attrs)
existing = Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
existing = WarpEngine::Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
return existing if existing
Release.create!(attrs)
WarpEngine::Release.create!(attrs)
end
def sync_release_assets(release, kind_paths)
kind_paths.each do |kind, path|
asset = ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind)
asset = WarpEngine::ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind)
asset.path = path
asset.deleted_at = nil
asset.save!
+3 -3
View File
@@ -16,10 +16,10 @@ class DownloadService
return nil unless File.file?(full_path)
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
asset = ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
Download.create!(
WarpEngine::Download.create!(
file_path: sanitized,
release: asset&.release,
ip_address: ip,
+1 -1
View File
@@ -1,5 +1,5 @@
class ImageService
def show(input)
Image.find(input.id)
WarpEngine::Image.find(input.id)
end
end
+1 -1
View File
@@ -26,7 +26,7 @@ class RssService
end
def releases_feed
releases = Release.includes(:software)
releases = WarpEngine::Release.includes(:software)
.order(created_at: :desc)
.limit(50)
@@ -2,13 +2,13 @@ class SoftwareHighlightedService
include SoftwareResponseBuilder
def index
software = Software.includes(:external_links, :software_images)
software = WarpEngine::Software.includes(:external_links, :software_images)
.where(highlighted: true)
.order(id: :desc)
.first
return nil unless software
releases = Release.includes(:release_assets).where(software_id: software.id).to_a
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
build_response(software, releases, download_counts_for(releases.map(&:id)))
end
end
@@ -20,6 +20,6 @@ module SoftwareResponseBuilder
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
def download_counts_for(release_ids)
return {} if release_ids.empty?
Download.where(release_id: release_ids).group(:release_id).count
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
end
end
+1 -1
View File
@@ -2,7 +2,7 @@ class SoftwareService
include SoftwareResponseBuilder
def index
softwares = Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end
+1 -1
View File
@@ -1,6 +1,6 @@
class UpdateService
def update(input)
unless PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end