Jedmund/fix image embeds 4 (#177)
* Update gitignore There is a mystery postgres folder and we are going to ignore it * Add migrations * Update preview state default to pending * Adds indexes * Adds PgHero and PgStatements * Update .gitignore * Update Gemfile Production: - `newrelic_rpm` Development: - `pg_query` - `prosopite` * Configure Sidekiq Create job for cleaning up party previews * Configure Prosopite and remove CacheFreeLogger * Enable query logging * Update api_controller.rb Add N+1 detectioin via Prosopite in development/test environments * Refactor canonical object blueprints * Refactor grid object blueprints * Remove N+1 from grid object models Reimplementing `character` `summon` and `weapon` was making N+1s which made queries really slow * Add counter caches to party * Add preview generation helpers The Party model can respond to queries about its preview state with the following models: - `schedule_preview_generation` - `preview_content_changed?` - `preview_expired?` - `should_generate_preview?` - `ready_for_preview?` - `needs_preview_generation?` - `preview_relevant_attributes` Removes the following methods: - `schedule_preview_regeneration` - `preview_relevant_changes?` * Add cache to is_favorited(user) * Refactored PartyBlueprint to minimize N+1s * Remove preview expiry constants These are defined in the Coordinator instead * Add method comments * Create parties_controller.rbs * Update logic and logs * Updates excluded methods and calculate_count * Use `includes` instead of `joins` * Use a less-insane way of counting * Adds a helper method for party privacy * Update filter condition helpers Just minor refactoring * Fix old view name in PartyBlueprint * Refactor parties#create * Remove redundant return * Update parties_controller.rbs * Update parties#index * Update parties_controller.rb Updates apply_includes and apply_excludes, along with modifying id_to_table and build_query * Update parties_controller.rb Adds the rest of the changes, too tired to write them all out. Some preview generation, some filtering * Refactor parties#index and parties#favorites These are mostly the same methods, so we remove common code into build_parties_query and render_paginated_parties * Alias table name to object to maintain API consistency * Maintain API consistency with raid blueprint * Optimize party loading by adding eager loading to `set_from_slug` - Refactored `set_from_slug` to use `includes` for eager loading associated models: - `user`, `job`, `raid` (with `group`) - `characters` (with `character` and `awakening`) - `weapons` (with `weapon`, `awakenings`, `weapon_key1`, `weapon_key2`, `weapon_key3`) - `summons` (with `summon`) - `guidebooks` (`guidebook1`, `guidebook2`, `guidebook3`) - `source_party`, `remixes`, `skills`, and `accessory` - This change improves query efficiency by reducing N+1 queries and ensures all relevant associations are preloaded. - Removed redundant favorite check as it was not necessary in this context. * Refactor grid blueprints - **GridCharacterBlueprint:** - Removed `:minimal` view restriction on `party` association. - Improved nil checks for `ring1`, `ring2`, and `earring` to prevent errors. - Converted string values in `awakening_level`, `over_mastery`, and `aetherial_mastery` fields to integers for consistency. - Ensured `over_mastery` and `aetherial_mastery` only include valid entries, filtering out blank or zero-modifier values. - **GridWeaponBlueprint:** - Removed `:minimal` view restriction on `party` association. - Ensured `weapon` association exists before accessing `ax`, `series`, or `awakening`. - Improved conditional checks for `weapon_keys` to prevent errors when `weapon` or `series` is nil. - Converted `awakening_level` field to integer for consistency. - **GridCharacterBlueprint:** - Removed `:minimal` view restriction on `party` association. * Update raid blueprints - Show flat representation of raid group in RaidBlueprint's nested view - Show nested representation of raid in RaidGroupBlueprint's full view * Move n+1 detection to around_action hook * Improve handling mastery bonuses - Improved handling of nested attributes: - Replaced old mastery structure with new `rings` and `awakening` assignments. - Added `new_rings` and `new_awakening` virtual attributes for easier updates. - Updated `assign_attributes` to exclude `rings` and `awakening` to prevent conflicts. - Enhanced parameter transformation: - Introduced `transform_character_params` to process `rings`, `awakening`, and `earring` more reliably. - Ensured proper type conversion (`to_i`) for numeric values in `uncap_level`, `transcendence_step`, and `awakening_level`. - Improved error handling for missing values by setting defaults where needed. - Optimized database queries: - Added `.includes(:awakening)` to `set` to prevent N+1 query issues. - Updated strong parameters: - Changed `rings` from individual keys (`ring1`, `ring2`, etc.) to a structured array format. - Refactored permitted attributes to align with the new nested structure. * Eager-load jobs when querying job skills * Eager load raids/groups when querying * Update users_controller.rb More efficient way of denoting favorited parties. * Update awakening.rb - Removes explicitly defined associations and adds ActiveRecord associations instead * Update party.rb - Removes favorited accessor - Renames derivative_parties to remixes and adds in-built sort * Update weapon_awakening.rb - Removes redefined explicit associations * Update grid_character.rb - Adds code transforming incoming ring and awakening values into something the db understands * Update character.rb Add explicit Awakenings enum * Update coordinator.rb Adds 'queued' as a state for generation
This commit is contained in:
parent
11d324efe9
commit
6cf11e6517
40 changed files with 1301 additions and 540 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -7,6 +7,9 @@
|
|||
# Ignore bundler config.
|
||||
/.bundle
|
||||
|
||||
# Ignore mystery Postgres folder
|
||||
/postgres
|
||||
|
||||
# Ignore the default SQLite database.
|
||||
/db/*.sqlite3
|
||||
/db/*.sqlite3-journal
|
||||
|
|
@ -46,3 +49,6 @@ config/application.yml
|
|||
.vscode/*
|
||||
|
||||
/config/credentials/production.key
|
||||
|
||||
# Ignore AI Codebase-generated files
|
||||
codebase.md
|
||||
|
|
|
|||
9
Gemfile
9
Gemfile
|
|
@ -2,7 +2,6 @@ source 'https://rubygems.org'
|
|||
ruby '3.3.7'
|
||||
|
||||
gem 'bootsnap'
|
||||
gem 'pg'
|
||||
gem 'rack-cors'
|
||||
gem 'rails'
|
||||
gem 'sprockets-rails'
|
||||
|
|
@ -10,6 +9,9 @@ gem 'sprockets-rails'
|
|||
# A Ruby Web Server Built For Concurrency
|
||||
gem 'puma'
|
||||
|
||||
# Pg is the Ruby interface to the PostgreSQL RDBMS
|
||||
gem 'pg'
|
||||
|
||||
# A sophisticated and secure hash algorithm for
|
||||
# hashing passwords.
|
||||
gem 'bcrypt'
|
||||
|
|
@ -71,6 +73,9 @@ gem 'httparty'
|
|||
# StringScanner provides for lexical scanning operations on a String.
|
||||
gem 'strscan'
|
||||
|
||||
# New Relic Ruby Agent
|
||||
gem 'newrelic_rpm'
|
||||
|
||||
group :doc do
|
||||
gem 'apipie-rails'
|
||||
gem 'sdoc'
|
||||
|
|
@ -88,6 +93,8 @@ end
|
|||
|
||||
group :development do
|
||||
gem 'listen'
|
||||
gem 'pg_query'
|
||||
gem 'prosopite'
|
||||
gem 'solargraph'
|
||||
gem 'spring'
|
||||
gem 'spring-commands-rspec'
|
||||
|
|
|
|||
96
Gemfile.lock
96
Gemfile.lock
|
|
@ -86,8 +86,8 @@ GEM
|
|||
awesome_nested_set (3.8.0)
|
||||
activerecord (>= 4.0.0, < 8.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1038.0)
|
||||
aws-sdk-core (3.216.0)
|
||||
aws-partitions (1.1044.0)
|
||||
aws-sdk-core (3.217.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
|
@ -95,7 +95,7 @@ GEM
|
|||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.178.0)
|
||||
aws-sdk-s3 (1.179.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
|
|
@ -135,14 +135,13 @@ GEM
|
|||
dotenv (= 3.1.7)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot (6.5.1)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 5.0.0)
|
||||
|
|
@ -166,14 +165,30 @@ GEM
|
|||
gemoji (>= 2.1.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.29.3)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-aarch64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-arm64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-darwin)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.29.3-x86_64-linux)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.6)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.3)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
|
|
@ -183,7 +198,7 @@ GEM
|
|||
rexml (>= 3.3.9)
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.3)
|
||||
language_server-protocol (3.17.0.4)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
|
|
@ -213,46 +228,54 @@ GEM
|
|||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
newrelic_rpm (9.17.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-aarch64-linux-musl)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm-linux-musl)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-linux-musl)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
observer (0.1.2)
|
||||
oj (3.16.9)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.6.0)
|
||||
parser (3.3.7.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pg_query (6.0.0)
|
||||
google-protobuf (>= 3.25.3)
|
||||
pg_search (2.3.7)
|
||||
activerecord (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prosopite (1.4.2)
|
||||
pry (0.15.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
psych (5.2.2)
|
||||
psych (5.2.3)
|
||||
date
|
||||
stringio
|
||||
puma (6.5.0)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack (3.1.9)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-session (2.1.0)
|
||||
|
|
@ -296,8 +319,9 @@ GEM
|
|||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (2.8.4)
|
||||
rdoc (6.10.0)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.11.0)
|
||||
psych (>= 4.0.0)
|
||||
redis (5.3.0)
|
||||
redis-client (>= 0.22.0)
|
||||
|
|
@ -309,7 +333,7 @@ GEM
|
|||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (2.1.1)
|
||||
reverse_markdown (3.0.0)
|
||||
nokogiri
|
||||
rexml (3.4.0)
|
||||
rspec (3.13.0)
|
||||
|
|
@ -335,17 +359,17 @@ GEM
|
|||
rspec-support (3.13.2)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.70.0)
|
||||
rubocop (1.71.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
|
|
@ -355,7 +379,8 @@ GEM
|
|||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.4.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.7)
|
||||
sidekiq (7.3.8)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
|
|
@ -366,18 +391,20 @@ GEM
|
|||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solargraph (0.50.0)
|
||||
solargraph (0.51.1)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
diff-lcs (~> 1.4)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
jaro_winkler (~> 1.6)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
logger (~> 1.6)
|
||||
observer (~> 0.1)
|
||||
ostruct (~> 0.6)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 2.0)
|
||||
reverse_markdown (~> 2.0)
|
||||
rbs (~> 3.0)
|
||||
reverse_markdown (>= 2.0, < 4)
|
||||
rubocop (~> 1.38)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
|
|
@ -446,9 +473,12 @@ DEPENDENCIES
|
|||
httparty
|
||||
listen
|
||||
mini_magick
|
||||
newrelic_rpm
|
||||
oj
|
||||
pg
|
||||
pg_query
|
||||
pg_search
|
||||
prosopite
|
||||
pry
|
||||
puma
|
||||
rack-cors
|
||||
|
|
|
|||
|
|
@ -3,72 +3,77 @@
|
|||
module Api
|
||||
module V1
|
||||
class CharacterBlueprint < ApiBlueprint
|
||||
field :name do |w|
|
||||
field :name do |c|
|
||||
{
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
en: c.name_en,
|
||||
ja: c.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :character_id, :rarity,
|
||||
:element, :gender, :special
|
||||
|
||||
field :uncap do |w|
|
||||
field :uncap do |c|
|
||||
{
|
||||
flb: w.flb,
|
||||
ulb: w.ulb
|
||||
flb: c.flb,
|
||||
ulb: c.ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb
|
||||
}
|
||||
field :race do |c|
|
||||
[c.race1, c.race2].compact
|
||||
end
|
||||
|
||||
field :atk do |w|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb
|
||||
}
|
||||
field :proficiency do |c|
|
||||
[c.proficiency1, c.proficiency2].compact
|
||||
end
|
||||
|
||||
field :race do |w|
|
||||
[
|
||||
w.race1,
|
||||
w.race2
|
||||
]
|
||||
end
|
||||
|
||||
field :proficiency do |w|
|
||||
[
|
||||
w.proficiency1,
|
||||
w.proficiency2
|
||||
]
|
||||
end
|
||||
|
||||
field :data do |w|
|
||||
{
|
||||
base_da: w.base_da,
|
||||
base_ta: w.base_ta
|
||||
}
|
||||
end
|
||||
|
||||
field :ougi_ratio do |w|
|
||||
{
|
||||
ougi_ratio: w.ougi_ratio,
|
||||
ougi_ratio_flb: w.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :rates
|
||||
include_view :dates
|
||||
|
||||
field :awakenings do
|
||||
Awakening.where(object_type: 'Character').map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
Character::AWAKENINGS.map do |awakening|
|
||||
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |c|
|
||||
{
|
||||
min_hp: c.min_hp,
|
||||
max_hp: c.max_hp,
|
||||
max_hp_flb: c.max_hp_flb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |c|
|
||||
{
|
||||
min_atk: c.min_atk,
|
||||
max_atk: c.max_atk,
|
||||
max_atk_flb: c.max_atk_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :rates do
|
||||
fields :base_da, :base_ta
|
||||
|
||||
field :ougi_ratio do |c|
|
||||
{
|
||||
ougi_ratio: c.ougi_ratio,
|
||||
ougi_ratio_flb: c.ougi_ratio_flb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,57 +3,64 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridCharacterBlueprint < ApiBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :nested do
|
||||
fields :position, :uncap_level, :perpetuity
|
||||
|
||||
field :transcendence_step, if: lambda { |_fn, obj, _opt|
|
||||
obj.character.ulb
|
||||
} do |c|
|
||||
c.transcendence_step
|
||||
end
|
||||
|
||||
field :awakening do |c|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(c.awakening),
|
||||
level: c.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
rings = []
|
||||
|
||||
rings.push(c.ring1) unless c.ring1['modifier'].nil?
|
||||
rings.push(c.ring2) unless c.ring2['modifier'].nil?
|
||||
rings.push(c.ring3) unless c.ring3['modifier'].nil?
|
||||
rings.push(c.ring4) unless c.ring4['modifier'].nil?
|
||||
|
||||
rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: lambda { |_fn, obj, _opt|
|
||||
!obj.earring['modifier'].nil?
|
||||
} do |c|
|
||||
c.earring
|
||||
field :transcendence_step, if: ->(_field, gc, _options) { gc.character&.ulb } do |gc|
|
||||
gc.transcendence_step
|
||||
end
|
||||
|
||||
view :preview do
|
||||
association :character, name: :object, blueprint: CharacterBlueprint
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
view :nested do
|
||||
include_view :mastery_bonuses
|
||||
association :character, name: :object, blueprint: CharacterBlueprint, view: :full
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
fields :position, :created_at, :updated_at
|
||||
end
|
||||
|
||||
view :mastery_bonuses do
|
||||
field :awakening, if: ->(_field_name, gc, _options) { gc.association(:awakening).loaded? } do |gc|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(gc.awakening),
|
||||
level: gc.awakening_level.to_i
|
||||
}
|
||||
end
|
||||
|
||||
field :over_mastery, if: lambda { |_fn, obj, _opt|
|
||||
obj.ring1.present? && obj.ring2.present? && !obj.ring1['modifier'].nil? && !obj.ring2['modifier'].nil?
|
||||
} do |c|
|
||||
mapped_rings = [c.ring1, c.ring2, c.ring3, c.ring4].each_with_object([]) do |ring, arr|
|
||||
# Skip if the ring is nil or its modifier is blank.
|
||||
next if ring.blank? || ring['modifier'].blank?
|
||||
|
||||
# Convert the string values to numbers.
|
||||
mod = ring['modifier'].to_i
|
||||
|
||||
# Only include if modifier is non-zero.
|
||||
next if mod.zero?
|
||||
|
||||
arr << { modifier: mod, strength: ring['strength'].to_i }
|
||||
end
|
||||
|
||||
mapped_rings
|
||||
end
|
||||
|
||||
field :aetherial_mastery, if: ->(_fn, obj, _opt) { obj.earring.present? && !obj.earring['modifier'].nil? } do |gc, _options|
|
||||
{
|
||||
modifier: gc.earring['modifier'].to_i,
|
||||
strength: gc.earring['strength'].to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,19 +3,24 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridSummonBlueprint < ApiBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
|
||||
view :preview do
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
end
|
||||
|
||||
view :nested do
|
||||
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step
|
||||
association :summon, name: :object, blueprint: SummonBlueprint
|
||||
association :summon, name: :object, blueprint: SummonBlueprint, view: :full
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level, :transcendence_step
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,45 +3,47 @@
|
|||
module Api
|
||||
module V1
|
||||
class GridWeaponBlueprint < ApiBlueprint
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
fields :position, :uncap_level
|
||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
|
||||
view :preview do
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
end
|
||||
|
||||
view :nested do
|
||||
fields :mainhand, :position, :uncap_level, :transcendence_step, :element
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint
|
||||
|
||||
association :weapon_keys,
|
||||
blueprint: WeaponKeyBlueprint,
|
||||
if: lambda { |_field_name, w, _options|
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
}
|
||||
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.ax } do |w|
|
||||
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w|
|
||||
[
|
||||
{
|
||||
modifier: w.ax_modifier1,
|
||||
strength: w.ax_strength1
|
||||
},
|
||||
{
|
||||
modifier: w.ax_modifier2,
|
||||
strength: w.ax_strength2
|
||||
}
|
||||
{ modifier: w.ax_modifier1, strength: w.ax_strength1 },
|
||||
{ modifier: w.ax_modifier2, strength: w.ax_strength2 }
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening_id } do |w|
|
||||
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
|
||||
{
|
||||
type: AwakeningBlueprint.render_as_hash(w.awakening),
|
||||
level: w.awakening_level
|
||||
}
|
||||
end
|
||||
|
||||
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full,
|
||||
if: ->(_field_name, w, _options) { w.weapon.present? }
|
||||
|
||||
association :weapon_keys,
|
||||
blueprint: WeaponKeyBlueprint,
|
||||
if: ->(_field_name, w, _options) {
|
||||
w.weapon.present? &&
|
||||
w.weapon.series.present? &&
|
||||
[2, 3, 17, 24, 34].include?(w.weapon.series)
|
||||
}
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :nested
|
||||
association :party, blueprint: PartyBlueprint, view: :minimal
|
||||
association :party, blueprint: PartyBlueprint
|
||||
end
|
||||
|
||||
view :uncap do
|
||||
association :party, blueprint: PartyBlueprint
|
||||
fields :position, :uncap_level
|
||||
end
|
||||
|
||||
view :destroyed do
|
||||
|
|
|
|||
|
|
@ -3,105 +3,131 @@
|
|||
module Api
|
||||
module V1
|
||||
class PartyBlueprint < ApiBlueprint
|
||||
view :weapons do
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :summons do
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :characters do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
view :job_skills do
|
||||
field :job_skills do |job|
|
||||
{
|
||||
'0' => !job.skill0.nil? ? JobSkillBlueprint.render_as_hash(job.skill0) : nil,
|
||||
'1' => !job.skill1.nil? ? JobSkillBlueprint.render_as_hash(job.skill1) : nil,
|
||||
'2' => !job.skill2.nil? ? JobSkillBlueprint.render_as_hash(job.skill2) : nil,
|
||||
'3' => !job.skill3.nil? ? JobSkillBlueprint.render_as_hash(job.skill3) : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :minimal do
|
||||
fields :name, :element, :shortcode, :favorited, :remix,
|
||||
:extra, :full_auto, :clear_time, :auto_guard, :auto_summon,
|
||||
:visibility, :created_at, :updated_at
|
||||
|
||||
field :guidebooks do |p|
|
||||
{
|
||||
'1' => !p.guidebook1.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook1) : nil,
|
||||
'2' => !p.guidebook2.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook2) : nil,
|
||||
'3' => !p.guidebook3.nil? ? GuidebookBlueprint.render_as_hash(p.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :full
|
||||
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
end
|
||||
|
||||
view :jobs do
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
include_view :job_skills
|
||||
end
|
||||
|
||||
view :preview do
|
||||
include_view :minimal
|
||||
include_view :characters
|
||||
include_view :weapons
|
||||
include_view :summons
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :preview
|
||||
include_view :summons
|
||||
include_view :characters
|
||||
include_view :job_skills
|
||||
# Base fields that are always needed
|
||||
fields :local_id, :description, :shortcode, :visibility,
|
||||
:name, :element, :extra, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count, :clear_time,
|
||||
:full_auto, :auto_guard, :auto_summon,
|
||||
:created_at, :updated_at
|
||||
|
||||
fields :local_id, :description, :charge_attack,
|
||||
:button_count, :turn_count, :chain_count,
|
||||
:master_level, :ultimate_mastery
|
||||
|
||||
association :accessory,
|
||||
blueprint: JobAccessoryBlueprint
|
||||
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
# Party associations
|
||||
association :user,
|
||||
blueprint: UserBlueprint,
|
||||
view: :minimal
|
||||
|
||||
# TODO: This should probably be paginated
|
||||
association :job,
|
||||
blueprint: JobBlueprint
|
||||
|
||||
association :raid,
|
||||
blueprint: RaidBlueprint,
|
||||
view: :nested
|
||||
|
||||
# Metadata associations
|
||||
field :favorited do |party, options|
|
||||
party.is_favorited(options[:current_user])
|
||||
end
|
||||
|
||||
# For collection views
|
||||
view :preview do
|
||||
include_view :preview_objects # Characters, Weapons, Summons
|
||||
include_view :preview_metadata # Object counts
|
||||
end
|
||||
|
||||
# For object views
|
||||
view :full do
|
||||
# Primary object associations
|
||||
include_view :nested_objects # Characters, Weapons, Summons
|
||||
include_view :nested_metadata # Remixes, Source party
|
||||
include_view :job_metadata # Accessory, Skills, Guidebooks
|
||||
end
|
||||
|
||||
# Primary object associations
|
||||
view :preview_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :preview
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :preview
|
||||
end
|
||||
|
||||
view :nested_objects do
|
||||
association :characters,
|
||||
blueprint: GridCharacterBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :weapons,
|
||||
blueprint: GridWeaponBlueprint,
|
||||
view: :nested
|
||||
|
||||
association :summons,
|
||||
blueprint: GridSummonBlueprint,
|
||||
view: :nested
|
||||
end
|
||||
|
||||
# Metadata views
|
||||
view :preview_metadata do
|
||||
field :counts do |party|
|
||||
{
|
||||
weapons: party.weapons_count,
|
||||
characters: party.characters_count,
|
||||
summons: party.summons_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :nested_metadata do
|
||||
association :source_party,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :minimal,
|
||||
if: ->(_field_name, party, _options) { party.source_party_id.present? }
|
||||
|
||||
# Re-added remixes association
|
||||
association :remixes,
|
||||
blueprint: PartyBlueprint,
|
||||
view: :collection
|
||||
view: :preview
|
||||
end
|
||||
|
||||
view :collection do
|
||||
include_view :preview
|
||||
# Job-related views
|
||||
view :job_metadata do
|
||||
field :job_skills, cache: true do |party|
|
||||
{
|
||||
'0' => party.skill0 ? JobSkillBlueprint.render_as_hash(party.skill0) : nil,
|
||||
'1' => party.skill1 ? JobSkillBlueprint.render_as_hash(party.skill1) : nil,
|
||||
'2' => party.skill2 ? JobSkillBlueprint.render_as_hash(party.skill2) : nil,
|
||||
'3' => party.skill3 ? JobSkillBlueprint.render_as_hash(party.skill3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
field :guidebooks, cache: true do |party|
|
||||
{
|
||||
'1' => party.guidebook1 ? GuidebookBlueprint.render_as_hash(party.guidebook1) : nil,
|
||||
'2' => party.guidebook2 ? GuidebookBlueprint.render_as_hash(party.guidebook2) : nil,
|
||||
'3' => party.guidebook3 ? GuidebookBlueprint.render_as_hash(party.guidebook3) : nil
|
||||
}
|
||||
end
|
||||
|
||||
association :accessory,
|
||||
blueprint: JobAccessoryBlueprint,
|
||||
if: ->(_field_name, party, _options) { party.accessory_id.present? }
|
||||
end
|
||||
|
||||
# Created view
|
||||
view :created do
|
||||
include_view :full
|
||||
fields :edit_key
|
||||
end
|
||||
|
||||
# Destroyed view
|
||||
view :destroyed do
|
||||
fields :name, :description, :created_at, :updated_at
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ module Api
|
|||
end
|
||||
|
||||
fields :slug, :level, :element
|
||||
|
||||
association :group, blueprint: RaidGroupBlueprint, view: :flat
|
||||
end
|
||||
|
||||
view :full do
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ module Api
|
|||
|
||||
view :full do
|
||||
include_view :flat
|
||||
association :raids, blueprint: RaidBlueprint, view: :full
|
||||
association :raids, blueprint: RaidBlueprint, view: :nested
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,42 +3,56 @@
|
|||
module Api
|
||||
module V1
|
||||
class SummonBlueprint < ApiBlueprint
|
||||
field :name do |w|
|
||||
field :name do |s|
|
||||
{
|
||||
en: w.name_en,
|
||||
ja: w.name_jp
|
||||
en: s.name_en,
|
||||
ja: s.name_jp
|
||||
}
|
||||
end
|
||||
|
||||
fields :granblue_id, :element, :rarity, :max_level
|
||||
|
||||
field :uncap do |w|
|
||||
field :uncap do |s|
|
||||
{
|
||||
flb: w.flb,
|
||||
ulb: w.ulb,
|
||||
transcendence: w.transcendence
|
||||
flb: s.flb,
|
||||
ulb: s.ulb,
|
||||
transcendence: s.transcendence
|
||||
}
|
||||
end
|
||||
|
||||
field :hp do |w|
|
||||
view :stats do
|
||||
field :hp do |s|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
max_hp: w.max_hp,
|
||||
max_hp_flb: w.max_hp_flb,
|
||||
max_hp_ulb: w.max_hp_ulb,
|
||||
max_hp_xlb: w.max_hp_xlb
|
||||
min_hp: s.min_hp,
|
||||
max_hp: s.max_hp,
|
||||
max_hp_flb: s.max_hp_flb,
|
||||
max_hp_ulb: s.max_hp_ulb,
|
||||
max_hp_xlb: s.max_hp_xlb
|
||||
}
|
||||
end
|
||||
|
||||
field :atk do |w|
|
||||
field :atk do |s|
|
||||
{
|
||||
min_atk: w.min_atk,
|
||||
max_atk: w.max_atk,
|
||||
max_atk_flb: w.max_atk_flb,
|
||||
max_atk_ulb: w.max_atk_ulb,
|
||||
max_atk_xlb: w.max_atk_xlb
|
||||
min_atk: s.min_atk,
|
||||
max_atk: s.max_atk,
|
||||
max_atk_flb: s.max_atk_flb,
|
||||
max_atk_ulb: s.max_atk_ulb,
|
||||
max_atk_xlb: s.max_atk_xlb
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
# Primary information
|
||||
fields :granblue_id, :element, :proficiency,
|
||||
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
|
||||
:series, :ax, :ax_type
|
||||
|
||||
# Uncap information
|
||||
field :uncap do |w|
|
||||
{
|
||||
flb: w.flb,
|
||||
|
|
@ -22,6 +24,7 @@ module Api
|
|||
}
|
||||
end
|
||||
|
||||
view :stats do
|
||||
field :hp do |w|
|
||||
{
|
||||
min_hp: w.min_hp,
|
||||
|
|
@ -39,11 +42,21 @@ module Api
|
|||
max_atk_ulb: w.max_atk_ulb
|
||||
}
|
||||
end
|
||||
|
||||
field :awakenings, if: ->(_fn, obj, _opt) { obj.awakenings.length.positive? } do |w|
|
||||
w.awakenings.map do |a|
|
||||
AwakeningBlueprint.render_as_hash(a)
|
||||
end
|
||||
|
||||
view :dates do
|
||||
field :release_date
|
||||
field :flb_date
|
||||
field :ulb_date
|
||||
field :transcendence_date
|
||||
end
|
||||
|
||||
view :full do
|
||||
include_view :stats
|
||||
include_view :dates
|
||||
association :awakenings,
|
||||
blueprint: AwakeningBlueprint,
|
||||
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ module Api
|
|||
##### Hooks
|
||||
before_action :current_user
|
||||
before_action :default_content_type
|
||||
around_action :n_plus_one_detection, unless: -> { Rails.env.production? }
|
||||
|
||||
##### Responders
|
||||
respond_to :json
|
||||
|
|
@ -119,6 +120,13 @@ module Api
|
|||
def restrict_access
|
||||
raise UnauthorizedError unless current_user
|
||||
end
|
||||
|
||||
def n_plus_one_detection
|
||||
Prosopite.scan
|
||||
yield
|
||||
ensure
|
||||
Prosopite.finish
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,18 +39,23 @@ module Api
|
|||
end
|
||||
|
||||
def update
|
||||
mastery = {}
|
||||
%i[ring1 ring2 ring3 ring4 earring awakening].each do |key|
|
||||
value = character_params.to_h[key]
|
||||
mastery[key] = value unless value.nil?
|
||||
end
|
||||
permitted = character_params.to_h.deep_symbolize_keys
|
||||
puts "Permitted:"
|
||||
ap permitted
|
||||
|
||||
@character.attributes = character_params.merge(mastery)
|
||||
# For the new nested structure, assign them to the virtual attributes:
|
||||
@character.new_rings = permitted[:rings] if permitted[:rings].present?
|
||||
@character.new_awakening = permitted[:awakening] if permitted[:awakening].present?
|
||||
|
||||
return render json: GridCharacterBlueprint.render(@character, view: :full) if @character.save
|
||||
# For the rest of the attributes, you can assign them normally.
|
||||
@character.assign_attributes(permitted.except(:rings, :awakening))
|
||||
|
||||
if @character.save
|
||||
render json: GridCharacterBlueprint.render(@character, view: :nested)
|
||||
else
|
||||
render_validation_error_response(@character)
|
||||
end
|
||||
end
|
||||
|
||||
def resolve
|
||||
incoming = Character.find(resolve_params[:incoming])
|
||||
|
|
@ -123,7 +128,7 @@ module Api
|
|||
end
|
||||
|
||||
def set
|
||||
@character = GridCharacter.find(params[:id])
|
||||
@character = GridCharacter.includes(:awakening).find(params[:id])
|
||||
end
|
||||
|
||||
def find_incoming_character
|
||||
|
|
@ -143,14 +148,59 @@ module Api
|
|||
render_unauthorized_response if unauthorized_create || unauthorized_update
|
||||
end
|
||||
|
||||
def transform_character_params(raw_params)
|
||||
# Convert to a symbolized hash for convenience.
|
||||
raw = raw_params.deep_symbolize_keys
|
||||
|
||||
# Only update keys that were provided.
|
||||
transformed = raw.slice(:uncap_level, :transcendence_step, :perpetuity)
|
||||
transformed[:uncap_level] = raw[:uncap_level].to_i if raw[:uncap_level].present?
|
||||
transformed[:transcendence_step] = raw[:transcendence_step].to_i if raw[:transcendence_step].present?
|
||||
|
||||
# Process rings if provided.
|
||||
transformed.merge!(transform_rings(raw[:rings])) if raw[:rings].present?
|
||||
|
||||
# Process earring if provided.
|
||||
transformed[:earring] = raw[:earring] if raw[:earring].present?
|
||||
|
||||
# Process awakening if provided.
|
||||
if raw[:awakening].present?
|
||||
transformed[:awakening_id] = raw[:awakening][:id]
|
||||
# Default to 1 if level is missing (to satisfy validations)
|
||||
transformed[:awakening_level] = raw[:awakening][:level].present? ? raw[:awakening][:level].to_i : 1
|
||||
end
|
||||
|
||||
transformed
|
||||
end
|
||||
|
||||
def transform_rings(rings)
|
||||
default_ring = { modifier: nil, strength: nil }
|
||||
# Ensure rings is an array of hashes.
|
||||
rings_array = Array(rings).map(&:to_h)
|
||||
# Pad the array to exactly four rings if needed.
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
{
|
||||
ring1: rings_array[0],
|
||||
ring2: rings_array[1],
|
||||
ring3: rings_array[2],
|
||||
ring4: rings_array[3]
|
||||
}
|
||||
end
|
||||
|
||||
# Specify whitelisted properties that can be modified.
|
||||
def character_params
|
||||
params.require(:character).permit(:id, :party_id, :character_id, :position,
|
||||
:uncap_level, :transcendence_step, :perpetuity,
|
||||
:awakening_id, :awakening_level,
|
||||
ring1: %i[modifier strength], ring2: %i[modifier strength],
|
||||
ring3: %i[modifier strength], ring4: %i[modifier strength],
|
||||
earring: %i[modifier strength])
|
||||
params.require(:character).permit(
|
||||
:id,
|
||||
:party_id,
|
||||
:character_id,
|
||||
:position,
|
||||
:uncap_level,
|
||||
:transcendence_step,
|
||||
:perpetuity,
|
||||
awakening: %i[id level],
|
||||
rings: %i[modifier strength],
|
||||
earring: %i[modifier strength]
|
||||
)
|
||||
end
|
||||
|
||||
def resolve_params
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ module Api
|
|||
module V1
|
||||
class JobSkillsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: JobSkillBlueprint.render(JobSkill.all)
|
||||
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
|
||||
end
|
||||
|
||||
def job
|
||||
@skills = JobSkill.where('job_id != ? AND emp = ?', params[:id], true)
|
||||
@skills = JobSkill.includes(:job)
|
||||
.where.not(job_id: params[:id])
|
||||
.where(emp: true)
|
||||
render json: JobSkillBlueprint.render(@skills)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,40 +2,65 @@
|
|||
|
||||
module Api
|
||||
module V1
|
||||
# Controller for managing party-related operations in the API
|
||||
# @api public
|
||||
class PartiesController < Api::V1::ApiController
|
||||
before_action :set_from_slug,
|
||||
except: %w[create destroy update index favorites]
|
||||
before_action :set, only: %w[update destroy]
|
||||
before_action :authorize, only: %w[update destroy]
|
||||
|
||||
# == Constants
|
||||
|
||||
# Maximum number of characters allowed in a party
|
||||
MAX_CHARACTERS = 5
|
||||
|
||||
# Maximum number of summons allowed in a party
|
||||
MAX_SUMMONS = 8
|
||||
|
||||
# Maximum number of weapons allowed in a party
|
||||
MAX_WEAPONS = 13
|
||||
|
||||
# Default minimum number of characters required for filtering
|
||||
DEFAULT_MIN_CHARACTERS = 3
|
||||
|
||||
# Default minimum number of summons required for filtering
|
||||
DEFAULT_MIN_SUMMONS = 2
|
||||
|
||||
# Default minimum number of weapons required for filtering
|
||||
DEFAULT_MIN_WEAPONS = 5
|
||||
|
||||
# Default maximum clear time in seconds
|
||||
DEFAULT_MAX_CLEAR_TIME = 5400
|
||||
|
||||
def create
|
||||
party = Party.new
|
||||
party.user = current_user if current_user
|
||||
party.attributes = party_params if party_params
|
||||
# == Primary CRUD Actions
|
||||
|
||||
if party_params && party_params[:raid_id]
|
||||
raid = Raid.find_by(id: party_params[:raid_id])
|
||||
# Creates a new party with optional user association
|
||||
# @return [void]
|
||||
def create
|
||||
# Build the party with the provided parameters and assign the user
|
||||
party = Party.new(party_params)
|
||||
party.user = current_user if current_user
|
||||
|
||||
# If a raid_id is given, look it up and assign the extra flag from its group.
|
||||
if party_params && party_params[:raid_id].present?
|
||||
if (raid = Raid.find_by(id: party_params[:raid_id]))
|
||||
party.extra = raid.group.extra
|
||||
end
|
||||
end
|
||||
|
||||
if party.save!
|
||||
return render json: PartyBlueprint.render(party, view: :created, root: :party),
|
||||
# Save and render the party, triggering preview generation if the party is ready
|
||||
if party.save
|
||||
party.schedule_preview_generation if party.ready_for_preview?
|
||||
render json: PartyBlueprint.render(party, view: :created, root: :party),
|
||||
status: :created
|
||||
else
|
||||
render_validation_error_response(party)
|
||||
end
|
||||
end
|
||||
|
||||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Shows a specific party if the user has permission to view it
|
||||
# @return [void]
|
||||
def show
|
||||
# If a party is private, check that the user is the owner or an admin
|
||||
if (@party.private? && !current_user) || (@party.private? && not_owner && !admin_mode)
|
||||
|
|
@ -47,6 +72,8 @@ module Api
|
|||
render_not_found_response('project')
|
||||
end
|
||||
|
||||
# Updates an existing party's attributes
|
||||
# @return [void]
|
||||
def update
|
||||
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
|
||||
|
||||
|
|
@ -62,10 +89,16 @@ module Api
|
|||
render_validation_error_response(@party)
|
||||
end
|
||||
|
||||
# Deletes a party if the user has permission
|
||||
# @return [void]
|
||||
def destroy
|
||||
return render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy
|
||||
end
|
||||
|
||||
# == Extended Party Actions
|
||||
|
||||
# Creates a copy of an existing party with attribution
|
||||
# @return [void]
|
||||
def remix
|
||||
new_party = @party.amoeba_dup
|
||||
new_party.attributes = {
|
||||
|
|
@ -78,6 +111,8 @@ module Api
|
|||
new_party.local_id = party_params[:local_id] unless party_params.nil?
|
||||
|
||||
if new_party.save
|
||||
# Remixed parties should have content, so generate preview
|
||||
new_party.schedule_preview_generation
|
||||
render json: PartyBlueprint.render(new_party, view: :created, root: :party),
|
||||
status: :created
|
||||
else
|
||||
|
|
@ -85,37 +120,30 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
# Lists parties based on various filter criteria
|
||||
# @return [void]
|
||||
def index
|
||||
conditions = build_filters
|
||||
|
||||
query = build_query(conditions)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
render_party_json(@parties, count, total_pages)
|
||||
query = build_parties_query
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
||||
# Lists parties favorited by the current user
|
||||
# @return [void]
|
||||
def favorites
|
||||
raise Api::V1::UnauthorizedError unless current_user
|
||||
|
||||
conditions = build_filters
|
||||
conditions[:favorites] = { user_id: current_user.id }
|
||||
|
||||
query = build_query(conditions, favorites: true)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
|
||||
@parties = fetch_parties(query)
|
||||
count = calculate_count(query)
|
||||
total_pages = calculate_total_pages(count)
|
||||
|
||||
render_party_json(@parties, count, total_pages)
|
||||
query = build_parties_query(favorites: true)
|
||||
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
# Mark each party as favorited (if needed)
|
||||
@parties.each { |party| party.favorited = true }
|
||||
render_paginated_parties(@parties)
|
||||
end
|
||||
|
||||
# == Preview Management
|
||||
|
||||
# Serves the party's preview image
|
||||
# @return [void]
|
||||
def preview
|
||||
coordinator = PreviewService::Coordinator.new(@party)
|
||||
|
||||
|
|
@ -154,6 +182,19 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
# Returns the current status of a party's preview
|
||||
# @return [void]
|
||||
def preview_status
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
render json: {
|
||||
state: party.preview_state,
|
||||
generated_at: party.preview_generated_at,
|
||||
ready_for_preview: party.ready_for_preview?
|
||||
}
|
||||
end
|
||||
|
||||
# Forces regeneration of a party's preview image
|
||||
# @return [void]
|
||||
def regenerate_preview
|
||||
party = Party.find_by!(shortcode: params[:id])
|
||||
|
||||
|
|
@ -173,12 +214,67 @@ module Api
|
|||
|
||||
private
|
||||
|
||||
# Builds the base query for parties, optionally including favorites-specific conditions.
|
||||
def build_parties_query(favorites: false)
|
||||
query = Party.includes(
|
||||
{ raid: :group },
|
||||
:job,
|
||||
:user,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
{ characters: :character },
|
||||
{ weapons: :weapon },
|
||||
{ summons: :summon }
|
||||
)
|
||||
# Add favorites join and condition if favorites is true.
|
||||
if favorites
|
||||
query = query.joins(:favorites)
|
||||
.where(favorites: { user_id: current_user.id })
|
||||
.distinct
|
||||
query = query.order(created_at: :desc)
|
||||
else
|
||||
query = query.order(visibility: :asc, created_at: :desc)
|
||||
end
|
||||
|
||||
query = apply_filters(query)
|
||||
query = apply_privacy_settings(query)
|
||||
query = apply_includes(query, params[:includes]) if params[:includes].present?
|
||||
query = apply_excludes(query, params[:excludes]) if params[:excludes].present?
|
||||
query
|
||||
end
|
||||
|
||||
# Renders the paginated parties with blueprint and meta data.
|
||||
def render_paginated_parties(parties)
|
||||
render json: PartyBlueprint.render(
|
||||
parties,
|
||||
view: :preview,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: parties.total_entries,
|
||||
total_pages: parties.total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
},
|
||||
current_user: current_user
|
||||
)
|
||||
end
|
||||
|
||||
# == Authorization Helpers
|
||||
|
||||
# Checks if the current user is authorized to modify the party
|
||||
# @return [void]
|
||||
def authorize
|
||||
return unless not_owner && !admin_mode
|
||||
|
||||
render_unauthorized_response
|
||||
end
|
||||
|
||||
# Determines if the current user is not the owner of the party
|
||||
# @return [Boolean]
|
||||
def not_owner
|
||||
if @party.user
|
||||
# party has a user and current_user does not match
|
||||
|
|
@ -197,54 +293,139 @@ module Api
|
|||
false
|
||||
end
|
||||
|
||||
def build_filters
|
||||
params = request.params
|
||||
# == Preview Generation
|
||||
|
||||
start_time = build_start_time(params['recency'])
|
||||
|
||||
min_characters_count = build_count(params['characters_count'], DEFAULT_MIN_CHARACTERS)
|
||||
min_summons_count = build_count(params['summons_count'], DEFAULT_MIN_SUMMONS)
|
||||
min_weapons_count = build_count(params['weapons_count'], DEFAULT_MIN_WEAPONS)
|
||||
max_clear_time = build_max_clear_time(params['max_clear_time'])
|
||||
|
||||
{
|
||||
element: build_element(params['element']),
|
||||
raid: params['raid'],
|
||||
created_at: params['recency'].present? ? start_time..DateTime.current : nil,
|
||||
full_auto: build_option(params['full_auto']),
|
||||
auto_guard: build_option(params['auto_guard']),
|
||||
charge_attack: build_option(params['charge_attack']),
|
||||
characters_count: min_characters_count..MAX_CHARACTERS,
|
||||
summons_count: min_summons_count..MAX_SUMMONS,
|
||||
weapons_count: min_weapons_count..MAX_WEAPONS
|
||||
}.delete_if { |_k, v| v.nil? }
|
||||
# Schedules a background job to generate the party preview
|
||||
# @return [void]
|
||||
def schedule_preview_generation
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
# == Query Building Helpers
|
||||
|
||||
def apply_filters(query)
|
||||
conditions = build_filters
|
||||
|
||||
# Use the compound indexes effectively
|
||||
query = query.where(conditions)
|
||||
.where(name_quality) if params[:name_quality].present?
|
||||
|
||||
# Use the counters index
|
||||
query = query.where(
|
||||
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS,
|
||||
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
|
||||
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS
|
||||
)
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def apply_privacy_settings(query)
|
||||
return query if admin_mode
|
||||
|
||||
if params[:favorites].present?
|
||||
query.where('visibility < 3')
|
||||
else
|
||||
query.where(visibility: 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds filter conditions from request parameters
|
||||
# @return [Hash] conditions for the query
|
||||
def build_filters
|
||||
{
|
||||
element: params[:element].present? ? params[:element].to_i : nil,
|
||||
raid_id: params[:raid],
|
||||
created_at: build_date_range,
|
||||
full_auto: build_option(params[:full_auto]),
|
||||
auto_guard: build_option(params[:auto_guard]),
|
||||
charge_attack: build_option(params[:charge_attack]),
|
||||
characters_count: build_count(params[:characters_count], DEFAULT_MIN_CHARACTERS)..MAX_CHARACTERS,
|
||||
summons_count: build_count(params[:summons_count], DEFAULT_MIN_SUMMONS)..MAX_SUMMONS,
|
||||
weapons_count: build_count(params[:weapons_count], DEFAULT_MIN_WEAPONS)..MAX_WEAPONS
|
||||
}.compact
|
||||
end
|
||||
|
||||
def build_date_range
|
||||
return nil unless params[:recency].present?
|
||||
|
||||
start_time = DateTime.current - params[:recency].to_i.seconds
|
||||
start_time.beginning_of_day..DateTime.current
|
||||
end
|
||||
|
||||
# Paginates the given query of parties and marks favorites for the current user
|
||||
#
|
||||
# @param query [ActiveRecord::Relation] The base query containing parties
|
||||
# @param page [Integer, nil] The page number for pagination (defaults to `params[:page]`)
|
||||
# @param per_page [Integer] The number of records per page (defaults to `COLLECTION_PER_PAGE`)
|
||||
# @return [ActiveRecord::Relation] The paginated and processed list of parties
|
||||
#
|
||||
# This method orders parties by creation date in descending order, applies pagination,
|
||||
# and marks each party as favorited if the current user has favorited it.
|
||||
def paginate_parties(query, page: nil, per_page: COLLECTION_PER_PAGE)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: page || params[:page], per_page: per_page)
|
||||
.tap do |parties|
|
||||
if current_user
|
||||
parties.each { |party| party.favorited = party.is_favorited(current_user) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Parameter Processing Helpers
|
||||
|
||||
# Converts start time parameter for filtering
|
||||
# @param recency [String, nil] time period in seconds
|
||||
# @return [DateTime, nil] calculated start time
|
||||
def build_start_time(recency)
|
||||
return unless recency.present?
|
||||
|
||||
(DateTime.current - recency.to_i.seconds).to_datetime.beginning_of_day
|
||||
end
|
||||
|
||||
# Builds count parameter with default fallback
|
||||
# @param value [String, nil] count value
|
||||
# @param default [Integer] default value
|
||||
# @return [Integer] processed count
|
||||
def build_count(value, default)
|
||||
value.blank? ? default : value.to_i
|
||||
end
|
||||
|
||||
# Processes maximum clear time parameter
|
||||
# @param value [String, nil] clear time value in seconds
|
||||
# @return [Integer] processed maximum clear time
|
||||
def build_max_clear_time(value)
|
||||
value.blank? ? DEFAULT_MAX_CLEAR_TIME : value.to_i
|
||||
end
|
||||
|
||||
# Processes element parameter
|
||||
# @param element [String, nil] element identifier
|
||||
# @return [Integer, nil] processed element value
|
||||
def build_element(element)
|
||||
element.to_i unless element.blank?
|
||||
end
|
||||
|
||||
# Processes boolean option parameters
|
||||
# @param value [String, nil] option value
|
||||
# @return [Integer, nil] processed option value
|
||||
def build_option(value)
|
||||
value.to_i unless value.blank? || value.to_i == -1
|
||||
end
|
||||
|
||||
# == Query Building Helpers
|
||||
|
||||
# Constructs the main query for party filtering
|
||||
# @param conditions [Hash] filter conditions
|
||||
# @param favorites [Boolean] whether to include favorites
|
||||
# @return [ActiveRecord::Relation] constructed query
|
||||
def build_query(conditions, favorites: false)
|
||||
query = Party.distinct
|
||||
.joins(weapons: [:object], summons: [:object], characters: [:object])
|
||||
# joins vs includes? -> reduces n+1s
|
||||
.preload(
|
||||
weapons: { object: %i[name_en name_jp granblue_id element] },
|
||||
summons: { object: %i[name_en name_jp granblue_id element] },
|
||||
characters: { object: %i[name_en name_jp granblue_id element] }
|
||||
)
|
||||
.group('parties.id')
|
||||
.where(conditions)
|
||||
.where(privacy(favorites: favorites))
|
||||
|
|
@ -252,104 +433,158 @@ module Api
|
|||
.where(user_quality)
|
||||
.where(original)
|
||||
|
||||
query = query.joins(:favorites) if favorites
|
||||
query = query.includes(:favorites) if favorites
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
def includes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
|
||||
end
|
||||
|
||||
def excludes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
|
||||
end
|
||||
|
||||
# Applies the include conditions to query
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @param includes [String] comma-separated list of IDs to include
|
||||
# @return [ActiveRecord::Relation] modified query
|
||||
def apply_includes(query, includes)
|
||||
included = includes.split(',')
|
||||
includes_condition = included.map { |id| includes(id) }.join(' AND ')
|
||||
query.where(includes_condition)
|
||||
return query unless includes.present?
|
||||
|
||||
includes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
|
||||
# Build a subquery that joins the grid table to the object table.
|
||||
condition = <<-SQL.squish
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ?
|
||||
AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
|
||||
def apply_excludes(query, _excludes)
|
||||
characters_subquery = excluded_characters.select(1).arel
|
||||
summons_subquery = excluded_summons.select(1).arel
|
||||
weapons_subquery = excluded_weapons.select(1).arel
|
||||
|
||||
query.where(characters_subquery.exists.not)
|
||||
.where(weapons_subquery.exists.not)
|
||||
.where(summons_subquery.exists.not)
|
||||
query
|
||||
end
|
||||
|
||||
# Applies the exclude conditions to query
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @return [ActiveRecord::Relation] modified query
|
||||
def apply_excludes(query, excludes)
|
||||
return query unless excludes.present?
|
||||
|
||||
excludes.split(',').each do |id|
|
||||
grid_table, object_table = grid_table_and_object_table(id)
|
||||
next unless grid_table && object_table
|
||||
|
||||
condition = <<-SQL.squish
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM #{grid_table}
|
||||
JOIN #{object_table} ON #{grid_table}.#{object_table.singularize}_id = #{object_table}.id
|
||||
WHERE #{object_table}.granblue_id = ?
|
||||
AND #{grid_table}.party_id = parties.id
|
||||
)
|
||||
SQL
|
||||
|
||||
query = query.where(condition, id)
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
# == Query Filtering Helpers
|
||||
|
||||
# Generates subquery for excluded characters
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_characters
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '3' }
|
||||
GridCharacter.joins(:object)
|
||||
GridCharacter.includes(:object)
|
||||
.where(characters: { granblue_id: excluded })
|
||||
.where('grid_characters.party_id = parties.id')
|
||||
end
|
||||
|
||||
# Generates subquery for excluded summons
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_summons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '2' }
|
||||
GridSummon.joins(:object)
|
||||
GridSummon.includes(:object)
|
||||
.where(summons: { granblue_id: excluded })
|
||||
.where('grid_summons.party_id = parties.id')
|
||||
end
|
||||
|
||||
# Generates subquery for excluded weapons
|
||||
# @return [ActiveRecord::Relation, nil] exclusion query
|
||||
def excluded_weapons
|
||||
return unless params[:excludes]
|
||||
|
||||
excluded = params[:excludes].split(',').filter { |id| id[0] == '1' }
|
||||
GridWeapon.joins(:object)
|
||||
GridWeapon.includes(:object)
|
||||
.where(weapons: { granblue_id: excluded })
|
||||
.where('grid_weapons.party_id = parties.id')
|
||||
end
|
||||
|
||||
# == Query Processing
|
||||
|
||||
# Fetches and processes parties query with pagination
|
||||
# @param query [ActiveRecord::Relation] base query
|
||||
# @return [ActiveRecord::Relation] processed and paginated parties
|
||||
def fetch_parties(query)
|
||||
query.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each { |party| party.favorited = current_user ? party.is_favorited(current_user) : false }
|
||||
end
|
||||
|
||||
# Calculates total count for pagination
|
||||
# @param query [ActiveRecord::Relation] current query
|
||||
# @return [Integer] total count
|
||||
def calculate_count(query)
|
||||
query.count.values.sum
|
||||
# query.count.values.sum
|
||||
query.count
|
||||
end
|
||||
|
||||
# Calculates total pages for pagination
|
||||
# @param count [Integer] total record count
|
||||
# @return [Integer] total pages
|
||||
def calculate_total_pages(count)
|
||||
count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
|
||||
# count.to_f / COLLECTION_PER_PAGE > 1 ? (count.to_f / COLLECTION_PER_PAGE).ceil : 1
|
||||
(count.to_f / COLLECTION_PER_PAGE).ceil
|
||||
end
|
||||
|
||||
def render_party_json(parties, count, total_pages)
|
||||
render json: PartyBlueprint.render(parties,
|
||||
view: :collection,
|
||||
root: :results,
|
||||
meta: {
|
||||
count: count,
|
||||
total_pages: total_pages,
|
||||
per_page: COLLECTION_PER_PAGE
|
||||
})
|
||||
# == Include/Exclude Processing
|
||||
|
||||
# Generates SQL for including specific items
|
||||
# @param id [String] item identifier
|
||||
# @return [String] SQL condition
|
||||
def includes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" = '#{id}')"
|
||||
end
|
||||
|
||||
def privacy(favorites: false)
|
||||
return if admin_mode
|
||||
|
||||
if favorites
|
||||
'visibility < 3'
|
||||
else
|
||||
'visibility = 1'
|
||||
end
|
||||
# Generates SQL for excluding specific items
|
||||
# @param id [String] item identifier
|
||||
# @return [String] SQL condition
|
||||
def excludes(id)
|
||||
"(\"#{id_to_table(id)}\".\"granblue_id\" != '#{id}')"
|
||||
end
|
||||
|
||||
# == Filter Condition Helpers
|
||||
|
||||
# Generates user quality condition
|
||||
# @return [String, nil] SQL condition for user quality
|
||||
def user_quality
|
||||
return if request.params[:user_quality].blank? || request.params[:user_quality] == 'false'
|
||||
return if params[:user_quality].blank? || params[:user_quality] == 'false'
|
||||
|
||||
'user_id IS NOT NULL'
|
||||
end
|
||||
|
||||
# Generates name quality condition
|
||||
# @return [String, nil] SQL condition for name quality
|
||||
def name_quality
|
||||
return if params[:name_quality].blank? || params[:name_quality] == 'false'
|
||||
|
||||
low_quality = [
|
||||
'Untitled',
|
||||
'Remix of Untitled',
|
||||
|
|
@ -364,33 +599,54 @@ module Api
|
|||
'無題のリミックスのリミックスのリミックスのリミックス',
|
||||
'無題のリミックスのリミックスのリミックスのリミックスのリミックス'
|
||||
]
|
||||
|
||||
joined_names = low_quality.map { |name| "'#{name}'" }.join(',')
|
||||
|
||||
return if request.params[:name_quality].blank? || request.params[:name_quality] == 'false'
|
||||
|
||||
"name NOT IN (#{joined_names})"
|
||||
end
|
||||
|
||||
# Generates original party condition
|
||||
# @return [String, nil] SQL condition for original parties
|
||||
def original
|
||||
return if request.params['original'].blank? || request.params['original'] == 'false'
|
||||
return if params['original'].blank? || params['original'] == 'false'
|
||||
|
||||
'source_party_id IS NULL'
|
||||
end
|
||||
|
||||
def id_to_table(id)
|
||||
# == Filter Condition Helpers
|
||||
|
||||
# Generates privacy condition based on favorites
|
||||
# @param favorites [Boolean] whether viewing favorites
|
||||
# @return [String, nil] SQL condition
|
||||
def privacy(favorites: false)
|
||||
return if admin_mode
|
||||
|
||||
if favorites
|
||||
'visibility < 3'
|
||||
else
|
||||
'visibility = 1'
|
||||
end
|
||||
end
|
||||
|
||||
# == Utility Methods
|
||||
|
||||
# Maps ID prefixes to table names
|
||||
# @param id [String] item identifier
|
||||
# @return [Array(String, String)] corresponding table name
|
||||
def grid_table_and_object_table(id)
|
||||
case id[0]
|
||||
when '3'
|
||||
table = 'characters'
|
||||
%w[grid_characters characters]
|
||||
when '2'
|
||||
table = 'summons'
|
||||
%w[grid_summons summons]
|
||||
when '1'
|
||||
table = 'weapons'
|
||||
end
|
||||
|
||||
table
|
||||
%w[grid_weapons weapons]
|
||||
else
|
||||
[nil, nil]
|
||||
end
|
||||
end
|
||||
|
||||
# Generates name for remixed party
|
||||
# @param name [String] original party name
|
||||
# @return [String] generated remix name
|
||||
def remixed_name(name)
|
||||
blanked_name = {
|
||||
en: name.blank? ? 'Untitled team' : name,
|
||||
|
|
@ -409,19 +665,54 @@ module Api
|
|||
end
|
||||
end
|
||||
|
||||
# == Party Loading
|
||||
|
||||
# Loads party by shortcode for routes using :id
|
||||
# @return [void]
|
||||
def set_from_slug
|
||||
@party = Party.where('shortcode = ?', params[:id]).first
|
||||
if @party
|
||||
@party.favorited = current_user && @party ? @party.is_favorited(current_user) : false
|
||||
else
|
||||
render_not_found_response('party')
|
||||
end
|
||||
@party = Party.includes(
|
||||
:user,
|
||||
:job,
|
||||
{ raid: :group },
|
||||
{ characters: [:character, :awakening] },
|
||||
{
|
||||
weapons: {
|
||||
# Eager load the associated weapon and its awakenings.
|
||||
weapon: [:awakenings],
|
||||
# Eager load the grid weapon’s own awakening (if applicable).
|
||||
awakening: {},
|
||||
# Eager load any weapon key associations.
|
||||
weapon_key1: {},
|
||||
weapon_key2: {},
|
||||
weapon_key3: {}
|
||||
}
|
||||
},
|
||||
{ summons: :summon },
|
||||
:guidebook1,
|
||||
:guidebook2,
|
||||
:guidebook3,
|
||||
:source_party,
|
||||
:remixes,
|
||||
:skill0,
|
||||
:skill1,
|
||||
:skill2,
|
||||
:skill3,
|
||||
:accessory
|
||||
).find_by(shortcode: params[:id])
|
||||
|
||||
render_not_found_response('party') unless @party
|
||||
end
|
||||
|
||||
# Loads party by ID for update/destroy actions
|
||||
# @return [void]
|
||||
def set
|
||||
@party = Party.where('id = ?', params[:id]).first
|
||||
end
|
||||
|
||||
# == Parameter Sanitization
|
||||
|
||||
# Sanitizes and permits party parameters
|
||||
# @return [Hash, nil] permitted parameters
|
||||
def party_params
|
||||
return unless params[:party].present?
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ module Api
|
|||
module V1
|
||||
class RaidsController < Api::V1::ApiController
|
||||
def all
|
||||
render json: RaidBlueprint.render(Raid.all, view: :full)
|
||||
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested)
|
||||
end
|
||||
|
||||
def show
|
||||
|
|
@ -13,7 +13,7 @@ module Api
|
|||
end
|
||||
|
||||
def groups
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.all, view: :full)
|
||||
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -58,17 +58,19 @@ module Api
|
|||
conditions = build_conditions
|
||||
conditions[:user_id] = @user.id
|
||||
|
||||
parties = Party
|
||||
.where(conditions)
|
||||
favorites_query = "EXISTS (SELECT 1 FROM favorites WHERE favorites.party_id = parties.id AND favorites.user_id = #{current_user&.id || 'NULL'}) AS is_favorited"
|
||||
parties = Party.where(conditions)
|
||||
.where(name_quality)
|
||||
.where(user_quality)
|
||||
.where(original)
|
||||
.where(privacy)
|
||||
.includes(:favorites)
|
||||
.select(Party.arel_table[Arel.star])
|
||||
.select(
|
||||
Arel.sql(favorites_query)
|
||||
)
|
||||
.order(created_at: :desc)
|
||||
.paginate(page: request.params[:page], per_page: COLLECTION_PER_PAGE)
|
||||
.each do |party|
|
||||
party.favorited = current_user ? party.is_favorited(current_user) : false
|
||||
end
|
||||
|
||||
count = Party.where(conditions).count
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Awakening < ApplicationRecord
|
||||
def weapon_awakenings
|
||||
WeaponAwakening.where(awakening_id: id)
|
||||
end
|
||||
|
||||
def weapons
|
||||
weapon_awakenings.map(&:weapon)
|
||||
end
|
||||
has_many :weapon_awakenings, foreign_key: :awakening_id
|
||||
has_many :weapons, through: :weapon_awakenings
|
||||
|
||||
def awakening
|
||||
AwakeningBlueprint
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ class Character < ApplicationRecord
|
|||
}
|
||||
}
|
||||
|
||||
AWAKENINGS = [
|
||||
{ slug: 'character-balanced', name_en: 'Balanced', name_jp: 'バランス', order: 0 },
|
||||
{ slug: 'character-atk', name_en: 'Attack', name_jp: '攻撃', order: 1 },
|
||||
{ slug: 'character-def', name_en: 'Defense', name_jp: '防御', order: 2 },
|
||||
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
|
||||
].freeze
|
||||
|
||||
def blueprint
|
||||
CharacterBlueprint
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GridCharacter < ApplicationRecord
|
||||
has_one :object, class_name: 'Character', foreign_key: :id, primary_key: :character_id
|
||||
belongs_to :character, foreign_key: :character_id, primary_key: :id
|
||||
|
||||
belongs_to :awakening, optional: true
|
||||
belongs_to :party,
|
||||
|
|
@ -15,6 +15,12 @@ class GridCharacter < ApplicationRecord
|
|||
validate :validate_aetherial_mastery_value, on: :update
|
||||
validate :over_mastery_attack_matches_hp, on: :update
|
||||
|
||||
# Virtual attribute for the new rings structure
|
||||
attr_accessor :new_rings
|
||||
|
||||
# Virtual attribute for the new awakening structure
|
||||
attr_accessor :new_awakening
|
||||
|
||||
##### Amoeba configuration
|
||||
amoeba do
|
||||
set ring1: { modifier: nil, strength: nil }
|
||||
|
|
@ -25,6 +31,9 @@ class GridCharacter < ApplicationRecord
|
|||
set perpetuity: false
|
||||
end
|
||||
|
||||
before_validation :apply_new_rings, if: -> { new_rings.present? }
|
||||
before_validation :apply_new_awakening, if: -> { new_awakening.present? }
|
||||
|
||||
# Add awakening before the model saves
|
||||
before_save :add_awakening
|
||||
|
||||
|
|
@ -78,10 +87,6 @@ class GridCharacter < ApplicationRecord
|
|||
'aetherial_mastery')
|
||||
end
|
||||
|
||||
def character
|
||||
Character.find(character_id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
GridCharacterBlueprint
|
||||
end
|
||||
|
|
@ -94,6 +99,24 @@ class GridCharacter < ApplicationRecord
|
|||
self.awakening = Awakening.where(slug: 'character-balanced').sole
|
||||
end
|
||||
|
||||
def apply_new_rings
|
||||
# Expect new_rings to be an array of hashes, e.g.,
|
||||
# [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}]
|
||||
default_ring = { "modifier" => nil, "strength" => nil }
|
||||
rings_array = Array(new_rings).map(&:to_h)
|
||||
# Pad with defaults so there are exactly four rings
|
||||
rings_array.fill(default_ring, rings_array.size...4)
|
||||
self.ring1 = rings_array[0]
|
||||
self.ring2 = rings_array[1]
|
||||
self.ring3 = rings_array[2]
|
||||
self.ring4 = rings_array[3]
|
||||
end
|
||||
|
||||
def apply_new_awakening
|
||||
self.awakening_id = new_awakening[:id]
|
||||
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
|
||||
end
|
||||
|
||||
def check_value(property, type)
|
||||
# Input format
|
||||
# { ring1: { atk: 300 } }
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GridSummon < ApplicationRecord
|
||||
belongs_to :summon, foreign_key: :summon_id, primary_key: :id
|
||||
|
||||
belongs_to :party,
|
||||
counter_cache: :summons_count,
|
||||
inverse_of: :summons
|
||||
validates_presence_of :party
|
||||
has_one :object, class_name: 'Summon', foreign_key: :id, primary_key: :summon_id
|
||||
|
||||
validate :compatible_with_position, on: :create
|
||||
validate :no_conflicts, on: :create
|
||||
|
||||
def summon
|
||||
Summon.find(summon_id)
|
||||
end
|
||||
|
||||
def blueprint
|
||||
GridSummonBlueprint
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GridWeapon < ApplicationRecord
|
||||
belongs_to :weapon, foreign_key: :weapon_id, primary_key: :id
|
||||
|
||||
belongs_to :party,
|
||||
counter_cache: :weapons_count,
|
||||
inverse_of: :weapons
|
||||
validates_presence_of :party
|
||||
|
||||
has_one :object, class_name: 'Weapon', foreign_key: :id, primary_key: :weapon_id
|
||||
|
||||
belongs_to :weapon_key1, class_name: 'WeaponKey', foreign_key: :weapon_key1_id, optional: true
|
||||
belongs_to :weapon_key2, class_name: 'WeaponKey', foreign_key: :weapon_key2_id, optional: true
|
||||
belongs_to :weapon_key3, class_name: 'WeaponKey', foreign_key: :weapon_key3_id, optional: true
|
||||
|
|
@ -33,10 +33,6 @@ class GridWeapon < ApplicationRecord
|
|||
GridWeaponBlueprint
|
||||
end
|
||||
|
||||
def weapon
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def weapon_keys
|
||||
[weapon_key1, weapon_key2, weapon_key3].compact
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ class Party < ApplicationRecord
|
|||
foreign_key: :source_party_id,
|
||||
optional: true
|
||||
|
||||
has_many :derivative_parties,
|
||||
has_many :remixes, -> { order(created_at: :desc) },
|
||||
class_name: 'Party',
|
||||
foreign_key: :source_party_id,
|
||||
foreign_key: 'source_party_id',
|
||||
inverse_of: :source_party,
|
||||
dependent: :nullify
|
||||
|
||||
|
|
@ -60,18 +60,21 @@ class Party < ApplicationRecord
|
|||
has_many :characters,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridCharacter',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :weapons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridWeapon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
has_many :summons,
|
||||
foreign_key: 'party_id',
|
||||
class_name: 'GridSummon',
|
||||
counter_cache: true,
|
||||
dependent: :destroy,
|
||||
inverse_of: :party
|
||||
|
||||
|
|
@ -103,8 +106,6 @@ class Party < ApplicationRecord
|
|||
validate :skills_are_unique
|
||||
validate :guidebooks_are_unique
|
||||
|
||||
attr_accessor :favorited
|
||||
|
||||
self.enum :preview_state, {
|
||||
pending: 0,
|
||||
queued: 1,
|
||||
|
|
@ -113,11 +114,7 @@ class Party < ApplicationRecord
|
|||
failed: 4
|
||||
}
|
||||
|
||||
after_commit :schedule_preview_regeneration, if: :preview_relevant_changes?
|
||||
|
||||
def is_favorited(user)
|
||||
user.favorite_parties.include? self if user
|
||||
end
|
||||
after_commit :schedule_preview_generation, if: :should_generate_preview?
|
||||
|
||||
def is_remix
|
||||
!source_party.nil?
|
||||
|
|
@ -143,6 +140,58 @@ class Party < ApplicationRecord
|
|||
visibility == 3
|
||||
end
|
||||
|
||||
def is_favorited(user)
|
||||
return false unless user
|
||||
|
||||
Rails.cache.fetch("party_#{id}_favorited_by_#{user.id}", expires_in: 1.hour) do
|
||||
user.favorite_parties.include?(self)
|
||||
end
|
||||
end
|
||||
|
||||
def ready_for_preview?
|
||||
return false if weapons_count < 1 # At least 1 weapon
|
||||
return false if characters_count < 1 # At least 1 character
|
||||
return false if summons_count < 1 # At least 1 summon
|
||||
true
|
||||
end
|
||||
|
||||
def should_generate_preview?
|
||||
return false unless ready_for_preview?
|
||||
|
||||
# Always generate if no preview exists
|
||||
return true if preview_state.nil? || preview_state == 'pending'
|
||||
|
||||
# Generate if failed and enough time has passed for conditions to change
|
||||
return true if preview_state == 'failed' && preview_generated_at < 5.minutes.ago
|
||||
|
||||
# Generate if preview is old
|
||||
return true if preview_state == 'generated' && preview_expired?
|
||||
|
||||
# Only regenerate on content changes if the last generation was > 5 minutes ago
|
||||
# This prevents rapid regeneration during party building
|
||||
if preview_content_changed?
|
||||
return true if preview_generated_at.nil? || preview_generated_at < 5.minutes.ago
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def preview_expired?
|
||||
preview_generated_at.nil? ||
|
||||
preview_generated_at < PreviewService::Coordinator::PREVIEW_EXPIRY.ago
|
||||
end
|
||||
|
||||
def preview_content_changed?
|
||||
saved_changes.keys.any? { |attr| preview_relevant_attributes.include?(attr) }
|
||||
end
|
||||
|
||||
def schedule_preview_generation
|
||||
return if preview_state == 'queued' || preview_state == 'in_progress'
|
||||
|
||||
update_column(:preview_state, 'queued')
|
||||
GeneratePartyPreviewJob.perform_later(id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_shortcode
|
||||
|
|
@ -188,17 +237,10 @@ class Party < ApplicationRecord
|
|||
errors.add(:guidebooks, 'must be unique')
|
||||
end
|
||||
|
||||
def preview_relevant_changes?
|
||||
return false if preview_state == 'queued'
|
||||
|
||||
(saved_changes.keys & %w[name job_id element weapons_count characters_count summons_count]).any?
|
||||
end
|
||||
|
||||
def schedule_preview_regeneration
|
||||
# Cancel any pending jobs
|
||||
GeneratePartyPreviewJob.cancel_scheduled_jobs(party_id: id)
|
||||
|
||||
# Mark as pending
|
||||
update_column(:preview_state, :pending)
|
||||
def preview_relevant_attributes
|
||||
%w[
|
||||
name job_id element weapons_count characters_count summons_count
|
||||
full_auto auto_guard charge_attack clear_time
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,12 +3,4 @@
|
|||
class WeaponAwakening < ApplicationRecord
|
||||
belongs_to :weapon
|
||||
belongs_to :awakening
|
||||
|
||||
def weapon
|
||||
Weapon.find(weapon_id)
|
||||
end
|
||||
|
||||
def awakening
|
||||
Awakening.find(awakening_id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ module PreviewService
|
|||
GENERATION_TIMEOUT = 5.minutes
|
||||
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
|
||||
|
||||
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
|
||||
PREVIEW_EXPIRY = 30.days
|
||||
|
||||
# Public Interface - Core Operations
|
||||
|
||||
# Initialize the party preview service
|
||||
|
|
@ -40,59 +43,59 @@ module PreviewService
|
|||
return false unless should_generate?
|
||||
|
||||
begin
|
||||
Rails.logger.info("Starting preview generation for party #{@party.id}")
|
||||
Rails.logger.info("🖼️ Starting preview generation for party #{@party.id}")
|
||||
|
||||
# Update state to in_progress
|
||||
Rails.logger.info("🖼️ Updating party state to in_progress")
|
||||
@party.update!(preview_state: :in_progress)
|
||||
set_generation_in_progress
|
||||
|
||||
Rails.logger.info("Checking ImageMagick installation...")
|
||||
Rails.logger.info("🖼️ Checking ImageMagick installation...")
|
||||
begin
|
||||
version = `convert -version`
|
||||
Rails.logger.info("ImageMagick version: #{version}")
|
||||
Rails.logger.info("🖼️ ImageMagick version: #{version}")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to get ImageMagick version: #{e.message}")
|
||||
Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
|
||||
end
|
||||
|
||||
Rails.logger.info("Creating preview image...")
|
||||
Rails.logger.info("🖼️ Creating preview image...")
|
||||
begin
|
||||
image = create_preview_image
|
||||
Rails.logger.info("Preview image created successfully")
|
||||
Rails.logger.info("🖼️ Preview image created successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to create preview image: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("Saving preview...")
|
||||
Rails.logger.info("🖼️ Saving preview...")
|
||||
begin
|
||||
save_preview(image)
|
||||
Rails.logger.info("Preview saved successfully")
|
||||
Rails.logger.info("🖼️ Preview saved successfully")
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to save preview: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
raise e
|
||||
end
|
||||
|
||||
Rails.logger.info("Updating party state...")
|
||||
Rails.logger.info("🖼️ Updating party state...")
|
||||
@party.update!(
|
||||
preview_state: :generated,
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
Rails.logger.info("Party state updated successfully")
|
||||
Rails.logger.info("🖼️ Party state updated successfully")
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("Preview generation failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("Stack trace:")
|
||||
Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error("🖼️ Stack trace:")
|
||||
Rails.logger.error(e.backtrace.join("\n"))
|
||||
handle_preview_generation_error(e)
|
||||
false
|
||||
ensure
|
||||
Rails.logger.info("Cleaning up resources...")
|
||||
Rails.logger.info("🖼️ Cleaning up resources...")
|
||||
@image_fetcher.cleanup
|
||||
clear_generation_in_progress
|
||||
Rails.logger.info("Cleanup completed")
|
||||
Rails.logger.info("🖼️ Cleanup completed")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -128,32 +131,48 @@ module PreviewService
|
|||
#
|
||||
# @return [Boolean] True if a new preview should be generated, false otherwise
|
||||
def should_generate?
|
||||
Rails.logger.info("Checking should_generate? conditions")
|
||||
Rails.logger.info("🖼️ Checking should_generate? conditions")
|
||||
|
||||
unless @party.ready_for_preview?
|
||||
Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
|
||||
return false
|
||||
end
|
||||
|
||||
if generation_in_progress?
|
||||
Rails.logger.info("Generation already in progress, returning false")
|
||||
Rails.logger.info("🖼️ Generation already in progress, returning false")
|
||||
return false
|
||||
end
|
||||
|
||||
Rails.logger.info("Preview state: #{@party.preview_state}")
|
||||
# Add 'queued' to the list of valid states for generation
|
||||
if @party.preview_state.in?(['pending', 'failed', 'queued'])
|
||||
Rails.logger.info("Preview state is #{@party.preview_state}, returning true")
|
||||
return true
|
||||
end
|
||||
Rails.logger.info("🖼️ Preview state: #{@party.preview_state}")
|
||||
|
||||
if @party.preview_state == 'generated'
|
||||
if @party.preview_generated_at < PREVIEW_EXPIRY.ago
|
||||
Rails.logger.info("Preview is older than expiry time, returning true")
|
||||
return true
|
||||
else
|
||||
Rails.logger.info("Preview is recent, returning false")
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info("No conditions met, returning false")
|
||||
case @party.preview_state
|
||||
when 'pending', 'queued'
|
||||
Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate")
|
||||
true
|
||||
when 'in_progress'
|
||||
Rails.logger.info("🖼️ State is in_progress, skipping generation")
|
||||
false
|
||||
when 'failed'
|
||||
should_retry = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}")
|
||||
should_retry
|
||||
when 'generated'
|
||||
expired = @party.preview_expired?
|
||||
changed = @party.preview_content_changed?
|
||||
debounced = @party.preview_generated_at.nil? ||
|
||||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
|
||||
|
||||
should_regenerate = expired || (changed && debounced)
|
||||
|
||||
Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}")
|
||||
Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}")
|
||||
|
||||
should_regenerate
|
||||
else
|
||||
Rails.logger.info("🖼️ Unknown state, will generate")
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a preview generation is currently in progress
|
||||
|
|
@ -480,6 +499,12 @@ module PreviewService
|
|||
)
|
||||
end
|
||||
|
||||
def self.cleanup_stalled_jobs
|
||||
Party.where(preview_state: :in_progress)
|
||||
.where('updated_at < ?', 10.minutes.ago)
|
||||
.update_all(preview_state: :pending)
|
||||
end
|
||||
|
||||
# Deletes local preview files
|
||||
#
|
||||
# @return [void]
|
||||
|
|
@ -498,8 +523,12 @@ module PreviewService
|
|||
def handle_preview_generation_error(error)
|
||||
Rails.logger.error("Preview generation failed for party #{@party.id}")
|
||||
Rails.logger.error("Error: #{error.class} - #{error.message}")
|
||||
Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}")
|
||||
@party.update!(preview_state: :failed)
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
|
||||
@party.update_columns(
|
||||
preview_state: 'failed',
|
||||
preview_generated_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ module HenseiApi
|
|||
config.paths["app/assets"].unshift(Rails.root.join("app", "assets").to_s)
|
||||
config.assets.paths << Rails.root.join("app", "assets", "fonts")
|
||||
|
||||
# Enable query logging
|
||||
config.active_record.query_log_tags_enabled = true
|
||||
config.active_record.query_log_tags = [:application, :controller, :action, :job]
|
||||
config.active_record.cache_query_log_tags = true
|
||||
|
||||
# API-only application configuration
|
||||
config.api_only = true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -76,4 +76,8 @@ Rails.application.configure do
|
|||
# This makes it easy to tag log lines with debug information like subdomain and request id -
|
||||
# both very helpful in debugging multi-user production applications.
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
config.after_initialize do
|
||||
Prosopite.rails_logger = true
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CacheFreeLogger < ActiveSupport::Logger
|
||||
def add(severity, message = nil, progname = nil, &block)
|
||||
return true if progname&.include? 'CACHE'
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Base.logger = CacheFreeLogger.new($stdout)
|
||||
ActiveRecord::Base.logger.level = 1
|
||||
# class CacheFreeLogger < ActiveSupport::Logger
|
||||
# def add(severity, message = nil, progname = nil, &block)
|
||||
# return true if progname&.include? 'CACHE'
|
||||
#
|
||||
# super
|
||||
# end
|
||||
# end
|
||||
#
|
||||
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
||||
# ActiveRecord::Base.logger.level = 1
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
# Fetch environment variables with defaults if not set
|
||||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
|
||||
redis_port = ENV.fetch('REDISPORT', '6379')
|
||||
|
||||
# Combine URL and port (adjust the path/DB as needed)
|
||||
full_redis_url = "#{redis_url}/0"
|
||||
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
Sidekiq.configure_server do |config|
|
||||
config.redis = { url: full_redis_url }
|
||||
config.redis = { url: redis_url }
|
||||
config.death_handlers << ->(job, ex) do
|
||||
Rails.logger.error("Preview generation job #{job['jid']} failed with: #{ex.message}")
|
||||
end
|
||||
end
|
||||
|
||||
Sidekiq.configure_client do |config|
|
||||
config.redis = { url: full_redis_url }
|
||||
config.redis = { url: redis_url }
|
||||
end
|
||||
|
|
|
|||
5
config/sidekiq.yml
Normal file
5
config/sidekiq.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:scheduler:
|
||||
cleanup_party_previews:
|
||||
cron: '0 0 * * *' # Daily at midnight
|
||||
class: CleanupPartyPreviewsJob
|
||||
queue: maintenance
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
class SetPreviewStateDefaultToPending < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
Party.where(preview_state: nil).find_each do |party|
|
||||
party.update_column(:preview_state, :pending)
|
||||
end
|
||||
end
|
||||
end
|
||||
12
db/migrate/20250120224953_add_missing_indexes_to_parties.rb
Normal file
12
db/migrate/20250120224953_add_missing_indexes_to_parties.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
class AddMissingIndexesToParties < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :parties, :visibility
|
||||
add_index :parties, :element
|
||||
add_index :parties, :created_at
|
||||
add_index :parties, [:weapons_count, :characters_count, :summons_count],
|
||||
name: 'index_parties_on_counters'
|
||||
add_index :parties, [:visibility, :created_at],
|
||||
name: 'index_parties_on_visibility_created_at'
|
||||
add_index :parties, :shortcode
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
class AddMissingIndexesToGridObjects < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :parties, :raid_id unless index_exists?(:parties, :raid_id)
|
||||
add_index :characters, :granblue_id unless index_exists?(:characters, :granblue_id)
|
||||
add_index :summons, :granblue_id unless index_exists?(:summons, :granblue_id)
|
||||
add_index :weapons, :granblue_id unless index_exists?(:weapons, :granblue_id)
|
||||
end
|
||||
end
|
||||
15
db/migrate/20250201041406_create_pghero_query_stats.rb
Normal file
15
db/migrate/20250201041406_create_pghero_query_stats.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
class CreatePgheroQueryStats < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :pghero_query_stats do |t|
|
||||
t.text :database
|
||||
t.text :user
|
||||
t.text :query
|
||||
t.integer :query_hash, limit: 8
|
||||
t.float :total_time
|
||||
t.integer :calls, limit: 8
|
||||
t.timestamp :captured_at
|
||||
end
|
||||
|
||||
add_index :pghero_query_stats, [:database, :captured_at]
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250201120655_enable_pg_statements.rb
Normal file
9
db/migrate/20250201120655_enable_pg_statements.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class EnablePgStatements < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements;'
|
||||
end
|
||||
|
||||
def down
|
||||
execute 'DROP EXTENSION IF EXISTS pg_stat_statements;'
|
||||
end
|
||||
end
|
||||
5
db/migrate/20250201120842_remove_unused_index.rb
Normal file
5
db/migrate/20250201120842_remove_unused_index.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class RemoveUnusedIndex < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_index :parties, :visibility
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
class AddOptimizedIndexesToParties < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# Add composite index for grid positions since we order by these
|
||||
add_index :grid_weapons, [:party_id, :position], name: 'index_grid_weapons_on_party_id_and_position'
|
||||
add_index :grid_characters, [:party_id, :position], name: 'index_grid_characters_on_party_id_and_position'
|
||||
add_index :grid_summons, [:party_id, :position], name: 'index_grid_summons_on_party_id_and_position'
|
||||
end
|
||||
end
|
||||
31
db/schema.rb
31
db/schema.rb
|
|
@ -10,13 +10,13 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_02_01_170037) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "uuid-ossp"
|
||||
|
||||
create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t|
|
||||
t.string "update_type", null: false
|
||||
|
|
@ -67,6 +67,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.string "kamigame", default: ""
|
||||
t.string "nicknames_en", default: [], null: false, array: true
|
||||
t.string "nicknames_jp", default: [], null: false, array: true
|
||||
t.index ["granblue_id"], name: "index_characters_on_granblue_id"
|
||||
t.index ["name_en"], name: "index_characters_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||
end
|
||||
|
||||
|
|
@ -129,6 +130,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.integer "awakening_level", default: 1
|
||||
t.index ["awakening_id"], name: "index_grid_characters_on_awakening_id"
|
||||
t.index ["character_id"], name: "index_grid_characters_on_character_id"
|
||||
t.index ["party_id", "position"], name: "index_grid_characters_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_characters_on_party_id"
|
||||
end
|
||||
|
||||
|
|
@ -143,6 +145,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.integer "transcendence_step", default: 0, null: false
|
||||
t.boolean "quick_summon", default: false
|
||||
t.index ["party_id", "position"], name: "index_grid_summons_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_summons_on_party_id"
|
||||
t.index ["summon_id"], name: "index_grid_summons_on_summon_id"
|
||||
end
|
||||
|
|
@ -168,6 +171,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.integer "transcendence_step", default: 0
|
||||
t.string "weapon_key4_id"
|
||||
t.index ["awakening_id"], name: "index_grid_weapons_on_awakening_id"
|
||||
t.index ["party_id", "position"], name: "index_grid_weapons_on_party_id_and_position"
|
||||
t.index ["party_id"], name: "index_grid_weapons_on_party_id"
|
||||
t.index ["weapon_id"], name: "index_grid_weapons_on_weapon_id"
|
||||
t.index ["weapon_key1_id"], name: "index_grid_weapons_on_weapon_key1_id"
|
||||
|
|
@ -304,18 +308,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.datetime "preview_generated_at"
|
||||
t.string "preview_s3_key"
|
||||
t.index ["accessory_id"], name: "index_parties_on_accessory_id"
|
||||
t.index ["created_at"], name: "index_parties_on_created_at"
|
||||
t.index ["element"], name: "index_parties_on_element"
|
||||
t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
|
||||
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
|
||||
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id"
|
||||
t.index ["job_id"], name: "index_parties_on_job_id"
|
||||
t.index ["preview_generated_at"], name: "index_parties_on_preview_generated_at"
|
||||
t.index ["preview_state"], name: "index_parties_on_preview_state"
|
||||
t.index ["raid_id"], name: "index_parties_on_raid_id"
|
||||
t.index ["shortcode"], name: "index_parties_on_shortcode"
|
||||
t.index ["skill0_id"], name: "index_parties_on_skill0_id"
|
||||
t.index ["skill1_id"], name: "index_parties_on_skill1_id"
|
||||
t.index ["skill2_id"], name: "index_parties_on_skill2_id"
|
||||
t.index ["skill3_id"], name: "index_parties_on_skill3_id"
|
||||
t.index ["source_party_id"], name: "index_parties_on_source_party_id"
|
||||
t.index ["user_id"], name: "index_parties_on_user_id"
|
||||
t.index ["visibility", "created_at"], name: "index_parties_on_visibility_created_at"
|
||||
t.index ["weapons_count", "characters_count", "summons_count"], name: "index_parties_on_counters"
|
||||
end
|
||||
|
||||
create_table "pg_search_documents", force: :cascade do |t|
|
||||
|
|
@ -331,6 +341,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
|
||||
end
|
||||
|
||||
create_table "pghero_query_stats", force: :cascade do |t|
|
||||
t.text "database"
|
||||
t.text "user"
|
||||
t.text "query"
|
||||
t.bigint "query_hash"
|
||||
t.float "total_time"
|
||||
t.bigint "calls"
|
||||
t.datetime "captured_at", precision: nil
|
||||
t.index ["database", "captured_at"], name: "index_pghero_query_stats_on_database_and_captured_at"
|
||||
end
|
||||
|
||||
create_table "raid_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name_en", null: false
|
||||
t.string "name_jp", null: false
|
||||
|
|
@ -349,7 +370,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.integer "element"
|
||||
t.string "slug"
|
||||
t.uuid "group_id"
|
||||
t.index ["group_id"], name: "index_raids_on_group_id"
|
||||
end
|
||||
|
||||
create_table "sparks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
@ -400,6 +420,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.date "transcendence_date"
|
||||
t.string "nicknames_en", default: [], null: false, array: true
|
||||
t.string "nicknames_jp", default: [], null: false, array: true
|
||||
t.index ["granblue_id"], name: "index_summons_on_granblue_id"
|
||||
t.index ["name_en"], name: "index_summons_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||
end
|
||||
|
||||
|
|
@ -474,6 +495,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
t.boolean "transcendence", default: false
|
||||
t.date "transcendence_date"
|
||||
t.string "recruits"
|
||||
t.index ["granblue_id"], name: "index_weapons_on_granblue_id"
|
||||
t.index ["name_en"], name: "index_weapons_on_name_en", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["recruits"], name: "index_weapons_on_recruits"
|
||||
end
|
||||
|
|
@ -501,9 +523,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_19_062554) do
|
|||
add_foreign_key "parties", "job_skills", column: "skill2_id"
|
||||
add_foreign_key "parties", "job_skills", column: "skill3_id"
|
||||
add_foreign_key "parties", "jobs"
|
||||
add_foreign_key "parties", "parties", column: "source_party_id"
|
||||
add_foreign_key "parties", "raids"
|
||||
add_foreign_key "parties", "users"
|
||||
add_foreign_key "raids", "raid_groups", column: "group_id"
|
||||
add_foreign_key "raids", "raid_groups", column: "group_id", name: "raids_group_id_fkey"
|
||||
add_foreign_key "weapon_awakenings", "awakenings"
|
||||
add_foreign_key "weapon_awakenings", "weapons"
|
||||
end
|
||||
|
|
|
|||
111
sig/api/v1/parties_controller.rbs
Normal file
111
sig/api/v1/parties_controller.rbs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
module Api
|
||||
module V1
|
||||
class PartiesController < Api::V1::ApiController
|
||||
@parties: ActiveRecord::Relation[Party]
|
||||
|
||||
MAX_CHARACTERS: Integer
|
||||
MAX_SUMMONS: Integer
|
||||
MAX_WEAPONS: Integer
|
||||
DEFAULT_MIN_CHARACTERS: Integer
|
||||
DEFAULT_MIN_SUMMONS: Integer
|
||||
DEFAULT_MIN_WEAPONS: Integer
|
||||
DEFAULT_MAX_CLEAR_TIME: Integer
|
||||
|
||||
def create: () -> void
|
||||
|
||||
def show: () -> void
|
||||
|
||||
def update: () -> void
|
||||
|
||||
def destroy: () -> void
|
||||
|
||||
def remix: () -> void
|
||||
|
||||
def index: () -> void
|
||||
|
||||
def favorites: () -> void
|
||||
|
||||
def preview: () -> void
|
||||
|
||||
def preview_status: () -> void
|
||||
|
||||
def regenerate_preview: () -> void
|
||||
|
||||
private
|
||||
|
||||
def authorize: () -> void
|
||||
|
||||
def grid_table_and_object_table: (String) -> [String?, String?]
|
||||
|
||||
def not_owner: () -> bool
|
||||
|
||||
def schedule_preview_generation: () -> void
|
||||
|
||||
def apply_filters: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_privacy_settings: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_includes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def apply_excludes: (ActiveRecord::Relation[Party], String) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def build_filters: () -> Hash[Symbol, untyped]
|
||||
|
||||
def build_date_range: () -> Range[DateTime]?
|
||||
|
||||
def build_start_time: (String?) -> DateTime?
|
||||
|
||||
def build_count: (String?, Integer) -> Integer
|
||||
|
||||
def build_max_clear_time: (String?) -> Integer
|
||||
|
||||
def build_element: (String?) -> Integer?
|
||||
|
||||
def build_option: (String?) -> Integer?
|
||||
|
||||
def build_query: (Hash[Symbol, untyped], bool) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def fetch_parties: (ActiveRecord::Relation[Party]) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def calculate_count: (ActiveRecord::Relation[Party]) -> Integer
|
||||
|
||||
def calculate_total_pages: (Integer) -> Integer
|
||||
|
||||
def paginate_parties: (
|
||||
ActiveRecord::Relation[Party],
|
||||
?page: Integer?,
|
||||
?per_page: Integer
|
||||
) -> ActiveRecord::Relation[Party]
|
||||
|
||||
def excluded_characters: () -> ActiveRecord::Relation[GridCharacter]?
|
||||
|
||||
def excluded_summons: () -> ActiveRecord::Relation[GridSummon]?
|
||||
|
||||
def excluded_weapons: () -> ActiveRecord::Relation[GridWeapon]?
|
||||
|
||||
def render_party_json: (ActiveRecord::Relation[Party]) -> void
|
||||
|
||||
def privacy: (?favorites: bool) -> String?
|
||||
|
||||
def user_quality: () -> String?
|
||||
|
||||
def name_quality: () -> String?
|
||||
|
||||
def original: () -> String?
|
||||
|
||||
def includes: (String) -> String
|
||||
|
||||
def excludes: (String) -> String
|
||||
|
||||
def id_to_table: (String) -> String
|
||||
|
||||
def remixed_name: (String) -> String
|
||||
|
||||
def set_from_slug: () -> void
|
||||
|
||||
def set: () -> void
|
||||
|
||||
def party_params: () -> Hash[Symbol, untyped]?
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue