Skip to content

Commit

Permalink
Program enrollment for roles (#750)
Browse files Browse the repository at this point in the history
Build access to programs by member roles

Allow roles to have program enrollments.
Members with the same role should have access
to the enrollment program resources.
This is similar to the enrollments via organization
memberships.

* Add migration for roles in program enrollments
* Associate the member, enrollment and role models
* Refactor dataset methods to also access role enrollments
* Expose :roles in program enrollments entity

* Implement admin pages for roles update

Refactor how 'enrollee' are exposed and listed.
Ensure that roles are reflected in programs and
enrollments CRUD pages.

---------

Co-authored-by: Rob Galanakis <[email protected]>
  • Loading branch information
DeeTheDev and rgalanakis authored Dec 3, 2024
1 parent 7f0d640 commit 43f8205
Show file tree
Hide file tree
Showing 20 changed files with 281 additions and 69 deletions.
1 change: 1 addition & 0 deletions adminapp/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export default {
searchVendors: (data) => post(`/adminapi/v1/search/vendors`, data),
searchMembers: (data) => post(`/adminapi/v1/search/members`, data),
searchOrganizations: (data) => post(`/adminapi/v1/search/organizations`, data),
searchRoles: (data) => post(`/adminapi/v1/search/roles`, data),
searchVendorServices: (data) => post(`/adminapi/v1/search/vendor_services`, data),
searchCommerceOffering: (data) => post(`/adminapi/v1/search/commerce_offerings`, data),
searchPrograms: (data) => post(`/adminapi/v1/search/programs`, data),
Expand Down
8 changes: 4 additions & 4 deletions adminapp/src/pages/ProgramDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ export default function ProgramDetailPage() {
/>
<RelatedList
title="Program Enrollments"
headers={["Id", "Member", "Organization", "Approved At", "Unenrolled At"]}
headers={["Id", "Enrollee", "Enrollee Type", "Approved At", "Unenrolled At"]}
rows={model.enrollments}
addNewLabel="Enroll member or organization"
addNewLabel="Enroll member, organization or role"
addNewLink={createRelativeUrl(`/program-enrollment/new`, {
programId: model.id,
programLabel: `(${model.id}) ${model.name.en}`,
Expand All @@ -124,8 +124,8 @@ export default function ProgramDetailPage() {
keyRowAttr="id"
toCells={(row) => [
<AdminLink key="id" model={row} />,
<AdminLink model={row.member}>{row.member?.name}</AdminLink>,
<AdminLink model={row.organization}>{row.organization?.name}</AdminLink>,
<AdminLink model={row.enrollee}>{row.enrollee?.name}</AdminLink>,
row.enrolleeType,
dayjsOrNull(row.approvedAt)?.format("lll"),
dayjsOrNull(row.unenrolledAt)?.format("lll"),
]}
Expand Down
1 change: 1 addition & 0 deletions adminapp/src/pages/ProgramEnrollmentCreatePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default function ProgramEnrollmentCreatePage() {
program: {},
member: {},
organization: {},
role: {},
};
return (
<ResourceCreate
Expand Down
17 changes: 4 additions & 13 deletions adminapp/src/pages/ProgramEnrollmentDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,10 @@ export default function ProgramEnrollmentDetailPage() {
label: "Program Name ES",
value: <AdminLink model={model.program}>{model.program.name.es}</AdminLink>,
},
model.member
? {
label: "Enrolled Member",
value: <AdminLink model={model.member}>{model.member?.name}</AdminLink>,
}
: {
label: "Enrolled Organization",
value: (
<AdminLink model={model.organization}>
{model.organization?.name}
</AdminLink>
),
},
{
label: "Enrolled " + model.enrolleeType,
value: <AdminLink model={model.enrollee}>{model.enrollee?.name}</AdminLink>,
},
{
label: "Approved",
children: (
Expand Down
30 changes: 24 additions & 6 deletions adminapp/src/pages/ProgramEnrollmentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export default function ProgramEnrollmentForm({
return (
<FormLayout
title={isCreate ? "Create a Program Enrollment" : "Update a Program Enrollment"}
subtitle="Program enrollment that are approved gives access to a member
or members in an organization to resources connected with an active program.
After creation, you can approve the enrollment and/or unenroll."
subtitle="Program enrollment that are approved gives access to a member,
members in an organization, or members with a role to resources connected
with an active program. After creation, you can approve the enrollment and/or unenroll."
onSubmit={onSubmit}
isBusy={isBusy}
>
Expand All @@ -82,23 +82,25 @@ export default function ProgramEnrollmentForm({
control={<Radio />}
label="Organization"
/>
<FormControlLabel value="role" control={<Radio />} label="Role" />
</RadioGroup>
</FormControl>
{enrolleeType === "member" ? (
{enrolleeType === "member" && (
<AutocompleteSearch
key="member"
{...register("member")}
label="Member"
helperText="Who can access this program?"
value={resource.member?.label || ""}
value={resource.member.label || ""}
fullWidth
search={api.searchMembers}
disabled={fixedEnrollee}
style={{ flex: 1 }}
onValueSelect={(mem) => setField("member", mem)}
onTextChange={() => setField("member", {})}
/>
) : (
)}
{enrolleeType === "organization" && (
<AutocompleteSearch
key="org"
{...register("organization")}
Expand All @@ -113,6 +115,22 @@ export default function ProgramEnrollmentForm({
onTextChange={() => setField("organization", {})}
/>
)}
{enrolleeType === "role" && (
<AutocompleteSearch
key="role"
{...register("role")}
label="Role"
helperText="What members with this role can access this program?"
value={resource.role.label || ""}
fullWidth
search={api.searchRoles}
searchEmpty={true}
disabled={fixedEnrollee}
style={{ flex: 1 }}
onValueSelect={(role) => setField("role", role)}
onTextChange={() => setField("role", {})}
/>
)}
</Stack>
</FormLayout>
);
Expand Down
14 changes: 6 additions & 8 deletions adminapp/src/pages/ProgramEnrollmentListPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,17 @@ export default function ProgramEnrollmentListPage() {
render: (c) => <AdminLink model={c.program}>{c.program.name.en}</AdminLink>,
},
{
id: "member",
label: "Member",
id: "enrollee",
label: "Enrollee",
align: "left",
render: (c) => <AdminLink model={c.member}>{c.member?.name}</AdminLink>,
render: (c) => <AdminLink model={c.enrollee}>{c.enrollee?.name}</AdminLink>,
hideEmpty: true,
},
{
id: "organization",
label: "Organization",
id: "enrollee_type",
label: "Enrollee Type",
align: "left",
render: (c) => (
<AdminLink model={c.organization}>{c.organization?.name}</AdminLink>
),
render: (c) => c.enrolleeType,
hideEmpty: true,
},
{
Expand Down
47 changes: 47 additions & 0 deletions db/migrations/056_role_based_program_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

require "sequel/unambiguous_constraint"

Sequel.migration do
up do
alter_table(:program_enrollments) do
add_foreign_key :role_id, :roles, on_delete: :cascade, index: true

drop_constraint(:one_enrollee_set)
add_constraint(
:one_enrollee_set,
Sequel.unambiguous_constraint([:member_id, :organization_id, :role_id]),
)

drop_index(:program_id, name: :unique_enrollee_in_program_idx)
add_index [
Sequel.function(:coalesce, :member_id, 0),
Sequel.function(:coalesce, :organization_id, 0),
Sequel.function(:coalesce, :role_id, 0),
:program_id,
],
name: :unique_enrollee_in_program_idx,
unique: true
end
end

down do
alter_table(:program_enrollments) do
drop_index(:program_id, name: :unique_enrollee_in_program_idx)
add_index [
Sequel.function(:coalesce, :member_id, 0),
Sequel.function(:coalesce, :organization_id, 0),
:program_id,
],
name: :unique_enrollee_in_program_idx,
unique: true

drop_constraint(:one_enrollee_set)
add_constraint(
:one_enrollee_set,
Sequel.unambiguous_constraint([:member_id, :organization_id]),
)
drop_column(:role_id)
end
end
end
11 changes: 9 additions & 2 deletions lib/suma/admin_api/entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,19 @@ class ProgramEntity < BaseEntity
expose :app_link_text, with: TranslatedTextEntity
end

class ProgramEnrolleeEntity < BaseEntity
include AutoExposeBase
expose :name do |inst|
inst.is_a?(Suma::Role) ? inst.name.titleize : inst.name
end
end

class ProgramEnrollmentEntity < BaseEntity
include AutoExposeBase
expose :admin_link
expose :program, with: ProgramEntity
expose :member, with: MemberEntity
expose :organization, with: OrganizationEntity
expose :enrollee, with: ProgramEnrolleeEntity
expose :enrollee_type
expose :approved_at
expose :unenrolled_at
expose :program_active do |pe|
Expand Down
3 changes: 2 additions & 1 deletion lib/suma/admin_api/program_enrollments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ class DetailedProgramEnrollmentEntity < ProgramEnrollmentEntity
requires(:program, type: JSON) { use :model_with_id }
optional(:member, type: JSON) { use :model_with_id }
optional(:organization, type: JSON) { use :model_with_id }
exactly_one_of :member, :organization
optional(:role, type: JSON) { use :model_with_id }
exactly_one_of :member, :organization, :role
end
end

Expand Down
22 changes: 22 additions & 0 deletions lib/suma/admin_api/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ def ds_search_or_order_by(column_sym, ds, params)
present_collection ds, with: SearchOrganizationEntity
end

params do
optional :q, type: String
end
post :roles do
check_role_access!(admin_member, :read, :admin_members)
# role names are sluggified by default.
params[:q] = Suma.to_slug(params[:q]) if params[:q].present?
ds = Suma::Role.dataset
ds = ds_search_or_order_by(:name, ds, params)
ds = ds.limit(15)
status 200
present_collection ds, with: SearchRoleEntity
end

params do
optional :q, type: String
end
Expand Down Expand Up @@ -335,6 +349,14 @@ class SearchOrganizationEntity < BaseEntity
expose :name, as: :label
end

class SearchRoleEntity < BaseEntity
expose :key, &self.delegate_to(:id, :to_s)
expose :id
expose :admin_link
expose :name
expose :label
end

class SearchVendorServiceEntity < BaseEntity
expose :key, &self.delegate_to(:id, :to_s)
expose :id
Expand Down
2 changes: 1 addition & 1 deletion lib/suma/fixtures/program_enrollments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Suma::Fixtures::ProgramEnrollments
end

before_saving do |instance|
instance.member ||= Suma::Fixtures.member.create if instance.organization_id.nil?
instance.member ||= Suma::Fixtures.member.create if instance.organization_id.nil? && instance.role_id.nil?
instance.program ||= Suma::Fixtures.program.create
instance
end
Expand Down
17 changes: 14 additions & 3 deletions lib/suma/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ class ReadOnlyMode < RuntimeError; end
right_primary_key: :organization_id,
read_only: true

many_through_many :program_enrollments_via_roles,
[
[:roles_members, :member_id, :role_id],
],
class: "Suma::Program::Enrollment",
left_primary_key: :id,
right_primary_key: :role_id,
read_only: true

one_to_many :combined_program_enrollments,
class: "Suma::Program::Enrollment",
read_only: true,
Expand All @@ -114,8 +123,10 @@ class ReadOnlyMode < RuntimeError; end
self.direct_program_enrollments_dataset.union(
self.program_enrollments_via_organizations_dataset,
alias: :program_enrollments,
).order(:program_id, :member_id).
distinct(:program_id)
).union(
self.program_enrollments_via_roles_dataset,
alias: :program_enrollments,
).order(:program_id, :member_id, :organization_id).distinct(:program_id)
},
eager_loader: (proc do |eo|
eo[:rows].each { |p| p.associations[:combined_program_enrollments] = [] }
Expand All @@ -124,7 +135,7 @@ class ReadOnlyMode < RuntimeError; end
# Get unique enrollments for a program. Prefer direct/member enrollments,
# so sort the rows by member_id so NULL member_id rows (indirect/org enrollments)
# sort last and are eliminated by the DISTINCT.
order(:program_id, :member_id).
order(:program_id, :member_id, :organization_id).
distinct(:program_id)
ds.all do |en|
m = eo[:id_map][en.member_id || en.values.fetch(:annotated_member_id)].first
Expand Down
24 changes: 14 additions & 10 deletions lib/suma/program.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,20 @@ def active(as_of:)
def enrollment_for(o, as_of:, include: :active)
# Use datasets for these checks, since otherwise we'd need to load a bunch of organization memberships,
# which could be very memory-intensive.
ds = if o.is_a?(Suma::Member)
self.enrollments_dataset.
where(
Sequel[member: o] |
Sequel[organization_id: o.organization_memberships_dataset.verified.select(:verified_organization_id)],
)
elsif o.is_a?(Suma::Organization)
self.enrollments_dataset.where(organization: o)
else
raise TypeError, "unhandled type: #{o.class}"
ds = case o
when Suma::Member
self.enrollments_dataset.
where(
Sequel[member: o] |
Sequel[organization_id: o.organization_memberships_dataset.verified.select(:verified_organization_id)] |
Sequel[role_id: o.roles_dataset.select(:id)],
)
when Suma::Organization
self.enrollments_dataset.where(organization: o)
when Suma::Role
self.enrollments_dataset.where(role: o)
else
raise TypeError, "unhandled type: #{o.class}"
end
ds = ds.active(as_of:) unless include == :all
return ds.first
Expand Down
18 changes: 14 additions & 4 deletions lib/suma/program/enrollment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Suma::Program::Enrollment < Suma::Postgres::Model(:program_enrollments)

many_to_one :program, class: "Suma::Program"
many_to_one :member, class: "Suma::Member"
many_to_one :role, class: "Suma::Role"
many_to_one :organization, class: "Suma::Organization"
many_to_one :approved_by, class: "Suma::Member"
many_to_one :unenrolled_by, class: "Suma::Member"
Expand All @@ -28,18 +29,25 @@ def for_members(member)
verified.
where(member:).
select(:verified_organization_id)
ds = self.where(Sequel[member:] | Sequel[organization_id: verified_org_ids])
ds = self.where(
Sequel[member:] |
Sequel[organization_id: verified_org_ids] |
Sequel[role: Suma::Role.dataset.where(members: member)],
)
ds = ds.
left_join(
:organization_memberships,
{verified_organization_id: Sequel[:program_enrollments][:organization_id]},
qualify: :deep,
).left_join(
:roles_members,
{role_id: Sequel[:program_enrollments][:role_id]},
).select(
Sequel[:program_enrollments][Sequel.lit("*")],
Sequel.function(
:coalesce,
Sequel[:program_enrollments][:member_id],
Sequel[:organization_memberships][:member_id],
Sequel[:roles_members][:member_id],
).as(:annotated_member_id),
)
return ds
Expand All @@ -66,8 +74,10 @@ def unenrolled=(v)
self.unenrolled_at = v ? Time.now : nil
end

# @return [Suma::Member,Suma::Organization]
def enrollee = self.member || self.organization
# @return [Suma::Member,Suma::Organization,Suma::Role]
def enrollee = self.member || self.organization || self.role

def enrollee_type = self.enrollee.class.name.demodulize

def rel_admin_link = "/program-enrollment/#{self.id}"
end
Loading

0 comments on commit 43f8205

Please sign in to comment.