diff --git a/.gitignore b/.gitignore index 8a1b113..b10b609 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ # Ignore master key for decrypting credentials and more. /config/master.key +.env* diff --git a/.pryrc b/.pryrc new file mode 100644 index 0000000..257566d --- /dev/null +++ b/.pryrc @@ -0,0 +1,3 @@ +# Enable the `reload!` method in the Rails console +require 'rails/console/app' +include Rails::ConsoleMethods diff --git a/.ruby-version b/.ruby-version index 338a5b5..ef538c2 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.6 +3.1.2 diff --git a/Gemfile b/Gemfile index b7ab363..7f17724 100644 --- a/Gemfile +++ b/Gemfile @@ -1,18 +1,18 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '2.6.6' +ruby '3.1.2' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main' -gem 'rails', '~> 6.1.3', '>= 6.1.3.1' +gem 'rails', '~> 6.1.7' # Use postgresql as the database for Active Record gem 'pg', '~> 1.1' # Use Puma as the app server gem 'puma', '~> 5.0' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder -# gem 'jbuilder', '~> 2.7' +gem 'jbuilder', '~> 2.7' # Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 4.0' +gem 'redis', '~> 3.3.3' # Use Active Model has_secure_password # gem 'bcrypt', '~> 3.1.7' @@ -23,14 +23,39 @@ gem 'puma', '~> 5.0' gem 'bootsnap', '>= 1.4.4', require: false # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible -# gem 'rack-cors' +gem 'rack-cors', require: 'rack/cors' + +# Added +gem 'cloudinary', '~> 1.16.0' +gem 'devise' +gem 'devise_token_auth', github: 'lynndylanhurley/devise_token_auth' +gem 'faker' +gem 'httparty' +gem 'omniauth' +gem 'net-smtp', require: false +gem 'net-imap', require: false +gem 'net-pop', require: false +gem 'progress_bar' +gem 'pundit' +gem 'rexml' +gem 'scout_apm' +gem 'sidekiq', '~> 5.0.4' +gem 'scenic' +gem 'sidekiq-failures', '~> 1.0' +gem "watir", "~> 7.1" +gem 'webdrivers' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] + gem 'dotenv-rails' + gem 'mailcatcher' # need to launch a mailcatcher server to catch dev emails + gem 'pry-byebug' end group :development do + gem 'bullet' + gem 'letter_opener' gem 'listen', '~> 3.3' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'spring' diff --git a/Gemfile.lock b/Gemfile.lock index 6f5cbe8..930c510 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,163 +1,368 @@ +GIT + remote: https://github.com/lynndylanhurley/devise_token_auth.git + revision: ec68e47f2a3e743bd51293369d059508974aed14 + specs: + devise_token_auth (1.2.1) + bcrypt (~> 3.0) + devise (> 3.5.2, < 5) + rails (>= 4.2.0, < 7.1) + GEM remote: https://rubygems.org/ specs: - actioncable (6.1.3.1) - actionpack (= 6.1.3.1) - activesupport (= 6.1.3.1) + actioncable (6.1.7) + actionpack (= 6.1.7) + activesupport (= 6.1.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.3.1) - actionpack (= 6.1.3.1) - activejob (= 6.1.3.1) - activerecord (= 6.1.3.1) - activestorage (= 6.1.3.1) - activesupport (= 6.1.3.1) + actionmailbox (6.1.7) + actionpack (= 6.1.7) + activejob (= 6.1.7) + activerecord (= 6.1.7) + activestorage (= 6.1.7) + activesupport (= 6.1.7) mail (>= 2.7.1) - actionmailer (6.1.3.1) - actionpack (= 6.1.3.1) - actionview (= 6.1.3.1) - activejob (= 6.1.3.1) - activesupport (= 6.1.3.1) + actionmailer (6.1.7) + actionpack (= 6.1.7) + actionview (= 6.1.7) + activejob (= 6.1.7) + activesupport (= 6.1.7) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.3.1) - actionview (= 6.1.3.1) - activesupport (= 6.1.3.1) + actionpack (6.1.7) + actionview (= 6.1.7) + activesupport (= 6.1.7) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.3.1) - actionpack (= 6.1.3.1) - activerecord (= 6.1.3.1) - activestorage (= 6.1.3.1) - activesupport (= 6.1.3.1) + actiontext (6.1.7) + actionpack (= 6.1.7) + activerecord (= 6.1.7) + activestorage (= 6.1.7) + activesupport (= 6.1.7) nokogiri (>= 1.8.5) - actionview (6.1.3.1) - activesupport (= 6.1.3.1) + actionview (6.1.7) + activesupport (= 6.1.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.3.1) - activesupport (= 6.1.3.1) + activejob (6.1.7) + activesupport (= 6.1.7) globalid (>= 0.3.6) - activemodel (6.1.3.1) - activesupport (= 6.1.3.1) - activerecord (6.1.3.1) - activemodel (= 6.1.3.1) - activesupport (= 6.1.3.1) - activestorage (6.1.3.1) - actionpack (= 6.1.3.1) - activejob (= 6.1.3.1) - activerecord (= 6.1.3.1) - activesupport (= 6.1.3.1) - marcel (~> 1.0.0) - mini_mime (~> 1.0.2) - activesupport (6.1.3.1) + activemodel (6.1.7) + activesupport (= 6.1.7) + activerecord (6.1.7) + activemodel (= 6.1.7) + activesupport (= 6.1.7) + activestorage (6.1.7) + actionpack (= 6.1.7) + activejob (= 6.1.7) + activerecord (= 6.1.7) + activesupport (= 6.1.7) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (6.1.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - bootsnap (1.7.3) - msgpack (~> 1.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + aws_cf_signer (0.1.3) + bcrypt (3.1.18) + bootsnap (1.13.0) + msgpack (~> 1.2) builder (3.2.4) + bullet (7.0.3) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) byebug (11.1.3) - concurrent-ruby (1.1.8) + childprocess (4.1.0) + cloudinary (1.16.1) + aws_cf_signer + rest-client + coderay (1.1.3) + concurrent-ruby (1.1.10) + connection_pool (2.3.0) crass (1.0.6) - erubi (1.10.0) - ffi (1.15.0) - globalid (0.4.2) - activesupport (>= 4.2.0) - i18n (1.8.10) + daemons (1.4.1) + devise (4.8.1) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + digest (3.1.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + dotenv-rails (2.8.1) + dotenv (= 2.8.1) + railties (>= 3.2) + erubi (1.11.0) + eventmachine (1.2.7) + faker (3.0.0) + i18n (>= 1.8.11, < 2) + ffi (1.15.5) + globalid (1.0.0) + activesupport (>= 5.0) + haml (6.0.10) + temple (>= 0.8.2) + thor + tilt + hashie (5.0.0) + highline (2.0.3) + http-accept (1.7.0) + http-cookie (1.0.5) + domain_name (~> 0.5) + httparty (0.20.0) + mime-types (~> 3.0) + multi_xml (>= 0.5.2) + i18n (1.12.0) concurrent-ruby (~> 1.0) - listen (3.5.1) + jbuilder (2.11.5) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + json (2.6.2) + launchy (2.5.0) + addressable (~> 2.7) + letter_opener (1.8.1) + launchy (>= 2.2, < 3) + listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.9.0) + loofah (2.19.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (1.0.1) + mailcatcher (0.2.4) + eventmachine + haml + i18n + json + mail + sinatra + skinny (>= 0.1.2) + sqlite3-ruby + thin + marcel (1.0.2) method_source (1.0.0) - mini_mime (1.0.3) - mini_portile2 (2.5.0) - minitest (5.14.4) - msgpack (1.4.2) - nio4r (2.5.7) - nokogiri (1.11.2) - mini_portile2 (~> 2.5.0) + mime-types (3.4.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2022.0105) + mini_mime (1.1.2) + minitest (5.16.3) + msgpack (1.6.0) + multi_xml (0.6.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + net-imap (0.3.1) + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.1.3) + timeout + net-smtp (0.3.1) + digest + net-protocol + timeout + netrc (0.11.0) + nio4r (2.5.8) + nokogiri (1.13.9-arm64-darwin) racc (~> 1.4) - pg (1.2.3) - puma (5.2.2) + nokogiri (1.13.9-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.13.9-x86_64-linux) + racc (~> 1.4) + omniauth (2.1.0) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + options (2.3.2) + orm_adapter (0.5.0) + parser (3.1.2.1) + ast (~> 2.4.1) + pg (1.4.4) + progress_bar (1.3.3) + highline (>= 1.6, < 3) + options (~> 2.3.0) + pry (0.14.1) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.10.1) + byebug (~> 11.0) + pry (>= 0.13, < 0.15) + public_suffix (5.0.0) + puma (5.6.5) nio4r (~> 2.0) - racc (1.5.2) - rack (2.2.3) - rack-test (1.1.0) - rack (>= 1.0, < 3) - rails (6.1.3.1) - actioncable (= 6.1.3.1) - actionmailbox (= 6.1.3.1) - actionmailer (= 6.1.3.1) - actionpack (= 6.1.3.1) - actiontext (= 6.1.3.1) - actionview (= 6.1.3.1) - activejob (= 6.1.3.1) - activemodel (= 6.1.3.1) - activerecord (= 6.1.3.1) - activestorage (= 6.1.3.1) - activesupport (= 6.1.3.1) + pundit (2.2.0) + activesupport (>= 3.0.0) + racc (1.6.0) + rack (2.2.4) + rack-cors (1.1.1) + rack (>= 2.0.0) + rack-protection (3.0.3) + rack + rack-test (2.0.2) + rack (>= 1.3) + rails (6.1.7) + actioncable (= 6.1.7) + actionmailbox (= 6.1.7) + actionmailer (= 6.1.7) + actionpack (= 6.1.7) + actiontext (= 6.1.7) + actionview (= 6.1.7) + activejob (= 6.1.7) + activemodel (= 6.1.7) + activerecord (= 6.1.7) + activestorage (= 6.1.7) + activesupport (= 6.1.7) bundler (>= 1.15.0) - railties (= 6.1.3.1) + railties (= 6.1.7) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.3.0) + rails-html-sanitizer (1.4.3) loofah (~> 2.3) - railties (6.1.3.1) - actionpack (= 6.1.3.1) - activesupport (= 6.1.3.1) + railties (6.1.7) + actionpack (= 6.1.7) + activesupport (= 6.1.7) method_source - rake (>= 0.8.7) + rake (>= 12.2) thor (~> 1.0) - rake (13.0.3) - rb-fsevent (0.10.4) + rake (13.0.6) + rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - spring (2.1.1) - sprockets (4.0.2) + redis (3.3.5) + regexp_parser (2.6.1) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.2.5) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + scenic (1.6.0) + activerecord (>= 4.0.0) + railties (>= 4.0.0) + scout_apm (5.3.2) + parser + selenium-webdriver (4.6.1) + childprocess (>= 0.5, < 5.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + sidekiq (5.0.5) + concurrent-ruby (~> 1.0) + connection_pool (~> 2.2, >= 2.2.0) + rack-protection (>= 1.5.0) + redis (>= 3.3.4, < 5) + sidekiq-failures (1.0.4) + sidekiq (>= 4.0.0) + sinatra (3.0.3) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.0.3) + tilt (~> 2.0) + skinny (0.2.2) + eventmachine (~> 1.0) + thin + spring (4.1.0) + sprockets (4.1.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) sprockets (>= 3.0.0) - thor (1.1.0) - tzinfo (2.0.4) + sqlite3 (1.5.3-arm64-darwin) + sqlite3 (1.5.3-x86_64-darwin) + sqlite3 (1.5.3-x86_64-linux) + sqlite3-ruby (1.3.3) + sqlite3 (>= 1.3.3) + temple (0.9.1) + thin (1.8.1) + daemons (~> 1.0, >= 1.0.9) + eventmachine (~> 1.0, >= 1.0.4) + rack (>= 1, < 3) + thor (1.2.1) + tilt (2.0.11) + timeout (0.3.0) + tzinfo (2.0.5) concurrent-ruby (~> 1.0) - websocket-driver (0.7.3) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + uniform_notifier (1.16.0) + warden (1.2.9) + rack (>= 2.0.9) + watir (7.1.0) + regexp_parser (>= 1.2, < 3) + selenium-webdriver (~> 4.0) + webdrivers (5.2.0) + nokogiri (~> 1.6) + rubyzip (>= 1.3.0) + selenium-webdriver (~> 4.0) + websocket (1.2.9) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.4.2) + zeitwerk (2.6.6) PLATFORMS - ruby + arm64-darwin-21 + x86_64-darwin-20 + x86_64-linux DEPENDENCIES bootsnap (>= 1.4.4) + bullet byebug + cloudinary (~> 1.16.0) + devise + devise_token_auth! + dotenv-rails + faker + httparty + jbuilder (~> 2.7) + letter_opener listen (~> 3.3) + mailcatcher + net-imap + net-pop + net-smtp + omniauth pg (~> 1.1) + progress_bar + pry-byebug puma (~> 5.0) - rails (~> 6.1.3, >= 6.1.3.1) + pundit + rack-cors + rails (~> 6.1.7) + redis (~> 3.3.3) + rexml + scenic + scout_apm + sidekiq (~> 5.0.4) + sidekiq-failures (~> 1.0) spring tzinfo-data + watir (~> 7.1) + webdrivers RUBY VERSION - ruby 2.6.6p146 + ruby 3.1.2p20 BUNDLED WITH - 2.1.4 + 2.3.25 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..7a10729 --- /dev/null +++ b/Procfile @@ -0,0 +1,3 @@ +release: rake db:migrate +web: bundle exec puma -C config/puma.rb +worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/README.md b/README.md index 7db80e4..7ad5044 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,48 @@ -# README +# predictor-app +## Project setup -This README would normally document whatever steps are necessary to get the -application up and running. +### Create DB / Migrate / Seed +``` +rails db:create +rails db:migrate +rails db:seed +``` +### Installs dependencies +``` +bundle install +``` +### Launch a server +``` +rails s +``` -Things you may want to cover: -* Ruby version +## Live-score API +#### Competition List +https://livescore-api.com/api-client/competitions/list.json?key=REPLACE_ME&secret=REPLACE_ME -* System dependencies +#### Matches for Euros +https://livescore-api.com/api-client/fixtures/matches.json?key=REPLACE_ME&secret=REPLACE_ME&competition_id=387 -* Configuration +To retreive the H2H info, you can access it in the matches list: +Screen Shot 2021-06-01 at 15 45 21 -* Database creation +#### Single Fixture Info +https://livescore-api.com/api-client/teams/head2head.json?key=REPLACE_ME&secret=REPLACE_ME&team1_id=1744&team2_id=1740 -* Database initialization +Screen Shot 2021-06-01 at 15 57 25 -* How to run the test suite +#### Getting Groups in Euros +https://livescore-api.com/api-client/competitions/groups.json?key=REPLACE_ME&secret=REPLACE_ME&competition_id=387 +Screen Shot 2021-06-01 at 16 02 54 -* Services (job queues, cache servers, search engines, etc.) +#### Getting Country Flag +https://livescore-api.com/api-client/countries/flag.json?key=REPLACE_ME&secret=REPLACE_ME&team_id=1440 -* Deployment instructions +#### Getting Live Scores +http://livescore-api.com/api-client/scores/live.json?key=REPLACE_ME&secret=REPLACE_ME&competition_id=387 -* ... +#### Getting Results +http://livescore-api.com/api-client/scores/history.json?key=REPLACE_ME&secret=REPLACE_ME&competition_id=387 + +Screen Shot 2021-06-01 at 16 17 48 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..8f03d68 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,44 @@ class ApplicationController < ActionController::API + include DeviseTokenAuth::Concerns::SetUserByToken + include Pundit::Authorization + + before_action :authenticate_user!, unless: :token_auth_controller? + + after_action :verify_authorized, except: :index, unless: :skip_pundit? + after_action :verify_policy_scoped, only: :index, unless: :skip_pundit? + + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + # Transforms JSON key names (UpperCamelCase) to lower_snake_case + before_action :underscore_params! + + private + + def underscore_params! + params.deep_transform_keys!(&:underscore) + end + + def user_not_authorized(exception) + render json: { + error: "Unauthorized #{exception.policy.class.to_s.underscore.camelize}.#{exception.query}" + }, status: :unauthorized + end + + def not_found(exception) + render json: { error: exception.message }, status: :not_found + end + + def skip_pundit? + devise_controller? || params[:controller] =~ /(^(rails_)?admin)|(^pages$)/ + end + + def token_auth_controller? + params[:controller].split('/').include? 'devise_token_auth' + end + + def render_error(resource) + render json: { errors: resource.errors.full_messages }, + status: :unprocessable_entity + end end diff --git a/app/controllers/auth/devise_token_auth/sessions_controller.rb b/app/controllers/auth/devise_token_auth/sessions_controller.rb new file mode 100644 index 0000000..6aaa1b8 --- /dev/null +++ b/app/controllers/auth/devise_token_auth/sessions_controller.rb @@ -0,0 +1,9 @@ +module Auth + module DeviseTokenAuth + class SessionsController < ::DeviseTokenAuth::SessionsController + # Prevent session parameter from being passed + # Unpermitted parameter: session + wrap_parameters format: [] + end + end +end diff --git a/app/controllers/v1/competitions_controller.rb b/app/controllers/v1/competitions_controller.rb new file mode 100644 index 0000000..8d6872a --- /dev/null +++ b/app/controllers/v1/competitions_controller.rb @@ -0,0 +1,7 @@ +class V1::CompetitionsController < ApplicationController + skip_before_action :authenticate_user!, only: :index + + def index + @competitions = policy_scope(Competition) + end +end diff --git a/app/controllers/v1/leaderboards_controller.rb b/app/controllers/v1/leaderboards_controller.rb new file mode 100644 index 0000000..4dd604a --- /dev/null +++ b/app/controllers/v1/leaderboards_controller.rb @@ -0,0 +1,35 @@ +class V1::LeaderboardsController < ApplicationController + def index + @competition = Competition.find(params[:competition_id]) + @leaderboards = policy_scope(Leaderboard).includes(:match_results, rankings: %i[user]) + .where(competition: @competition) + end + + def create + @competition = Competition.find(params[:competition_id]) + @leaderboard = Leaderboard.new(leaderboard_params) + @leaderboard.competition = @competition + @leaderboard.user = current_user + authorize @leaderboard + if @leaderboard.save + render :show, status: :created + else + render_error(@leaderboard) + end + end + + def destroy + @leaderboard = Leaderboard.find(params[:id]) + authorize @leaderboard + membership = @leaderboard.memberships.find_by!(user: current_user) + authorize membership + membership.destroy + head :no_content + end + + private + + def leaderboard_params + params.require(:leaderboard).permit(:name) + end +end diff --git a/app/controllers/v1/matches_controller.rb b/app/controllers/v1/matches_controller.rb new file mode 100644 index 0000000..257b7ca --- /dev/null +++ b/app/controllers/v1/matches_controller.rb @@ -0,0 +1,12 @@ +class V1::MatchesController < ApplicationController + # /matches?competition_id=:id&user_id=:id + def index + @user = User.find_by(id: params[:user_id]) || current_user + competition = Competition.find_by(id: params[:competition_id]) + @matches = policy_scope(Match).includes( + :round, + team_home: [badge_attachment: :blob, flag_attachment: :blob], + team_away: [badge_attachment: :blob ,flag_attachment: :blob] + ).where(competition: competition) + end +end diff --git a/app/controllers/v1/memberships_controller.rb b/app/controllers/v1/memberships_controller.rb new file mode 100644 index 0000000..9776056 --- /dev/null +++ b/app/controllers/v1/memberships_controller.rb @@ -0,0 +1,12 @@ +class V1::MembershipsController < ApplicationController + def create + @leaderboard = Leaderboard.find_by(password: params[:password]) + @membership = Membership.find_or_initialize_by(user: current_user, leaderboard: @leaderboard) + authorize @membership + if @membership.save + render :show, status: :created + else + render_error(@membership) + end + end +end diff --git a/app/controllers/v1/predictions_controller.rb b/app/controllers/v1/predictions_controller.rb new file mode 100644 index 0000000..52141df --- /dev/null +++ b/app/controllers/v1/predictions_controller.rb @@ -0,0 +1,30 @@ +class V1::PredictionsController < ApplicationController + def create + @match = Match.upcoming.find(params[:match_id]) + @prediction = Prediction.new(prediction_params) + @prediction.match = @match + @prediction.user = current_user + authorize @prediction + if @prediction.save + render :show, status: :created + else + render_error(@prediction) + end + end + + def update + @prediction = Prediction.editable.find_by(user: current_user, match: params[:match_id]) + authorize @prediction + if @prediction.update(prediction_params) + render :show + else + render_error(@prediction) + end + end + + private + + def prediction_params + params.require(:prediction).permit(:choice) + end +end diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb new file mode 100644 index 0000000..c759abf --- /dev/null +++ b/app/controllers/v1/users_controller.rb @@ -0,0 +1,26 @@ +require 'open-uri' + +class V1::UsersController < ApplicationController + def show + @user = User.find(params[:id]) + @competition = Competition.find(params[:competition_id]) if params[:competition_id] + authorize @user + end + + def update + @user = current_user + authorize @user + + if @user.update(prediction_params) + render :show + else + render_error(@user) + end + end + + private + + def prediction_params + params.require(:user).permit(:name, :timezone, :photo_key) + end +end diff --git a/app/jobs/attach_flags_job.rb b/app/jobs/attach_flags_job.rb new file mode 100644 index 0000000..078b458 --- /dev/null +++ b/app/jobs/attach_flags_job.rb @@ -0,0 +1,23 @@ +class AttachFlagsJob < ApplicationJob + queue_as :default + + def perform(competition_id) + competition = Competition.find(competition_id) + url = DataFootballApi.teams_url(competition.api_code) + response = HTTParty.get( + url, + headers: { + 'Content-Type' => 'application/json', + 'X-Auth-Token' => ENV['FOOTBALL_DATA_TOKEN'] + } + ).body + teams = JSON.parse(response)['teams'] + teams.each do |team_hash| + team = Team.find_by(abbrev: team_hash['tla']) + puts "#{team.name}: #{team_hash['crest']}" + team.flag.attach(io: URI.open(team_hash['crest']), filename: 'flag.png', content_type: 'image/png') unless team.flag.attached? + team.badge.attach(io: URI.open(team_hash['crest']), filename: 'badge.png', content_type: 'image/png') unless team.badge.attached? + puts team.flag.attached? ? 'Success' : 'Failed' + end + end +end diff --git a/app/jobs/competition_create_job.rb b/app/jobs/competition_create_job.rb new file mode 100644 index 0000000..9ecde56 --- /dev/null +++ b/app/jobs/competition_create_job.rb @@ -0,0 +1,96 @@ +class CompetitionCreateJob < ApplicationJob + queue_as :default + + def perform(competition_code) + url = DataFootballApi.teams_url(competition_code) + response = HTTParty.get( + url, + headers: { + 'Content-Type' => 'application/json', + 'X-Auth-Token' => ENV['FOOTBALL_DATA_TOKEN'] + } + ).body + parsed_response = JSON.parse(response) + competition_parsed = parsed_response['competition'] + season_parsed = parsed_response['season'] + puts 'Creating the competition...' + competition = Competition.find_or_create_by!(name: "#{competition_parsed['name']} #{Date.today.year}", start_date: Date.parse(season_parsed["startDate"]), end_date: Date.parse(season_parsed["endDate"]), api_id: competition_parsed['id'], api_code: competition_parsed['code']) + if competition["emblem"] && !competition.photo.attached? + file = URI.open(competition["emblem"]) + competition.photo.attach(io: file, filename: 'logo.png', content_type: 'image/png') + end + puts '.. created the competition' + + puts 'Creating or finding the rounds...' + season_parsed['stages'].each_with_index do |stage_key, index| + round = Round.find_or_create_by!(name: stage_key.titleize, number: index + 1, competition: competition, api_name: stage_key) + competition.update!(current_round: round) if index.zero? + end + + url = DataFootballApi.matches_url(competition_code) + response = HTTParty.get( + url, + headers: { + 'Content-Type' => 'application/json', + 'X-Auth-Token' => ENV['FOOTBALL_DATA_TOKEN'] + } + ).body + parsed_response = JSON.parse(response) + matches = parsed_response['matches'] + DatabaseViews.run_without_callback(then_refresh: true) do + matches.each do |match_info| + kickoff_time = DateTime.parse(match_info['utcDate']) + puts "Finding the match between : #{match_info['homeTeam']['name']} v #{match_info['awayTeam']['name']} (#{kickoff_time})" + next unless match_info['homeTeam']['tla'] && match_info['awayTeam']['tla'] # knock-out rounds with no teams yet + + team_home = Team.find_by(abbrev: match_info['homeTeam']['tla']) || Team.create!(name: match_info['homeTeam']['shortName'], abbrev: match_info['homeTeam']['tla']) + team_away = Team.find_by(abbrev: match_info['awayTeam']['tla']) || Team.create!(name: match_info['awayTeam']['shortName'], abbrev: match_info['awayTeam']['tla']) + + puts 'Getting/creating round and group...' + round = Round.find_by!(competition: competition, api_name: match_info['stage']) + group = Group.find_or_create_by!(name: match_info['group'].titleize, round: round, api_code: match_info['group']) + + puts "Adding #{team_home.abbrev} and #{team_away.abbrev} to #{group.name}" + Affiliation.find_or_create_by!(team: team_home, group: group) + Affiliation.find_or_create_by!(team: team_away, group: group) + + match = + competition.matches.where(team_home: team_home, team_away: team_away) + .find_by(kickoff_time: kickoff_time) || Match.new + match.team_home ||= team_home + match.team_away ||= team_away + match.round = round + match.group = Group.find_by(round: match.round, api_code: match_info["group"]) if match_info["group"] + match.api_id = match_info['id'] + # TODO: Don't think we're getting the location from the API + # match.location = match_info['location'] + match.kickoff_time = kickoff_time + match.save + p match.errors.full_messages if match.errors.any? + + # Update scores + match.update_with_api(match_info) + puts 'Match Update' + end + + leaderboard_hash = { + name: 'Global Top Players', + description: 'The top players on Octacle', + rankings_top_n: 10, + leave_disabled: true, + auto_join: true, + } + admin = User.find_by(admin: true) + leaderboard = competition.leaderboards.find_or_initialize_by(leaderboard_hash.slice(:name)) + leaderboard.assign_attributes(leaderboard_hash) + leaderboard.user ||= admin + leaderboard.save! + + puts "-----> Creating memberships for #{leaderboard.name} (#{leaderboard.competition.name})" + User.find_each { |user| leaderboard.memberships.find_or_create_by!(user: user) } + AttachFlagsJob.perform_later(competition.id) + end + + + end +end diff --git a/app/jobs/match_started_job.rb b/app/jobs/match_started_job.rb new file mode 100644 index 0000000..d362de9 --- /dev/null +++ b/app/jobs/match_started_job.rb @@ -0,0 +1,8 @@ +class MatchStartedJob < ApplicationJob + queue_as :default + + def perform(kickoff_time) + matches = Match.where(kickoff_time: kickoff_time) + matches.map(&:started!) + end +end diff --git a/app/jobs/match_update_future_job.rb b/app/jobs/match_update_future_job.rb new file mode 100644 index 0000000..38f9981 --- /dev/null +++ b/app/jobs/match_update_future_job.rb @@ -0,0 +1,47 @@ +class MatchUpdateFutureJob < ApplicationJob + queue_as :default + + def perform(competition_id) + @competition = Competition.find(competition_id) + url_to_update = LiveScoreApi.matches_future_url(@competition.api_id) + while url_to_update + url_to_update = update_matches_future(url_to_update) + end + end + + def update_matches_future(url) + response = HTTParty.get(url).body + parsed_response = JSON.parse(response)['data'] + matches = parsed_response['fixtures'] + DatabaseViews.run_without_callback(then_refresh: true) do + matches.each do |match_info| + kickoff_time = DateTime.parse("#{match_info['date']} #{match_info['time']}") + puts "Finding the match between : #{match_info['home_name']} v #{match_info['away_name']} (#{kickoff_time})" + # The API gives duplicate matches with different IDs, so need to find by day and teams + # match = @competition.matches.find_by(api_id: match_info['id']) || Match.new + team_home = Team.find_by(api_id: match_info['home_id']) + team_away = Team.find_by(api_id: match_info['away_id']) + match = + @competition.matches.where(team_home: team_home, team_away: team_away) + .find_by('kickoff_time::date = ?', match_info['date']) || Match.new + match.team_home ||= team_home + match.team_away ||= team_away + next unless match.team_home && match.team_away # knock-out rounds with no teams yet + + # Only adding a round for knockout stages, group isn't provided by API :/ + if %w[1 2 3].include?(match_info['round']) + match.group = @competition.groups.find_by(api_id: match_info["group_id"]) + else + match.round = Round.find_by(competition: @competition, api_name: match_info['round']) + end + match.api_id = match_info['id'] + match.location = match_info['location'] + match.kickoff_time = kickoff_time + match.save + p match.errors.full_messages if match.errors.any? + puts 'Match Update' + end + end + return parsed_response['next_page'] + end +end diff --git a/app/jobs/match_update_history_job.rb b/app/jobs/match_update_history_job.rb new file mode 100644 index 0000000..d6f0f84 --- /dev/null +++ b/app/jobs/match_update_history_job.rb @@ -0,0 +1,32 @@ +class MatchUpdateHistoryJob < ApplicationJob + queue_as :default + + def perform(competition_id) + competition = Competition.find(competition_id) + url_to_update = LiveScoreApi.matches_history_url(competition.api_id) + while url_to_update + url_to_update = update_matches_history(url_to_update, competition) + end + end + + def get_team(id) + Team.find_by(api_id: id) + end + + def update_matches_history(url, competition) + response = HTTParty.get(url).body + parsed_response = JSON.parse(response)['data'] + matches = parsed_response['match'] + DatabaseViews.run_without_callback(then_refresh: true) do + matches.each do |match_info| + kickoff_time = DateTime.parse("#{match_info['date']} #{match_info['scheduled']}") + puts "Finding the match between : #{match_info['home_name']} v #{match_info['away_name']} (#{kickoff_time})" + match = competition.matches.find_by(api_id: match_info['fixture_id']) || competition.matches.find_by(team_home: get_team(match_info['home_id']), team_away: get_team(match_info['away_id']), kickoff_time: kickoff_time) + next unless match + + match.update_with_api(match_info) + end + end + return parsed_response['next_page'] + end +end diff --git a/app/jobs/match_update_job.rb b/app/jobs/match_update_job.rb new file mode 100644 index 0000000..551f68d --- /dev/null +++ b/app/jobs/match_update_job.rb @@ -0,0 +1,48 @@ +class MatchUpdateJob < ApplicationJob + queue_as :default + + def perform(competition_id) + @competition = Competition.find(competition_id) + url_to_update = DataFootballApi.matches_url(@competition.api_code) + update_matches_future(url_to_update) + end + + def update_matches_future(url) + response = HTTParty.get( + url, + headers: { + 'Content-Type' => 'application/json', + 'X-Auth-Token' => ENV['FOOTBALL_DATA_TOKEN'] + } + ).body + parsed_response = JSON.parse(response) + matches = parsed_response['matches'] + DatabaseViews.run_without_callback(then_refresh: true) do + matches.each do |match_info| + kickoff_time = DateTime.parse(match_info['utcDate']) + puts "Finding the match between : #{match_info['homeTeam']['name']} v #{match_info['awayTeam']['name']} (#{kickoff_time})" + team_home = Team.find_by(abbrev: match_info['homeTeam']['tla']) + team_away = Team.find_by(abbrev: match_info['awayTeam']['tla']) + match = + @competition.matches.where(team_home: team_home, team_away: team_away) + .find_by(kickoff_time: kickoff_time) || Match.new + match.team_home ||= team_home + match.team_away ||= team_away + next unless match.team_home && match.team_away # knock-out rounds with no teams yet + + match.round = Round.find_by(competition: @competition, api_name: match_info['stage']) + match.group = Group.find_by(round: match.round, api_code: match_info["group"]) if match_info["group"] + match.api_id = match_info['id'] + # TODO: Don't think we're getting the location from the API + # match.location = match_info['location'] + match.kickoff_time = kickoff_time + match.save + p match.errors.full_messages if match.errors.any? + + # Update scores + match.update_with_api(match_info) + puts 'Match Update' + end + end + end +end diff --git a/app/jobs/match_update_live_job.rb b/app/jobs/match_update_live_job.rb new file mode 100644 index 0000000..bc6855b --- /dev/null +++ b/app/jobs/match_update_live_job.rb @@ -0,0 +1,31 @@ +class MatchUpdateLiveJob < ApplicationJob + queue_as :default + + def perform(competition_id) + competition = Competition.find(competition_id) + url_to_update = LiveScoreApi.matches_live_url(competition.api_id) + update_matches_live(url_to_update, competition) + end + + def get_team(id) + Team.find_by(api_id: id) + end + + def update_matches_live(url, competition) + # To test it locally, switch out the response: + # response = File.open('db/live_score_example.json').read + response = HTTParty.get(url).body + parsed_response = JSON.parse(response)['data'] + matches = parsed_response['match'] + DatabaseViews.run_without_callback(then_refresh: true) do + matches.each do |match_info| + kickoff_time = DateTime.parse("#{match_info['date']} #{match_info['scheduled']}") + puts "Finding the match between : #{match_info['home_name']} v #{match_info['away_name']} (#{kickoff_time})" + match = competition.matches.find_by(api_id: match_info['fixture_id']) || competition.matches.find_by(team_home: get_team(match_info['home_id']), team_away: get_team(match_info['away_id']), kickoff_time: kickoff_time) + next unless match + + match.update_with_api(match_info) + end + end + end +end diff --git a/app/jobs/refresh_database_views_job.rb b/app/jobs/refresh_database_views_job.rb new file mode 100644 index 0000000..46c8b97 --- /dev/null +++ b/app/jobs/refresh_database_views_job.rb @@ -0,0 +1,10 @@ +class RefreshDatabaseViewsJob < ApplicationJob + queue_as :default + + def perform + # The materialized views need to be refreshed in this order + MatchResult.refresh + UserScore.refresh + LeaderboardRanking.refresh + end +end diff --git a/app/jobs/schedule_daily_tasks_job.rb b/app/jobs/schedule_daily_tasks_job.rb new file mode 100644 index 0000000..3df356f --- /dev/null +++ b/app/jobs/schedule_daily_tasks_job.rb @@ -0,0 +1,13 @@ +class ScheduleDailyTasksJob < ApplicationJob + queue_as :default + + def perform + competitions = Competition.on_going + competitions.each do |competition| + matches = competition.matches.where(kickoff_time: Date.today.all_day) + matches.pluck(:kickoff_time).uniq.each do |kickoff_time| + MatchStartedJob.set(wait_until: kickoff_time).perform_later(kickoff_time) + end + end + end +end diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb new file mode 100644 index 0000000..a3186bc --- /dev/null +++ b/app/models/affiliation.rb @@ -0,0 +1,5 @@ +class Affiliation < ApplicationRecord + belongs_to :team + belongs_to :group + validates_uniqueness_of :team, scope: :group +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 10a4cba..ea02fb7 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,17 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + private + + def self.execute_sql(query, args = {}) + query = sanitize_sql([query, args]) + results = ActiveRecord::Base.connection.execute(query) + return unless results.present? + + results + end + + def refresh_materialized_views + DatabaseViews.refresh(async: true) + end end diff --git a/app/models/competition.rb b/app/models/competition.rb new file mode 100644 index 0000000..6032651 --- /dev/null +++ b/app/models/competition.rb @@ -0,0 +1,29 @@ +class Competition < ApplicationRecord + belongs_to :current_round, class_name: 'Round', optional: true + has_many :matches, dependent: :destroy + has_many :rounds, -> { distinct }, through: :matches + has_many :groups, through: :rounds + has_many :affiliations, through: :groups + has_many :teams, through: :affiliations + has_many :leaderboards, dependent: :destroy + has_many :predictions, through: :matches, dependent: :destroy + has_many :users, through: :leaderboards, source: :users + has_one_attached :photo + + validates :name, presence: true, uniqueness: { scope: :start_date} + validates :start_date, presence: true + validates :end_date, presence: true + + scope :on_going, -> { where('start_date < :start AND end_date > :end', start: Date.today + 1, end: Date.today - 1) } + + after_commit :refresh_materialized_views + before_destroy :destroy_rounds + + def destroy_rounds + Round.where(competition: self).destroy_all + end + + def max_possible_score + matches.finished.joins(:round).sum('rounds.points') + end +end diff --git a/app/models/group.rb b/app/models/group.rb new file mode 100644 index 0000000..3888ab3 --- /dev/null +++ b/app/models/group.rb @@ -0,0 +1,8 @@ +class Group < ApplicationRecord + belongs_to :round + has_many :matches, dependent: :destroy + has_many :affiliations, dependent: :destroy + has_many :teams, through: :affiliations + validates :name, presence: true + validates_uniqueness_of :name, scope: :round +end diff --git a/app/models/leaderboard.rb b/app/models/leaderboard.rb new file mode 100644 index 0000000..bb36a94 --- /dev/null +++ b/app/models/leaderboard.rb @@ -0,0 +1,31 @@ +class Leaderboard < ApplicationRecord + belongs_to :user + belongs_to :competition + has_many :memberships, dependent: :destroy + has_many :users, through: :memberships + has_many :locked_predictions, -> { locked }, through: :users, source: :predictions + + # Scenic views + has_many :match_results, -> { distinct } + has_many :rankings, class_name: 'LeaderboardRanking' + + validates :name, presence: true + has_secure_token :password + after_create :create_owner_membership + after_commit :refresh_materialized_views + + scope :auto_join, -> { where(auto_join: true) } + + def transfer_ownership + membership = memberships.first + membership.destroy + self.user = membership.user + save + end + + private + + def create_owner_membership + memberships.create(user: user) + end +end diff --git a/app/models/leaderboard_ranking.rb b/app/models/leaderboard_ranking.rb new file mode 100644 index 0000000..6b9b3bd --- /dev/null +++ b/app/models/leaderboard_ranking.rb @@ -0,0 +1,5 @@ +class LeaderboardRanking < ScenicViewRecord + belongs_to :leaderboard + belongs_to :competition + belongs_to :user +end diff --git a/app/models/match.rb b/app/models/match.rb new file mode 100644 index 0000000..07b6f6e --- /dev/null +++ b/app/models/match.rb @@ -0,0 +1,61 @@ +class Match < ApplicationRecord + belongs_to :competition + belongs_to :team_away, class_name: 'Team' + belongs_to :team_home, class_name: 'Team' + belongs_to :group, optional: true + belongs_to :round + belongs_to :next_match, class_name: 'Match', optional: true + has_many :predictions, dependent: :destroy + has_many :users, through: :predictions + validates :kickoff_time, presence: true + validates :status, presence: true + validates :api_id, uniqueness: { allow_nil: true } + validates_uniqueness_of :kickoff_time, scope: %i[team_home team_away] + validate :check_team_and_day_uniqueness + enum status: { upcoming: 'upcoming', started: 'started', finished: 'finished' }, _default: :upcoming + + # Scenic views + has_many :results, class_name: 'MatchResult' + + before_validation :set_round_and_competition, on: :create + after_commit :refresh_materialized_views + + def check_team_and_day_uniqueness + if Match.where(team_away: team_away, team_home: team_home).where.not(id: self).find_by("kickoff_time::date = ?", kickoff_time.to_date) + errors.add(:kickoff_time, "isn't available on this date") + end + end + + def update_with_api(match_info) + finished! if match_info['status'] == 'FINISHED' + started! if match_info['status'] == 'IN PLAY' || match_info['status'] == 'LIVE' + self.team_home_score = match_info['score']['fullTime']['home'] + self.team_away_score = match_info['score']['fullTime']['away'] + self.team_home_et_score = match_info['score']['extraTime']['home'] if match_info['score']['extraTime'] + self.team_away_et_score = match_info['score']['extraTime']['away'] if match_info['score']['extraTime'] + self.team_home_ps_score = match_info['score']['penalties']['home'] if match_info['score']['penalties'] + self.team_away_ps_score = match_info['score']['penalties']['away'] if match_info['score']['penalties'] + # TODO: I'm not sure how we're displaying the scores in the view + save + + scores = ["FT Score > #{build_regular_time_score}"] + scores << "Extra-time > #{team_home_et_score} - #{team_away_et_score}" unless match_info['score']['extraTime']&.blank? + scores << "Penalties > #{team_home_ps_score} - #{team_away_ps_score}" unless match_info['score']['penalties']&.blank? + puts "Match Update:\n#{scores.join("\n")}" + end + + private + + def set_round_and_competition + self.round ||= group&.round + self.competition ||= round&.competition + end + + def build_regular_time_score + if team_home_ps_score || team_away_ps_score + "#{team_home_score - team_home_ps_score} - #{team_away_score - team_away_ps_score}" + else + "#{team_home_score} - #{team_away_score}" + end + end +end diff --git a/app/models/match_result.rb b/app/models/match_result.rb new file mode 100644 index 0000000..c3ba600 --- /dev/null +++ b/app/models/match_result.rb @@ -0,0 +1,10 @@ +class MatchResult < ScenicViewRecord + belongs_to :match + belongs_to :group, optional: true + belongs_to :round + belongs_to :competition + belongs_to :team_home, class_name: 'Team' + belongs_to :team_away, class_name: 'Team' + + enum status: { upcoming: 'upcoming', started: 'started', finished: 'finished' } +end diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 0000000..5b09bd6 --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,16 @@ +class Membership < ApplicationRecord + belongs_to :leaderboard + belongs_to :user + has_one :competition, through: :leaderboard + validates_uniqueness_of :user, scope: :leaderboard + after_destroy :update_leaderboard_ownership + after_commit :refresh_materialized_views + + private + + def update_leaderboard_ownership + return unless user == leaderboard.user + + leaderboard.memberships.any? ? leaderboard.transfer_ownership : leaderboard.destroy + end +end diff --git a/app/models/prediction.rb b/app/models/prediction.rb new file mode 100644 index 0000000..ed4f488 --- /dev/null +++ b/app/models/prediction.rb @@ -0,0 +1,13 @@ +class Prediction < ApplicationRecord + belongs_to :match + has_one :competition, through: :match + belongs_to :user + validates_uniqueness_of :user, scope: :match + validates :choice, presence: true + enum choice: { home: 'home', away: 'away', draw: 'draw' } + + scope :editable, -> { joins(:match).where(matches: { status: :upcoming }) } + scope :locked, -> { joins(:match).where.not(matches: { status: :upcoming }) } + + after_commit :refresh_materialized_views +end diff --git a/app/models/round.rb b/app/models/round.rb new file mode 100644 index 0000000..5d8736e --- /dev/null +++ b/app/models/round.rb @@ -0,0 +1,15 @@ +class Round < ApplicationRecord + belongs_to :competition + has_many :groups, dependent: :destroy + has_many :matches, through: :groups + validates :name, presence: true, uniqueness: { scope: :competition } + + before_validation :set_points, on: :create + after_commit :refresh_materialized_views + + private + + def set_points + self.points ||= number + 2 + end +end diff --git a/app/models/scenic_view_record.rb b/app/models/scenic_view_record.rb new file mode 100644 index 0000000..a846f9b --- /dev/null +++ b/app/models/scenic_view_record.rb @@ -0,0 +1,7 @@ +class ScenicViewRecord < ApplicationRecord + self.abstract_class = true + + def self.refresh + Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false) + end +end diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 0000000..26405d1 --- /dev/null +++ b/app/models/team.rb @@ -0,0 +1,13 @@ +class Team < ApplicationRecord + has_many :affiliations, dependent: :destroy + has_many :groups, through: :affiliations + validates :name, presence: true, uniqueness: true + validates :abbrev, presence: true, uniqueness: true + has_one_attached :badge + has_one_attached :flag + + def matches + # teams can either be home or away + Match.where('team_home_id = :id OR team_away_id = :id', id: id) + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..79c214a --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,34 @@ +class User < ApplicationRecord + # Include default devise modules. + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :trackable, :validatable, + :omniauthable # :confirmable + include DeviseTokenAuth::Concerns::User + has_many :memberships, dependent: :destroy + has_many :leaderboards, through: :memberships + # TODO: Fix this + # has_many :competitions, through: :leaderboards + has_many :predictions, dependent: :destroy + has_many :matches, through: :predictions + + # Scenic views + has_many :scores, class_name: 'UserScore' + + validates :name, presence: true, on: :update, if: :name_changed? + + after_create :auto_join_leaderboards + + def name + super || email.split('@').first + end + + private + + def auto_join_leaderboards + DatabaseViews.run_without_callback(then_refresh: true) do + Leaderboard.auto_join.each do |leaderboard| + leaderboard.memberships.create(user: self) + end + end + end +end diff --git a/app/models/user_score.rb b/app/models/user_score.rb new file mode 100644 index 0000000..4ce3964 --- /dev/null +++ b/app/models/user_score.rb @@ -0,0 +1,4 @@ +class UserScore < ScenicViewRecord + belongs_to :user + belongs_to :competition +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..eefe976 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,49 @@ +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + attr_reader :user, :scope + + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + scope.all + end + end +end diff --git a/app/policies/competition_policy.rb b/app/policies/competition_policy.rb new file mode 100644 index 0000000..ebcd7c5 --- /dev/null +++ b/app/policies/competition_policy.rb @@ -0,0 +1,7 @@ +class CompetitionPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/leaderboard_policy.rb b/app/policies/leaderboard_policy.rb new file mode 100644 index 0000000..46b4d18 --- /dev/null +++ b/app/policies/leaderboard_policy.rb @@ -0,0 +1,15 @@ +class LeaderboardPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.joins(:memberships).where(memberships: { user: user }) + end + end + + def create? + true + end + + def destroy? + record.user == user || record.memberships.find_by(user: user) + end +end diff --git a/app/policies/match_policy.rb b/app/policies/match_policy.rb new file mode 100644 index 0000000..fad5381 --- /dev/null +++ b/app/policies/match_policy.rb @@ -0,0 +1,7 @@ +class MatchPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/membership_policy.rb b/app/policies/membership_policy.rb new file mode 100644 index 0000000..1940528 --- /dev/null +++ b/app/policies/membership_policy.rb @@ -0,0 +1,15 @@ +class MembershipPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end + + def create? + true + end + + def destroy? + record.user == user && !record.leaderboard.leave_disabled? + end +end diff --git a/app/policies/prediction_policy.rb b/app/policies/prediction_policy.rb new file mode 100644 index 0000000..7787de4 --- /dev/null +++ b/app/policies/prediction_policy.rb @@ -0,0 +1,23 @@ +class PredictionPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.joins(:match).where(user: user).or( + scope.joins(:match).where.not(user: user).where.not(matches: { status: :upcoming }) + ).distinct + end + end + + def create? + true + end + + def update? + owner_or_admin? + end + + private + + def owner_or_admin? + record.user == user || user.admin? + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 0000000..be2db90 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,15 @@ +class UserPolicy < ApplicationPolicy + class Scope < Scope + def resolve + scope.all + end + end + + def show? + true + end + + def update? + user == record + end +end diff --git a/app/services/data_football_api.rb b/app/services/data_football_api.rb new file mode 100644 index 0000000..69cb842 --- /dev/null +++ b/app/services/data_football_api.rb @@ -0,0 +1,9 @@ +class DataFootballApi + def self.matches_url(competition_api_code) + "https://api.football-data.org/v4/competitions/#{competition_api_code}/matches" + end + + def self.teams_url(competition_api_code) + "https://api.football-data.org/v4/competitions/#{competition_api_code}/teams" + end +end diff --git a/app/services/database_views.rb b/app/services/database_views.rb new file mode 100644 index 0000000..4cc60cf --- /dev/null +++ b/app/services/database_views.rb @@ -0,0 +1,26 @@ +class DatabaseViews + MODELS = [Membership, Leaderboard, Competition, Match, Prediction, Round] + + def self.refresh(async: true) + async ? RefreshDatabaseViewsJob.perform_later : RefreshDatabaseViewsJob.perform_now + end + + def self.deactivate_callback + MODELS.each do |model| + model.skip_callback(:commit, :after, :refresh_materialized_views) + end + end + + def self.activate_callback(then_refresh: false) + MODELS.each do |model| + model.set_callback(:commit, :after, :refresh_materialized_views) + end + refresh if then_refresh + end + + def self.run_without_callback(then_refresh: false, &block) + deactivate_callback + yield + activate_callback(then_refresh: then_refresh) + end +end diff --git a/app/services/live_score_api.rb b/app/services/live_score_api.rb new file mode 100644 index 0000000..7a5fe84 --- /dev/null +++ b/app/services/live_score_api.rb @@ -0,0 +1,13 @@ +class LiveScoreApi + def self.matches_future_url(competition_api_id) + "https://livescore-api.com/api-client/fixtures/matches.json?key=#{ENV['LIVE_SCORE_KEY']}&secret=#{ENV['LIVE_SCORE_SECRET']}&competition_id=#{competition_api_id}" + end + + def self.matches_history_url(competition_api_id) + "http://livescore-api.com/api-client/scores/history.json?key=#{ENV['LIVE_SCORE_KEY']}&secret=#{ENV['LIVE_SCORE_SECRET']}&competition_id=#{competition_api_id}" + end + + def self.matches_live_url(competition_api_id) + "https://livescore-api.com/api-client/scores/live.json?key=#{ENV['LIVE_SCORE_KEY']}&secret=#{ENV['LIVE_SCORE_SECRET']}&competition_id=#{competition_api_id}" + end +end diff --git a/app/services/scrape_matches_service.rb b/app/services/scrape_matches_service.rb new file mode 100644 index 0000000..79267a3 --- /dev/null +++ b/app/services/scrape_matches_service.rb @@ -0,0 +1,73 @@ +# TODO: We'll need to install the drivers to work on Heroku +require 'nokogiri' + +class ScrapeMatchesService + attr_reader :urls, :browser + + def initialize + # ids pulled from the UEFA website to scrape matches + @urls = [] + group_stages_ids = [33673, 33674, 33675] + group_stage_url = 'https://www.uefa.com/uefaeuro-2020/fixtures-results/#/md/' + group_stages_ids.each { |id| @urls << "#{group_stage_url}#{id}" } + + # TODO: Knockout stages have placeholders for winners of groups, not team names. + # knockout_ids = [2001025, 2001026, 2001027, 2001028] + # knockout_url = 'https://www.uefa.com/uefaeuro-2020/fixtures-results/#/rd/' + # knockout_ids.each { |id| @urls << "#{knockout_url}#{id}" } + end + + def call + browser_options = %w[--headless --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=9222] + DatabaseViews.run_without_callback(then_refresh: true) do + @browser = Watir::Browser.new :chrome, options: {args: browser_options} + urls.each do |url| + scrape(url) + end + end + end + + def scrape(url) + browser.goto url + sleep(10) + html_doc = Nokogiri::HTML.parse(browser.html) + + # The HTML is crazily organized + groups = html_doc.search('.match-row_group .match-group') + puts "Found #{groups.count} groups (should be 12)" + + html_doc.search('.match-row_link').each_with_index do |match_row, index| + p Match.find_or_create_by( + kickoff_time: get_kickoff_time(match_row), + team_home: get_team_home(match_row), + team_away: get_team_away(match_row), + group: get_group(groups[index]) + ) + end + end + + def get_team_home(match_row) + home_name = match_row.search('.team-home .team-name').text.strip + puts "Home team: #{home_name}" + Team.find_by(name: home_name) + end + + def get_team_away(match_row) + away_name = match_row.search('.team-away .team-name').text.strip + puts "Away team: #{away_name}" + Team.find_by(name: away_name) + end + + def get_kickoff_time(match_row) + # Tried about 100 ways before I got to this. Others weren't loading in time(?) + epoch = JSON.parse(match_row.search('.match-row_match').first.attributes["data-options"].value)['match']["MatchDateTime"].delete('/Date()/') + puts "Epoch: #{epoch}" + DateTime.strptime(epoch, '%Q') + end + + def get_group(group_element) + group_name = group_element.attributes['title'].value + puts "Group: #{group_name}" + Group.find_by(name: group_name) + end +end diff --git a/app/services/scrape_photo_service.rb b/app/services/scrape_photo_service.rb new file mode 100644 index 0000000..8be6c33 --- /dev/null +++ b/app/services/scrape_photo_service.rb @@ -0,0 +1,34 @@ +require 'watir' + +class ScrapePhotoService + attr_reader :user, :competition + + def initialize(attrs = {}) + @user = attrs[:user] + @competition = attrs[:competition] + end + + def call + DatabaseViews.deactivate_callback + url = "https://www.fifa.com/fifaplus/en/tournaments/mens/worldcup/qatar2022/teams/#{competition.teams.sample.name.split.join('-').downcase}/squad" + browser = Watir::Browser.new :chrome, options: { args: %w[--headless --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=9222] } + browser.goto url + puts "Going to: #{url}" + sleep(15) + html_doc = Nokogiri::HTML.parse(browser.html) + main_div = html_doc.search('main section')[2] + return unless main_div + + forwards = main_div.search('.entire-squad_container__3W4Hl')[3] + images = forwards.search('.player-badge-card_playerImage__301X0') + image_url = images[rand(0...images.length)].attribute("style").value.gsub('background-image: url(', '').delete('\"());') + file = URI.open(image_url) + puts "#{user.display_name}: \nUploading #{image_url} ..." + cl_response = Cloudinary::Uploader.upload(file) + user.photo_key = cl_response['public_id'] + user.save + # TODO: Most likely the ending numbers and names might change... + # document.querySelectorAll('main section')[2].querySelectorAll('.entire-squad_container__3W4Hl')[3].querySelectorAll('.player-badge-card_playerImage__301X0')[0].style.backgroundImage + DatabaseViews.activate_callback + end +end diff --git a/app/views/v1/competitions/_competition.json.jbuilder b/app/views/v1/competitions/_competition.json.jbuilder new file mode 100644 index 0000000..7162034 --- /dev/null +++ b/app/views/v1/competitions/_competition.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! competition, :id, :name, :start_date, :end_date, :current_round_id +json.photo_url cl_image_path(competition.photo.key) if competition.photo.attached? diff --git a/app/views/v1/competitions/index.json.jbuilder b/app/views/v1/competitions/index.json.jbuilder new file mode 100644 index 0000000..8d8ccb7 --- /dev/null +++ b/app/views/v1/competitions/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @competitions do |competition| + json.partial! competition +end diff --git a/app/views/v1/competitions/show.json.jbuilder b/app/views/v1/competitions/show.json.jbuilder new file mode 100644 index 0000000..e6f12ac --- /dev/null +++ b/app/views/v1/competitions/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! @competition diff --git a/app/views/v1/leaderboards/_leaderboard.json.jbuilder b/app/views/v1/leaderboards/_leaderboard.json.jbuilder new file mode 100644 index 0000000..2a22348 --- /dev/null +++ b/app/views/v1/leaderboards/_leaderboard.json.jbuilder @@ -0,0 +1 @@ +json.extract! leaderboard, :id, :name, :description, :password, :user_id, :competition_id, :auto_join, :leave_disabled, :rankings_top_n diff --git a/app/views/v1/leaderboards/_predictions.json.jbuilder b/app/views/v1/leaderboards/_predictions.json.jbuilder new file mode 100644 index 0000000..c079557 --- /dev/null +++ b/app/views/v1/leaderboards/_predictions.json.jbuilder @@ -0,0 +1,5 @@ +json.set! result.match_id do + json.home result.predicted_home + json.draw result.predicted_draw + json.away result.predicted_away +end diff --git a/app/views/v1/leaderboards/_ranking.json.jbuilder b/app/views/v1/leaderboards/_ranking.json.jbuilder new file mode 100644 index 0000000..9b9e156 --- /dev/null +++ b/app/views/v1/leaderboards/_ranking.json.jbuilder @@ -0,0 +1,9 @@ +json.user_id ranking.user_id +json.name ranking.user.name +json.photo_key ranking.user.photo_key +json.points ranking.score +json.total_predictions ranking.total_predictions +json.completed_predictions ranking.completed_predictions +json.correct_predictions ranking.correct_predictions +json.accuracy ranking.accuracy +json.rank ranking.user_rank diff --git a/app/views/v1/leaderboards/index.json.jbuilder b/app/views/v1/leaderboards/index.json.jbuilder new file mode 100644 index 0000000..797b040 --- /dev/null +++ b/app/views/v1/leaderboards/index.json.jbuilder @@ -0,0 +1,12 @@ +json.array! @leaderboards do |leaderboard| + json.partial! leaderboard + rankings = leaderboard.rankings.order(:user_rank) + json.users rankings do |ranking| + json.partial! 'v1/leaderboards/ranking', ranking: ranking + end + json.results do + leaderboard.match_results.each do |result| + json.partial! 'v1/leaderboards/predictions', result: result + end + end +end diff --git a/app/views/v1/leaderboards/show.json.jbuilder b/app/views/v1/leaderboards/show.json.jbuilder new file mode 100644 index 0000000..cd4e1f3 --- /dev/null +++ b/app/views/v1/leaderboards/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! @leaderboard diff --git a/app/views/v1/matches/_match.json.jbuilder b/app/views/v1/matches/_match.json.jbuilder new file mode 100644 index 0000000..f5d9efb --- /dev/null +++ b/app/views/v1/matches/_match.json.jbuilder @@ -0,0 +1,18 @@ +json.extract! match, :id, :kickoff_time, :status, :group_id, :next_match_id, :round_id, :location +json.round_number match.round.number +json.team_home do + json.partial! match.team_home + if %w[finished started].include?(match[:status]) + json.score match.team_home_score + json.et_score match.team_home_et_score + json.ps_score match.team_home_ps_score + end +end +json.team_away do + json.partial! match.team_away + if %w[finished started].include?(match[:status]) + json.score match.team_away_score + json.et_score match.team_away_et_score + json.ps_score match.team_away_ps_score + end +end diff --git a/app/views/v1/matches/index.json.jbuilder b/app/views/v1/matches/index.json.jbuilder new file mode 100644 index 0000000..dfd4d59 --- /dev/null +++ b/app/views/v1/matches/index.json.jbuilder @@ -0,0 +1,7 @@ +json.array! @matches do |match| + json.partial! match + json.prediction do + prediction = match.predictions.find_by(user: @user) + json.partial! prediction if prediction.present? + end +end diff --git a/app/views/v1/memberships/_membership.json.jbuilder b/app/views/v1/memberships/_membership.json.jbuilder new file mode 100644 index 0000000..20c1280 --- /dev/null +++ b/app/views/v1/memberships/_membership.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! membership, :id, :leaderboard_id, :user_id +json.competition_id membership.competition.id diff --git a/app/views/v1/memberships/show.json.jbuilder b/app/views/v1/memberships/show.json.jbuilder new file mode 100644 index 0000000..d6f8e70 --- /dev/null +++ b/app/views/v1/memberships/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! @membership diff --git a/app/views/v1/predictions/_prediction.json.jbuilder b/app/views/v1/predictions/_prediction.json.jbuilder new file mode 100644 index 0000000..919f96c --- /dev/null +++ b/app/views/v1/predictions/_prediction.json.jbuilder @@ -0,0 +1 @@ +json.extract! prediction, :id, :choice, :match_id, :user_id diff --git a/app/views/v1/predictions/show.json.jbuilder b/app/views/v1/predictions/show.json.jbuilder new file mode 100644 index 0000000..15e8435 --- /dev/null +++ b/app/views/v1/predictions/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! @prediction diff --git a/app/views/v1/teams/_team.json.jbuilder b/app/views/v1/teams/_team.json.jbuilder new file mode 100644 index 0000000..c773cd6 --- /dev/null +++ b/app/views/v1/teams/_team.json.jbuilder @@ -0,0 +1,3 @@ +json.extract! team, :id, :name, :abbrev +json.badge_url cl_image_path(team.badge.key) if team.badge.attached? +json.flag_url cl_image_path(team.flag.key) if team.flag.attached? diff --git a/app/views/v1/users/_user.json.jbuilder b/app/views/v1/users/_user.json.jbuilder new file mode 100644 index 0000000..fcb41bd --- /dev/null +++ b/app/views/v1/users/_user.json.jbuilder @@ -0,0 +1 @@ +json.extract! user, :id, :email, :timezone, :admin, :photo_key, :name diff --git a/app/views/v1/users/show.json.jbuilder b/app/views/v1/users/show.json.jbuilder new file mode 100644 index 0000000..2b9bb93 --- /dev/null +++ b/app/views/v1/users/show.json.jbuilder @@ -0,0 +1,6 @@ +json.partial! @user +if @competition + json.points @user.scores.find_by(competition: @competition).score + json.accuracy @user.scores.find_by(competition: @competition).accuracy.to_f + json.possible_points @competition.max_possible_score +end diff --git a/bin/sidekiq b/bin/sidekiq new file mode 100755 index 0000000..9e75499 --- /dev/null +++ b/bin/sidekiq @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiq' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path("../bundle", __FILE__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("sidekiq", "sidekiq") diff --git a/bin/sidekiqmon b/bin/sidekiqmon new file mode 100755 index 0000000..fedda51 --- /dev/null +++ b/bin/sidekiqmon @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiqmon' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path("../bundle", __FILE__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("sidekiq", "sidekiqmon") diff --git a/config/application.rb b/config/application.rb index 5d3b657..03fc6fc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -36,5 +36,11 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + config.active_job.queue_adapter = :sidekiq + + # Adding back session middleware for Sidekiq::Web + config.session_store :cookie_store, key: '_interslice_session' + config.middleware.use ActionDispatch::Cookies + config.middleware.use config.session_store, config.session_options end end diff --git a/config/cable.yml b/config/cable.yml index 0b3553b..1ae16c5 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -6,5 +6,5 @@ test: production: adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + url: <%= ENV.fetch("REDISCLOUD_URL") { "redis://localhost:6379/1" } %> channel_prefix: predictor_api_production diff --git a/config/environment.rb b/config/environment.rb index cac5315..ef2d4c8 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,5 +1,9 @@ # Load the Rails application. -require_relative "application" +require_relative 'application' # Initialize the Rails application. Rails.application.initialize! + +# Configure Jbuilder +Jbuilder.key_format camelize: :lower +Jbuilder.deep_format_keys true diff --git a/config/environments/development.rb b/config/environments/development.rb index 231c2a6..6f09d00 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,6 +1,12 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + config.after_initialize do + Bullet.enable = true + Bullet.console = true + Bullet.rails_logger = true + end + # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded any time @@ -28,9 +34,14 @@ end # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :cloudinary + + config.action_mailer.default_url_options = { host: 'localhost:3000' } # Don't care if the mailer can't send. + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true + # config.action_mailer.smtp_settings = { address: '127.0.0.1', port: 1025 } config.action_mailer.raise_delivery_errors = false config.action_mailer.perform_caching = false diff --git a/config/environments/production.rb b/config/environments/production.rb index a929a51..2eaff2e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -31,7 +31,7 @@ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :local + config.active_storage.service = :cloudinary # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil @@ -55,6 +55,8 @@ # config.active_job.queue_adapter = :resque # config.active_job.queue_name_prefix = "predictor_api_production" + config.action_mailer.delivery_method = :smtp + config.action_mailer.default_url_options = { host: 'http://predict-to-win.herokuapp.com' } config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. diff --git a/config/environments/test.rb b/config/environments/test.rb index 93ed4f1..3c692e5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -38,6 +38,8 @@ config.action_mailer.perform_caching = false + + config.action_mailer.default_url_options = { host: 'localhost:3000' } # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 3b1c1b5..89b2fe1 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -5,12 +5,22 @@ # Read more: https://github.com/cyu/rack-cors -# Rails.application.config.middleware.insert_before 0, Rack::Cors do -# allow do -# origins 'example.com' -# -# resource '*', -# headers: :any, -# methods: [:get, :post, :put, :patch, :delete, :options, :head] -# end -# end +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins [ + # Local server + %r{\Ahttps?://localhost:\d{4}}, + %r{\Ahttps?://192\.168\.\d\.\d{1,3}:\d{4}}, + # Netlify app and preview deploys + %r{\Ahttps?://(.+--)?octacle\.netlify\.app}, + # Production app + %r{\Ahttps?:\/\/.+\.octacle\.app} + ] + + resource '*', + headers: :any, + expose: %w[access-token expiry token-type uid client], + methods: %i[get post options delete put patch], + credentials: true + end +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 0000000..b96b787 --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,16 @@ +Devise.setup do |config| + # The e-mail address that mail will appear to be sent from + # If absent, mail is sent from "please-change-me-at-config-initializers-devise@example.com" + config.mailer_sender = "hello@octacle.app" + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # If using rails-api, you may want to tell devise to not use ActionDispatch::Flash + # middleware b/c rails-api does not include it. + # See: https://stackoverflow.com/q/19600905/806956 + config.navigational_formats = [:json] +end diff --git a/config/initializers/devise_token_auth.rb b/config/initializers/devise_token_auth.rb new file mode 100644 index 0000000..9d5ea52 --- /dev/null +++ b/config/initializers/devise_token_auth.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +DeviseTokenAuth.setup do |config| + # By default the authorization headers will change after each request. The + # client is responsible for keeping track of the changing tokens. Change + # this to false to prevent the Authorization header from changing after + # each request. + config.change_headers_on_each_request = !Rails.env.development? + + # By default, users will need to re-authenticate after 2 weeks. This setting + # determines how long tokens will remain valid after they are issued. + # config.token_lifespan = 2.weeks + + # Limiting the token_cost to just 4 in testing will increase the performance of + # your test suite dramatically. The possible cost value is within range from 4 + # to 31. It is recommended to not use a value more than 10 in other environments. + config.token_cost = Rails.env.test? ? 4 : 10 + + # Sets the max number of concurrent devices per user, which is 10 by default. + # After this limit is reached, the oldest tokens will be removed. + # config.max_number_of_devices = 10 + + # Sometimes it's necessary to make several requests to the API at the same + # time. In this case, each request in the batch will need to share the same + # auth token. This setting determines how far apart the requests can be while + # still using the same auth token. + # config.batch_request_buffer_throttle = 5.seconds + + # This route will be the prefix for all oauth2 redirect callbacks. For + # example, using the default '/omniauth', the github oauth2 provider will + # redirect successful authentications to '/omniauth/github/callback' + # config.omniauth_prefix = "/omniauth" + + # By default sending current password is not needed for the password update. + # Uncomment to enforce current_password param to be checked before all + # attribute updates. Set it to :password if you want it to be checked only if + # password is updated. + # config.check_current_password_before_update = :attributes + + # By default we will use callbacks for single omniauth. + # It depends on fields like email, provider and uid. + # config.default_callbacks = true + + # Makes it possible to change the headers names + # config.headers_names = {:'access-token' => 'access-token', + # :'client' => 'client', + # :'expiry' => 'expiry', + # :'uid' => 'uid', + # :'token-type' => 'token-type' } + + # By default, only Bearer Token authentication is implemented out of the box. + # If, however, you wish to integrate with legacy Devise authentication, you can + # do so by enabling this flag. NOTE: This feature is highly experimental! + # config.enable_standard_devise_support = false + + # By default DeviseTokenAuth will not send confirmation email, even when including + # devise confirmable module. If you want to use devise confirmable module and + # send email, set it to true. (This is a setting for compatibility) + # config.send_confirmation_email = true +end diff --git a/config/initializers/redis.rb b/config/initializers/redis.rb new file mode 100644 index 0000000..5cd2827 --- /dev/null +++ b/config/initializers/redis.rb @@ -0,0 +1,14 @@ +$redis = Redis.new + +url = ENV["REDISCLOUD_URL"] + +if url + Sidekiq.configure_server do |config| + config.redis = { url: url } + end + + Sidekiq.configure_client do |config| + config.redis = { url: url } + end + $redis = Redis.new(:url => url) +end diff --git a/config/initializers/smtp.rb b/config/initializers/smtp.rb new file mode 100644 index 0000000..cbc8447 --- /dev/null +++ b/config/initializers/smtp.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +ActionMailer::Base.smtp_settings = { + user_name: 'apikey', + password: ENV['SENDGRID_API_KEY'], + domain: 'octacle.app', + address: 'smtp.sendgrid.net', + port: 587, + authentication: :plain, + enable_starttls_auto: true +} diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 0000000..ab1f070 --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again" + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/routes.rb b/config/routes.rb index c06383a..8394d36 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,31 @@ Rails.application.routes.draw do - # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html + mount_devise_token_auth_for 'User', at: 'auth', controllers: { + sessions: 'auth/devise_token_auth/sessions' + } + + # Sidekiq Web UI, only for admins. + Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| + ActiveSupport::SecurityUtils.secure_compare( + username, ENV['SIDEKIQ_USERNAME'] + ) && ActiveSupport::SecurityUtils.secure_compare( + password, ENV['SIDEKIQ_PASSWORD'] + ) + end + + mount Sidekiq::Web => '/sidekiq' + + namespace :v1, defaults: { format: :json } do + resources :competitions, only: [:index] do + resources :leaderboards, only: [:index, :create] + end + resources :matches, only: [:index], shallow: true do + resources :predictions, only: [:create] + patch :predictions, to: 'predictions#update', as: :prediction + end + resources :leaderboards, only: [:destroy], shallow: true do + resources :memberships, only: [:create, :destroy] + end + resources :users, only: [:show, :update] + get 'join/:password', to: 'memberships#create' + end end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..5c836ef --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,8 @@ +:concurrency: 3 +:timeout: 60 +:verbose: true +:queues: + - default + - mailers + - active_storage_analysis + - active_storage_purge diff --git a/config/storage.yml b/config/storage.yml index d32f76e..796066a 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -1,34 +1,2 @@ -test: - service: Disk - root: <%= Rails.root.join("tmp/storage") %> - -local: - service: Disk - root: <%= Rails.root.join("storage") %> - -# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# bucket: your_own_bucket - -# Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket - -# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name - -# mirror: -# service: Mirror -# primary: local -# mirrors: [ amazon, google, microsoft ] +cloudinary: + service: Cloudinary diff --git a/db/live_score_example.json b/db/live_score_example.json new file mode 100644 index 0000000..c598fb2 --- /dev/null +++ b/db/live_score_example.json @@ -0,0 +1,50 @@ +{"success":true,"data":{"match":[ +{ +"ht_score": "0 - 0", +"has_lineups": true, +"location": "Lusail Iconic Stadium, Lusail", +"events": "https://livescore-api.com/api-client/scores/events.json?key=ApDugiN21K7nGZRl&secret=uNR7nJIwjpFLurX5RBA7TnaV8hjOhaaL&id=382953", +"score": "2 - 0", +"league_id": 0, +"scheduled": "19:00", +"odds": { +"pre": { +"1": 1.55, +"2": 7, +"X": 3.8 +}, +"live": { +"1": 1.04, +"2": 151, +"X": 13 +} +}, +"competition_name": "FIFA World Cup", +"id": 382953, +"country": null, +"status": "IN PLAY", +"last_changed": "2022-11-26 20:53:03", +"et_score": "", +"competition_id": 362, +"fixture_id": 1527784, +"away_id": 1450, +"home_id": 1443, +"h2h": "https://livescore-api.com/api-client/teams/head2head.json?key=ApDugiN21K7nGZRl&secret=uNR7nJIwjpFLurX5RBA7TnaV8hjOhaaL&team1_id=1443&team2_id=1450", +"home_name": "Argentina", +"ps_score": "", +"time": "90+", +"league_name": "", +"away_name": "Mexico", +"ft_score": "", +"federation": { +"id": 1, +"name": "FIFA" +}, +"added": "2022-11-26 18:45:18", +"outcomes": { +"half_time": "X", +"full_time": null, +"extra_time": null +} +} +]}} diff --git a/db/migrate/20210407055612_devise_token_auth_create_users.rb b/db/migrate/20210407055612_devise_token_auth_create_users.rb new file mode 100644 index 0000000..7c62c5b --- /dev/null +++ b/db/migrate/20210407055612_devise_token_auth_create_users.rb @@ -0,0 +1,53 @@ +class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.1] + def change + + create_table(:users) do |t| + ## Required + t.string :provider, :null => false, :default => "email" + t.string :uid, :null => false, :default => "" + + ## Database authenticatable + t.string :encrypted_password, :null => false, :default => "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + t.boolean :allow_password_change, :default => false + + ## Rememberable + t.datetime :remember_created_at + + ## Confirmable + t.string :confirmation_token + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + # t.integer :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts + # t.string :unlock_token # Only if unlock strategy is :email or :both + # t.datetime :locked_at + + ## User Info (these were auto-included but all not necessary) + t.string :email + t.string :name + # t.string :nickname + # t.string :image + + ## Manually Added + t.boolean :admin, default: false + t.string :timezone + + ## Tokens + t.json :tokens + + t.timestamps + end + + add_index :users, :email, unique: true + add_index :users, [:uid, :provider], unique: true + add_index :users, :reset_password_token, unique: true + add_index :users, :confirmation_token, unique: true + # add_index :users, :unlock_token, unique: true + end +end diff --git a/db/migrate/20210407060207_create_competitions.rb b/db/migrate/20210407060207_create_competitions.rb new file mode 100644 index 0000000..6e38fb2 --- /dev/null +++ b/db/migrate/20210407060207_create_competitions.rb @@ -0,0 +1,11 @@ +class CreateCompetitions < ActiveRecord::Migration[6.1] + def change + create_table :competitions do |t| + t.string :name + t.date :start_date + t.date :end_date + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060356_create_leagues.rb b/db/migrate/20210407060356_create_leagues.rb new file mode 100644 index 0000000..6fc3276 --- /dev/null +++ b/db/migrate/20210407060356_create_leagues.rb @@ -0,0 +1,12 @@ +class CreateLeagues < ActiveRecord::Migration[6.1] + def change + create_table :leagues do |t| + t.string :name + t.string :password + t.references :user, null: false, foreign_key: true + t.references :competition, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060437_create_memberships.rb b/db/migrate/20210407060437_create_memberships.rb new file mode 100644 index 0000000..c0e22c3 --- /dev/null +++ b/db/migrate/20210407060437_create_memberships.rb @@ -0,0 +1,10 @@ +class CreateMemberships < ActiveRecord::Migration[6.1] + def change + create_table :memberships do |t| + t.references :league, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060556_create_rounds.rb b/db/migrate/20210407060556_create_rounds.rb new file mode 100644 index 0000000..79baeb4 --- /dev/null +++ b/db/migrate/20210407060556_create_rounds.rb @@ -0,0 +1,11 @@ +class CreateRounds < ActiveRecord::Migration[6.1] + def change + create_table :rounds do |t| + t.integer :number + t.string :name + t.references :competition, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060617_create_groups.rb b/db/migrate/20210407060617_create_groups.rb new file mode 100644 index 0000000..71ad95c --- /dev/null +++ b/db/migrate/20210407060617_create_groups.rb @@ -0,0 +1,10 @@ +class CreateGroups < ActiveRecord::Migration[6.1] + def change + create_table :groups do |t| + t.string :name + t.references :round, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060657_create_teams.rb b/db/migrate/20210407060657_create_teams.rb new file mode 100644 index 0000000..c55813a --- /dev/null +++ b/db/migrate/20210407060657_create_teams.rb @@ -0,0 +1,10 @@ +class CreateTeams < ActiveRecord::Migration[6.1] + def change + create_table :teams do |t| + t.string :name + t.string :abbrev + + t.timestamps + end + end +end diff --git a/db/migrate/20210407060723_create_affiliations.rb b/db/migrate/20210407060723_create_affiliations.rb new file mode 100644 index 0000000..d6e839e --- /dev/null +++ b/db/migrate/20210407060723_create_affiliations.rb @@ -0,0 +1,10 @@ +class CreateAffiliations < ActiveRecord::Migration[6.1] + def change + create_table :affiliations do |t| + t.references :team, null: false, foreign_key: true + t.references :group, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407061008_create_matches.rb b/db/migrate/20210407061008_create_matches.rb new file mode 100644 index 0000000..a55900a --- /dev/null +++ b/db/migrate/20210407061008_create_matches.rb @@ -0,0 +1,15 @@ +class CreateMatches < ActiveRecord::Migration[6.1] + def change + create_table :matches do |t| + t.datetime :kickoff_time + t.integer :team_home_score + t.integer :team_away_score + t.integer :status, default: 0 + t.references :group, null: false, foreign_key: true + t.references :team_away, foreign_key: { to_table: :teams } + t.references :team_home, foreign_key: { to_table: :teams } + + t.timestamps + end + end +end diff --git a/db/migrate/20210407061425_create_predictions.rb b/db/migrate/20210407061425_create_predictions.rb new file mode 100644 index 0000000..73d7690 --- /dev/null +++ b/db/migrate/20210407061425_create_predictions.rb @@ -0,0 +1,11 @@ +class CreatePredictions < ActiveRecord::Migration[6.1] + def change + create_table :predictions do |t| + t.integer :choice + t.references :match, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210407061624_add_current_round_to_competitions.rb b/db/migrate/20210407061624_add_current_round_to_competitions.rb new file mode 100644 index 0000000..0814001 --- /dev/null +++ b/db/migrate/20210407061624_add_current_round_to_competitions.rb @@ -0,0 +1,6 @@ +class AddCurrentRoundToCompetitions < ActiveRecord::Migration[6.1] + def change + add_reference :competitions, :current_round, foreign_key: { to_table: :rounds } + add_reference :matches, :next_match, foreign_key: { to_table: :matches } + end +end diff --git a/db/migrate/20210407082013_create_active_storage_tables.active_storage.rb b/db/migrate/20210407082013_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..8779826 --- /dev/null +++ b/db/migrate/20210407082013_create_active_storage_tables.active_storage.rb @@ -0,0 +1,36 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/migrate/20210409090454_add_trackable_fields_to_user.rb b/db/migrate/20210409090454_add_trackable_fields_to_user.rb new file mode 100644 index 0000000..41212bf --- /dev/null +++ b/db/migrate/20210409090454_add_trackable_fields_to_user.rb @@ -0,0 +1,11 @@ +class AddTrackableFieldsToUser < ActiveRecord::Migration[6.1] + def change + change_table :users do |t| + t.integer :sign_in_count, default: 0, null: false + t.datetime :current_sign_in_at + t.datetime :last_sign_in_at + t.inet :current_sign_in_ip + t.inet :last_sign_in_ip + end + end +end diff --git a/db/migrate/20210415013709_add_round_to_matches.rb b/db/migrate/20210415013709_add_round_to_matches.rb new file mode 100644 index 0000000..367f8c1 --- /dev/null +++ b/db/migrate/20210415013709_add_round_to_matches.rb @@ -0,0 +1,6 @@ +class AddRoundToMatches < ActiveRecord::Migration[6.1] + def change + add_reference :matches, :round, null: true, foreign_key: true + change_column_null :matches, :group_id, true + end +end diff --git a/db/migrate/20210416124931_change_status_to_string.rb b/db/migrate/20210416124931_change_status_to_string.rb new file mode 100644 index 0000000..a253fdb --- /dev/null +++ b/db/migrate/20210416124931_change_status_to_string.rb @@ -0,0 +1,9 @@ +class ChangeStatusToString < ActiveRecord::Migration[6.1] + def up + change_column :matches, :status, :string, default: nil + end + + def down + change_column :matches, :status, :integer, using: 'status::integer', default: 0 + end +end diff --git a/db/migrate/20210421110232_change_leagues_to_leaderboards.rb b/db/migrate/20210421110232_change_leagues_to_leaderboards.rb new file mode 100644 index 0000000..072d94a --- /dev/null +++ b/db/migrate/20210421110232_change_leagues_to_leaderboards.rb @@ -0,0 +1,7 @@ +class ChangeLeaguesToLeaderboards < ActiveRecord::Migration[6.1] + def change + remove_reference :memberships, :league, index: true, foreign_key: true + rename_table :leagues, :leaderboards + add_reference :memberships, :leaderboard, foreign_key: true + end +end diff --git a/db/migrate/20210424101146_add_uniqueness_to_leagues.rb b/db/migrate/20210424101146_add_uniqueness_to_leagues.rb new file mode 100644 index 0000000..6cc452b --- /dev/null +++ b/db/migrate/20210424101146_add_uniqueness_to_leagues.rb @@ -0,0 +1,5 @@ +class AddUniquenessToLeagues < ActiveRecord::Migration[6.1] + def change + change_column :leaderboards, :password, :string, unique: true + end +end diff --git a/db/migrate/20210602120404_add_api_id_to_teams.rb b/db/migrate/20210602120404_add_api_id_to_teams.rb new file mode 100644 index 0000000..c998539 --- /dev/null +++ b/db/migrate/20210602120404_add_api_id_to_teams.rb @@ -0,0 +1,5 @@ +class AddApiIdToTeams < ActiveRecord::Migration[6.1] + def change + add_column :teams, :api_id, :integer + end +end diff --git a/db/migrate/20210604153807_add_photo_key_to_users.rb b/db/migrate/20210604153807_add_photo_key_to_users.rb new file mode 100644 index 0000000..c9de259 --- /dev/null +++ b/db/migrate/20210604153807_add_photo_key_to_users.rb @@ -0,0 +1,5 @@ +class AddPhotoKeyToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :photo_key, :string + end +end diff --git a/db/migrate/20210606093702_add_api_id_to_competition.rb b/db/migrate/20210606093702_add_api_id_to_competition.rb new file mode 100644 index 0000000..8f3e57c --- /dev/null +++ b/db/migrate/20210606093702_add_api_id_to_competition.rb @@ -0,0 +1,12 @@ +class AddApiIdToCompetition < ActiveRecord::Migration[6.1] + def change + add_column :competitions, :api_id, :integer + Competition.find_each do |competition| + next if competition.api_id + + # Live-Score API ID for the the Euros + competition.api_id = 387 + competition.save + end + end +end diff --git a/db/migrate/20210606100056_add_api_id_to_match.rb b/db/migrate/20210606100056_add_api_id_to_match.rb new file mode 100644 index 0000000..3b3086e --- /dev/null +++ b/db/migrate/20210606100056_add_api_id_to_match.rb @@ -0,0 +1,9 @@ +class AddApiIdToMatch < ActiveRecord::Migration[6.1] + def change + add_column :matches, :api_id, :integer + add_column :matches, :location, :string + Competition.find_each do |competition| + MatchUpdateFutureJob.perform_now(competition.id) + end + end +end diff --git a/db/migrate/20210622083343_add_api_name_to_rounds.rb b/db/migrate/20210622083343_add_api_name_to_rounds.rb new file mode 100644 index 0000000..37f7695 --- /dev/null +++ b/db/migrate/20210622083343_add_api_name_to_rounds.rb @@ -0,0 +1,5 @@ +class AddApiNameToRounds < ActiveRecord::Migration[6.1] + def change + add_column :rounds, :api_name, :string + end +end diff --git a/db/migrate/20210705023732_add_more_scores_to_matches.rb b/db/migrate/20210705023732_add_more_scores_to_matches.rb new file mode 100644 index 0000000..e2b36d6 --- /dev/null +++ b/db/migrate/20210705023732_add_more_scores_to_matches.rb @@ -0,0 +1,8 @@ +class AddMoreScoresToMatches < ActiveRecord::Migration[6.1] + def change + add_column :matches, :team_home_et_score, :integer + add_column :matches, :team_away_et_score, :integer + add_column :matches, :team_home_ps_score, :integer + add_column :matches, :team_away_ps_score, :integer + end +end diff --git a/db/migrate/20221108131452_add_api_id_to_groups.rb b/db/migrate/20221108131452_add_api_id_to_groups.rb new file mode 100644 index 0000000..c9e6daa --- /dev/null +++ b/db/migrate/20221108131452_add_api_id_to_groups.rb @@ -0,0 +1,5 @@ +class AddApiIdToGroups < ActiveRecord::Migration[6.1] + def change + add_column :groups, :api_id, :integer + end +end diff --git a/db/migrate/20221204113607_add_competition_and_round_to_matches.rb b/db/migrate/20221204113607_add_competition_and_round_to_matches.rb new file mode 100644 index 0000000..c08e7e7 --- /dev/null +++ b/db/migrate/20221204113607_add_competition_and_round_to_matches.rb @@ -0,0 +1,22 @@ +class AddCompetitionAndRoundToMatches < ActiveRecord::Migration[6.1] + def up + add_reference :matches, :competition, foreign_key: true + + Match.reset_column_information + Match.find_each do |match| + match.update_columns(round_id: match.group.round.id) if match.round.blank? + match.update_columns(competition_id: match.round.competition.id) if match.competition.blank? + end + + change_column_null :matches, :competition_id, false + change_column_null :matches, :round_id, false + end + + def down + remove_reference :matches, :competition, foreign_key: true + change_column_null :matches, :round_id, true + Match.where.not(group: nil).find_each do |match| + match.update_columns(round_id: nil) + end + end +end diff --git a/db/migrate/20221204123021_add_points_to_rounds.rb b/db/migrate/20221204123021_add_points_to_rounds.rb new file mode 100644 index 0000000..b2df0d4 --- /dev/null +++ b/db/migrate/20221204123021_add_points_to_rounds.rb @@ -0,0 +1,15 @@ +class AddPointsToRounds < ActiveRecord::Migration[6.1] + def up + add_column :rounds, :points, :integer + + Round.find_each do |round| + round.update_columns(points: round.number + 2) + end + + change_column_null :rounds, :points, false + end + + def down + remove_column :rounds, :points + end +end diff --git a/db/migrate/20221204123938_change_predictions_choice_to_string.rb b/db/migrate/20221204123938_change_predictions_choice_to_string.rb new file mode 100644 index 0000000..e633c81 --- /dev/null +++ b/db/migrate/20221204123938_change_predictions_choice_to_string.rb @@ -0,0 +1,33 @@ +class ChangePredictionsChoiceToString < ActiveRecord::Migration[6.1] + def up + rename_column :predictions, :choice, :choice_integer + add_column :predictions, :choice, :string + + say_with_time "Converting integer enum to string" do + predictions = Prediction.where.not(choice_integer: nil) + bar = ProgressBar.new(predictions.count) + predictions.find_each do |prediction| + prediction.update_columns(choice: %w[home away draw][prediction.choice_integer]) + bar.increment! + end + end + + remove_column :predictions, :choice_integer + end + + def down + rename_column :predictions, :choice, :choice_string + add_column :predictions, :choice, :integer + + say_with_time "Converting string enum to integer" do + predictions = Prediction.where.not(choice_string: nil) + bar = ProgressBar.new(predictions.count) + predictions.find_each do |prediction| + prediction.update_columns(choice: %w[home away draw].index(prediction.choice_string)) + bar.increment! + end + end + + remove_column :predictions, :choice_string + end +end diff --git a/db/migrate/20221204123940_create_match_results.rb b/db/migrate/20221204123940_create_match_results.rb new file mode 100644 index 0000000..ea77168 --- /dev/null +++ b/db/migrate/20221204123940_create_match_results.rb @@ -0,0 +1,5 @@ +class CreateMatchResults < ActiveRecord::Migration[6.1] + def change + create_view :match_results, materialized: true + end +end diff --git a/db/migrate/20221204124451_create_user_scores.rb b/db/migrate/20221204124451_create_user_scores.rb new file mode 100644 index 0000000..6c92925 --- /dev/null +++ b/db/migrate/20221204124451_create_user_scores.rb @@ -0,0 +1,5 @@ +class CreateUserScores < ActiveRecord::Migration[6.1] + def change + create_view :user_scores, materialized: true + end +end diff --git a/db/migrate/20221204153322_create_leaderboard_rankings.rb b/db/migrate/20221204153322_create_leaderboard_rankings.rb new file mode 100644 index 0000000..933d0c4 --- /dev/null +++ b/db/migrate/20221204153322_create_leaderboard_rankings.rb @@ -0,0 +1,5 @@ +class CreateLeaderboardRankings < ActiveRecord::Migration[6.1] + def change + create_view :leaderboard_rankings, materialized: true + end +end diff --git a/db/migrate/20221205114427_add_settings_to_leaderboards.rb b/db/migrate/20221205114427_add_settings_to_leaderboards.rb new file mode 100644 index 0000000..5d30b82 --- /dev/null +++ b/db/migrate/20221205114427_add_settings_to_leaderboards.rb @@ -0,0 +1,8 @@ +class AddSettingsToLeaderboards < ActiveRecord::Migration[6.1] + def change + add_column :leaderboards, :description, :string + add_column :leaderboards, :auto_join, :boolean, null: false, default: false + add_column :leaderboards, :leave_disabled, :boolean, null: false, default: false + add_column :leaderboards, :rankings_top_n, :integer + end +end diff --git a/db/migrate/20240606050948_add_api_code_to_competitions.rb b/db/migrate/20240606050948_add_api_code_to_competitions.rb new file mode 100644 index 0000000..a5b8aea --- /dev/null +++ b/db/migrate/20240606050948_add_api_code_to_competitions.rb @@ -0,0 +1,5 @@ +class AddApiCodeToCompetitions < ActiveRecord::Migration[6.1] + def change + add_column :competitions, :api_code, :string + end +end diff --git a/db/migrate/20240606055739_add_api_code_to_groups.rb b/db/migrate/20240606055739_add_api_code_to_groups.rb new file mode 100644 index 0000000..a9c01df --- /dev/null +++ b/db/migrate/20240606055739_add_api_code_to_groups.rb @@ -0,0 +1,5 @@ +class AddApiCodeToGroups < ActiveRecord::Migration[6.1] + def change + add_column :groups, :api_code, :string + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..b86f751 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,322 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 2024_06_06_055739) do + + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "affiliations", force: :cascade do |t| + t.bigint "team_id", null: false + t.bigint "group_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["group_id"], name: "index_affiliations_on_group_id" + t.index ["team_id"], name: "index_affiliations_on_team_id" + end + + create_table "competitions", force: :cascade do |t| + t.string "name" + t.date "start_date" + t.date "end_date" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "current_round_id" + t.integer "api_id" + t.string "api_code" + t.index ["current_round_id"], name: "index_competitions_on_current_round_id" + end + + create_table "groups", force: :cascade do |t| + t.string "name" + t.bigint "round_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "api_id" + t.string "api_code" + t.index ["round_id"], name: "index_groups_on_round_id" + end + + create_table "leaderboards", force: :cascade do |t| + t.string "name" + t.string "password" + t.bigint "user_id", null: false + t.bigint "competition_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "description" + t.boolean "auto_join", default: false, null: false + t.boolean "leave_disabled", default: false, null: false + t.integer "rankings_top_n" + t.index ["competition_id"], name: "index_leaderboards_on_competition_id" + t.index ["user_id"], name: "index_leaderboards_on_user_id" + end + + create_table "matches", force: :cascade do |t| + t.datetime "kickoff_time" + t.integer "team_home_score" + t.integer "team_away_score" + t.string "status" + t.bigint "group_id" + t.bigint "team_away_id" + t.bigint "team_home_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "next_match_id" + t.bigint "round_id", null: false + t.integer "api_id" + t.string "location" + t.integer "team_home_et_score" + t.integer "team_away_et_score" + t.integer "team_home_ps_score" + t.integer "team_away_ps_score" + t.bigint "competition_id", null: false + t.index ["competition_id"], name: "index_matches_on_competition_id" + t.index ["group_id"], name: "index_matches_on_group_id" + t.index ["next_match_id"], name: "index_matches_on_next_match_id" + t.index ["round_id"], name: "index_matches_on_round_id" + t.index ["team_away_id"], name: "index_matches_on_team_away_id" + t.index ["team_home_id"], name: "index_matches_on_team_home_id" + end + + create_table "memberships", force: :cascade do |t| + t.bigint "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "leaderboard_id" + t.index ["leaderboard_id"], name: "index_memberships_on_leaderboard_id" + t.index ["user_id"], name: "index_memberships_on_user_id" + end + + create_table "predictions", force: :cascade do |t| + t.bigint "match_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "choice" + t.index ["match_id"], name: "index_predictions_on_match_id" + t.index ["user_id"], name: "index_predictions_on_user_id" + end + + create_table "rounds", force: :cascade do |t| + t.integer "number" + t.string "name" + t.bigint "competition_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "api_name" + t.integer "points", null: false + t.index ["competition_id"], name: "index_rounds_on_competition_id" + end + + create_table "teams", force: :cascade do |t| + t.string "name" + t.string "abbrev" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "api_id" + end + + create_table "users", force: :cascade do |t| + t.string "provider", default: "email", null: false + t.string "uid", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.boolean "allow_password_change", default: false + t.datetime "remember_created_at" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.string "email" + t.string "name" + t.boolean "admin", default: false + t.string "timezone" + t.json "tokens" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.inet "current_sign_in_ip" + t.inet "last_sign_in_ip" + t.string "photo_key" + t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "affiliations", "groups" + add_foreign_key "affiliations", "teams" + add_foreign_key "competitions", "rounds", column: "current_round_id" + add_foreign_key "groups", "rounds" + add_foreign_key "leaderboards", "competitions" + add_foreign_key "leaderboards", "users" + add_foreign_key "matches", "competitions" + add_foreign_key "matches", "groups" + add_foreign_key "matches", "matches", column: "next_match_id" + add_foreign_key "matches", "rounds" + add_foreign_key "matches", "teams", column: "team_away_id" + add_foreign_key "matches", "teams", column: "team_home_id" + add_foreign_key "memberships", "leaderboards" + add_foreign_key "memberships", "users" + add_foreign_key "predictions", "matches" + add_foreign_key "predictions", "users" + add_foreign_key "rounds", "competitions" + + create_view "match_results", materialized: true, sql_definition: <<-SQL + WITH leaderboard_users AS ( + SELECT leaderboards.id, + mb.user_id + FROM (leaderboards + JOIN memberships mb ON ((mb.leaderboard_id = leaderboards.id))) + ) + SELECT matches.id AS match_id, + matches.round_id, + matches.competition_id, + l.id AS leaderboard_id, + matches.status, + matches.group_id, + matches.team_away_id, + matches.team_home_id, + matches.next_match_id, + CASE + WHEN (((matches.status)::text = 'upcoming'::text) OR ((matches.status)::text = 'started'::text)) THEN NULL::text + WHEN ((matches.team_home_et_score IS NULL) AND (matches.team_away_et_score IS NULL) AND (matches.team_home_ps_score IS NULL) AND (matches.team_away_ps_score IS NULL) AND (matches.team_home_score = matches.team_away_score)) THEN 'draw'::text + WHEN ((matches.team_home_score > matches.team_away_score) OR ((matches.team_home_et_score IS NOT NULL) AND (matches.team_home_et_score > matches.team_away_et_score)) OR ((matches.team_home_ps_score IS NOT NULL) AND (matches.team_home_ps_score > matches.team_away_ps_score))) THEN 'home'::text + ELSE 'away'::text + END AS winning_side, + r.number AS round_number, + r.points, + r.name AS round_name, + ARRAY( SELECT DISTINCT p.user_id + FROM predictions p + WHERE ((p.match_id = matches.id) AND (p.user_id IN ( SELECT lu.user_id + FROM leaderboard_users lu + WHERE (lu.id = l.id))) AND ((p.choice)::text = 'home'::text))) AS predicted_home, + ARRAY( SELECT DISTINCT p.user_id + FROM predictions p + WHERE ((p.match_id = matches.id) AND (p.user_id IN ( SELECT lu.user_id + FROM leaderboard_users lu + WHERE (lu.id = l.id))) AND ((p.choice)::text = 'draw'::text))) AS predicted_draw, + ARRAY( SELECT DISTINCT p.user_id + FROM predictions p + WHERE ((p.match_id = matches.id) AND (p.user_id IN ( SELECT lu.user_id + FROM leaderboard_users lu + WHERE (lu.id = l.id))) AND ((p.choice)::text = 'away'::text))) AS predicted_away + FROM ((matches + JOIN rounds r ON ((matches.round_id = r.id))) + JOIN leaderboards l ON ((l.competition_id = matches.competition_id))) + ORDER BY matches.id; + SQL + create_view "user_scores", materialized: true, sql_definition: <<-SQL + WITH prediction_scores AS ( + SELECT DISTINCT p.id AS prediction_id, + p.user_id, + p.match_id, + r.points, + mr.competition_id, + CASE + WHEN (((mr.status)::text = 'finished'::text) AND (p.choice IS NOT NULL)) THEN true + ELSE false + END AS completed, + CASE + WHEN (((mr.status)::text = 'finished'::text) AND ((p.choice)::text = mr.winning_side)) THEN true + ELSE false + END AS correct, + CASE + WHEN (((mr.status)::text = 'finished'::text) AND ((p.choice)::text = mr.winning_side)) THEN mr.points + ELSE 0 + END AS prediction_score + FROM ((predictions p + LEFT JOIN match_results mr ON ((mr.match_id = p.match_id))) + LEFT JOIN rounds r ON ((r.id = mr.round_id))) + ), prediction_numbers AS ( + SELECT u.id AS user_id, + ps_1.competition_id, + sum(ps_1.prediction_score) AS score, + count(DISTINCT ps_1.prediction_id) AS total_predictions, + ( SELECT count(DISTINCT ps2.prediction_id) AS count + FROM prediction_scores ps2 + WHERE (ps2.completed IS TRUE) + GROUP BY ps2.user_id, ps2.competition_id + HAVING ((ps2.user_id = u.id) AND (ps2.competition_id = ps_1.competition_id))) AS completed_predictions, + ( SELECT count(DISTINCT ps2.prediction_id) AS count + FROM prediction_scores ps2 + WHERE (ps2.correct IS TRUE) + GROUP BY ps2.user_id, ps2.competition_id + HAVING ((ps2.user_id = u.id) AND (ps2.competition_id = ps_1.competition_id))) AS correct_predictions + FROM (users u + LEFT JOIN prediction_scores ps_1 ON ((ps_1.user_id = u.id))) + GROUP BY u.id, ps_1.competition_id + ) + SELECT DISTINCT ON (ps.user_id, ps.competition_id) ps.user_id, + ps.competition_id, + pn.score, + (pn.completed_predictions * ps.points) AS max_possible_score, + pn.total_predictions, + pn.completed_predictions, + pn.correct_predictions, + round((((pn.correct_predictions)::numeric * 1.0) / (NULLIF(pn.total_predictions, 0))::numeric), 3) AS accuracy + FROM (prediction_scores ps + JOIN prediction_numbers pn ON (((pn.user_id = ps.user_id) AND (pn.competition_id = ps.competition_id)))) + ORDER BY ps.user_id; + SQL + create_view "leaderboard_rankings", materialized: true, sql_definition: <<-SQL + SELECT DISTINCT ON ((rank() OVER (PARTITION BY l.id ORDER BY us.score DESC, us.accuracy DESC, us.completed_predictions DESC)), us.score, us.accuracy, us.completed_predictions, l.id, l.competition_id, us.user_id) l.id AS leaderboard_id, + us.user_id, + us.competition_id, + us.score, + us.max_possible_score, + us.total_predictions, + us.completed_predictions, + us.correct_predictions, + us.accuracy, + rank() OVER (PARTITION BY l.id ORDER BY us.score DESC, us.accuracy DESC, us.completed_predictions DESC) AS user_rank + FROM ((leaderboards l + JOIN memberships m ON ((m.leaderboard_id = l.id))) + JOIN user_scores us ON (((us.user_id = m.user_id) AND (us.competition_id = l.competition_id)))) + ORDER BY (rank() OVER (PARTITION BY l.id ORDER BY us.score DESC, us.accuracy DESC, us.completed_predictions DESC)), us.score, us.accuracy, us.completed_predictions; + SQL +end diff --git a/db/seeds.rb b/db/seeds.rb index f3a0480..87f5d97 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,7 +1,4 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) -# Character.create(name: 'Luke', movie: movies.first) +Dir[File.join(Rails.root, 'db', 'seeds', '*.rb')].sort.each do |filename| + puts "------> Seeding #{filename}..." + load(filename) +end diff --git a/db/seeds/0_default.rb b/db/seeds/0_default.rb new file mode 100644 index 0000000..35c401d --- /dev/null +++ b/db/seeds/0_default.rb @@ -0,0 +1,127 @@ +require 'open-uri' + +DatabaseViews.deactivate_callback + +Leaderboard.destroy_all + +puts 'Getting Admin users...' +doug = User.find_by(email: 'douglasmberkley@gmail.com') || User.create(email: 'douglasmberkley@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) +trouni = User.find_by(email: 'trouni@gmail.com') || User.create(email: 'trouni@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) +james = User.find_by(email: 'devereuxjj@gmail.com') || User.create(email: 'devereuxjj@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) + +puts 'Creating test users...' +20.times do + User.create( + # fake emails for testing purposes + email: Faker::Internet.email, + password: '123123' + ) +end +puts "... #{User.count} Total Users" + +groups = { + 'Group A' => [ + { name: 'Italy', abbrev: 'ITA' }, + { name: 'Switzerland', abbrev: 'SUI' }, + { name: 'Turkey', abbrev: 'TUR' }, + { name: 'Wales', abbrev: 'WAL' } + ], + 'Group B' => [ + { name: 'Belgium', abbrev: 'BEL' }, + { name: 'Denmark', abbrev: 'DEN' }, + { name: 'Finland', abbrev: 'FIN' }, + { name: 'Russia', abbrev: 'RUS' } + ], + 'Group C' => [ + { name: 'Austria', abbrev: 'AUT' }, + { name: 'Netherlands', abbrev: 'NED' }, + { name: 'FYR Macedonia', abbrev: 'MKD' }, + { name: 'Ukraine', abbrev: 'UKR' } + ], + 'Group D' => [ + { name: 'Croatia', abbrev: 'CRO' }, + { name: 'Czech Republic', abbrev: 'CZE' }, + { name: 'England', abbrev: 'ENG' }, + { name: 'Scotland', abbrev: 'SCO' } + ], + 'Group E' => [ + { name: 'Poland', abbrev: 'POL' }, + { name: 'Slovakia', abbrev: 'SVK' }, + { name: 'Spain', abbrev: 'ESP' }, + { name: 'Sweden', abbrev: 'SWE' } + ], + 'Group F' => [ + { name: 'France', abbrev: 'FRA' }, + { name: 'Germany', abbrev: 'GER' }, + { name: 'Hungary', abbrev: 'HUN' }, + { name: 'Portugal', abbrev: 'POR' } + ] +} + +puts 'Creating the Euros...' +euros = Competition.find_or_create_by!(name: 'Euro 2020', start_date: Date.new(2021, 6, 12), end_date: Date.new(2021, 7, 12)) +puts '.. created the Euros' + +puts 'Creating or finding first round...' +first_round = Round.find_or_create_by!(name: 'Group Stage', number: 1, competition: euros, api_name: '3') +puts "...#{Round.count} Total Rounds" + +puts 'Creating or finding groups...' +groups.each_key do |group_name| + puts "...#{group_name}..." + group = Group.find_or_create_by!(name: group_name, round: first_round) + groups[group_name].each do |team_hash| + puts "Name: #{team_hash[:name]}, Abbrev: #{team_hash[:abbrev]}" + team = Team.find_or_create_by!(team_hash) + Affiliation.find_or_create_by!(team: team, group: group) + end +end +puts "...#{Team.count} Total Teams" +puts "...#{Group.count} Total Groups" + +Team.find_each do |team| + next if team.badge.attached? + + url = "https://www.uefa.com/imgml/flags/140x140/#{team.abbrev}.png?imwidth=2048%202048w" + puts "#{team.name}: #{url}" + file = URI.open(url) + team.badge.attach(io: file, filename: 'badge.png', content_type: 'image/png') + puts team.badge.attached? ? 'Success' : 'Failed' +end + +puts 'Creating a test leaderboard w/ James as creator' +leaderboard = Leaderboard.find_or_create_by!( + name: 'Admin Leaderboard', + competition: euros, + user: trouni +) + +puts 'Creating test users...' +5.times do + Leaderboard.create!( + # fake emails for testing purposes + name: Faker::Sports::Football.team, + competition: euros, + user: trouni + ) +end +puts "... #{User.count} Total Users" + +puts 'Adding Users to the leaderboard' +Leaderboard.find_each do |ldbrd| + ([doug] + User.last(20)).each do |user| + Membership.find_or_create_by!(leaderboard: ldbrd, user: user) + end +end + +ScrapeMatchesService.new.call + +# puts 'Assigning random scores to matches before June 22nd' +# Match.where('kickoff_time < ?', Date.new(2021, 6, 22)).each do |match| +# match.update(team_away_score: rand(4), team_home_score: rand(4), status: :finished) +# end +# # Needed when migrating status enum from integer to string: +# Match.where('kickoff_time >= ?', Date.new(2021, 6, 22)).update(status: :upcoming) +# puts "...#{Match.finished.count} Finished Matches and #{Match.upcoming.count} Upcoming Matches" + +DatabaseViews.activate_callback(then_refresh: true) \ No newline at end of file diff --git a/db/seeds/1_leaderboards.rb b/db/seeds/1_leaderboards.rb new file mode 100644 index 0000000..138b84f --- /dev/null +++ b/db/seeds/1_leaderboards.rb @@ -0,0 +1,32 @@ +LEADERBOARDS = [ + { + name: 'Global Top 10', + description: 'The Top 10 players on Octacle', + rankings_top_n: 10, + leave_disabled: true, + auto_join: true, + } +] + +admin = User.find_by(email: 'trouni@gmail.com') + +DatabaseViews.deactivate_callback + +puts '-----> Seeding auto-join leaderboards...' +LEADERBOARDS.each do |leaderboard_hash| + Competition.find_each do |competition| + leaderboard = competition.leaderboards.find_or_initialize_by(leaderboard_hash.slice(:name)) + leaderboard.assign_attributes(leaderboard_hash) + leaderboard.user ||= admin + leaderboard.save! + + puts "-----> Creating memberships for #{leaderboard.name} (#{leaderboard.competition.name})" + bar = ProgressBar.new(User.count) + User.find_each do |user| + leaderboard.memberships.find_or_create_by!(user: user) + bar.increment! + end + end +end + +DatabaseViews.activate_callback(then_refresh: true) diff --git a/db/views/leaderboard_rankings_v01.sql b/db/views/leaderboard_rankings_v01.sql new file mode 100644 index 0000000..e2af4f9 --- /dev/null +++ b/db/views/leaderboard_rankings_v01.sql @@ -0,0 +1,11 @@ +SELECT DISTINCT ON (l.id, l.competition_id, us.user_id, user_rank, us.score, us.accuracy, us.completed_predictions) + l.id AS leaderboard_id, + us.*, + RANK () OVER ( + PARTITION BY l.id + ORDER BY us.score DESC, us.accuracy DESC, us.completed_predictions DESC + ) user_rank +FROM leaderboards l +JOIN memberships m ON m.leaderboard_id = l.id +JOIN user_scores us ON us.user_id = m.user_id AND us.competition_id = l.competition_id +ORDER BY user_rank, us.score, us.accuracy, us.completed_predictions diff --git a/db/views/match_results_v01.sql b/db/views/match_results_v01.sql new file mode 100644 index 0000000..7e9bf6e --- /dev/null +++ b/db/views/match_results_v01.sql @@ -0,0 +1,45 @@ +WITH leaderboard_users AS ( + SELECT + leaderboards.id, + mb.user_id + FROM leaderboards + JOIN memberships mb ON mb.leaderboard_id = leaderboards.id +) +SELECT + matches.id AS match_id, + matches.round_id, + matches.competition_id, + l.id AS leaderboard_id, + matches.status, + matches.group_id, + matches.team_away_id, + matches.team_home_id, + matches.next_match_id, + ( + CASE + WHEN (status = 'upcoming' OR status = 'started') THEN NULL + WHEN ( + team_home_et_score IS NULL AND + team_away_et_score IS NULL AND + team_home_ps_score IS NULL AND + team_away_ps_score IS NULL AND + team_home_score = team_away_score + ) THEN 'draw' + WHEN ( + team_home_score > team_away_score OR + (team_home_et_score IS NOT NULL AND team_home_et_score > team_away_et_score) OR + (team_home_ps_score IS NOT NULL AND team_home_ps_score > team_away_ps_score) + ) THEN 'home' + ELSE 'away' + END + ) AS winning_side, + r.number AS round_number, + r.points AS points, + r.name AS round_name, + ARRAY(SELECT DISTINCT p.user_id FROM predictions p WHERE p.match_id = matches.id AND p.user_id IN (SELECT user_id FROM leaderboard_users lu WHERE lu.id = l.id) AND p.choice = 'home') as predicted_home, + ARRAY(SELECT DISTINCT p.user_id FROM predictions p WHERE p.match_id = matches.id AND p.user_id IN (SELECT user_id FROM leaderboard_users lu WHERE lu.id = l.id) AND p.choice = 'draw') as predicted_draw, + ARRAY(SELECT DISTINCT p.user_id FROM predictions p WHERE p.match_id = matches.id AND p.user_id IN (SELECT user_id FROM leaderboard_users lu WHERE lu.id = l.id) AND p.choice = 'away') as predicted_away +FROM matches +JOIN rounds r ON matches.round_id = r.id +JOIN leaderboards l ON l.competition_id = matches.competition_id +ORDER BY matches.id diff --git a/db/views/user_scores_v01.sql b/db/views/user_scores_v01.sql new file mode 100644 index 0000000..5fcae96 --- /dev/null +++ b/db/views/user_scores_v01.sql @@ -0,0 +1,64 @@ +WITH prediction_scores AS ( + SELECT + DISTINCT p.id AS prediction_id, + p.user_id, + p.match_id, + r.points, + mr.competition_id, + ( + CASE + WHEN mr.status = 'finished' AND p.choice IS NOT NULL THEN TRUE + ELSE FALSE + END + ) AS completed, + ( + CASE + WHEN mr.status = 'finished' AND p.choice = mr.winning_side THEN TRUE + ELSE FALSE + END + ) AS correct, + ( + CASE + WHEN mr.status = 'finished' AND p.choice = mr.winning_side THEN mr.points + ELSE 0 + END + ) AS prediction_score + FROM predictions p + LEFT JOIN match_results mr ON mr.match_id = p.match_id + LEFT JOIN rounds r ON r.id = mr.round_id +), prediction_numbers AS ( + SELECT + u.id AS user_id, + ps.competition_id, + SUM(ps.prediction_score) AS score, + COUNT(DISTINCT ps.prediction_id) AS total_predictions, + ( + SELECT COUNT(DISTINCT ps2.prediction_id) + FROM prediction_scores ps2 + WHERE ps2.completed IS TRUE + GROUP BY ps2.user_id, ps2.competition_id + HAVING ps2.user_id = u.id AND ps2.competition_id = ps.competition_id + ) AS completed_predictions, + ( + SELECT COUNT(DISTINCT ps2.prediction_id) + FROM prediction_scores ps2 + WHERE ps2.correct IS TRUE + GROUP BY ps2.user_id, ps2.competition_id + HAVING ps2.user_id = u.id AND ps2.competition_id = ps.competition_id + ) AS correct_predictions + FROM users u + LEFT JOIN prediction_scores ps ON ps.user_id = u.id + GROUP BY u.id, ps.competition_id +) +SELECT DISTINCT ON (ps.user_id, ps.competition_id) + ps.user_id, + ps.competition_id, + pn.score, + pn.completed_predictions * ps.points AS max_possible_score, + pn.total_predictions, + pn.completed_predictions, + pn.correct_predictions, + ROUND(pn.correct_predictions * 1.0 / NULLIF(pn.total_predictions, 0), 3) AS accuracy +FROM prediction_scores ps +JOIN prediction_numbers pn ON pn.user_id = ps.user_id AND pn.competition_id = ps.competition_id +ORDER BY ps.user_id diff --git a/lib/tasks/competition.rake b/lib/tasks/competition.rake new file mode 100644 index 0000000..c2b69df --- /dev/null +++ b/lib/tasks/competition.rake @@ -0,0 +1,234 @@ +namespace :competition do + desc "Create World Cup 2022" + task world_cup: :environment do + groups = { + 'Group A' => { + api_id: 1913, + teams: [ + { name: 'Qatar', abbrev: 'QAT' }, + { name: 'Ecuador', abbrev: 'ECU' }, + { name: 'Senegal', abbrev: 'SEN' }, + { name: 'Netherlands', abbrev: 'NED' } + ] + }, + 'Group B' => { + api_id: 1914, + teams: [ + { name: 'England', abbrev: 'ENG' }, + { name: 'Iran', abbrev: 'IRN' }, + { name: 'USA', abbrev: 'USA' }, + { name: 'Wales', abbrev: 'WAL' } + ] + }, + 'Group C' => { + api_id: 1915, + teams: [ + { name: 'Argentina', abbrev: 'ARG' }, + { name: 'Saudi Arabia', abbrev: 'KSA' }, + { name: 'Mexico', abbrev: 'MEX' }, + { name: 'Poland', abbrev: 'POL' } + ] + }, + 'Group D' => { + api_id: 1916, + teams: [ + { name: 'France', abbrev: 'FRA' }, + { name: 'Australia', abbrev: 'AUS' }, + { name: 'Denmark', abbrev: 'DEN' }, + { name: 'Tunisia', abbrev: 'TUN' } + ] + }, + 'Group E' => { + api_id: 1917, + teams: [ + { name: 'Spain', abbrev: 'ESP' }, + { name: 'Costa Rica', abbrev: 'CRC' }, + { name: 'Germany', abbrev: 'GER' }, + { name: 'Japan', abbrev: 'JPN' } + ] + }, + 'Group F' => { + api_id: 1918, + teams: [ + { name: 'Belgium', abbrev: 'BEL' }, + { name: 'Canada', abbrev: 'CAN' }, + { name: 'Morocco', abbrev: 'MAR' }, + { name: 'Croatia', abbrev: 'CRO' } + ] + }, + 'Group G' => { + api_id: 1919, + teams: [ + { name: 'Brazil', abbrev: 'BRA' }, + { name: 'Serbia', abbrev: 'SRB' }, + { name: 'Switzerland', abbrev: 'SUI' }, + { name: 'Cameroon', abbrev: 'CMR' } + ] + }, + 'Group H' => { + api_id: 1920, + teams: [ + { name: 'Portugal', abbrev: 'POR' }, + { name: 'Ghana', abbrev: 'GHA' }, + { name: 'Uruguay', abbrev: 'URU' }, + { name: 'South Korea', abbrev: 'KOR' } + ] + } + } + puts 'Creating the World Cup...' + world_cup = Competition.find_or_create_by(name: 'World Cup 2022', start_date: Date.new(2022, 11, 20), end_date: Date.new(2022, 12, 18), api_id: 362) + puts '.. created the World Cup' + + puts 'Creating or finding first round...' + first_round = Round.find_or_create_by(name: 'Group Stage', number: 1, competition: world_cup, api_name: '1') + puts "...#{world_cup.rounds.count} Total Rounds" + + puts 'Creating or finding groups...' + groups.each_key do |group_name| + puts "...#{group_name}..." + group = Group.find_or_create_by!(name: group_name, round: first_round, api_id: groups[group_name][:api_id]) + groups[group_name][:teams].each do |team_hash| + puts "Name: #{team_hash[:name]}, Abbrev: #{team_hash[:abbrev]}" + team = Team.find_or_create_by(team_hash) + Affiliation.find_or_create_by(team: team, group: group) + end + end + puts "...#{world_cup.teams.count} Total Teams" + puts "...#{world_cup.groups.count} Total Groups" + + doug = User.find_by(email: 'douglasmberkley@gmail.com') + trouni = User.find_by(email: 'trouni@gmail.com') + + puts 'Creating a test leaderboards' + leaderboard = Leaderboard.find_or_create_by( + name: 'Admin Leaderboard', + competition: world_cup, + user: trouni + ) + Membership.find_or_create_by(leaderboard: leaderboard, user: doug) + leaderboard = Leaderboard.find_or_create_by( + name: 'Admin Leaderboard', + competition: world_cup, + user: doug + ) + Membership.find_or_create_by(leaderboard: leaderboard, user: trouni) + end + + desc "Update upcoming fixtures for on-going competitions" + task update_ongoing_matches: :environment do + competitions = Competition.on_going + competitions.each do |competition| + # MatchUpdateFutureJob.perform_later(competition.id) + # MatchUpdateHistoryJob.perform_later(competition.id) + # MatchUpdateLiveJob.perform_later(competition.id) + MatchUpdateJob.perform_later(competition.id) + end + end + + desc "Update upcoming fixtures for a competition" + task :update_matches_future, [:competition_id] => :environment do |t, args| + competition = Competition.find(args[:competition_id]) + # MatchUpdateFutureJob.perform_later(competition.id) + MatchUpdateJob.perform_later(competition.id) + end + + desc "Update upcoming fixtures for a competition" + task :update_matches_history, [:competition_id] => :environment do |t, args| + competition = Competition.find(args[:competition_id]) + # MatchUpdateHistoryJob.perform_later(competition.id) + MatchUpdateJob.perform_later(competition.id) + end + + desc "Copy the first competition and start it today" + task copy: :environment do + euros = Competition.find_or_create_by!(name: 'Euro 2020') + + groups = { + 'Group A' => [ + { name: 'Italy', abbrev: 'ITA' }, + { name: 'Switzerland', abbrev: 'SUI' }, + { name: 'Turkey', abbrev: 'TUR' }, + { name: 'Wales', abbrev: 'WAL' } + ], + 'Group B' => [ + { name: 'Belgium', abbrev: 'BEL' }, + { name: 'Denmark', abbrev: 'DEN' }, + { name: 'Finland', abbrev: 'FIN' }, + { name: 'Russia', abbrev: 'RUS' } + ], + 'Group C' => [ + { name: 'Austria', abbrev: 'AUT' }, + { name: 'Netherlands', abbrev: 'NED' }, + { name: 'North Macedonia', abbrev: 'MKD' }, + { name: 'Ukraine', abbrev: 'UKR' } + ], + 'Group D' => [ + { name: 'Croatia', abbrev: 'CRO' }, + { name: 'Czech Republic', abbrev: 'CZE' }, + { name: 'England', abbrev: 'ENG' }, + { name: 'Scotland', abbrev: 'SCO' } + ], + 'Group E' => [ + { name: 'Poland', abbrev: 'POL' }, + { name: 'Slovakia', abbrev: 'SVK' }, + { name: 'Spain', abbrev: 'ESP' }, + { name: 'Sweden', abbrev: 'SWE' } + ], + 'Group F' => [ + { name: 'France', abbrev: 'FRA' }, + { name: 'Germany', abbrev: 'GER' }, + { name: 'Hungary', abbrev: 'HUN' }, + { name: 'Portugal', abbrev: 'POR' } + ] + } + + puts 'Creating the Euros...' + euros_test = Competition.find_or_create_by!(name: 'Euro Test 2020', start_date: Date.today, end_date: Date.today + 35.days) + puts '.. created the Euros_test' + + puts 'Creating or finding first round...' + first_round = Round.find_or_create_by!(name: 'Group Stage', number: 1, competition: euros_test) + puts "...#{Round.count} Total Rounds" + + puts 'Creating or finding groups...' + groups.each_key do |group_name| + puts "...#{group_name}..." + group = Group.find_or_create_by!(name: group_name, round: first_round) + groups[group_name].each do |team_hash| + puts "Name: #{team_hash[:name]}, Abbrev: #{team_hash[:abbrev]}" + team = Team.find_or_create_by!(team_hash) + Affiliation.find_or_create_by!(team: team, group: group) + end + end + + euros.matches.each do |match| + new_match = match.dup + new_match.group = euros_test.groups.find_by(name: match.group.name) + new_match.kickoff_time = match.kickoff_time - 9.days + new_match.team_home_score = nil + new_match.team_away_score = nil + new_match.status = "upcoming" + new_match.save + end + end + + desc "Adds the photos to old competiton" + task add_photos: :environment do + euro_2020 = Competition.find_by(name: 'Euro 2020') + file = URI.open('https://upload.wikimedia.org/wikipedia/en/9/96/UEFA_Euro_2020_Logo.svg') + euro_2020.photo.attach(io: file, filename: 'logo.png', content_type: 'image/png') unless euro_2020.photo.attached? + + wc_2022 = Competition.find_by(name: 'World Cup 2022') + file = URI.open('https://crests.football-data.org/qatar.png') + wc_2022.photo.attach(io: file, filename: 'logo.png', content_type: 'image/png') unless wc_2022.photo.attached? + + euro_2024 = Competition.find_by(name: 'Euros 2024') + file = URI.open('https://www.football-data.org/assets/logo-euro_2020.svg') + euro_2024.photo.attach(io: file, filename: 'logo.png', content_type: 'image/png') unless euro_2024.photo.attached? + + copa_2024 = Competition.find_by(name: 'Copa America 2024') + file = URI.open('https://static.wikia.nocookie.net/internationalbroadcasts/images/8/80/2024_Copa_Am%C3%A9rica_logo.png/revision/latest/thumbnail/width/360/height/360?cb=20240402104618') + copa_2024.photo.attach(io: file, filename: 'logo.png', content_type: 'image/png') unless copa_2024.photo.attached? + end + +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake new file mode 100644 index 0000000..d6f385e --- /dev/null +++ b/lib/tasks/db.rake @@ -0,0 +1,17 @@ +namespace :db do + namespace :seed do + Dir[File.join(Rails.root, 'db', 'seeds', '*.rb')].each do |filename| + task_name = File.basename(filename, '.rb').intern + task task_name => :environment do + load(filename) + end + end + + # This is for if you want to run all seeds inside db/seeds directory + task :all => :environment do + Dir[File.join(Rails.root, 'db', 'seeds', '*.rb')].sort.each do |filename| + load(filename) + end + end + end +end diff --git a/lib/tasks/euros.rake b/lib/tasks/euros.rake new file mode 100644 index 0000000..09a0a1c --- /dev/null +++ b/lib/tasks/euros.rake @@ -0,0 +1,128 @@ +namespace :euros do + desc "Create the UEFA Euros competition" + task create: :environment do + groups = { + 'Group A' => { + api_id: nil, + api_code: 'GROUP_A', + teams: [ + { name: 'Germany', abbrev: 'GER' }, + { name: 'Scotland', abbrev: 'SCO' }, + { name: 'Switzerland', abbrev: 'SUI' }, + { name: 'Hungary', abbrev: 'HUN' } + ] + }, + 'Group B' => { + api_id: nil, + api_code: 'GROUP_B', + teams: [ + { name: 'Albania', abbrev: 'ALB' }, + { name: 'Italy', abbrev: 'ITA' }, + { name: 'Croatia', abbrev: 'CRO' }, + { name: 'Spain', abbrev: 'ESP' } + ] + }, + 'Group C' => { + api_id: nil, + api_code: 'GROUP_C', + teams: [ + { name: 'Denmark', abbrev: 'DEN' }, + { name: 'England', abbrev: 'ENG' }, + { name: 'Serbia', abbrev: 'SRB' }, + { name: 'Slovenia', abbrev: 'SVN' } + ] + }, + 'Group D' => { + api_id: nil, + api_code: 'GROUP_D', + teams: [ + { name: 'France', abbrev: 'FRA' }, + { name: 'Netherlands', abbrev: 'NED' }, + { name: 'Austria', abbrev: 'AUT' }, + { name: 'Poland', abbrev: 'POL' } + ] + }, + 'Group E' => { + api_id: nil, + api_code: 'GROUP_E', + teams: [ + { name: 'Belgium', abbrev: 'BEL' }, + { name: 'Romania', abbrev: 'ROU' }, + { name: 'Slovakia', abbrev: 'SVK' }, + { name: 'Ukraine', abbrev: 'UKR' } + ] + }, + 'Group F' => { + api_id: nil, + api_code: 'GROUP_F', + teams: [ + { name: 'Georgia', abbrev: 'GEO' }, + { name: 'Portugal', abbrev: 'POR' }, + { name: 'Czechia', abbrev: 'CZE' }, + { name: 'Turkey', abbrev: 'TUR' } + ] + }, + } + puts 'Creating the Euros...' + euros = Competition.find_or_create_by!(name: 'Euros 2024', start_date: Date.new(2024, 06, 14), end_date: Date.new(2024, 07, 14), api_id: 2018, api_code: 'EC') + puts '.. created the Euros' + + puts 'Creating or finding first round...' + first_round = Round.find_or_create_by!(name: 'Group Stage', number: 1, competition: euros, api_name: 'GROUP_STAGE') + euros.update!(current_round: first_round) + Round.find_or_create_by!(name: 'Round of 16', number: 2, competition: euros, api_name: 'LAST_16') + Round.find_or_create_by!(name: 'Quarter-finals', number: 3, competition: euros, api_name: 'QUARTER_FINALS') + Round.find_or_create_by!(name: 'Semi-finals', number: 4, competition: euros, api_name: 'SEMI_FINALS') + # TODO: Doesn't look like the API has the 3rd place playoff + # Round.find_or_create_by!(name: 'Third Place', number: 5, competition: euros, api_name: '3PPO') + Round.find_or_create_by!(name: 'Final', number: 6, competition: euros, api_name: 'FINAL') + + puts 'Creating or finding groups...' + groups.each_key do |group_name| + puts "...#{group_name}..." + group = Group.find_or_create_by!(name: group_name, round: first_round, api_id: groups[group_name][:api_id], api_code: groups[group_name][:api_code]) + groups[group_name][:teams].each do |team_hash| + puts "Name: #{team_hash[:name]}, Abbrev: #{team_hash[:abbrev]}" + team = Team.find_by(abbrev: team_hash[:abbrev]) || Team.create!(team_hash) + Affiliation.find_or_create_by!(team: team, group: group) + end + end + # Calling the API to create the matches + MatchUpdateJob.perform_now(euros.id) + + # TODO: this only works when there are matches so you'll see 1 for now + puts "...#{euros.rounds.count} Total Round" + puts "...#{euros.teams.count} Total Teams" + puts "...#{euros.groups.count} Total Groups" + + doug = User.find_by(email: 'douglasmberkley@gmail.com') || User.create(email: 'douglasmberkley@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) + trouni = User.find_by(email: 'trouni@gmail.com') || User.create(email: 'trouni@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) + james = User.find_by(email: 'devereuxjj@gmail.com') || User.create(email: 'devereuxjj@gmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) + renato = User.find_by(email: 'renatonato_jr@hotmail.com') || User.create(email: 'renatonato_jr@hotmail.com', password: ENV['ADMIN_PASSWORD'], admin: true) + caio = User.find_by(email: 'caio.santos@msn.com') || User.create(email: 'caio.santos@msn.com', password: ENV['ADMIN_PASSWORD'], admin: true) + + puts 'Creating test leaderboards' + leaderboard = Leaderboard.find_or_create_by!( + name: 'Admin Leaderboard 1', + competition: euros, + user: trouni + ) + Membership.find_or_create_by!(leaderboard: leaderboard, user: doug) + Membership.find_or_create_by!(leaderboard: leaderboard, user: james) + Membership.find_or_create_by!(leaderboard: leaderboard, user: renato) + Membership.find_or_create_by!(leaderboard: leaderboard, user: caio) + + leaderboard = Leaderboard.find_or_create_by!( + name: 'Admin Leaderboard 2', + competition: euros, + user: doug + ) + Membership.find_or_create_by!(leaderboard: leaderboard, user: trouni) + Membership.find_or_create_by!(leaderboard: leaderboard, user: james) + Membership.find_or_create_by!(leaderboard: leaderboard, user: renato) + Membership.find_or_create_by!(leaderboard: leaderboard, user: caio) + + AttachFlagsJob.perform_now(euros.id) + end + +end diff --git a/lib/tasks/heroku.rake b/lib/tasks/heroku.rake new file mode 100644 index 0000000..e315b1d --- /dev/null +++ b/lib/tasks/heroku.rake @@ -0,0 +1,18 @@ +namespace :heroku do + desc 'Drops development DB and replaces it with the production DB' + task pg_pull: :environment do + puts '-----> Setting the environment...' + run 'RAILS_ENV=development rails db:environment:set' + + puts '-----> dropping DB…' + run 'rails db:drop' + + puts '-----> pulling the DB...' + run 'heroku pg:pull DATABASE_URL predictor_api_development -a predict-to-win' + end + + def run(*cmd) + system(*cmd) + raise "Command #{cmd.inspect} failed!" unless $?.success? + end +end diff --git a/lib/tasks/match.rake b/lib/tasks/match.rake new file mode 100644 index 0000000..e5b4acd --- /dev/null +++ b/lib/tasks/match.rake @@ -0,0 +1,37 @@ +namespace :match do + desc "Randomly assigns results for the first 5 matches" + task add_fake_results: :environment do + return 'Not allowed in production' if Rails.env.production? + + @competition = Competition.last + completed_matches = @competition.matches.order(kickoff_time: :asc).first(5) + completed_matches.each do |match| + puts "#{match.team_home.name} (H) vs. #{match.team_away.name} (A)" + User.find_each do |user| + prediction = Prediction.find_or_initialize_by(user: user, match: match) + prediction.choice = Prediction.choices.keys.sample + prediction.save + puts "- #{prediction.user.name} choose #{prediction.choice}" + end + match.finished! + match.team_home_score = rand(0..3) + match.team_away_score = rand(0..3) + match.save + puts "Result: #{match.team_home_score} vs. #{match.team_away_score}" + puts + end + end + + desc "Restarts all the matches" + task restart_all: :environment do + @competition = Competition.last + completed_matches = @competition.matches.order(kickoff_time: :asc) + completed_matches.each do |match| + match.upcoming! + match.team_home_score = nil + match.team_away_score = nil + match.save + puts "#{match.team_home.name} vs. #{match.team_away.name} restarted" + end + end +end diff --git a/lib/tasks/round.rake b/lib/tasks/round.rake new file mode 100644 index 0000000..706bfe4 --- /dev/null +++ b/lib/tasks/round.rake @@ -0,0 +1,16 @@ +namespace :round do + desc 'Creating the rounds for the Euros' + task create_all: :environment do + world_cup = Competition.find_by(name: 'World Cup 2022') + + puts 'Creating or finding first round...' + # First round was created when the competition was created. Next time, run all together + # Round.find_or_create_by!(name: 'Group Stage', number: 1, competition: world_cup, api_name: '3') + Round.find_or_create_by!(name: 'Round of 16', number: 2, competition: world_cup, api_name: 'R16') + Round.find_or_create_by!(name: 'Quarter-finals', number: 3, competition: world_cup, api_name: 'QF') + Round.find_or_create_by!(name: 'Semi-finals', number: 4, competition: world_cup, api_name: 'SF') + Round.find_or_create_by!(name: 'Third Place', number: 5, competition: world_cup, api_name: '3PPO') + Round.find_or_create_by!(name: 'Final', number: 6, competition: world_cup, api_name: 'F') + puts "...#{world_cup.rounds.count} Total Rounds" + end +end diff --git a/lib/tasks/schedule.rake b/lib/tasks/schedule.rake new file mode 100644 index 0000000..3ea0582 --- /dev/null +++ b/lib/tasks/schedule.rake @@ -0,0 +1,7 @@ +namespace :schedule do + desc "Runs at midnight(~) to schedule daily background jobs" + task daily: :environment do + ScheduleDailyTasksJob.perform_later + end + +end diff --git a/lib/tasks/team.rake b/lib/tasks/team.rake new file mode 100644 index 0000000..7d99a5a --- /dev/null +++ b/lib/tasks/team.rake @@ -0,0 +1,24 @@ +namespace :team do + desc "Calls Data-Football API and gets the flags" + task add_flag: :environment do + Competition.find_each do |competition| + AttachFlagsJob.perform_now(competition.id) + end + end + + def fetch_flag(team) + url = "https://livescore-api.com/api-client/countries/flag.json?key=#{ENV['LIVE_SCORE_KEY']}&secret=#{ENV['LIVE_SCORE_SECRET']}&team_id=#{team.api_id}" + puts "#{team.name}: #{url}" + file = URI.open(url) + team.flag.attach(io: file, filename: 'flag.png', content_type: 'image/png') unless team.flag.attached? + team.badge.attach(io: file, filename: 'badge.png', content_type: 'image/png') unless team.badge.attached? + puts team.flag.attached? ? 'Success' : 'Failed' + end + + desc "Changes North Macedonia to FYR Macedonia" + task update_macedonia: :environment do + team = Team.find_by(name: 'North Macedonia') + team.name = 'FYR Macedonia' if team + team.save + end +end diff --git a/lib/tasks/user.rake b/lib/tasks/user.rake new file mode 100644 index 0000000..0427322 --- /dev/null +++ b/lib/tasks/user.rake @@ -0,0 +1,11 @@ +namespace :user do + desc "Scrapes a photo from worlcup.com and attaches to users who don't have an image" + task :attach_photos, [:competition_id] => :environment do |t, args| + competition = Competition.find(args[:competition_id]) + competition.users.uniq.each do |user| + next if user.photo_key + + ScrapePhotoService.new(user: user, competition: competition).call + end + end +end diff --git a/test/controllers/v1/leaderboards_controller_test.rb b/test/controllers/v1/leaderboards_controller_test.rb new file mode 100644 index 0000000..89fea76 --- /dev/null +++ b/test/controllers/v1/leaderboards_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class V1::LeaderboardsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/v1/matches_controller_test.rb b/test/controllers/v1/matches_controller_test.rb new file mode 100644 index 0000000..95ecb63 --- /dev/null +++ b/test/controllers/v1/matches_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class V1::MatchesControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/v1/memberships_controller_test.rb b/test/controllers/v1/memberships_controller_test.rb new file mode 100644 index 0000000..a41b5d8 --- /dev/null +++ b/test/controllers/v1/memberships_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class V1::MembershipsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/v1/predictions_controller_test.rb b/test/controllers/v1/predictions_controller_test.rb new file mode 100644 index 0000000..174393c --- /dev/null +++ b/test/controllers/v1/predictions_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class V1::PredictionsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/v1/users_controller_test.rb b/test/controllers/v1/users_controller_test.rb new file mode 100644 index 0000000..201c212 --- /dev/null +++ b/test/controllers/v1/users_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class V1::UsersControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/fixtures/affiliations.yml b/test/fixtures/affiliations.yml new file mode 100644 index 0000000..ff59a07 --- /dev/null +++ b/test/fixtures/affiliations.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + team: one + group: one + +two: + team: two + group: two diff --git a/test/fixtures/competitions.yml b/test/fixtures/competitions.yml new file mode 100644 index 0000000..53b38a1 --- /dev/null +++ b/test/fixtures/competitions.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + start_date: 2021-04-07 + end_date: 2021-04-07 + +two: + name: MyString + start_date: 2021-04-07 + end_date: 2021-04-07 diff --git a/test/fixtures/groups.yml b/test/fixtures/groups.yml new file mode 100644 index 0000000..dd54fdf --- /dev/null +++ b/test/fixtures/groups.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + round: one + +two: + name: MyString + round: two diff --git a/test/fixtures/leagues.yml b/test/fixtures/leagues.yml new file mode 100644 index 0000000..90fc450 --- /dev/null +++ b/test/fixtures/leagues.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + password: MyString + user: one + competition: one + +two: + name: MyString + password: MyString + user: two + competition: two diff --git a/test/fixtures/matches.yml b/test/fixtures/matches.yml new file mode 100644 index 0000000..0d04a67 --- /dev/null +++ b/test/fixtures/matches.yml @@ -0,0 +1,21 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + kickoff_time: 2021-04-07 15:10:08 + team_home_score: 1 + team_away_score: 1 + status: 1 + group: one + team_away: one + team_home: one + next_match: one + +two: + kickoff_time: 2021-04-07 15:10:08 + team_home_score: 1 + team_away_score: 1 + status: 1 + group: two + team_away: two + team_home: two + next_match: two diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml new file mode 100644 index 0000000..818ce23 --- /dev/null +++ b/test/fixtures/memberships.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + league: one + user: one + +two: + league: two + user: two diff --git a/test/fixtures/predictions.yml b/test/fixtures/predictions.yml new file mode 100644 index 0000000..2df6041 --- /dev/null +++ b/test/fixtures/predictions.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + choice: 1 + match: one + user: one + +two: + choice: 1 + match: two + user: two diff --git a/test/fixtures/rounds.yml b/test/fixtures/rounds.yml new file mode 100644 index 0000000..871da78 --- /dev/null +++ b/test/fixtures/rounds.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + number: 1 + name: MyString + competition: one + +two: + number: 1 + name: MyString + competition: two diff --git a/test/fixtures/teams.yml b/test/fixtures/teams.yml new file mode 100644 index 0000000..e23a176 --- /dev/null +++ b/test/fixtures/teams.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + abbrev: MyString + +two: + name: MyString + abbrev: MyString diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..5181636 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# This model initially had no columns defined. If you add columns to the +# model remove the '{}' from the fixture names and add the columns immediately +# below each fixture, per the syntax in the comments below +# +one: {} +# column: value +# +two: {} +# column: value diff --git a/test/jobs/attach_flags_job_test.rb b/test/jobs/attach_flags_job_test.rb new file mode 100644 index 0000000..d9c190a --- /dev/null +++ b/test/jobs/attach_flags_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AttachFlagsJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/competition_create_job_test.rb b/test/jobs/competition_create_job_test.rb new file mode 100644 index 0000000..3d22709 --- /dev/null +++ b/test/jobs/competition_create_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class CompetitionCreateJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/match_started_job_test.rb b/test/jobs/match_started_job_test.rb new file mode 100644 index 0000000..a696d00 --- /dev/null +++ b/test/jobs/match_started_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MatchStartedJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/match_update_all_job_test.rb b/test/jobs/match_update_all_job_test.rb new file mode 100644 index 0000000..06db8bf --- /dev/null +++ b/test/jobs/match_update_all_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MatchUpdateAllJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/match_update_job_test.rb b/test/jobs/match_update_job_test.rb new file mode 100644 index 0000000..a6c451b --- /dev/null +++ b/test/jobs/match_update_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MatchUpdateJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/match_update_live_job_test.rb b/test/jobs/match_update_live_job_test.rb new file mode 100644 index 0000000..d36eafd --- /dev/null +++ b/test/jobs/match_update_live_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MatchUpdateLiveJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/schedule_daily_tasks_job_test.rb b/test/jobs/schedule_daily_tasks_job_test.rb new file mode 100644 index 0000000..e0ba225 --- /dev/null +++ b/test/jobs/schedule_daily_tasks_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ScheduleDailyTasksJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/affiliation_test.rb b/test/models/affiliation_test.rb new file mode 100644 index 0000000..e59f623 --- /dev/null +++ b/test/models/affiliation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AffiliationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/competition_test.rb b/test/models/competition_test.rb new file mode 100644 index 0000000..97b649e --- /dev/null +++ b/test/models/competition_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class CompetitionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/group_test.rb b/test/models/group_test.rb new file mode 100644 index 0000000..eddbcc8 --- /dev/null +++ b/test/models/group_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class GroupTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/league_test.rb b/test/models/league_test.rb new file mode 100644 index 0000000..d3ae350 --- /dev/null +++ b/test/models/league_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class LeagueTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/match_test.rb b/test/models/match_test.rb new file mode 100644 index 0000000..6d3de7c --- /dev/null +++ b/test/models/match_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MatchTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb new file mode 100644 index 0000000..8506331 --- /dev/null +++ b/test/models/membership_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MembershipTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/prediction_test.rb b/test/models/prediction_test.rb new file mode 100644 index 0000000..b776078 --- /dev/null +++ b/test/models/prediction_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PredictionTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/round_test.rb b/test/models/round_test.rb new file mode 100644 index 0000000..2f5b4bb --- /dev/null +++ b/test/models/round_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class RoundTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/team_test.rb b/test/models/team_test.rb new file mode 100644 index 0000000..c6cf23d --- /dev/null +++ b/test/models/team_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class TeamTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..5c07f49 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/policies/leaderboard_policy_test.rb b/test/policies/leaderboard_policy_test.rb new file mode 100644 index 0000000..49b79ff --- /dev/null +++ b/test/policies/leaderboard_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class LeaderboardPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end diff --git a/test/policies/match_policy_test.rb b/test/policies/match_policy_test.rb new file mode 100644 index 0000000..e449d02 --- /dev/null +++ b/test/policies/match_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class MatchPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end diff --git a/test/policies/membership_policy_test.rb b/test/policies/membership_policy_test.rb new file mode 100644 index 0000000..ed1600f --- /dev/null +++ b/test/policies/membership_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class MembershipPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end diff --git a/test/policies/prediction_policy_test.rb b/test/policies/prediction_policy_test.rb new file mode 100644 index 0000000..bebc40f --- /dev/null +++ b/test/policies/prediction_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class PredictionPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end diff --git a/test/policies/user/match_policy_test.rb b/test/policies/user/match_policy_test.rb new file mode 100644 index 0000000..8c6eb3d --- /dev/null +++ b/test/policies/user/match_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class User::MatchPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end diff --git a/test/policies/user_policy_test.rb b/test/policies/user_policy_test.rb new file mode 100644 index 0000000..577ac60 --- /dev/null +++ b/test/policies/user_policy_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' + +class UserPolicyTest < ActiveSupport::TestCase + def test_scope + end + + def test_show + end + + def test_create + end + + def test_update + end + + def test_destroy + end +end