Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include Pundit::Authorization
include ApplicationHelper
include Pagy::Method
include ContestInviteSession
before_action :authenticate_user!
before_action :set_sentry_context

Expand Down
4 changes: 3 additions & 1 deletion app/controllers/bulk_contest_instances_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ def create_contest_instances
last_instance = description.contest_instances.order(created_at: :desc).first

if last_instance
# Create from existing instance
# Create from existing instance. Assign a fresh access_token —
# dup copies the unique token, and has_secure_token only runs on initialize.
new_instance = last_instance.dup
new_instance.access_token = ContestInstance.generate_unique_secure_token
else
# Create new instance with default values
new_instance = description.contest_instances.new(
Expand Down
31 changes: 31 additions & 0 deletions app/controllers/concerns/contest_invite_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module ContestInviteSession
extend ActiveSupport::Concern

REDEEMED_SESSION_KEY = :redeemed_contest_instances

included do
before_action :load_redeemed_contest_instances_into_current
end

private

def load_redeemed_contest_instances_into_current
Current.redeemed_contest_instances = session[REDEEMED_SESSION_KEY] || {}
end

def redeem_contest_instance!(contest_instance)
session[REDEEMED_SESSION_KEY] ||= {}
session[REDEEMED_SESSION_KEY][contest_instance.id.to_s] = contest_instance.access_token
Current.redeemed_contest_instances = session[REDEEMED_SESSION_KEY]
end

def redeemed_token_for(contest_instance)
Current.redeemed_contest_instances&.dig(contest_instance.id.to_s)
end

def contest_instance_redeemed?(contest_instance)
redeemed_token_for(contest_instance) == contest_instance.access_token
end
end
10 changes: 9 additions & 1 deletion app/controllers/contest_instances_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class ContestInstancesController < ApplicationController
before_action :set_container
before_action :set_contest_description
before_action :set_contest_instance, only: %i[show edit update destroy send_round_results deactivate]
before_action :set_contest_instance, only: %i[show edit update destroy send_round_results deactivate regenerate_access_token]
before_action :authorize_container_access

# GET /contest_instances
Expand Down Expand Up @@ -228,6 +228,13 @@ def deactivate
end
end

def regenerate_access_token
authorize @contest_instance, :regenerate_access_token?
@contest_instance.regenerate_access_token
redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance),
notice: 'Invite link regenerated. Previous links no longer work.'
end

private

def authorize_container_access
Expand Down Expand Up @@ -261,6 +268,7 @@ def contest_instance_params
:recletter_required, :transcript_required,
:require_internal_comments, :require_external_comments,
:min_internal_comment_words, :min_external_comment_words,
:access_mode,
category_ids: [], class_level_ids: []
)
end
Expand Down
89 changes: 89 additions & 0 deletions app/controllers/contest_invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

class ContestInvitationsController < ApplicationController
before_action :set_container
before_action :set_contest_description
before_action :set_contest_instance
before_action :authorize_contest_instance

def create
emails = parse_emails(params[:emails].presence || params.dig(:contest_invitation, :email))
created_invitations = []
skipped = []

emails.each do |email|
invitation = @contest_instance.contest_invitations.find_or_initialize_by(email: email)
if invitation.new_record?
invitation.invited_by = current_user
if invitation.save
created_invitations << invitation
else
skipped << email
end
else
skipped << email
end
end

created_invitations.each do |invitation|
ContestInviteMailer.invite_to_submit(invitation).deliver_later
end

notice_parts = []
if created_invitations.any?
notice_parts << "Added #{created_invitations.size} invite#{'s' unless created_invitations.size == 1}."
notice_parts << "Invite email#{'s' unless created_invitations.size == 1} queued for #{created_invitations.size} new invitee#{'s' unless created_invitations.size == 1}."
end
notice_parts << "Skipped #{skipped.size} duplicate or invalid email#{'s' unless skipped.size == 1}." if skipped.any?

redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance),
notice: notice_parts.presence&.join(' ') || 'No invitations were added.'
end

def destroy
invitation = @contest_instance.contest_invitations.find(params[:id])
invitation.destroy
redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance),
notice: 'Invitee removed.'
end

def email_all
authorize @contest_instance, :send_invite_emails?

invitations = @contest_instance.contest_invitations
if invitations.none?
redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance),
alert: 'There are no invitees to email.'
return
end

invitations.find_each do |invitation|
ContestInviteMailer.invite_to_submit(invitation).deliver_later
end

redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance),
notice: "Queued invite emails for #{invitations.count} invitee#{'s' unless invitations.count == 1}."
end

private

def set_container
@container = policy_scope(Container).find(params[:container_id])
end

def set_contest_description
@contest_description = @container.contest_descriptions.find(params[:contest_description_id])
end

def set_contest_instance
@contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id])
end

def authorize_contest_instance
authorize @contest_instance, :manage_invitations?
end

def parse_emails(raw)
Array(raw.to_s.split(/[\s,;]+/)).map { |email| email.strip.downcase }.reject(&:blank?).uniq
end
end
35 changes: 35 additions & 0 deletions app/controllers/contest_invites_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

class ContestInvitesController < ApplicationController
def show
@contest_instance = ContestInstance.find_by!(access_token: params[:token])

unless current_user.profile
redirect_to new_profile_path, alert: 'Please create your profile before accessing this contest.'
return
end

if @contest_instance.private_visibility? && @contest_instance.invite_list? && !@contest_instance.invited?(current_user)
redirect_to applicant_dashboard_path, alert: 'You are not invited to submit to this contest.'
return
end

redeem_contest_instance!(@contest_instance)

unless @contest_instance.open?
redirect_to applicant_dashboard_path, alert: 'This contest is not currently open for submissions.'
return
end

unless @contest_instance.available_for_profile?(current_user.profile)
redirect_to applicant_dashboard_path,
alert: 'You are not eligible to submit to this contest (class level or entry limit).'
return
end

redirect_to new_entry_path(contest_instance_id: @contest_instance.id),
notice: "Welcome to #{@contest_instance.contest_description.name}."
rescue ActiveRecord::RecordNotFound
redirect_to applicant_dashboard_path, alert: 'Invalid or expired contest invite link.'
end
end
18 changes: 18 additions & 0 deletions app/javascript/controllers/access_mode_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"

// Toggles invite-list management UI based on access mode selection
export default class extends Controller {
static targets = ["inviteListSection"]
static values = { inviteListMode: { type: String, default: "invite_list" } }

toggle(event) {
const mode = event.target.value
if (!this.hasInviteListSectionTarget) return

if (mode === this.inviteListModeValue) {
this.inviteListSectionTarget.classList.remove("d-none")
} else {
this.inviteListSectionTarget.classList.add("d-none")
}
}
}
30 changes: 30 additions & 0 deletions app/javascript/controllers/clipboard_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus"

// Copies text from a target input/element to the clipboard
export default class extends Controller {
static targets = ["source", "feedback"]
static values = { successMessage: { type: String, default: "Copied!" } }

async copy(event) {
event.preventDefault()
const text = this.sourceTarget.value || this.sourceTarget.textContent

try {
await navigator.clipboard.writeText(text)
this.showFeedback(this.successMessageValue)
} catch (_error) {
this.sourceTarget.select?.()
this.showFeedback("Select and copy manually")
}
}

showFeedback(message) {
if (!this.hasFeedbackTarget) return

this.feedbackTarget.textContent = message
this.feedbackTarget.classList.remove("d-none")
window.setTimeout(() => {
this.feedbackTarget.classList.add("d-none")
}, 2000)
}
}
6 changes: 6 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ import { application } from "./application"
import CharacterCounterController from "./character_counter_controller"
application.register("character-counter", CharacterCounterController)

import AccessModeController from "./access_mode_controller"
application.register("access-mode", AccessModeController)

import CheckboxselectController from "./checkboxselect_controller"
application.register("checkboxselect", CheckboxselectController)

import ClipboardController from "./clipboard_controller"
application.register("clipboard", ClipboardController)

import CommentsCounterController from "./comments_counter_controller"
application.register("comments-counter", CommentsCounterController)

Expand Down
22 changes: 22 additions & 0 deletions app/mailers/contest_invite_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class ContestInviteMailer < ApplicationMailer
def invite_to_submit(contest_invitation)
@invitation = contest_invitation
@contest_instance = contest_invitation.contest_instance
@contest_description = @contest_instance.contest_description
@container = @contest_description.container
@invite_url = contest_invite_url(token: @contest_instance.access_token)
@contact_email = @container.contact_email.presence || 'LSA Evaluate Support <lsa-evaluate-support@umich.edu>'
@date_open = @contest_instance.date_open
@date_closed = @contest_instance.date_closed

mail_options = {
to: @invitation.email,
subject: "You're invited to submit: #{@contest_description.name}"
}
mail_options[:reply_to] = @container.contact_email if @container.contact_email.present?

mail(mail_options)
end
end
52 changes: 52 additions & 0 deletions app/models/contest_instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Table name: contest_instances
#
# id :bigint not null, primary key
# access_mode :string(255) default("capability_url"), not null
# access_token :string(255) not null
# active :boolean default(FALSE), not null
# archived :boolean default(FALSE), not null
# course_requirement_description :text(65535)
Expand All @@ -25,13 +27,21 @@
#
# contest_description_id_idx (contest_description_id)
# id_unq_idx (id) UNIQUE
# index_contest_instances_on_access_token (access_token) UNIQUE
# index_contest_instances_on_contest_description_id (contest_description_id)
#
# Foreign Keys
#
# fk_rails_... (contest_description_id => contest_descriptions.id)
#
class ContestInstance < ApplicationRecord
ACCESS_MODES = {
capability_url: 'capability_url',
invite_list: 'invite_list'
}.freeze

has_secure_token :access_token

# Associations
belongs_to :contest_description
has_many :class_level_requirements, dependent: :destroy
Expand All @@ -42,6 +52,9 @@ class ContestInstance < ApplicationRecord
has_many :judging_assignments, dependent: :restrict_with_error
has_many :judges, through: :judging_assignments, source: :user
has_many :judging_rounds, dependent: :restrict_with_error
has_many :contest_invitations, dependent: :destroy

enum :access_mode, ACCESS_MODES, default: :capability_url

# Validations
validates :date_open, presence: true
Expand All @@ -52,6 +65,7 @@ class ContestInstance < ApplicationRecord
validates :has_course_requirement, inclusion: { in: [ true, false ] }
validates :recletter_required, inclusion: { in: [ true, false ] }
validates :transcript_required, inclusion: { in: [ true, false ] }
validates :access_mode, presence: true, inclusion: { in: ACCESS_MODES.values }
validate :must_have_at_least_one_class_level_requirement
validate :must_have_at_least_one_category
validate :only_one_active_per_contest_description
Expand Down Expand Up @@ -101,6 +115,44 @@ def open?
active && Time.current.between?(date_open, date_closed)
end

def container
contest_description.container
end

def private_visibility?
container.visibility.kind == 'Private'
end

def public_visibility?
!private_visibility?
end

def invited?(user)
return false if user&.email.blank?

contest_invitations.for_email(user.email).exists?
end

def available_for_profile?(profile)
return false if profile.blank?
return false unless class_levels.exists?(id: profile.class_level_id)

entries.active.where(profile: profile).count < maximum_number_entries_per_applicant
end

def eligible_for_submission?(profile)
open? && available_for_profile?(profile)
end

def access_granted_for?(user, redeemed_token: nil)
return true if public_visibility?
return false if redeemed_token.blank? || redeemed_token != access_token
return true if capability_url?
return invited?(user) if invite_list?

false
end

def judging_open?(user = nil)
current_round = judging_rounds.where(active: true)
.where('start_date <= ? AND end_date >= ?', Time.zone.now, Time.zone.now)
Expand Down
Loading
Loading