Skip to content

Commit

Permalink
[FEATURE][MER-1912] Add ability to enroll users
Browse files Browse the repository at this point in the history
[FEATURE][MER-1912] Add ability to enroll users
  • Loading branch information
darrensiegel authored Aug 9, 2023
2 parents 92720a5 + 2ba13ed commit 0abdcbe
Show file tree
Hide file tree
Showing 21 changed files with 912 additions and 61 deletions.
8 changes: 7 additions & 1 deletion assets/css/button.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ button.torus-button.outline {
}

button.torus-button.primary:disabled,
button.torus-button.secondary:disabled {
button.torus-button.secondary:disabled
button.torus-button.error:disabled {
@apply bg-gray-200 text-white pointer-events-none cursor-not-allowed;
}

a.torus-button.error,
button.torus-button.error {
@apply btn text-white bg-red-500 hover:bg-red-600 active:bg-red-700;
}

a {
@apply cursor-pointer;
}
56 changes: 56 additions & 0 deletions assets/src/hooks/email_list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const isValidEmail = (email: string): boolean =>
email.match(/^[\w]+@([\w-]+\.)+[\w-]{2,4}$/) !== null;

const parseEmails = (content: string): string[] =>
content.match(/[\w]+@([\w-]+\.)+[\w-]{2,4}/g) || [];

export const EmailList = {
refresh() {
const element = this.el as HTMLDivElement;
const phxEvent = element.getAttribute('phx-event');
const input = element.querySelector('input') as HTMLInputElement;
input.focus();
element.addEventListener('click', (event: any) => {
if (event.target.matches(`#${element.id}`) && !input.getAttribute('focus')) {
input.focus();
}
});
input.addEventListener('input', () => {
input.style.width = 'auto';
input.style.width = `${input.scrollWidth}px`;
});
input.addEventListener('keypress', (event: KeyboardEvent) => {
if (event.code === 'Enter' || event.code === 'Comma') {
input.blur();
}
});
input.addEventListener('blur', () => {
const value = input.value.trim();
isValidEmail(value)
? this.pushEvent(phxEvent, {
value,
})
: (input.value = '');
});
input.addEventListener('paste', (event: ClipboardEvent) => {
event.preventDefault();

const clipboardData = event.clipboardData || (window as any).clipboardData;
const pastedText = clipboardData?.getData('text/plain');

const emails = parseEmails(pastedText);

if (emails.length) {
this.pushEvent(phxEvent, {
value: emails,
});
}
});
},
mounted() {
this.refresh();
},
updated() {
this.refresh();
},
};
2 changes: 2 additions & 0 deletions assets/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CheckboxListener } from './checkbox_listener';
import { CopyListener } from './copy_listener';
import { DateTimeLocalInputListener } from './datetimelocal_input_listener';
import { DragSource, DropTarget } from './dragdrop';
import { EmailList } from './email_list';
import { GraphNavigation } from './graph';
import { HierarchySelector } from './hierarchy_selector';
import { InputAutoSelect } from './input_auto_select';
Expand Down Expand Up @@ -47,4 +48,5 @@ export const Hooks = {
SubmitForm,
LoadSurveyScripts,
LiveModal,
EmailList,
};
42 changes: 42 additions & 0 deletions lib/oli/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,48 @@ defmodule Oli.Accounts do
Repo.all(query)
end

@spec get_users_by_email(list(String.t())) :: list(User.t())
def get_users_by_email(email_list) do
User
|> where([u], u.independent_learner == true and u.email in ^email_list)
|> select([u], %{
id: u.id,
email: u.email
})
|> Repo.all()
end

@doc """
Creates multiple invited users
## Examples
iex> bulk_invite_users(["[email protected]", "[email protected]"], %Author{id: 1})
[%User{id: 3}, %User{id: 4}]
"""
def bulk_invite_users(user_emails, %Author{} = inviter_user) do
date = DateTime.utc_now() |> DateTime.truncate(:second)

users =
Enum.map(
user_emails,
fn email ->
User
|> struct()
|> User.invite_changeset(inviter_user, %{
email: email
})
|> Map.get(:changes)
|> Map.delete(:invited_by_id)
|> Map.merge(%{
state: %{},
inserted_at: date,
updated_at: date
})
end
)

Repo.insert_all(User, users, returning: [:id, :invitation_token, :email])
end

def browse_authors(
%Paging{limit: limit, offset: offset},
%Sorting{field: field, direction: direction},
Expand Down
6 changes: 6 additions & 0 deletions lib/oli/accounts/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ defmodule Oli.Accounts.User do
|> maybe_name_from_given_and_family()
end

def invite_changeset(user_or_changeset, invited_by, attrs) do
user_or_changeset
|> Ecto.Changeset.cast(attrs, [:name, :given_name, :family_name])
|> pow_invite_changeset(invited_by, attrs)
end

@doc """
Creates a changeset that if configured, runs the age check validation.
Used on user creation through frontend form.
Expand Down
42 changes: 40 additions & 2 deletions lib/oli/delivery/sections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ defmodule Oli.Delivery.Sections do

filter_by_role =
case options do
%EnrollmentBrowseOptions{is_instructor: true, is_student: true} ->
true

%EnrollmentBrowseOptions{is_student: true} ->
dynamic(
[u, e],
Expand Down Expand Up @@ -193,7 +196,7 @@ defmodule Oli.Delivery.Sections do
sorting,
options
)
|> where([_, e], e.status != :suspended)
|> where([u, e], e.status != :suspended)
|> join(:left, [_, e, p], ecr in EnrollmentContextRole, on: ecr.enrollment_id == e.id)
|> group_by([_, _, _, ecr], [ecr.context_role_id])
|> preload([u], :platform_roles)
Expand Down Expand Up @@ -315,14 +318,49 @@ defmodule Oli.Delivery.Sections do
end

@doc """
Enrolls a user in a course section
Enrolls a user or users in a course section
## Examples
iex> enroll(user_id, section_id, [%ContextRole{}])
{:ok, %Enrollment{}} # Inserted or updated with success
iex> enroll(user_id, section_id, :open_and_free)
{:error, changeset} # Something went wrong
"""
@spec enroll(list(number()), number(), [%ContextRole{}]) :: {:ok, list(%Enrollment{})}
def enroll(user_ids, section_id, context_roles) when is_list(user_ids) do
Repo.transaction(fn ->
context_roles = EctoProvider.Marshaler.to(context_roles)
date = DateTime.utc_now() |> DateTime.truncate(:second)

# Insert all the enrollments at the same time
enrollments =
Enum.map(
user_ids,
&%{
user_id: &1,
section_id: section_id,
inserted_at: date,
updated_at: date,
status: :enrolled,
state: %{}
}
)

{_cont, enrollments} = Repo.insert_all(Enrollment, enrollments, returning: [:id], conflict_target: [:user_id, :section_id], on_conflict: {:replace, [:user_id]})

# Insert the enrollment context roles at the same time based on the previously created enrollments
enrollment_context_roles =
Enum.reduce(context_roles, [], fn role, enrollment_context_roles ->
Enum.map(enrollments, &%{enrollment_id: &1.id, context_role_id: role.id}) ++
enrollment_context_roles
end)

Repo.insert_all(EnrollmentContextRole, enrollment_context_roles, on_conflict: :nothing)

{:ok, enrollments}
end)
end

@spec enroll(number(), number(), [%ContextRole{}]) :: {:ok, %Enrollment{}}
def enroll(user_id, section_id, context_roles) do
context_roles = EctoProvider.Marshaler.to(context_roles)
Expand Down
10 changes: 10 additions & 0 deletions lib/oli/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ defmodule Oli.Email do
use Bamboo.Phoenix, view: OliWeb.EmailView

@spec invitation_email(String.t(), atom(), map()) :: Bamboo.Email.t()
def invitation_email(recipient_email, :enrollment_invitation, assigns) do
base_email()
|> to(recipient_email)
|> subject(
"You were invited as #{if assigns.role == "instructor", do: "an instructor", else: "a student"} to \"#{assigns.section_title}\""
)
|> render(:enrollment_invitation, assigns)
|> html_text_body()
end

def invitation_email(recipient_email, view, assigns) do
base_email()
|> to(recipient_email)
Expand Down
33 changes: 33 additions & 0 deletions lib/oli_web/components/email_list.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule OliWeb.Components.EmailList do
use Phoenix.Component

attr :id, :string, required: true
attr :users_list, :list, required: true
attr :on_update, :string, required: true
attr :on_remove, :string, required: true

attr :is_list_empty, :boolean, default: true
attr :current_user, :string, default: ""

def render(assigns) do
assigns = assign(assigns, :is_list_empty, List.first(assigns.users_list) == nil)

~H"""
<div id={@id} class="flex flex-wrap rounded-md border border-gray-300 p-4 gap-2 cursor-text" phx-hook="EmailList" phx-event={@on_update}>
<%= for user <- @users_list do %>
<div class="rounded-md bg-gray-100 cursor-default p-2 shadow-md flex items-center gap-2 user-email max-h-80 scroll-y-overflow">
<p><%= user %></p>
<button
phx-click={@on_remove}
phx-value-user={user}
class="close"
>
<i class="fa-solid fa-xmark mt-0" />
</button>
</div>
<% end %>
<input placeholder={if @is_list_empty, do: "[email protected]", else: nil} class="p-2 outline-none" value={@current_user} />
</div>
"""
end
end
4 changes: 3 additions & 1 deletion lib/oli_web/components/live_modal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule OliWeb.Components.LiveModal do
class: assigns[:class] || "",
on_confirm: assigns[:on_confirm],
on_confirm_label: assigns[:on_confirm_label] || "Confirm",
on_confirm_disabled: assigns[:on_confirm_disabled] || false,
on_cancel: assigns[:on_cancel],
on_cancel_label: assigns[:on_cancel_label] || "Cancel",
show_actions: !is_nil(assigns[:on_confirm]) || !is_nil(assigns[:on_cancel])
Expand All @@ -24,6 +25,7 @@ defmodule OliWeb.Components.LiveModal do
attr :title, :string
attr :class, :string, default: ""
attr :on_confirm, :string
attr :on_confirm_disabled, :boolean, default: false
attr :on_confirm_label, :string, default: "Confirm"
attr :on_cancel, :string
attr :on_cancel_label, :string, default: "Cancel"
Expand Down Expand Up @@ -52,7 +54,7 @@ defmodule OliWeb.Components.LiveModal do
</button>
<% end %>
<%= if @on_confirm do %>
<button phx-click={@on_confirm} class="torus-button primary">
<button disabled={@on_confirm_disabled} phx-click={@on_confirm} class="torus-button primary">
<%= @on_confirm_label %>
</button>
<% end %>
Expand Down
62 changes: 62 additions & 0 deletions lib/oli_web/controllers/invite_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,68 @@ defmodule OliWeb.InviteController do
end
end

def create_bulk(conn, %{"emails" => users, "role" => role, "section_slug" => section_slug}) do
existing_users = Oli.Accounts.get_users_by_email(users)
non_found_users = users -- Enum.map(existing_users, & &1.email)
inviter_user = conn.assigns.current_author
section = conn.assigns.section

# Enroll users
Oli.Repo.transaction(fn ->
{_count, new_users} = Oli.Accounts.bulk_invite_users(non_found_users, inviter_user)

users_ids = Enum.map(new_users, & &1.id) ++ Enum.map(existing_users, & &1.id)

Oli.Delivery.Sections.enroll(users_ids, section.id, [
Lti_1p3.Tool.ContextRoles.get_role(
if role == "instructor", do: :context_instructor, else: :context_learner
)
])

# Send emails to users
users =
Enum.map(existing_users, &Map.put(&1, :status, :existing_user)) ++
Enum.map(new_users, &Map.put(&1, :status, :new_user))

emails =
Enum.map(
users,
fn user ->
{button_label, url} =
case user.status do
:new_user ->
token = PowInvitation.Plug.sign_invitation_token(conn, user)
{"Join now", Routes.delivery_pow_invitation_invitation_path(conn, :edit, token)}

:existing_user ->
{"Go to the course",
Routes.page_delivery_path(OliWeb.Endpoint, :index, section.slug)}
end

Oli.Email.invitation_email(
user.email,
:enrollment_invitation,
%{
inviter: inviter_user.name,
url: Routes.url(conn) <> url,
role: role,
section_title: section.title,
button_label: button_label
}
)
end
)

Enum.each(emails, fn email -> Oli.Mailer.deliver_now(email) end)
end)

conn
|> put_flash(:info, "Users were enrolled successfully")
|> redirect(
to: Routes.live_path(OliWeb.Endpoint, OliWeb.Sections.EnrollmentsViewLive, section_slug)
)
end

defp render_invite_page(conn, page, keywords) do
render(conn, page, Keyword.put_new(keywords, :active, :invite))
end
Expand Down
Loading

0 comments on commit 0abdcbe

Please sign in to comment.