diff --git a/app/controllers/contest_registrations_controller.rb b/app/controllers/contest_registrations_controller.rb index 59938c52..a41acef1 100644 --- a/app/controllers/contest_registrations_controller.rb +++ b/app/controllers/contest_registrations_controller.rb @@ -248,10 +248,12 @@ def destroy def index registrations = @contest.contest_registrations.includes(:user) @duplicate_names = registrations.group_by{|x| x.user.username }.select{|k, v| v.size > 1 }.map(&:first).to_set - groups = registrations.group_by{|x| x.approved ? (x.user.type == 'User' ? 1 : 0) : -1} - @contest_users = groups[0] - @registered_users = groups[1] - @unapproved_users = groups[-1] + + @registered_teams = registrations.select{|x| x.approved && x.team.present?}.sort_by{|x| x.team} # make same team consecutive + approved_users = registrations.select{|x| x.approved && x.team.nil?}.group_by{|x| x.user.type} + @contest_users = approved_users['ContestUser'] + @registered_users = approved_users['User'] + @unapproved_users = registrations.select{|x| not x.approved}.sort_by{|x| x.team} # make same team consecutive end private diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index 985dc6a2..e3b6617e 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -1,11 +1,11 @@ class ContestsController < ApplicationController before_action :authenticate_user_and_running_if_single_contest!, only: [:dashboard, :dashboard_update] - before_action :authenticate_user!, only: [:register] + before_action :authenticate_user!, only: [:register, :register_update] before_action :authenticate_admin!, only: [:set_contest_task, :new, :create, :edit, :update, :destroy] before_action :check_started!, only: [:dashboard] before_action :set_tasks, only: [:show, :dashboard, :dashboard_update, :set_contest_task] before_action :calculate_ranking, only: [:dashboard, :dashboard_update] - layout :set_contest_layout, only: [:show, :edit, :dashboard, :sign_in] + layout :set_contest_layout, only: [:show, :edit, :register, :dashboard, :sign_in] def set_contest_task redirect_to contest_path(@contest) @@ -43,12 +43,28 @@ def calculate_ranking flash.now[:notice] = "Scoreboard is now frozen." end - @data = helpers.ranklist_data(c_submissions.order(:created_at), @contest.start_time, freeze_start, @contest.contest_type) - @data[:participants] |= @contest.approved_registered_users.ids + user_team_mapping = @contest.contest_registrations.where.not(team_id: nil).map{|x| [x.user_id, x.team_id]}.to_h + @data = helpers.ranklist_data( + c_submissions.order(:created_at), + @contest.start_time, freeze_start, @contest.contest_type, + user_team_mapping + ) + @data[:participants] |= @contest.approved_registered_users.ids - user_team_mapping.keys + @data[:teams] |= user_team_mapping.values @participants = UserBase.where(id: @data[:participants]) + @teams = Team.where(id: @data[:teams]) @data[:tasks] = @tasks.map(&:id) @data[:contest_type] = @contest.contest_type - @data[:user_id] = current_user&.id + + current_team = user_team_mapping[current_user&.id] + if current_user.nil? + @data[:user_id] = nil + elsif current_team.nil? + @data[:user_id] = "user_#{current_user.id}" + else + @data[:user_id] = "team_#{current_user.id}" + end + @data[:timestamps] = { start: helpers.to_us(@contest.start_time), end: helpers.to_us(@contest.end_time), @@ -68,7 +84,7 @@ def dashboard_update def index @contests = Contest.order(id: :desc).page(params[:page]) - @registrations = ContestRegistration.where(contest_id: @contests.map(&:id), user_id: current_user&.id).all + @registrations = @contests.map { |contest| contest.find_registration(current_user) }.compact @registrations = @registrations.map{|x| [x.contest_id, x.approved]}.to_h end @@ -146,19 +162,43 @@ def destroy end def register + @teams = current_user.teams + @registration = @contest.find_registration(current_user) + team = @registration&.team + if team.present? + @teammate_registrations = team.users.map{|user| + registration = @contest.contest_registrations.where(user: user, team: team).first + [user, registration] + } + end + end + + def register_update unless @contest.can_register? flash[:alert] = 'Registration is closed.' redirect_to @contest return end + user_id = current_user.id + team_id = nil + if params[:team_id].present? + team = Team.find(params[:team_id]) + if not current_user or not current_user.teams.include?(team) then + flash[:alert] = 'Cannot (un)register as this team.' + redirect_to @contest + return + end + team_id = team.id + end + if params[:cancel] == '1' - @contest.contest_registrations.where(user_id: current_user.id).destroy_all + @contest.contest_registrations.where(user_id: user_id, team_id: team_id).destroy_all respond_to do |format| format.html { redirect_back fallback_location: root_path, notice: 'Successfully unregistered.' } format.json { head :no_content } end else - entry = @contest.contest_registrations.new(user_id: current_user.id, approved: !@contest.require_approval?) + entry = @contest.contest_registrations.new(user_id: user_id, team_id: team_id, approved: !@contest.require_approval?) respond_to do |format| begin entry.save! diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index f65a21ac..e9b046f7 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -34,7 +34,7 @@ def index end def show - unless effective_admin? or current_user&.id == @submission.user_id or not @submission.contest + unless effective_admin? or user_can_view? or not @submission.contest if not @submission.contest.is_ended? redirect_to contest_path(@submission.contest), notice: 'Submission is censored during contest.' return @@ -148,16 +148,22 @@ def set_submissions if @contest @submissions = @submissions.where(contest_id: @contest.id) unless effective_admin? + user_ids = [] if user_signed_in? - @submissions = @submissions.where('submissions.created_at < ? OR submissions.user_id = ?', @contest.freeze_after, current_user.id) - else - @submissions = @submissions.where('submissions.created_at < ?', @contest.freeze_after) + user_ids = [current_user.id] + registration = @contest.find_registration(current_user) + if registration&.team + registered_members = @contest.contest_registrations.where(team: registration.team).select(:user_id).map(&:user_id) + user_ids += registered_members + end end + @submissions = @submissions.where('submissions.created_at < ?', @contest.freeze_after) \ + .or(@submissions.where(user_id: user_ids)) # TODO: Add an option to still hide submission after contest unless @contest.is_ended? # only self submission if user_signed_in? - @submissions = @submissions.where(user_id: current_user.id) + @submissions = @submissions.where(user_id: user_ids) else @submissions = Submission.none return @@ -188,8 +194,8 @@ def set_submission if @contest unless effective_admin? # TODO: Add an option to still hide submission after contest - raise_not_found if @submission.created_at >= @contest.freeze_after && current_user&.id != @submission.user_id - raise_not_found unless @contest.is_ended? or current_user&.id == @submission.user_id + raise_not_found if @submission.created_at >= @contest.freeze_after and (not user_can_view?) + raise_not_found unless @contest.is_ended? or user_can_view? end end end @@ -221,7 +227,8 @@ def set_default_compiler if @submission&.compiler_id @default_compiler_id = @submission.compiler_id else - last_compiler = current_user&.last_compiler_id + user = current_user + last_compiler = user&.last_compiler_id if @compiler.map(&:id).include?(last_compiler) @default_compiler_id = last_compiler else @@ -256,12 +263,16 @@ def check_problem_visibility end def check_code_visibility - unless effective_admin? || current_user&.id == @submission.user_id + unless effective_admin? || user_can_view? redirect_to problem_path(@problem), alert: 'Insufficient User Permissions.' return end end + def user_can_view? + helpers.user_can_view?(current_user, @submission, @contest) + end + def normalize_code if params[:submission][:code_file] code = params[:submission][:code_file].read diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb new file mode 100644 index 00000000..e951734b --- /dev/null +++ b/app/controllers/teams_controller.rb @@ -0,0 +1,101 @@ +class TeamsController < ApplicationController + before_action :authenticate_user! + before_action :set_team, only: [:show, :edit, :update, :destroy, :invite, :invite_accept] + before_action :check_user_in_team!, only: [:edit, :update, :destroy] + + def index + @teams = Team.order(id: :desc).page(params[:page]).per(100) + end + + def show + end + + def new + @team = Team.new + end + + def create + @team = Team.new(team_params) + @team.generate_random_avatar + @team.users = [current_user] + if @team.save + redirect_to @team, notice: 'Team was successfully created.' + else + render action: "new" + end + end + + def edit + end + + def invite + @token = params[:token] + end + + def invite_accept + if @team.token != params[:token] + redirect_to teams_path, alert: 'Invalid token' + return + end + + begin + @team.users << current_user + if @team.save + flash[:notice] = 'Joined successfully.' + else + flash[:alert] = 'Failed to join.' + end + rescue ActiveRecord::RecordNotUnique + flash[:alert] = 'User has already been added to this team.' + end + + redirect_to @team + end + + def update + @team.attributes = team_params + if @team.save + redirect_to @team, notice: 'Team was successfully updated.' + else + render action: "edit" + end + end + + def destroy + @team.destroy + redirect_to teams_url + end + + private + + def set_team + begin + @team = Team.find(params[:id]) + if @team.blank? + redirect_to teams_path, alert: "Teamname '#{params[:id]}' not found." + return + end + rescue ActiveRecord::RecordNotFound => e + redirect_to teams_path, alert: "Teamname '#{params[:id]}' not found." + return + end + end + + # Never trust parameters from the scary internet, only allow the white list through. + def team_params + params.require(:team).permit( + :teamname, + :avatar, :avatar_cache, + :motto, + :school, + users_attributes: [ + :id, + :_destroy + ] + ) + end + + def check_user_in_team! + raise_not_found unless effective_admin? or @team.users.include?(current_user) + end +end diff --git a/app/helpers/contests_helper.rb b/app/helpers/contests_helper.rb index 5ccc71f4..c4c3c537 100644 --- a/app/helpers/contests_helper.rb +++ b/app/helpers/contests_helper.rb @@ -86,9 +86,10 @@ def ioi_new_ranklist_state(submission, start_time, item_state, is_waiting) public - def ranklist_data(submissions, start_time, freeze_start, rule) + def ranklist_data(submissions, start_time, freeze_start, rule, user_team_mapping) res = Hash.new { |h, k| h[k] = [] } participants = Set[] + teams = Set[] func = { 'acm' => method(:acm_ranklist_state), 'ioi' => method(:ioi_ranklist_state), @@ -97,18 +98,25 @@ def ranklist_data(submissions, start_time, freeze_start, rule) first_ac = {} submissions = submissions.to_a submissions.each do |sub| - participants << sub.user_id + team_id = user_team_mapping[sub.user_id] + if team_id.nil? + effective_id = "user_#{sub.user_id}" + participants << sub.user_id + else + effective_id = "team_#{team_id}" + teams << team_id + end next if ['CE', 'ER', 'CLE', 'JE'].include?(sub.result) && sub.created_at < freeze_start - key = "#{sub.user_id}_#{sub.problem_id}" + key = "#{effective_id}_#{sub.problem_id}" is_waiting = ['queued', 'received', 'Validating'].include?(sub.result) || sub.created_at >= freeze_start orig_state = res[key][-1]&.dig(:state) new_state = func.call(sub, start_time, orig_state, is_waiting) res[key] << {timestamp: submission_rel_timestamp(sub, start_time), state: new_state} unless new_state.nil? - first_ac[sub.problem_id] = first_ac.fetch(sub.problem_id, sub.user_id) if sub.result == 'AC' && sub.created_at < freeze_start + first_ac[sub.problem_id] = first_ac.fetch(sub.problem_id, effective_id) if sub.result == 'AC' && sub.created_at < freeze_start end res.delete_if { |key, value| value.empty? } res.each_value {|x| x.each {|item| item[:state].pop}} if rule == 'ioi_new' - {result: res, participants: participants.to_a, first_ac: first_ac} + {result: res, participants: participants.to_a, teams: teams.to_a, first_ac: first_ac} end def problem_index_text(index) @@ -122,21 +130,20 @@ def problem_index_text(index) ## --- HTML --- - def contest_register_button(contest, status, standalone) + def contest_registration_page_button(contest, status, standalone) if contest.can_register? btn_class = standalone ? 'btn-lg' : 'btn-xs' - btn_class += status.nil? ? ' btn-success' : ' btn-danger' - button_text = status.nil? ? "Register" : "Unregister" + btn_class += ' btn-primary' + button_text = standalone ? 'Change Registration' : 'Change' content_tag(:div, class: 'pull-right') if standalone html = button_to( button_text, register_contest_path(contest), - method: :post, + method: :get, class: "btn #{btn_class}", form: {style: 'display: inline'}, - params: status.nil? ? {} : { cancel: 1 }, ) if standalone content_tag(:div, class: 'pull-right') do diff --git a/app/helpers/submissions_helper.rb b/app/helpers/submissions_helper.rb index b5bee320..46960304 100644 --- a/app/helpers/submissions_helper.rb +++ b/app/helpers/submissions_helper.rb @@ -27,4 +27,14 @@ def time_str(x) prefix = '0' * [0, 6 - ret.length].max raw("#{prefix}" + ret) end + + def user_can_view?(user, submission, contest) + return true if user&.id == submission.user_id + if contest && contest == submission.contest + sub_team = contest.find_registration(submission.user)&.team + team = contest.find_registration(user)&.team + return true if team && team == sub_team + end + false + end end diff --git a/app/helpers/teams_helper.rb b/app/helpers/teams_helper.rb new file mode 100644 index 00000000..5fb41eb3 --- /dev/null +++ b/app/helpers/teams_helper.rb @@ -0,0 +1,2 @@ +module TeamsHelper +end diff --git a/app/javascript/pages/contests/contest_ranklist_reorder.js b/app/javascript/pages/contests/contest_ranklist_reorder.js index d6560fbe..cd53b329 100644 --- a/app/javascript/pages/contests/contest_ranklist_reorder.js +++ b/app/javascript/pages/contests/contest_ranklist_reorder.js @@ -80,14 +80,26 @@ function reorderTableInternal(data, timestamp, initUserState, cellText, rowSumma for (const user_id of data.participants) { let user_state = {...initUserState}; for (const prob_id of data.tasks) { - let key = user_id + '_' + prob_id; + let key = 'user_' + user_id + '_' + prob_id; let value = data.result[key]; let current = getValue(value); - let first_ac = data.first_ac[String(prob_id)] === user_id; + let first_ac = data.first_ac[String(prob_id)] === 'user_' + user_id; $('#cell_item_' + key).html(cellText(current, user_state, first_ac)); } - compare_keys['row_user_' + user_id] = rowSummary(user_id, user_state); + compare_keys['row_user_' + user_id] = rowSummary('user_' + user_id, user_state); } + for (const team_id of data.teams) { + let team_state = {...initUserState}; + for (const prob_id of data.tasks) { + let key = 'team_' + team_id + '_' + prob_id; + let value = data.result[key]; + let current = getValue(value); + let first_ac = data.first_ac[String(prob_id)] === 'team_' + team_id; + $('#cell_item_' + key).html(cellText(current, team_state, first_ac)); + } + compare_keys['row_team_' + team_id] = rowSummary('team_' + team_id, team_state); + } + console.log({ compare_keys }); let tbody = $('#dashboard_table_body'); tbody.append(tbody.children().detach().sort((a, b) => { let key_a = compare_keys[a.id].concat([rowUserID(a)]); @@ -138,4 +150,4 @@ export function contestRanklistReorder(data, timestamp) { ioiRowSummary ); } -} \ No newline at end of file +} diff --git a/app/models/contest.rb b/app/models/contest.rb index 69971ba4..6e37880a 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -86,12 +86,16 @@ def can_register? Time.now < effective_register_before end + def find_registration(usr) + contest_registrations.where(user_id: usr&.id).first + end + # nil if not registered, false if pending approval, true if registered def user_register_status(usr) - contest_registrations.where(user_id: usr&.id).first&.approved + find_registration(usr)&.approved end def user_can_submit?(usr) - usr && (no_register? || approved_registered_users.exists?(usr.id)) + usr && (no_register? || find_registration(usr)&.approved) end end diff --git a/app/models/contest_registration.rb b/app/models/contest_registration.rb index 55cc792c..c3a683f4 100644 --- a/app/models/contest_registration.rb +++ b/app/models/contest_registration.rb @@ -8,17 +8,27 @@ # approved :boolean not null # created_at :datetime not null # updated_at :datetime not null +# team_id :bigint # # Indexes # # index_contest_registrations_on_contest_id_and_approved (contest_id,approved) +# index_contest_registrations_on_contest_id_and_team_id (contest_id,team_id) # index_contest_registrations_on_contest_id_and_user_id (contest_id,user_id) UNIQUE +# index_contest_registrations_on_team_id (team_id) # index_contest_registrations_on_user_id_and_approved (user_id,approved) # +# Foreign Keys +# +# fk_rails_... (team_id => teams.id) +# class ContestRegistration < ApplicationRecord default_scope { order('id ASC') } belongs_to :contest belongs_to :user, class_name: 'UserBase', foreign_key: :user_id + belongs_to :team, optional: true + + validates_uniqueness_of :user, scope: :contest_id end diff --git a/app/models/team.rb b/app/models/team.rb new file mode 100644 index 00000000..72835027 --- /dev/null +++ b/app/models/team.rb @@ -0,0 +1,54 @@ +# == Schema Information +# +# Table name: teams +# +# id :bigint not null, primary key +# teamname :string(255) +# avatar :string(255) +# motto :string(255) +# school :string(255) +# created_at :datetime not null +# updated_at :datetime not null +# token :string(255) +# + +require 'file_size_validator' + +class Team < ApplicationRecord + has_and_belongs_to_many :users + accepts_nested_attributes_for :users, allow_destroy: true + + # validates_presence_of :teamname + validates_length_of :teamname, in: 1..45 + + validates :teamname, username_convention: true + # uniqueness: {case_sensitive: false}, + +# validates_uniqueness_of :nickname + validates_length_of :motto, maximum: 75 + validates :school, presence: true, length: {in: 1..64} + + mount_uploader :avatar, AvatarUploader + validates :avatar, + #presence: true, + file_size: { + maximum: 5.megabytes.to_i + } + + def generate_random_avatar + Tempfile.create(['', '.png']) do |tmpfile| + Visicon.new(SecureRandom.random_bytes(16), '', 128).draw_image.write(tmpfile.path) + self.avatar = tmpfile + end + end + + # extend FriendlyId + # friendly_id :teamname + + before_create :generate_token + + def generate_token + self.token = SecureRandom.hex(20) + end +end + diff --git a/app/models/user.rb b/app/models/user.rb index e8a37b05..792f531d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -112,6 +112,7 @@ class User < UserBase extend FriendlyId friendly_id :username + has_and_belongs_to_many :teams def self.ransackable_attributes(auth_object = nil) [ diff --git a/app/views/contest_registrations/index.html.erb b/app/views/contest_registrations/index.html.erb index cf0f9e6d..9333cd7f 100644 --- a/app/views/contest_registrations/index.html.erb +++ b/app/views/contest_registrations/index.html.erb @@ -92,6 +92,41 @@
| + | Teamname | +Member | ++ <%= button_to 'Unregister All', batch_op_contest_contest_registrations_path(@contest), params: {action_type: 'unregister_all'}, class: 'btn btn-xs btn-danger', style: 'font-size: 14px', data: {confirm: 'Are you sure to unregister all teams?'} %> + | +
|---|---|---|---|
| <%= link_to image_tag(team.avatar.mini_thumb.to_s, class: 'img-rounded'), team_path(team) %> | +<%= link_to team.teamname, team_path(team) %> | ++ <%= link_to image_tag(user.avatar.mini_thumb.to_s, class: 'img-rounded'), user_path(user) %> + <%= link_to user.username, user_path(user) %> + <% if @duplicate_names.include?(user.username) %> + + <% end %> + | ++ <%= link_to 'Unregister', contest_contest_registration_path(@contest, item), method: :patch, class:'btn btn-xs btn-danger' %> + | +
| + | Username | +Nickname | ++ Register Status + | +
|---|---|---|---|
| + <%= link_to image_tag(user.avatar.mini_thumb.to_s, class: 'img-rounded'), user_path(user) %> + | ++ <%= link_to user.username, user_path(user) %> + | ++ <%= user.nickname %> + | ++ <%= contest_register_status(registration&.approved, true) %> + | +
+ +<%= link_to "Back", :back, class: 'btn btn-default btn-xs' %> diff --git a/app/views/contests/show.html.erb b/app/views/contests/show.html.erb index 6678efa6..4ba59157 100644 --- a/app/views/contests/show.html.erb +++ b/app/views/contests/show.html.erb @@ -12,7 +12,7 @@ - <%= contest_register_button(@contest, @register_status, true) %> + <%= contest_registration_page_button(@contest, @register_status, true) %> <% end %>