Merge: First Iteration of Match-Scheduling feature

This commit is contained in:
Absurdon 2017-10-14 21:04:21 +02:00
commit 7c4a7b9c69
16 changed files with 352 additions and 10 deletions

View file

@ -1,5 +1,5 @@
class ContestsController < ApplicationController
before_filter :get_contest, only: [:show, :edit, :update, :destroy, :del_map, :scores, :recalc]
before_filter :get_contest, only: [:show, :edit, :update, :destroy, :del_map, :scores, :recalc, :confirmed_matches]
def index
# @contests = Contest.all
@ -102,6 +102,10 @@ class ContestsController < ApplicationController
redirect_to contests_url
end
def confirmed_matches
@match_props = MatchProposal.confirmed_for_contest(@contest)
end
private
def get_contest

View file

@ -0,0 +1,115 @@
class MatchProposalsController < ApplicationController
before_filter :get_match
def index
raise AccessError unless @match.user_in_match?(cuser)
end
def new
# Don't allow creation of new proposals if there is a confirmed one already
if MatchProposal.exists?(
match_id: @match.id,
status: MatchProposal::STATUS_CONFIRMED
)
flash[:error] = 'Cannot create a new proposal if there is already a confirmed one'
redirect_to(match_proposals_path(@match)) && return
end
@proposal = MatchProposal.new
@proposal.match = @match
raise AccessError unless @proposal.can_create? cuser
end
def create
@proposal = MatchProposal.new(params[:match_proposal])
@proposal.match = @match
raise AccessError unless @proposal.can_create? cuser
@proposal.team = cuser.team
@proposal.status = MatchProposal::STATUS_PENDING
if @proposal.save
# TODO: send message to teamleaders of opposite team
msg = Message.new
msg.sender_type = 'System'
msg.recipient_type = 'Team'
msg.title = 'New Scheduling Proposal'
recipient = @match.get_opposing_team(cuser.team)
msg.recipient = recipient
msg.text = "There is a new scheduling proposal for your match against #{recipient.name}.\n" \
"Find it [url=#{match_proposals_path(@match)}]here[/url]"
msg.save
flash[:notice] = 'Created new proposal'
redirect_to(match_proposals_path(@match))
else
render :new
end
end
def update
raise AccessError unless request.xhr? # Only respond to ajax requests
rjson = {}
proposal = MatchProposal.find(params[:id])
unless proposal
rjson[:error] = {
code: 404,
message: "No proposal with id #{params[:id]}"
}
render(json: rjson, status: :not_found) && return
end
unless proposal.can_update?(cuser, params[:match_proposal])
rjson[:error] = {
code: 403,
message: "You are not allowed to update the state to #{MatchProposal.status_strings[params[:match_proposal][:status].to_i]}"
}
render(json: rjson, status: :forbidden) && return
end
new_status = params[:match_proposal][:status]
curr_status = proposal.status
status_updated = curr_status != new_status
proposal.status = new_status
if proposal.save
if status_updated
msg = Message.new
msg.sender_type = 'System'
msg.recipient_type = 'Team'
msg.title = 'New Scheduling Proposal'
recipient = @match.get_opposing_team(cuser.team)
msg.recipient = recipient
msg.text = message_text(new_status)
msg.save if msg.text
end
rjson[:status] = MatchProposal.status_strings[proposal.status]
rjson[:message] = "Successfully updated status to #{MatchProposal.status_strings[proposal.status]}"
render(json: rjson, status: :accepted)
else
rjson[:error] = {
code: 500,
message: 'Something went wrong! Please try again.'
}
render(json: rjson, status: 500)
end
end
private
def get_match
@match = Match.find params[:match_id]
end
def message_text(new_status)
case new_status
when MatchProposal::STATUS_CONFIRMED
"A scheduling proposal for your match against #{recipient.name} was confirmed!.\n" \
"Find it [url=#{match_proposals_path(@match)}]here[/url]"
when MatchProposal::STATUS_REJECTED
"A scheduling proposal for your match against #{recipient.name} was rejected!.\n" \
"Find it [url=#{match_proposals_path(@match)}]here[/url]"
when MatchProposal::STATUS_REVOKED
"A scheduling proposal for your match against #{recipient.name} was revoked!.\n" \
"Find it [url=#{match_proposals_path(@match)}]here[/url]"
when MatchProposal::STATUS_DELAYED
"Delaying for your match against #{recipient.name} was permitted!.\n" \
"Schedule a new time as soon as possible [url=#{match_proposals_path(@match)}]here[/url]"
else
false # Should not happen as transition to any other state is not allowed
end
end
end

View file

@ -42,6 +42,7 @@ class Match < ActiveRecord::Base
has_many :users, through: :matchers
has_many :predictions, dependent: :destroy
has_many :comments, as: :commentable, order: "created_at", dependent: :destroy
has_many :match_proposals, inverse_of: :match, dependent: :destroy
belongs_to :challenge
belongs_to :contest
belongs_to :contester1, class_name: "Contester", include: "team"
@ -188,6 +189,11 @@ class Match < ActiveRecord::Base
end
end
def get_opposing_team(team)
team == contester1.team ? contester2.team : contester1.team
end
def set_hltv
get_hltv if match_time.future?
end
@ -357,4 +363,12 @@ class Match < ActiveRecord::Base
def can_destroy?(cuser)
cuser && cuser.admin?
end
def can_make_proposal?(cuser)
cuser && (contester1.team.is_leader?(cuser) || contester2.team.is_leader?(cuser))
end
def user_in_match?(user)
user && (user.team == contester1.team || user.team == contester2.team)
end
end

View file

@ -0,0 +1,87 @@
class MatchProposal < ActiveRecord::Base
STATUS_PENDING = 0
STATUS_REVOKED = 1
STATUS_REJECTED = 2
STATUS_CONFIRMED = 3
STATUS_DELAYED = 4
# latest time before a match to be confirmed/rejected (in minutes)
CONFIRMATION_LIMIT = 30
belongs_to :match
belongs_to :team
#has_many :confirmed_by, class_name: 'Team', uniq: true
attr_accessible :proposed_time, :status
validates_presence_of :match, :team, :proposed_time
scope :of_match, ->(match) { where('match_id = ?', match.id) }
scope :confirmed_for_match, ->(match) { where('match_id = ? AND status = ?', match.id, STATUS_CONFIRMED) }
scope :confirmed_upcoming, ->{ where('status = ? AND proposed_time > UTC_TIMESTAMP()', STATUS_CONFIRMED) }
scope :confirmed_for_contest,
->(contest){ includes(:match).where(matches:{contest_id: contest.id}, status: STATUS_CONFIRMED).all}
def self.status_strings
{STATUS_PENDING => 'Pending',
STATUS_REVOKED => 'Revoked',
STATUS_REJECTED => 'Rejected',
STATUS_CONFIRMED => 'Confirmed',
STATUS_DELAYED => 'Delayed'}
end
def can_create? cuser
return false unless cuser && match
return true if cuser.admin?
match.can_make_proposal?(cuser)
end
def can_update? cuser, params = {}
return false unless cuser && match && (cuser.admin? || match.can_make_proposal?(cuser))
if params.key?(:status) && (self.status !=(new_status = params[:status].to_i))
return status_change_allowed?(cuser,new_status)
end
true
end
def can_destroy?
cuser && cuser.admin?
end
def state_immutable?
status == STATUS_REJECTED ||
status == STATUS_DELAYED ||
status == STATUS_REVOKED
end
def status_change_allowed?(cuser, new_status)
case new_status
when STATUS_PENDING
# never go back to pending
return false
when STATUS_DELAYED
# only confirmed matches can be set to delayed
# only admins can set matches to delayed and only if they are not playing in that match
# matches can only be delayed if they are not to far in the future
return self.status == STATUS_CONFIRMED && cuser.admin? &&
!self.match.user_in_match?(cuser) && self.proposed_time <= CONFIRMATION_LIMIT.minutes.from_now
when STATUS_REVOKED
# unconfirmed can only be revoked by team making the proposal
# confirmed can only be revoked if soon enough before match time
return self.status == STATUS_PENDING && self.team == cuser.team ||
self.status == STATUS_CONFIRMED && self.proposed_time > CONFIRMATION_LIMIT.minutes.from_now
when STATUS_CONFIRMED, STATUS_REJECTED
# only team proposed to can reject or confirm and only if soon enough before match time
status_ok = self.status == STATUS_PENDING
team_ok = self.team != cuser.team
time_ok = CONFIRMATION_LIMIT.minutes.from_now < self.proposed_time
return status_ok && team_ok && time_ok
else
# invalid status
return false
end
end
end

View file

@ -44,12 +44,16 @@ class Message < ActiveRecord::Base
end
def thread
if sender_type == 'System'
Message.where(recipient_id: recipient.id, sender_type: 'System')
else
Message.find_by_sql ["
(SELECT `messages`.* FROM `messages` WHERE `messages`.`sender_id` = ? AND `messages`.`sender_type` = 'User' AND `messages`.`recipient_id` = ?)
UNION
(SELECT `messages`.* FROM `messages` WHERE `messages`.`sender_id` = ? AND `messages`.`sender_type` = 'User' AND `messages`.`recipient_id` = ?)
ORDER BY id", sender.id, recipient.id, recipient.id, sender.id]
end
end
def parse_text
if self.text

View file

@ -199,6 +199,10 @@ class User < ActiveRecord::Base
groups.exists? id: Group::STAFF
end
def staff?
groups.exists? :id => Group::STAFF
end
def caster?
groups.exists? id: Group::CASTERS
end
@ -216,6 +220,14 @@ class User < ActiveRecord::Base
admin? or moderator?
end
def gather_moderator?
groups.exists? id: Group::GATHER_MODERATORS
end
def allowed_to_ban?
admin? or gather_moderator?
end
def verified?
# created_at < DateTime.now.ago(VERIFICATION_TIME)
true

View file

@ -0,0 +1,13 @@
<h1 class="title">Confirmed Matches for <%= @contest.name %></h1>
<table class="striped">
<tr>
<th>Match</th>
<th>Scheduled for</th>
</tr>
<% @match_props && @match_props.each do |mp| %>
<tr class="<%= cycle('even', 'odd') %>">
<td><%= link_to mp.match.contester1, contester_path(mp.match.contester1) %> VS <%= link_to mp.match.contester2, contester_path(mp.match.contester2) %></td>
<td><%= link_to longertime(mp.proposed_time), match_path(mp.match) %></td>
</tr>
<% end %>
</table>

View file

@ -20,6 +20,8 @@
<dd>Sunday: <%= Time.use_zone(timezone_offset) { @contest.default_time.strftime("%H:%M %Z") } %></dd>
</dl>
<%= link_to 'Scheduled Matches', confirmed_matches_path(@contest), class: 'button' %>
<% if cuser and cuser.admin? %>
<%= link_to 'Edit Contest', edit_contest_path(@contest), class: 'button' %>
<% end %>

View file

@ -0,0 +1,61 @@
<h1>Proposals</h1>
<h2><%= @match.contester1 %> VS <%= @match.contester2 %></h2>
<%= link_to 'Back', match_path(@match), class: 'button' %>
<% if @match.match_proposals.empty? %>
<h4 style="clear: both;">There are no proposals yet</h4>
<% else %>
<table id="proposals" class="striped">
<tr>
<th>Team</th>
<th>Time</th>
<th>Status</th>
<% if @match.can_make_proposal?(cuser) %>
<th class="actions">Actions</th>
<% end %>
</tr>
<% @match.match_proposals.each do |proposal| %>
<tr class="<%=cycle('even', 'odd') %>">
<td><%= proposal.team.name %></td>
<td><%= longtime proposal.proposed_time %> </td>
<td><%= MatchProposal.status_strings[proposal.status] %></td>
<% if @match.can_make_proposal?(cuser) %>
<td class="actions">
<% unless proposal.state_immutable? %>
<%= form_for proposal, url: match_proposal_path(@match, proposal) do |f| %>
<%= f.hidden_field :status, value: 0 %>
<% if proposal.status_change_allowed?(cuser, MatchProposal::STATUS_CONFIRMED) %>
<%= link_to_function icon('check'), "proposalStateSubmit(#{MatchProposal::STATUS_CONFIRMED},#{proposal.id})", title: 'Confirm' %>
<% end %>
<% if proposal.status_change_allowed?(cuser, MatchProposal::STATUS_REJECTED) %>
<%= link_to_function icon('times'), "proposalStateSubmit(#{MatchProposal::STATUS_REJECTED},#{proposal.id})", title: 'Reject' %>
<% end %>
<% if proposal.status_change_allowed?(cuser, MatchProposal::STATUS_REVOKED) %>
<%= link_to_function icon('undo'), "proposalStateSubmit(#{MatchProposal::STATUS_REVOKED},#{proposal.id})", title: 'Revoke' %>
<% end %>
<% if proposal.status_change_allowed?(cuser, MatchProposal::STATUS_DELAYED) %>
<%= link_to_function icon('hourglass'), "proposalStateSubmit(#{MatchProposal::STATUS_DELAYED},#{proposal.id})", title: 'Delay' %>
<% end %>
<% end %>
<% end %>
</td>
<% end %>
</tr>
<% end %>
</table>
<script type="text/javascript">
function proposalStateSubmit(newState, formID) {
var form = $('#edit_match_proposal_' + formID);
form.children("input[type='hidden']").val(newState);
$.post(form.attr('action'),form.serialize(), function(data) {
tr = form.closest('tr');
tr.children('td').eq(2).text(data.status);
if(data.status === 'Revoked' || data.status === 'Rejected') tr.children('td').eq(3).empty();
}, 'json')
.error(function (err) {
errjson = JSON.parse(err.responseText);
alert(errjson.error.message);
});
}
</script>
<% end %>
<%= link_to 'Back', match_path(@match), class: 'button' %><%= link_to 'Propose match time', new_match_proposal_path(@match), class: 'button' %>

View file

@ -0,0 +1,11 @@
<h1>New Proposal</h1>
<%= form_for @proposal, html: { class: 'square' } do |f| %>
<%= render 'shared/errors', messages: @proposal.errors.full_messages %>
<div class="fields horizontal">
<%= f.label :proposed_time %>
<%= f.datetime_select :proposed_time, datetime_separator: '', time_separator: '', minute_step: 15 %>
</div>
<div class="controls">
<%= f.submit 'Propose' %>
</div>
<% end %>

View file

@ -116,6 +116,11 @@
<p><%= @match.report.html_safe %></p>
</div>
<% end %>
<% if cuser and (cuser.admin? or @match.user_in_match? cuser) %>
<div class="referee">
<%= link_to 'Proposals', match_proposals_path(@match), class: 'button' %>
</div>
<% end %>
<% if cuser and @match.can_update? cuser, [:report] %>
<div class="referee">

View file

@ -5,5 +5,5 @@
</p>
<p>
To: <%= namelink message.recipient %> From: <%= namelink message.sender %> on <em><%= longdate message.created_at %></em></em>
To: <%= namelink message.recipient %> From: <% if message.sender_type == 'System' %>System<% else %><%= namelink message.sender %><% end %> on <em><%= longdate message.created_at %></em></em>
</p>

View file

@ -10,7 +10,7 @@
<div class="content">
<%= raw message.text_parsed %>
</div>
<p>Sent by: <%= namelink message.sender %> on <%= shortdate message.created_at %></p>
<p>Sent by: <% if message.sender_type == 'System' %>System<% else %><%= namelink message.sender %><% end %> on <%= shortdate message.created_at %></p>
<p>
<%= link_to "Reply",
{ controller: "messages", action: "new", id: message.sender_type, id2: message.sender_id, title: message.title },

View file

@ -51,6 +51,8 @@ Ensl::Application.routes.draw do
resources :users
resources :locks
resources :contesters
get "contests/:id/confirmedmatches" => "contests#confirmed_matches", as: :confirmed_matches
resources :contests
resources :challenges
resources :servers
@ -60,6 +62,7 @@ Ensl::Application.routes.draw do
get "matches/ref/:id" => "matches#ref", as: :match_ref
resources :matches do
get :admin, to: "matches#admin", on: :collection
resources :match_proposals, path: "proposals", as: :proposals, only: [:index, :new, :create, :update]
end
resources :maps

View file

@ -0,0 +1,14 @@
class CreateMatchProposals < ActiveRecord::Migration
def up
create_table :match_proposals do |t|
t.references :match, index: true, forign_key: true
t.references :team, forign_key: true
t.datetime :proposed_time
t.integer :status
end
add_index :match_proposals, :status
end
def down; end
end

View file

@ -11,7 +11,7 @@
#
# It's strongly recommended to check this file into your version control system.
ActiveRecord::Schema.define(:version => 20170702150454) do
ActiveRecord::Schema.define(:version => 20171013154050) do
create_table "admin_requests", :force => true do |t|
t.string "addr"
@ -435,14 +435,11 @@ ActiveRecord::Schema.define(:version => 20170702150454) do
create_table "match_proposals", :force => true do |t|
t.integer "match_id"
t.integer "team_id"
t.integer "status"
t.datetime "proposed_time"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.integer "status"
end
add_index "match_proposals", ["match_id"], :name => "index_match_proposals_on_match_id"
add_index "match_proposals", ["team_id"], :name => "index_match_proposals_on_team_id"
add_index "match_proposals", ["status"], :name => "index_match_proposals_on_status"
create_table "matchers", :force => true do |t|
t.integer "match_id", :null => false