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:
+
-* 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
+
-* 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
+
-* 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
+
+
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