Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Role program access #750

Merged
merged 5 commits into from
Dec 3, 2024
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 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
Loading