hr_lite 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +18 -0
- data/app/assets/javascripts/hr_lite/select_all.js +21 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +26 -0
- data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +96 -0
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +50 -0
- data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +71 -0
- data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
- data/app/helpers/hr_lite/application_helper.rb +38 -0
- data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
- data/app/models/hr_lite/hiring_campaign.rb +23 -0
- data/app/models/hr_lite/hiring_candidate.rb +56 -0
- data/app/models/hr_lite/hiring_interview.rb +13 -0
- data/app/models/hr_lite/hiring_message.rb +14 -0
- data/app/models/hr_lite/hiring_reply.rb +144 -0
- data/app/models/hr_lite/hiring_template.rb +11 -0
- data/app/services/hr_lite/hiring_import.rb +106 -0
- data/app/services/hr_lite/hiring_merge.rb +54 -0
- data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +19 -0
- data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
- data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
- data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
- data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +106 -0
- data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +186 -0
- data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +20 -0
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +43 -0
- data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
- data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
- data/app/views/hr_lite/hiring_replies/show.html.erb +105 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -0
- data/app/views/layouts/hr_lite/public.html.erb +21 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
- data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
- data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
- data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
- data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
- data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
- data/db/migrate/20261022090000_create_hr_lite_hiring_replies.rb +26 -0
- data/db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb +6 -0
- data/lib/hr_lite/configuration.rb +14 -1
- data/lib/hr_lite/permissions.rb +2 -1
- data/lib/hr_lite/role_seeds.rb +2 -2
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +6 -0
- metadata +40 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A person from a university's sheet, moving through one campaign. Every
|
|
3
|
+
# change goes on their timeline (AuditLog rows whose subject is them).
|
|
4
|
+
class HiringCandidate < ApplicationRecord
|
|
5
|
+
STAGES = %w[imported invited interested not_interested interview selected rejected on_hold].freeze
|
|
6
|
+
# Where a stage may go next. Sending moves imported → invited; scheduling moves to interview.
|
|
7
|
+
MOVES = {
|
|
8
|
+
"imported" => %w[invited interested not_interested],
|
|
9
|
+
"invited" => %w[interested not_interested],
|
|
10
|
+
"interested" => %w[interview not_interested on_hold rejected],
|
|
11
|
+
"not_interested" => %w[interested],
|
|
12
|
+
"interview" => %w[interview selected rejected on_hold],
|
|
13
|
+
"on_hold" => %w[interview selected rejected],
|
|
14
|
+
"selected" => [], "rejected" => []
|
|
15
|
+
}.freeze
|
|
16
|
+
REJECTION_REASONS = [ "Communication skills", "Not available for 6 months", "Location / relocation",
|
|
17
|
+
"Stipend not acceptable", "Did not attend interview", "Other" ].freeze
|
|
18
|
+
|
|
19
|
+
belongs_to :campaign, class_name: "HrLite::HiringCampaign", inverse_of: :candidates
|
|
20
|
+
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
21
|
+
has_many :interviews, class_name: "HrLite::HiringInterview", foreign_key: :candidate_id, dependent: :restrict_with_error
|
|
22
|
+
has_many :replies, class_name: "HrLite::HiringReply", foreign_key: :candidate_id, inverse_of: :candidate, dependent: :restrict_with_error
|
|
23
|
+
|
|
24
|
+
encrypts :phone, :date_of_birth
|
|
25
|
+
normalizes :email, with: ->(email) { email.to_s.strip.downcase }
|
|
26
|
+
|
|
27
|
+
validates :name, presence: true
|
|
28
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { scope: :campaign_id }
|
|
29
|
+
validates :stage, inclusion: { in: STAGES }
|
|
30
|
+
|
|
31
|
+
def first_name = name.to_s.split.first
|
|
32
|
+
def stage_label = stage == "interview" ? "Interview · round #{round}" : stage.humanize
|
|
33
|
+
def timeline = AuditLog.where(subject_type: self.class.name, subject_id: id).includes(:actor).order(created_at: :desc, id: :desc)
|
|
34
|
+
|
|
35
|
+
# The one door for a stage change: refuses a move the pipeline does not
|
|
36
|
+
# allow, needs a listed reason to reject, and writes the timeline row.
|
|
37
|
+
def move_to!(to, reason: nil, note: nil, round: self.round, actor: HrLite::Current.actor)
|
|
38
|
+
raise ArgumentError, "#{stage_label} cannot move to #{to.humanize}." unless MOVES.fetch(stage).include?(to)
|
|
39
|
+
raise ArgumentError, "Pick a rejection reason." if to == "rejected" && !REJECTION_REASONS.include?(reason)
|
|
40
|
+
raise ArgumentError, "Write a note for “Other”." if reason == "Other" && note.blank?
|
|
41
|
+
|
|
42
|
+
transaction do
|
|
43
|
+
from = stage
|
|
44
|
+
update!(stage: to, round: round, rejection_reason: (to == "rejected" ? [ reason, note.presence ].compact.join(": ") : rejection_reason))
|
|
45
|
+
log!("stage", actor: actor, from: from, to: to, reason: (reason if to == "rejected"))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# This round's reply link, made on first use (a template that names it).
|
|
50
|
+
def reply_for(round) = replies.find_or_create_by!(round: round)
|
|
51
|
+
|
|
52
|
+
def log!(action, actor: HrLite::Current.actor, **changes)
|
|
53
|
+
AuditLog.record!(action: "hiring.#{action}", subject: self, actor: actor, changes: changes.compact)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One interview round on the sender's Google Calendar. A new round is a new
|
|
3
|
+
# row, so earlier rounds keep their own history.
|
|
4
|
+
class HiringInterview < ApplicationRecord
|
|
5
|
+
STATUSES = %w[unknown scheduled failed cancelled].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate"
|
|
8
|
+
|
|
9
|
+
validates :status, inclusion: { in: STATUSES }
|
|
10
|
+
validates :round, numericality: { greater_than: 0 }
|
|
11
|
+
validates :starts_at, :ends_at, presence: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One template sent (or being sent) to one candidate. The unique index on
|
|
3
|
+
# candidate + template is what makes a repeated click or job a no-op.
|
|
4
|
+
# "sending" left behind by a crash, and "unknown", both mean: look in Gmail.
|
|
5
|
+
class HiringMessage < ApplicationRecord
|
|
6
|
+
STATUSES = %w[queued sending sent failed unknown].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate"
|
|
9
|
+
belongs_to :template, class_name: "HrLite::HiringTemplate"
|
|
10
|
+
belongs_to :sent_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A candidate's reply link for one interview round (@round1_link … @round3_link):
|
|
3
|
+
# the day and 2-hour slot they can do, an optional alternate phone, round 1's
|
|
4
|
+
# interest questions, and from round 2 their documents. One reply per link.
|
|
5
|
+
class HiringReply < ApplicationRecord
|
|
6
|
+
SLOTS = { "10-12" => "10 AM – 12 PM", "12-2" => "12 – 2 PM", "2-4" => "2 – 4 PM", "4-6" => "4 – 6 PM", "6-8" => "6 – 8 PM" }.freeze
|
|
7
|
+
SLOT_START = { "10-12" => "10:00", "12-2" => "12:00", "2-4" => "14:00", "4-6" => "16:00", "6-8" => "18:00" }.freeze
|
|
8
|
+
SLOT_CAPACITY = 4 # candidates per slot per day; HR calls each to confirm
|
|
9
|
+
JOINING = { "immediately" => "Immediately", "two_weeks" => "Within 2 weeks", "one_month" => "Within a month" }.freeze
|
|
10
|
+
YES_NO = { "stipend_ok" => "Fine with the stipend", "location_ok" => "Fine with the office location",
|
|
11
|
+
"schedule_ok" => "Fine with the schedule", "programme_ok" => "Commits to the programme" }.freeze
|
|
12
|
+
DOCUMENTS = { 2 => { "cv" => "CV", "photo_id" => "Photo ID" },
|
|
13
|
+
3 => { "marksheets" => "Marksheets", "aadhaar" => "Aadhaar card", "pan" => "PAN card",
|
|
14
|
+
"salary_slip_1" => "Salary slip (last month)", "salary_slip_2" => "Salary slip (2 months ago)",
|
|
15
|
+
"salary_slip_3" => "Salary slip (3 months ago)" } }.freeze
|
|
16
|
+
PAN_FORMAT = /\A[A-Z]{5}[0-9]{4}[A-Z]\z/
|
|
17
|
+
# Verhoeff tables, for the Aadhaar check digit.
|
|
18
|
+
VERHOEFF_D = [ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], [ 1, 2, 3, 4, 0, 6, 7, 8, 9, 5 ], [ 2, 3, 4, 0, 1, 7, 8, 9, 5, 6 ],
|
|
19
|
+
[ 3, 4, 0, 1, 2, 8, 9, 5, 6, 7 ], [ 4, 0, 1, 2, 3, 9, 5, 6, 7, 8 ], [ 5, 9, 8, 7, 6, 0, 4, 3, 2, 1 ],
|
|
20
|
+
[ 6, 5, 9, 8, 7, 1, 0, 4, 3, 2 ], [ 7, 6, 5, 9, 8, 2, 1, 0, 4, 3 ], [ 8, 7, 6, 5, 9, 3, 2, 1, 0, 4 ],
|
|
21
|
+
[ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 ] ].freeze
|
|
22
|
+
VERHOEFF_P = [ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], [ 1, 5, 7, 6, 2, 8, 3, 0, 9, 4 ], [ 5, 8, 0, 3, 7, 9, 6, 1, 4, 2 ],
|
|
23
|
+
[ 8, 9, 1, 6, 0, 4, 3, 5, 2, 7 ], [ 9, 4, 5, 3, 1, 2, 6, 8, 7, 0 ], [ 4, 2, 8, 6, 5, 7, 3, 9, 0, 1 ],
|
|
24
|
+
[ 2, 7, 9, 3, 8, 0, 6, 4, 1, 5 ], [ 7, 0, 4, 6, 9, 1, 3, 2, 5, 8 ] ].freeze
|
|
25
|
+
DOCUMENT_TYPES = %w[application/pdf image/jpeg image/png].freeze
|
|
26
|
+
DOCUMENT_MAX = 5.megabytes
|
|
27
|
+
|
|
28
|
+
belongs_to :candidate, class_name: "HrLite::HiringCandidate", inverse_of: :replies
|
|
29
|
+
has_many_attached :documents
|
|
30
|
+
encrypts :alternate_phone, :aadhaar_number, :pan_number
|
|
31
|
+
has_secure_token :token, length: 24
|
|
32
|
+
|
|
33
|
+
validates :round, inclusion: { in: 1..3 }
|
|
34
|
+
|
|
35
|
+
scope :responded, -> { where.not(responded_at: nil) }
|
|
36
|
+
|
|
37
|
+
def self.slot_full?(date, slot) = responded.where(preferred_date: date, preferred_slot: slot).count >= SLOT_CAPACITY
|
|
38
|
+
|
|
39
|
+
# The next ten interview days. Sunday is off unless the campaign allows it.
|
|
40
|
+
def self.dates(allow_sunday:, from: Date.current) = (1..20).map { |i| from + i }.reject { |d| d.sunday? && !allow_sunday }.first(10)
|
|
41
|
+
|
|
42
|
+
def self.aadhaar_valid?(number)
|
|
43
|
+
number.to_s.match?(/\A[2-9]\d{11}\z/) &&
|
|
44
|
+
number.reverse.chars.each_with_index.reduce(0) { |c, (digit, i)| VERHOEFF_D[c][VERHOEFF_P[i % 8][digit.to_i]] }.zero?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What this round asks for; round 3 skips PAN and slips the candidate says they lack.
|
|
48
|
+
def documents_asked
|
|
49
|
+
asked = DOCUMENTS.fetch(round, {})
|
|
50
|
+
asked = asked.except("pan") if pan_pending
|
|
51
|
+
asked = asked.except("salary_slip_1", "salary_slip_2", "salary_slip_3") if no_salary_slips
|
|
52
|
+
asked
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def masked_aadhaar = aadhaar_number && "XXXX XXXX #{aadhaar_number.last(4)}"
|
|
56
|
+
def masked_pan = pan_number && "XXXXX#{pan_number[5, 4]}X"
|
|
57
|
+
|
|
58
|
+
# Documents are stored encrypted; this is the only way back to the bytes.
|
|
59
|
+
def read_document(attachment) = ActiveRecord::Encryption.encryptor.decrypt(attachment.download)
|
|
60
|
+
def document_label(attachment) = documents_asked[attachment.filename.to_s.split("--").first] || "Document"
|
|
61
|
+
|
|
62
|
+
# Saves the reply, moves round 1's candidate to interested / not interested,
|
|
63
|
+
# puts it on their timeline and rings the HR person who emailed them.
|
|
64
|
+
def submit!(interested: nil, date: nil, slot: nil, joining: nil, yes_no: {}, alternate_phone: nil, files: {}, kyc: {})
|
|
65
|
+
raise ArgumentError, "You have already replied. Thank you." if responded_at
|
|
66
|
+
|
|
67
|
+
assign_kyc!(kyc) if round == 3
|
|
68
|
+
|
|
69
|
+
declined = round == 1 && interested == "no"
|
|
70
|
+
raise ArgumentError, "Tell us whether you want to go ahead." if round == 1 && !%w[yes no].include?(interested)
|
|
71
|
+
|
|
72
|
+
answers = round == 1 ? { "interested" => interested } : {}
|
|
73
|
+
unless declined
|
|
74
|
+
date = self.class.dates(allow_sunday: candidate.campaign.allow_sunday).find { |day| day.iso8601 == date.to_s }
|
|
75
|
+
raise ArgumentError, "Pick a day for the interview." unless date
|
|
76
|
+
raise ArgumentError, "Pick a time slot." unless SLOTS.key?(slot)
|
|
77
|
+
raise ArgumentError, "That slot just filled up. Pick another one." if self.class.slot_full?(date, slot)
|
|
78
|
+
raise ArgumentError, "That phone number does not look right." if alternate_phone.present? && !alternate_phone.to_s.gsub(/[\s-]/, "").match?(/\A\+?\d{7,15}\z/)
|
|
79
|
+
|
|
80
|
+
if round == 1
|
|
81
|
+
raise ArgumentError, "Tell us how soon you can join." unless JOINING.key?(joining)
|
|
82
|
+
|
|
83
|
+
answers.merge!("joining" => joining, **YES_NO.keys.to_h { |key| [ key, yes_no[key] == "yes" ? "yes" : "no" ] })
|
|
84
|
+
end
|
|
85
|
+
check_files!(files)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
transaction do
|
|
89
|
+
update!(responded_at: Time.current, answers: answers, alternate_phone: alternate_phone.presence,
|
|
90
|
+
preferred_date: (date unless declined), preferred_slot: (slot unless declined))
|
|
91
|
+
files.slice(*documents_asked.keys).each do |kind, file|
|
|
92
|
+
# Encrypted before it reaches storage: a leaked blob is ciphertext.
|
|
93
|
+
cipher = ActiveRecord::Encryption.encryptor.encrypt(file.tempfile.read.b)
|
|
94
|
+
documents.attach(io: StringIO.new(cipher), filename: "#{kind}--#{file.original_filename}", content_type: "application/octet-stream",
|
|
95
|
+
identify: false)
|
|
96
|
+
end
|
|
97
|
+
if round == 1
|
|
98
|
+
to = declined ? "not_interested" : "interested"
|
|
99
|
+
candidate.move_to!(to, actor: nil) if HiringCandidate::MOVES.fetch(candidate.stage).include?(to)
|
|
100
|
+
end
|
|
101
|
+
candidate.log!("replied", actor: nil, round: round, interested: answers["interested"],
|
|
102
|
+
slot: ([ date.strftime("%-d %b"), SLOTS[slot] ].join(", ") unless declined))
|
|
103
|
+
end
|
|
104
|
+
notify_sender
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Round 3's joining KYC. Errors name the field, never the number.
|
|
110
|
+
def assign_kyc!(kyc)
|
|
111
|
+
raise ArgumentError, "Tick the consent box to share your documents." unless kyc[:consent] == "1"
|
|
112
|
+
|
|
113
|
+
aadhaar = kyc[:aadhaar_number].to_s.gsub(/\s/, "")
|
|
114
|
+
raise ArgumentError, "That Aadhaar number does not look right." unless self.class.aadhaar_valid?(aadhaar)
|
|
115
|
+
|
|
116
|
+
self.pan_pending = kyc[:pan_pending] == "1"
|
|
117
|
+
pan = kyc[:pan_number].to_s.strip.upcase
|
|
118
|
+
raise ArgumentError, "That PAN does not look right." unless pan_pending || pan.match?(PAN_FORMAT)
|
|
119
|
+
|
|
120
|
+
self.no_salary_slips = kyc[:no_salary_slips] == "1"
|
|
121
|
+
self.aadhaar_number = aadhaar
|
|
122
|
+
self.pan_number = (pan unless pan_pending)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Every asked document, a real PDF/JPG/PNG (read from the bytes, not the
|
|
126
|
+
# browser's claim), at most 5 MB.
|
|
127
|
+
def check_files!(files)
|
|
128
|
+
documents_asked.each do |kind, label|
|
|
129
|
+
file = files[kind]
|
|
130
|
+
raise ArgumentError, "Attach your #{label}." unless file.respond_to?(:original_filename)
|
|
131
|
+
raise ArgumentError, "#{label} must be a PDF, JPG or PNG." unless DOCUMENT_TYPES.include?(sniff(file))
|
|
132
|
+
raise ArgumentError, "#{label} must be 5 MB or smaller." if file.size > DOCUMENT_MAX
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def sniff(file) = Marcel::MimeType.for(file.tempfile, name: file.original_filename).tap { file.tempfile.rewind }
|
|
137
|
+
|
|
138
|
+
def notify_sender
|
|
139
|
+
user = candidate.messages.where(status: "sent").order(:sent_at).last&.sent_by or return
|
|
140
|
+
HrLite.notify(user: user, kind: "hiring.reply", body: candidate.campaign.title, path: "/admin/hiring_candidates/#{candidate_id}",
|
|
141
|
+
title: "#{candidate.name} replied to round #{round}#{": #{answers['interested'] == 'yes' ? 'interested' : 'not interested'}" if round == 1}")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# An email HR writes once and sends to many. @placeholders are filled per
|
|
3
|
+
# candidate by HiringMerge at send time.
|
|
4
|
+
class HiringTemplate < ApplicationRecord
|
|
5
|
+
has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :template_id, dependent: :restrict_with_error
|
|
6
|
+
|
|
7
|
+
validates :name, :subject, :body, presence: true
|
|
8
|
+
|
|
9
|
+
scope :alphabetical, -> { order(:name) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Reads a university's candidate sheet into a preview, then imports it.
|
|
3
|
+
# Headers match an alias list after case-folding and dropping punctuation
|
|
4
|
+
# and spaces. A column nobody recognises is kept in `extra`, never dropped;
|
|
5
|
+
# two columns claiming one field wait for HR to pick one.
|
|
6
|
+
class HiringImport
|
|
7
|
+
ALIASES = {
|
|
8
|
+
"name" => %w[studentname name candidatename fullname],
|
|
9
|
+
"email" => %w[emailid email emailaddress mailid],
|
|
10
|
+
"phone" => %w[contactno contactnumber phone phoneno mobile mobileno],
|
|
11
|
+
"date_of_birth" => %w[dob dobddmmyyyy dateofbirth],
|
|
12
|
+
"campus" => %w[campus college],
|
|
13
|
+
"course" => %w[coursepursuing course programme program],
|
|
14
|
+
"branch" => %w[branchspecialization branch specialization],
|
|
15
|
+
"cv_url" => %w[linkforcv cv cvlink resume resumelink]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
Column = Data.define(:index, :header, :field, :extra_key)
|
|
19
|
+
Row = Data.define(:number, :attributes, :extra, :outcome, :reason)
|
|
20
|
+
|
|
21
|
+
def self.normalize(header) = header.to_s.downcase.gsub(/[^a-z0-9]/, "")
|
|
22
|
+
|
|
23
|
+
# Cell values arrive as the sheet stored them: never evaluated, never fetched.
|
|
24
|
+
def self.text(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil then ""
|
|
27
|
+
when Float then value == value.floor ? value.to_i.to_s : value.to_s
|
|
28
|
+
when Date, Time then value.strftime("%d/%m/%Y")
|
|
29
|
+
else value.to_s.strip
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :columns, :conflicts, :rows
|
|
34
|
+
|
|
35
|
+
# rows: arrays of cell values, headers first. picks: { "phone" => "3" }
|
|
36
|
+
# names the column that wins a field two columns claim.
|
|
37
|
+
def initialize(campaign, rows, picks: {})
|
|
38
|
+
@campaign = campaign
|
|
39
|
+
header, *body = rows
|
|
40
|
+
map_columns(Array(header), picks.to_h.transform_values(&:to_i))
|
|
41
|
+
@rows = classify(body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def counts = %w[create update skip].index_with { |outcome| rows.count { |row| row.outcome == outcome } }
|
|
45
|
+
|
|
46
|
+
def import!
|
|
47
|
+
return false if conflicts.any?
|
|
48
|
+
|
|
49
|
+
HiringCandidate.transaction do
|
|
50
|
+
rows.reject { |row| row.outcome == "skip" }.each do |row|
|
|
51
|
+
candidate = @campaign.candidates.find_or_initialize_by(email: row.attributes["email"])
|
|
52
|
+
created = candidate.new_record?
|
|
53
|
+
candidate.assign_attributes(row.attributes.merge("extra" => candidate.extra.to_h.merge(row.extra)))
|
|
54
|
+
candidate.save!
|
|
55
|
+
candidate.log!(created ? "imported" : "reimported") if created || candidate.saved_changes.any?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def map_columns(header, picks)
|
|
64
|
+
@columns = header.each_with_index.map do |cell, index|
|
|
65
|
+
field = ALIASES.find { |_, names| names.include?(self.class.normalize(cell)) }&.first
|
|
66
|
+
Column.new(index:, header: cell.to_s.strip, field:, extra_key: nil)
|
|
67
|
+
end
|
|
68
|
+
claimed = @columns.select(&:field).group_by(&:field)
|
|
69
|
+
@conflicts = claimed.select { |field, cols| cols.size > 1 && cols.none? { |c| c.index == picks[field] } }
|
|
70
|
+
# A column that lost its field (or is unknown) keeps its data in `extra`.
|
|
71
|
+
taken = HiringMerge::RESERVED + ALIASES.keys
|
|
72
|
+
@columns = @columns.map do |col|
|
|
73
|
+
loses = col.field && claimed[col.field].size > 1 && picks[col.field] != col.index
|
|
74
|
+
next col if col.field && !loses
|
|
75
|
+
|
|
76
|
+
key = col.header.parameterize(separator: "_").presence || "column_#{col.index + 1}"
|
|
77
|
+
key = "sheet_#{key}" if taken.include?(key) || !key.match?(/\A[a-z]/) # @placeholders start with a letter
|
|
78
|
+
taken << key
|
|
79
|
+
col.with(field: nil, extra_key: key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def classify(body)
|
|
84
|
+
seen = {}
|
|
85
|
+
existing = @campaign.candidates.pluck(:email).to_set
|
|
86
|
+
body.each_with_index.filter_map do |cells, i|
|
|
87
|
+
number = i + 2 # sheet row, counting the header
|
|
88
|
+
values = @columns.to_h { |col| [ col, self.class.text(Array(cells)[col.index]) ] }
|
|
89
|
+
next if values.values.all?(&:blank?) # an empty row in the sheet is not a candidate
|
|
90
|
+
|
|
91
|
+
attributes = values.select { |col, _| col.field }.to_h { |col, v| [ col.field, v.presence ] }
|
|
92
|
+
attributes["email"] = attributes["email"].to_s.strip.downcase.presence
|
|
93
|
+
extra = values.select { |col, v| col.extra_key && v.present? }.to_h { |col, v| [ col.extra_key, v ] }
|
|
94
|
+
outcome, reason =
|
|
95
|
+
if attributes["name"].blank? then [ "skip", "no name" ]
|
|
96
|
+
elsif attributes["email"].blank? then [ "skip", "no email address" ]
|
|
97
|
+
elsif !attributes["email"].match?(URI::MailTo::EMAIL_REGEXP) then [ "skip", "email address looks wrong" ]
|
|
98
|
+
elsif seen[attributes["email"]] then [ "skip", "same email as row #{seen[attributes['email']]}" ]
|
|
99
|
+
else [ existing.include?(attributes["email"]) ? "update" : "create", nil ]
|
|
100
|
+
end
|
|
101
|
+
seen[attributes["email"]] ||= number if attributes["email"]
|
|
102
|
+
Row.new(number:, attributes:, extra:, outcome:, reason:)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Fills a hiring email's @placeholders for one candidate. Reserved names
|
|
3
|
+
# (campaign, candidate, interview, sender) win over a sheet's extra columns.
|
|
4
|
+
# An unknown or blank placeholder is reported, never sent as "".
|
|
5
|
+
class HiringMerge
|
|
6
|
+
TOKEN = /(?<![\w.@])@([a-z][a-z0-9_]*)/
|
|
7
|
+
GROUPS = { "Candidate" => %w[first_name name course branch campus],
|
|
8
|
+
"Offer" => %w[position programme stipend ctc location schedule],
|
|
9
|
+
"Interview" => %w[interview_date interview_time meet_link sender_name],
|
|
10
|
+
"Reply links" => %w[round1_link round2_link round3_link] }.freeze
|
|
11
|
+
RESERVED = GROUPS.values.flatten.freeze
|
|
12
|
+
|
|
13
|
+
Result = Data.define(:subject, :body, :missing)
|
|
14
|
+
|
|
15
|
+
def self.tokens(text) = text.to_s.scan(TOKEN).flatten.uniq
|
|
16
|
+
|
|
17
|
+
def initialize(candidate, sender_name:)
|
|
18
|
+
@extra = candidate.extra.to_h.transform_keys(&:to_s)
|
|
19
|
+
@values = @extra.merge(reserved(candidate, sender_name))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render(template)
|
|
23
|
+
missing = []
|
|
24
|
+
fill = ->(text) {
|
|
25
|
+
text.to_s.gsub(TOKEN) do
|
|
26
|
+
key = Regexp.last_match(1)
|
|
27
|
+
value = @values[key]
|
|
28
|
+
value = (value.respond_to?(:call) ? value.call : value).to_s.strip
|
|
29
|
+
next value unless value.empty?
|
|
30
|
+
|
|
31
|
+
missing << "@#{key} (#{RESERVED.include?(key) || @extra.key?(key) ? 'empty' : 'unknown'})"
|
|
32
|
+
Regexp.last_match(0)
|
|
33
|
+
end
|
|
34
|
+
}
|
|
35
|
+
Result.new(subject: fill.(template.subject), body: fill.(template.body), missing: missing.uniq)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def reserved(candidate, sender_name)
|
|
41
|
+
campaign = candidate.campaign
|
|
42
|
+
interview = candidate.interviews.where(status: "scheduled").order(:round).last
|
|
43
|
+
at = interview&.starts_at&.in_time_zone(HrLite.config.time_zone)
|
|
44
|
+
{ "name" => candidate.name, "first_name" => candidate.first_name, "course" => candidate.course,
|
|
45
|
+
"branch" => candidate.branch, "campus" => candidate.campus, "position" => campaign.position,
|
|
46
|
+
"programme" => campaign.programme, "stipend" => campaign.stipend, "ctc" => campaign.ctc,
|
|
47
|
+
"location" => campaign.location, "schedule" => campaign.schedule,
|
|
48
|
+
"interview_date" => at&.strftime("%-d %B %Y"), "interview_time" => at&.strftime("%-l:%M %p"),
|
|
49
|
+
"meet_link" => interview&.meet_link, "sender_name" => sender_name,
|
|
50
|
+
# Lazy: a round's link (and its row) is made only when a template names it.
|
|
51
|
+
**(1..3).to_h { |r| [ "round#{r}_link", -> { HrLite.public_url("/apply/#{candidate.reply_for(r).token}") } ] } }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%= form_with model: campaign, url: (campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path), local: true do |f| %>
|
|
2
|
+
<%= render "hr_lite/shared/form_errors", record: campaign %>
|
|
3
|
+
<p class="hrl-small hrl-muted">These fill @position, @programme, @stipend, @ctc, @location and @schedule in every email.</p>
|
|
4
|
+
<div class="hrl-field"><%= f.label :title, "Campaign name" %><%= f.text_field :title, required: true, placeholder: "e.g. Campus drive, October" %></div>
|
|
5
|
+
<div class="hrl-field"><%= f.label :position %><%= f.text_field :position, required: true %></div>
|
|
6
|
+
<div class="hrl-field"><%= f.label :programme %><%= f.text_field :programme %></div>
|
|
7
|
+
<div class="hrl-field"><%= f.label :stipend, "Monthly stipend" %><%= f.text_field :stipend %></div>
|
|
8
|
+
<div class="hrl-field"><%= f.label :ctc, "Full-time CTC" %><%= f.text_field :ctc %></div>
|
|
9
|
+
<div class="hrl-field"><%= f.label :location, "Work location" %><%= f.text_area :location, rows: 2 %></div>
|
|
10
|
+
<div class="hrl-field"><%= f.label :schedule, "Work schedule" %><%= f.text_field :schedule %></div>
|
|
11
|
+
<div class="hrl-field"><label class="hrl-field--check"><%= f.check_box :allow_sunday %> Allow Sunday interviews (Sunday is off by default)</label></div>
|
|
12
|
+
<% if campaign.persisted? %>
|
|
13
|
+
<div class="hrl-field"><%= f.label :status %><%= f.select :status, [ %w[Open open], %w[Closed closed] ] %></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
<div class="hrl-form-actions">
|
|
16
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
17
|
+
<a class="hrl-btn" href="<%= campaign.persisted? ? hr_lite.admin_hiring_campaign_path(campaign) : hr_lite.admin_hiring_campaigns_path %>">Cancel</a>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Upload candidates" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Upload candidate sheet</h1>
|
|
4
|
+
<p class="hrl-page__sub"><%= @campaign.title %>. Nothing is saved until you confirm the preview.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<section class="hrl-card">
|
|
7
|
+
<%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true, multipart: true do %>
|
|
8
|
+
<div class="hrl-field">
|
|
9
|
+
<label for="sheet">Sheet (.xlsx or .csv, headers in the first row)</label>
|
|
10
|
+
<input type="file" name="sheet" id="sheet" accept=".xlsx,.xls,.csv" required>
|
|
11
|
+
</div>
|
|
12
|
+
<p class="hrl-small hrl-muted">Name and email are required. Columns we do not recognise are kept, and can be used as @placeholders.</p>
|
|
13
|
+
<div class="hrl-form-actions">
|
|
14
|
+
<button class="hrl-btn hrl-btn--primary" type="submit">Preview</button>
|
|
15
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</section>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Hiring" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Hiring</h1>
|
|
4
|
+
<div class="hrl-page__actions"><a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_hiring_campaign_path %>">New campaign</a></div>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% if @campaigns.empty? %>
|
|
8
|
+
<section class="hrl-card hrl-card--empty"><p class="hrl-muted">No campaigns yet. Create one, then upload the university's candidate sheet.</p></section>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% @campaigns.each do |campaign| %>
|
|
11
|
+
<section class="hrl-card">
|
|
12
|
+
<h2 class="hrl-card__title"><a class="hrl-card__link" href="<%= hr_lite.admin_hiring_campaign_path(campaign) %>"><%= campaign.title %></a></h2>
|
|
13
|
+
<p class="hrl-small hrl-muted"><%= [ campaign.position, campaign.stipend, (campaign.status == "closed" ? "Closed" : nil) ].compact_blank.join(" · ") %></p>
|
|
14
|
+
<div class="hrl-chiprow">
|
|
15
|
+
<% HrLite::HiringCandidate::STAGES.each do |stage| %>
|
|
16
|
+
<% if (n = @counts[[ campaign.id, stage ]]) %><span class="hrl-badge hrl-badge--muted"><%= n %> <%= stage.humanize.downcase %></span><% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
<% end %>
|
|
21
|
+
<%= hrl_pagination %>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Check the import" } %>
|
|
2
|
+
<% counts = @import.counts %>
|
|
3
|
+
<div class="hrl-page__head">
|
|
4
|
+
<h1 class="hrl-page__title">Check the import</h1>
|
|
5
|
+
<p class="hrl-page__sub"><%= @campaign.title %></p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="hrl-kpis">
|
|
8
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["create"] %></div><div class="hrl-kpi__label">New</div></div>
|
|
9
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["update"] %></div><div class="hrl-kpi__label">Update</div></div>
|
|
10
|
+
<div class="hrl-kpi"><div class="hrl-kpi__value"><%= counts["skip"] %></div><div class="hrl-kpi__label">Skip</div></div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<%= form_with url: hr_lite.import_admin_hiring_campaign_path(@campaign), method: :post, local: true do %>
|
|
14
|
+
<input type="hidden" name="rows" value="<%= @sealed %>">
|
|
15
|
+
<% if @import.conflicts.any? %>
|
|
16
|
+
<section class="hrl-card hrl-card--warn">
|
|
17
|
+
<h2 class="hrl-card__title">Pick one column for each field</h2>
|
|
18
|
+
<% @import.conflicts.each do |field, columns| %>
|
|
19
|
+
<fieldset class="hrl-field">
|
|
20
|
+
<legend><%= field.humanize %></legend>
|
|
21
|
+
<% columns.each do |column| %>
|
|
22
|
+
<label class="hrl-field--check"><input type="radio" name="picks[<%= field %>]" value="<%= column.index %>" required> “<%= column.header %>”</label>
|
|
23
|
+
<% end %>
|
|
24
|
+
</fieldset>
|
|
25
|
+
<% end %>
|
|
26
|
+
<p class="hrl-small hrl-muted">The column you do not pick is kept as extra data.</p>
|
|
27
|
+
<button class="hrl-btn" type="submit">Update preview</button>
|
|
28
|
+
</section>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<section class="hrl-card">
|
|
32
|
+
<h2 class="hrl-card__title">Columns</h2>
|
|
33
|
+
<div class="hrl-table-wrap">
|
|
34
|
+
<table class="hrl-table hrl-table--stack hrl-table--tight">
|
|
35
|
+
<thead><tr><th>In the sheet</th><th>Saved as</th></tr></thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
<% @import.columns.each do |column| %>
|
|
38
|
+
<tr>
|
|
39
|
+
<td data-label="In the sheet"><%= column.header.presence || "(no header)" %></td>
|
|
40
|
+
<td data-label="Saved as"><%= column.field ? (column.field == "cv_url" ? "CV link" : column.field.humanize) : "Extra: @#{column.extra_key}" %></td>
|
|
41
|
+
</tr>
|
|
42
|
+
<% end %>
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
<% skipped = @import.rows.select { |row| row.outcome == "skip" } %>
|
|
49
|
+
<% if skipped.any? %>
|
|
50
|
+
<section class="hrl-card">
|
|
51
|
+
<h2 class="hrl-card__title">Rows that will be skipped</h2>
|
|
52
|
+
<ul class="hrl-feed"><% skipped.each do |row| %><li class="hrl-feed__item">Row <%= row.number %>: <%= row.reason %></li><% end %></ul>
|
|
53
|
+
</section>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<div class="hrl-form-actions">
|
|
57
|
+
<% if @import.conflicts.empty? %>
|
|
58
|
+
<button class="hrl-btn hrl-btn--primary" type="submit" name="confirm" value="1">Import <%= counts["create"] + counts["update"] %> candidates</button>
|
|
59
|
+
<% end %>
|
|
60
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_hiring_campaign_path(@campaign) %>">Cancel</a>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|