hr_lite 0.5.2 → 0.6.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 +112 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +5 -2
- data/app/models/hr_lite/audit_log.rb +26 -1
- data/app/models/hr_lite/comp_off_request.rb +3 -1
- data/app/models/hr_lite/leave_request.rb +27 -4
- data/app/models/hr_lite/payroll_run.rb +30 -4
- data/app/models/hr_lite/regularization_request.rb +3 -4
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/models/hr_lite/salary_slip.rb +1 -2
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/payroll_run_processor.rb +4 -1
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +11 -7
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/financial_year.rb +26 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/statutory_rate_card.rb +45 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +81 -11
- data/lib/tasks/hr_lite_tasks.rake +10 -2
- metadata +17 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
module Admin
|
|
3
|
+
# Who may do what. Gated on `role.manage`, which by default only the
|
|
4
|
+
# money tier holds — the authority to grant yourself payroll is the
|
|
5
|
+
# authority to read everyone's pay, so it lives with the pay.
|
|
6
|
+
class RolesController < LeadershipController
|
|
7
|
+
skip_before_action :require_governing_access!
|
|
8
|
+
before_action :require_role_management!
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@roles = Role.alphabetical.includes(:role_grants, :role_assignments)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@role = Role.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@role = Role.new(role_params)
|
|
20
|
+
if @role.save
|
|
21
|
+
@role.replace_grants!(grant_params)
|
|
22
|
+
redirect_to admin_roles_path, notice: "#{@role.name} created."
|
|
23
|
+
else
|
|
24
|
+
render :new, status: :unprocessable_entity
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def edit
|
|
29
|
+
@role = Role.find(params[:id])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update
|
|
33
|
+
@role = Role.find(params[:id])
|
|
34
|
+
if @role.update(role_params)
|
|
35
|
+
@role.replace_grants!(grant_params)
|
|
36
|
+
redirect_to admin_roles_path, notice: "#{@role.name} updated."
|
|
37
|
+
else
|
|
38
|
+
render :edit, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def destroy
|
|
43
|
+
role = Role.find(params[:id])
|
|
44
|
+
if role.destroy
|
|
45
|
+
redirect_to admin_roles_path, notice: "#{role.name} deleted.", status: :see_other
|
|
46
|
+
else
|
|
47
|
+
redirect_to admin_roles_path, alert: role.errors.full_messages.to_sentence,
|
|
48
|
+
status: :see_other
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def require_role_management!
|
|
55
|
+
hr_require_permission!("role.manage", scope: :all)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def role_params
|
|
59
|
+
permitted = params.require(:role).permit(:name, :description)
|
|
60
|
+
# A built-in role's NAME is what the seed, the upgrade migration and
|
|
61
|
+
# the specs all identify it by; the model refuses a rename, and not
|
|
62
|
+
# sending one keeps the form from failing on an untouched field.
|
|
63
|
+
@role&.system? ? permitted.except(:name) : permitted
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The form posts the COMPLETE picture, so a permission absent from it is
|
|
67
|
+
# one being taken away. "none" is how the form says "not granted".
|
|
68
|
+
def grant_params
|
|
69
|
+
params.fetch(:grants, {}).permit(Permissions::KEYS).to_h
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -43,12 +43,11 @@ module HrLite
|
|
|
43
43
|
)
|
|
44
44
|
slip.update!(attributes)
|
|
45
45
|
|
|
46
|
-
AuditLog.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"tds_override" => tds_override ? "[changed]" : "cleared" }
|
|
46
|
+
AuditLog.record!(
|
|
47
|
+
action: "override", subject: slip, actor: hr_current_user,
|
|
48
|
+
changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
|
|
49
|
+
"lop_override" => lop_override || "cleared",
|
|
50
|
+
"tds_override" => tds_override ? "[changed]" : "cleared" }
|
|
52
51
|
)
|
|
53
52
|
redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
|
|
54
53
|
rescue ArgumentError
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
module HrLite
|
|
2
2
|
module Admin
|
|
3
|
-
# Money
|
|
4
|
-
# promotions.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
3
|
+
# Money screens: salary structures, payroll, slips, appraisals and
|
|
4
|
+
# promotions. Ordinary governing authority reaches none of it — leadership
|
|
5
|
+
# governs policy and people but never sees another person's pay.
|
|
6
|
+
#
|
|
7
|
+
# The money gate stands on its own rather than layering on the governing
|
|
8
|
+
# one: a payroll operator does not have to also govern people and policy.
|
|
9
|
+
# Stacking the two locked a configured money-tier user who was absent from
|
|
10
|
+
# the leadership list out of payroll entirely.
|
|
10
11
|
class SuperadminController < LeadershipController
|
|
11
|
-
skip_before_action :
|
|
12
|
-
before_action :
|
|
12
|
+
skip_before_action :require_governing_access!
|
|
13
|
+
before_action :require_money_access!
|
|
13
14
|
|
|
14
15
|
private
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Deliberately payroll.manage rather than a "super admin" role name:
|
|
18
|
+
# roles are data an install re-cuts, and gating on a NAME would make
|
|
19
|
+
# renaming one a silent lock-out.
|
|
20
|
+
def require_money_access!
|
|
21
|
+
hr_require_permission!("payroll.manage", scope: :all)
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -10,10 +10,48 @@ module HrLite
|
|
|
10
10
|
before_action :hr_set_current_actor
|
|
11
11
|
around_action :hr_use_time_zone
|
|
12
12
|
|
|
13
|
-
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
|
|
13
|
+
helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name,
|
|
14
|
+
:hr_can?, :hr_reaches?, :hr_access
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
# --- authorization -----------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def hr_access
|
|
21
|
+
HrLite::Access.for(hr_current_user)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# May the signed-in person do this at all, at `scope` or wider?
|
|
25
|
+
def hr_can?(key, scope: :self)
|
|
26
|
+
hr_access.can?(key, scope: scope)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# May they do it to THIS person's records? The question the module could
|
|
30
|
+
# not ask before roles, and whose absence let any admin approve anybody's
|
|
31
|
+
# leave.
|
|
32
|
+
def hr_reaches?(key, subject_user)
|
|
33
|
+
hr_access.reaches?(key, subject_user)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Gate a whole controller or action. `scope` is the weakest scope that can
|
|
37
|
+
# reach the screen at all — a manager reaching a team screen still has
|
|
38
|
+
# every ROW checked separately by `hr_require_reach!`.
|
|
39
|
+
def hr_require_permission!(key, scope: :self)
|
|
40
|
+
hr_access_denied unless hr_can?(key, scope: scope)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Gate one record. Deliberately a 404, not a 403: whether a particular
|
|
44
|
+
# person exists is not something a stranger gets to learn from us.
|
|
45
|
+
def hr_require_reach!(key, subject_user)
|
|
46
|
+
raise ActiveRecord::RecordNotFound unless hr_reaches?(key, subject_user)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Narrow a relation to the rows this permission reaches, so an index
|
|
50
|
+
# never renders a row the member action would refuse.
|
|
51
|
+
def hr_scope(relation, key, column: :user_id)
|
|
52
|
+
hr_access.scope_relation(relation, key, column: column)
|
|
53
|
+
end
|
|
54
|
+
|
|
17
55
|
def hr_authenticate!
|
|
18
56
|
send(HrLite.config.authenticate_method)
|
|
19
57
|
end
|
|
@@ -24,9 +24,12 @@ module HrLite
|
|
|
24
24
|
{ label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
-
# Money
|
|
27
|
+
# Money screens, plus role management: granting somebody payroll is the
|
|
28
|
+
# same act as holding it, so it sits with the pay rather than with the
|
|
29
|
+
# other governing screens.
|
|
28
30
|
SUPERADMIN_NAV_ITEMS = [
|
|
29
|
-
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
|
|
31
|
+
{ label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
|
|
32
|
+
{ label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] }
|
|
30
33
|
].freeze
|
|
31
34
|
|
|
32
35
|
# Only items whose routes exist yet (the nav grows with each phase).
|
|
@@ -12,11 +12,36 @@ module HrLite
|
|
|
12
12
|
# encrypt their amounts so the diff is already redacted, but appraisal
|
|
13
13
|
# ratings and review text are plain columns — they must not reach the
|
|
14
14
|
# leadership audit screen or the policy.changed email.
|
|
15
|
-
MONEY_TIER_TYPES = %w[
|
|
15
|
+
MONEY_TIER_TYPES = %w[
|
|
16
|
+
HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
|
|
17
|
+
HrLite::PayrollRun HrLite::SalarySlip
|
|
18
|
+
].freeze
|
|
16
19
|
|
|
17
20
|
scope :recent, -> { order(created_at: :desc) }
|
|
18
21
|
scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
|
|
19
22
|
|
|
23
|
+
# The five-line `create!` that four call sites had each spelled out.
|
|
24
|
+
#
|
|
25
|
+
# This RAISES, unlike the Audited concern, which swallows failures so an
|
|
26
|
+
# audit hiccup cannot roll back an ordinary policy edit. On the money
|
|
27
|
+
# path that trade goes the other way: a payroll transition nobody can
|
|
28
|
+
# explain afterwards should not be allowed to happen at all, so callers
|
|
29
|
+
# run it inside the same transaction as the write it describes.
|
|
30
|
+
#
|
|
31
|
+
# `changes` is a whitelist the caller writes out by hand. Never pass
|
|
32
|
+
# amounts — this table is not encrypted.
|
|
33
|
+
def self.record!(action:, subject:, actor: HrLite::Current.actor, changes: {})
|
|
34
|
+
create!(
|
|
35
|
+
actor: actor,
|
|
36
|
+
action: action,
|
|
37
|
+
subject_type: subject.class.name,
|
|
38
|
+
# A destroyed record has no id left, and the row still has to say
|
|
39
|
+
# what happened.
|
|
40
|
+
subject_id: subject.id || 0,
|
|
41
|
+
audited_changes: changes
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
20
45
|
def money_tier?
|
|
21
46
|
MONEY_TIER_TYPES.include?(subject_type)
|
|
22
47
|
end
|
|
@@ -54,7 +54,9 @@ module HrLite
|
|
|
54
54
|
|
|
55
55
|
Notifications.publish(
|
|
56
56
|
"comp_off.approved",
|
|
57
|
-
|
|
57
|
+
# credit_days is 0.5 or 1 and nothing else, so there is no plural to
|
|
58
|
+
# reach for here.
|
|
59
|
+
title: "Comp-off approved — #{credit_days.to_f} day credited for #{date_worked.strftime('%d %b')}",
|
|
58
60
|
body: decision_note.presence,
|
|
59
61
|
path: "/comp_off_requests",
|
|
60
62
|
bell_to: [ user ], email_to: [ user ]
|
|
@@ -78,10 +78,15 @@ module HrLite
|
|
|
78
78
|
|
|
79
79
|
def cancel!(actor:)
|
|
80
80
|
was_approved = approved?
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
transaction do
|
|
82
|
+
self.status = "cancelled"
|
|
83
|
+
self.decided_by_id = actor.id
|
|
84
|
+
self.decided_at = Time.current
|
|
85
|
+
save!
|
|
86
|
+
# Cancelling an APPROVED leave hands the days back to the balance, so
|
|
87
|
+
# it is a quota change as much as a status change.
|
|
88
|
+
audit!("cancelled", actor, was_approved ? "was approved" : nil)
|
|
89
|
+
end
|
|
85
90
|
|
|
86
91
|
Notifications.publish(
|
|
87
92
|
"leave.cancelled",
|
|
@@ -117,9 +122,27 @@ module HrLite
|
|
|
117
122
|
self.decided_at = Time.current
|
|
118
123
|
self.decision_note = note
|
|
119
124
|
save!
|
|
125
|
+
audit!(new_status, actor, note)
|
|
120
126
|
end
|
|
121
127
|
end
|
|
122
128
|
|
|
129
|
+
# Approve and reject both come through `transition!`, and `with_lock` is
|
|
130
|
+
# already a transaction — so a failed audit row rolls the decision back
|
|
131
|
+
# rather than leaving one that nobody can account for. The employee's own
|
|
132
|
+
# `reason` is deliberately not copied here; the approver's note is.
|
|
133
|
+
def audit!(action, actor, note)
|
|
134
|
+
AuditLog.record!(
|
|
135
|
+
action: "leave.#{action}", subject: self, actor: actor,
|
|
136
|
+
changes: {
|
|
137
|
+
"employee" => HrLite.display_name(user),
|
|
138
|
+
"type" => leave_type.code,
|
|
139
|
+
"dates" => date_range_label,
|
|
140
|
+
"days" => days_count&.to_s("F"),
|
|
141
|
+
"note" => note.presence
|
|
142
|
+
}.compact
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
123
146
|
# This request is still pending here, so balance.used excludes it:
|
|
124
147
|
# approving is valid iff the request fits the remaining balance.
|
|
125
148
|
def insufficient_balance_now?
|
|
@@ -39,7 +39,12 @@ module HrLite
|
|
|
39
39
|
begin
|
|
40
40
|
update!(status: "processing")
|
|
41
41
|
PayrollRunProcessor.call(self)
|
|
42
|
-
|
|
42
|
+
transaction do
|
|
43
|
+
update!(status: "review", processed_at: Time.current)
|
|
44
|
+
audit!("payroll.computed", actor,
|
|
45
|
+
"slips" => salary_slips.count, "warnings" => warnings.length,
|
|
46
|
+
"from_status" => previous)
|
|
47
|
+
end
|
|
43
48
|
true
|
|
44
49
|
rescue => e
|
|
45
50
|
# Restore what the run WAS. Hardcoding "draft" here used to demote a
|
|
@@ -54,7 +59,12 @@ module HrLite
|
|
|
54
59
|
raise_unless %w[review]
|
|
55
60
|
raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
transaction do
|
|
63
|
+
update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
|
|
64
|
+
# Finalizing freezes every slip in the run. Who did it, to how many
|
|
65
|
+
# people, is the row an investigation starts from.
|
|
66
|
+
audit!("payroll.finalized", actor, "slips" => salary_slips.count)
|
|
67
|
+
end
|
|
58
68
|
Notifications.publish(
|
|
59
69
|
"payroll.finalized",
|
|
60
70
|
title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
|
|
@@ -65,13 +75,21 @@ module HrLite
|
|
|
65
75
|
|
|
66
76
|
def unlock!(actor:)
|
|
67
77
|
raise_unless %w[finalized]
|
|
68
|
-
|
|
78
|
+
transaction do
|
|
79
|
+
update!(status: "review")
|
|
80
|
+
# Reopening frozen slips for editing is the single most sensitive
|
|
81
|
+
# transition in the module — it is the one that has to leave a trace.
|
|
82
|
+
audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
|
|
83
|
+
end
|
|
69
84
|
true
|
|
70
85
|
end
|
|
71
86
|
|
|
72
87
|
def publish!(actor:)
|
|
73
88
|
raise_unless %w[finalized]
|
|
74
|
-
|
|
89
|
+
transaction do
|
|
90
|
+
update!(status: "published", published_at: Time.current, published_by_id: actor.id)
|
|
91
|
+
audit!("payroll.published", actor, "slips" => salary_slips.count)
|
|
92
|
+
end
|
|
75
93
|
|
|
76
94
|
slips = salary_slips.includes(:user).to_a
|
|
77
95
|
# Everyone is emailed — a final settlement matters most to the person who
|
|
@@ -106,6 +124,14 @@ module HrLite
|
|
|
106
124
|
|
|
107
125
|
private
|
|
108
126
|
|
|
127
|
+
# Amounts stay out of it on purpose: audit_logs is a plaintext table and
|
|
128
|
+
# every slip amount in this module is encrypted. The row records WHO
|
|
129
|
+
# moved the run and HOW FAR, never what anyone is paid.
|
|
130
|
+
def audit!(action, actor, changes)
|
|
131
|
+
AuditLog.record!(action: action, subject: self, actor: actor,
|
|
132
|
+
changes: changes.merge("period" => label))
|
|
133
|
+
end
|
|
134
|
+
|
|
109
135
|
def sum_slips(attribute)
|
|
110
136
|
salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
|
|
111
137
|
end
|
|
@@ -71,10 +71,9 @@ module HrLite
|
|
|
71
71
|
raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
|
|
72
72
|
|
|
73
73
|
record.save!
|
|
74
|
-
AuditLog.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
|
|
74
|
+
AuditLog.record!(
|
|
75
|
+
action: "regularize", subject: record, actor: actor,
|
|
76
|
+
changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
|
|
78
77
|
)
|
|
79
78
|
end
|
|
80
79
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# A named bundle of grants. Roles are DATA — the seeded six are a starting
|
|
3
|
+
# point an install is expected to edit, not a fixed ladder in code.
|
|
4
|
+
class Role < ApplicationRecord
|
|
5
|
+
include Audited
|
|
6
|
+
|
|
7
|
+
# Seeded by name. `Employee` in particular is load-bearing: it is what a
|
|
8
|
+
# new hire is given so that self-service works on day one.
|
|
9
|
+
EMPLOYEE = "Employee".freeze
|
|
10
|
+
MANAGER = "Manager".freeze
|
|
11
|
+
HR = "HR".freeze
|
|
12
|
+
FINANCE = "Finance".freeze
|
|
13
|
+
LEADERSHIP = "Leadership".freeze
|
|
14
|
+
SUPER_ADMIN = "Super Admin".freeze
|
|
15
|
+
|
|
16
|
+
has_many :role_grants, dependent: :destroy
|
|
17
|
+
has_many :role_assignments, dependent: :destroy
|
|
18
|
+
|
|
19
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
20
|
+
before_destroy :protect_system_role
|
|
21
|
+
validate :system_role_keeps_its_name, on: :update
|
|
22
|
+
|
|
23
|
+
scope :alphabetical, -> { order(:name) }
|
|
24
|
+
|
|
25
|
+
# Every key this role grants, mapped to its scope. The shape the access
|
|
26
|
+
# resolver merges.
|
|
27
|
+
def grant_map
|
|
28
|
+
role_grants.to_h { |grant| [ grant.permission_key, grant.scope ] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replaces the whole grant set in one transaction — the roles screen posts
|
|
32
|
+
# the complete picture, so a permission absent from the form is a
|
|
33
|
+
# permission being taken away, not one left alone.
|
|
34
|
+
def replace_grants!(grants)
|
|
35
|
+
transaction do
|
|
36
|
+
role_grants.destroy_all
|
|
37
|
+
grants.each do |key, scope|
|
|
38
|
+
next if scope.blank? || scope.to_s == "none"
|
|
39
|
+
|
|
40
|
+
role_grants.create!(permission_key: Permissions.validate!(key), scope: scope.to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
reload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def system? = self[:system]
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def protect_system_role
|
|
51
|
+
return unless system?
|
|
52
|
+
|
|
53
|
+
errors.add(:base, "#{name} is a built-in role and cannot be deleted")
|
|
54
|
+
throw :abort
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The name is the identifier the seed, the upgrade path and the specs all
|
|
58
|
+
# use. Permissions on a system role are free to change; what it is called
|
|
59
|
+
# is not.
|
|
60
|
+
def system_role_keeps_its_name
|
|
61
|
+
return unless system? && name_changed?
|
|
62
|
+
|
|
63
|
+
errors.add(:name, "cannot be changed on a built-in role")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# Who holds which role. Assignments are audited because granting somebody
|
|
3
|
+
# the money tier is exactly the kind of change that has to be explainable
|
|
4
|
+
# afterwards.
|
|
5
|
+
class RoleAssignment < ApplicationRecord
|
|
6
|
+
include Audited
|
|
7
|
+
|
|
8
|
+
belongs_to :role
|
|
9
|
+
belongs_to :user, class_name: HrLite.config.user_class
|
|
10
|
+
belongs_to :granted_by, class_name: HrLite.config.user_class, optional: true
|
|
11
|
+
|
|
12
|
+
validates :user_id, uniqueness: { scope: :role_id }
|
|
13
|
+
|
|
14
|
+
scope :for_user, ->(user) { where(user_id: user.id) }
|
|
15
|
+
|
|
16
|
+
def hr_lite_audit_label = "#{HrLite.display_name(user)} — #{role.name}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# One (role, permission, scope) triple. The permission key is validated
|
|
3
|
+
# against the declared registry, so a typo is a refused write rather than a
|
|
4
|
+
# grant that silently never matches anything.
|
|
5
|
+
class RoleGrant < ApplicationRecord
|
|
6
|
+
belongs_to :role
|
|
7
|
+
|
|
8
|
+
validates :permission_key, presence: true,
|
|
9
|
+
inclusion: { in: Permissions::KEYS, message: "is not a known permission" },
|
|
10
|
+
uniqueness: { scope: :role_id }
|
|
11
|
+
validates :scope, inclusion: { in: Permissions::SCOPES.map(&:to_s) }
|
|
12
|
+
|
|
13
|
+
def covers?(needed_scope) = Permissions.scope_covers?(scope, needed_scope)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -40,8 +40,7 @@ module HrLite
|
|
|
40
40
|
# Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
|
|
41
41
|
# feeds the TDS projector and the slip's YTD table.
|
|
42
42
|
def self.fy_to_date(user, period_month)
|
|
43
|
-
fy_start =
|
|
44
|
-
: Date.new(period_month.year - 1, 4, 1)
|
|
43
|
+
fy_start = FinancialYear.start_for(period_month)
|
|
45
44
|
slips = settled.where(user_id: user.id)
|
|
46
45
|
.where(period_month: fy_start...period_month)
|
|
47
46
|
{
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The one place that answers "may this person do this, and to whose rows".
|
|
3
|
+
#
|
|
4
|
+
# Two questions, deliberately separated:
|
|
5
|
+
#
|
|
6
|
+
# can?(user, "leave.approve") — may they at all, at any scope
|
|
7
|
+
# scope_for(user, "leave.approve") — :self, :team, :all or nil
|
|
8
|
+
# reaches?(user, "leave.approve", other) — may they, for THIS person
|
|
9
|
+
#
|
|
10
|
+
# A controller that only asks the first question is the bug this class
|
|
11
|
+
# exists to prevent: before roles, any admin could approve anybody's leave
|
|
12
|
+
# because nothing ever asked whose leave it was.
|
|
13
|
+
class Access
|
|
14
|
+
# Resolution touches three tables and every screen asks several times per
|
|
15
|
+
# request, so it is memoized per user for the life of the request.
|
|
16
|
+
def self.for(user)
|
|
17
|
+
# An unsaved user holds nothing: roles are rows keyed by user id, and
|
|
18
|
+
# there is no id yet to key them by.
|
|
19
|
+
return new(nil, {}) if user.nil? || user.id.nil?
|
|
20
|
+
|
|
21
|
+
Current.access_cache ||= {}
|
|
22
|
+
Current.access_cache[user.id] ||= new(user, resolve(user))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The strongest scope held for each key across all of the user's roles.
|
|
26
|
+
# Two roles granting the same key keep the WIDER of the two — roles add
|
|
27
|
+
# up, they do not narrow each other.
|
|
28
|
+
def self.resolve(user)
|
|
29
|
+
grants = RoleGrant.joins(role: :role_assignments)
|
|
30
|
+
.where(hr_lite_role_assignments: { user_id: user.id })
|
|
31
|
+
.pluck(:permission_key, :scope)
|
|
32
|
+
|
|
33
|
+
grants.each_with_object({}) do |(key, scope), map|
|
|
34
|
+
held = map[key]
|
|
35
|
+
map[key] = scope if held.nil? || Permissions.scope_covers?(scope, held)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(user, permission_map)
|
|
40
|
+
@user = user
|
|
41
|
+
@map = permission_map
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :user
|
|
45
|
+
|
|
46
|
+
def scope_for(key)
|
|
47
|
+
@map[Permissions.validate!(key)]&.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def can?(key, scope: :self)
|
|
51
|
+
held = scope_for(key)
|
|
52
|
+
held.present? && Permissions.scope_covers?(held, scope)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether this permission reaches a particular person's rows. `self`
|
|
56
|
+
# reaches only the holder; `team` reaches their direct and indirect
|
|
57
|
+
# reports; `all` reaches everyone.
|
|
58
|
+
def reaches?(key, subject_user)
|
|
59
|
+
return false if user.nil? || subject_user.nil?
|
|
60
|
+
|
|
61
|
+
case scope_for(key)
|
|
62
|
+
when :all then true
|
|
63
|
+
when :team then subject_user.id == user.id || report_ids.include?(subject_user.id)
|
|
64
|
+
when :self then subject_user.id == user.id
|
|
65
|
+
else false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# User ids this person's `team` scope covers, for scoping a whole
|
|
70
|
+
# relation rather than checking one row at a time. `all` returns nil,
|
|
71
|
+
# meaning "do not filter" — a caller that treats nil as an empty list
|
|
72
|
+
# would show leadership nothing, so the callers use `scope_relation`.
|
|
73
|
+
def visible_user_ids(key)
|
|
74
|
+
case scope_for(key)
|
|
75
|
+
when :all then nil
|
|
76
|
+
when :team then [ user.id, *report_ids ]
|
|
77
|
+
when :self then [ user.id ]
|
|
78
|
+
else []
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Narrows a relation to the rows this permission reaches. The column is
|
|
83
|
+
# named because not every table calls it user_id.
|
|
84
|
+
def scope_relation(relation, key, column: :user_id)
|
|
85
|
+
ids = visible_user_ids(key)
|
|
86
|
+
ids.nil? ? relation : relation.where(column => ids)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Everyone below this person in the reporting chain. Walked breadth-first
|
|
90
|
+
# with a seen set — EmployeeProfile validates the chain is acyclic, but a
|
|
91
|
+
# cycle written directly to the database must not spin here forever.
|
|
92
|
+
def report_ids
|
|
93
|
+
@report_ids ||= begin
|
|
94
|
+
by_manager = EmployeeProfile.where.not(manager_id: nil).pluck(:manager_id, :user_id)
|
|
95
|
+
.group_by(&:first)
|
|
96
|
+
.transform_values { |pairs| pairs.map(&:last) }
|
|
97
|
+
found = []
|
|
98
|
+
seen = [ user.id ].to_set
|
|
99
|
+
queue = by_manager[user.id]&.dup || []
|
|
100
|
+
|
|
101
|
+
while (id = queue.shift)
|
|
102
|
+
next unless seen.add?(id)
|
|
103
|
+
|
|
104
|
+
found << id
|
|
105
|
+
queue.concat(by_manager[id] || [])
|
|
106
|
+
end
|
|
107
|
+
found
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -12,7 +12,10 @@ module HrLite
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
|
-
|
|
15
|
+
# First in the list on purpose: if the statutory card is from an older
|
|
16
|
+
# financial year, every number below it is computed on last year's
|
|
17
|
+
# rates and that is the thing to read first.
|
|
18
|
+
warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
|
|
16
19
|
|
|
17
20
|
ActiveRecord::Base.transaction do
|
|
18
21
|
eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<section class="hrl-card">
|
|
2
|
+
<%= form_with model: role, url: url, method: method, local: true do |f| %>
|
|
3
|
+
<%= render "hr_lite/shared/form_errors", record: role %>
|
|
4
|
+
|
|
5
|
+
<div class="hrl-field">
|
|
6
|
+
<%= f.label :name %>
|
|
7
|
+
<% if role.system? %>
|
|
8
|
+
<%= f.text_field :name, disabled: true %>
|
|
9
|
+
<span class="hrl-small hrl-muted">
|
|
10
|
+
Built-in roles keep their name — the upgrade path and the seed both
|
|
11
|
+
identify them by it. The permissions below are yours to change.
|
|
12
|
+
</span>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= f.text_field :name %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="hrl-field">
|
|
18
|
+
<%= f.label :description, "Description (shown on the roles list)" %>
|
|
19
|
+
<%= f.text_field :description %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<% HrLite::Permissions.grouped.each do |group, keys| %>
|
|
23
|
+
<fieldset>
|
|
24
|
+
<legend><%= group %></legend>
|
|
25
|
+
<% keys.each do |key| %>
|
|
26
|
+
<% held = role.grant_map[key] || "none" %>
|
|
27
|
+
<div class="hrl-field">
|
|
28
|
+
<%= label_tag "grants_#{key.tr('.', '_')}", HrLite::Permissions.description(key) %>
|
|
29
|
+
<%= select_tag "grants[#{key}]",
|
|
30
|
+
options_for_select([
|
|
31
|
+
[ "Not granted", "none" ],
|
|
32
|
+
[ "Their own records", "self" ],
|
|
33
|
+
[ "Their team's records", "team" ],
|
|
34
|
+
[ "Everyone's records", "all" ]
|
|
35
|
+
], held),
|
|
36
|
+
id: "grants_#{key.tr('.', '_')}" %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
</fieldset>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<div class="hrl-form-actions">
|
|
43
|
+
<%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
|
|
44
|
+
<a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|