Skip to content

Commit

Permalink
Add admin features for user management and Discord role assignment (p…
Browse files Browse the repository at this point in the history
…upilfirst#1627)

- Implements user viewing and editing capabilities for admins
- Adds functionality to assign Discord roles to users
- Introduces a feature to manage Discord roles

---------

Co-authored-by: Raj Suhail <[email protected]>
Co-authored-by: Hari Gopal <[email protected]>
  • Loading branch information
3 people authored Aug 28, 2024
1 parent 403333d commit 050625d
Show file tree
Hide file tree
Showing 52 changed files with 2,128 additions and 108 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

env:
PF_IMAGE_NAME: pupilfirst
PF_VERSION: "2024.1"
PF_VERSION: "2024.2"
YARN_CHECKSUM_BEHAVIOR: ignore
jobs:
tests:
Expand Down
File renamed without changes
102 changes: 102 additions & 0 deletions app/controllers/schools/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
module Schools
class UsersController < ApplicationController
layout "school"

before_action :set_user, except: :index
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index

# GET school/users
def index
authorize([:schools, current_user])
@users = policy_scope([:schools, User])

@presenter = Schools::Users::IndexPresenter.new(view_context, @users)
end

# GET school/users/:id
def show
authorize([:schools, @user])

@presenter = Schools::Users::ShowPresenter.new(view_context, @user)
end

# GET school/users/:id/edit
def edit
authorize([:schools, @user])

cohort_roles =
@user.cohorts.map { |c| { name: c.name, role_ids: c.discord_role_ids } }
fixed_role_ids = cohort_roles.flat_map { |c| c[:role_ids] }

@fixed_roles =
current_school
.discord_roles
.where(discord_id: fixed_role_ids)
.map do |role|
cohort_name =
cohort_roles.find do |cr|
cr[:role_ids].include?(role.discord_id)
end[
:name
]
OpenStruct.new(
cohort_name: cohort_name,
role_name: role.name,
role_color: role.color_hex
)
end

@discord_roles =
current_school.discord_roles.where.not(discord_id: fixed_role_ids)

@user_roles = @user.discord_roles.where.not(discord_id: fixed_role_ids)
end

# PATCH /school/users/:id
def update
authorize([:schools, @user])

unless Schools::Configuration::Discord.new(current_school).configured?
flash[:error] = t(".add_discord_config")

redirect_to edit_school_user_path(@user)
return
end

if @user.discord_user_id.blank?
flash[:error] = t(".user_has_not_connected_discord")
redirect_to school_user_path(@user)
return
end

role_params = params.require(:user).permit(discord_role_ids: [])

sync_service =
Discord::SyncProfileService.new(
@user,
additional_discord_role_ids: role_params[:discord_role_ids]
)

sync_service.execute

if sync_service.warning_msg.blank?
flash[:success] = t(".successfully_synced_roles")
elsif sync_service.warning_msg.present?
flash[:warning] = sync_service.warning_msg
end

redirect_to school_user_path(@user)
rescue Discord::SyncProfileService::SyncError => e
flash[:error] = e.message

redirect_to school_user_path(@user)
end

private

def set_user
@user = policy_scope([:schools, User]).find(params["id"])
end
end
end
105 changes: 105 additions & 0 deletions app/controllers/schools_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
class SchoolsController < ApplicationController
before_action :authenticate_user!
before_action :set_discord_roles,
only: %i[
discord_configuration
discord_server_roles
discord_sync_roles
]
layout "school"

# Enforce authorization with Pundit in all school administration routes.
Expand Down Expand Up @@ -104,9 +110,108 @@ def update_code_of_conduct
redirect_to standing_school_path
end

# GET /school/discord
def discord_configuration
authorize current_school
end

# GET /school/discord_server_roles
def discord_server_roles
authorize current_school
end

# PATCH /school/discord_credentials
def discord_credentials
authorize current_school

form = Schools::DiscordConfigurationForm.new(Reform::OpenForm.new)

form.current_school = current_school

if form.validate(params)
form.save

flash[:success] = t(".discord_config_stored")
else
flash[:error] = form.errors.full_messages.join(", ")
end

redirect_to discord_configuration_school_path
end

# POST /school/discord_sync_roles
def discord_sync_roles
authorize current_school

@sync_service = Discord::SyncRolesService.new(school: current_school)

if @sync_service.deleted_roles? && params[:confirmed].blank?
flash.now[:warn] = t(".sync_service_result.warn")
return
else
@sync_service.save

flash[:success] = t(".sync_service_result.success")
end

redirect_to discord_server_roles_school_path
rescue Discord::SyncRolesService::SyncError
flash[:error] = t(".sync_service_result.warn")

redirect_to discord_server_roles_school_path
end

# POST /school/update_default_discord_roles
def update_default_discord_roles
authorize current_school

roles = current_school.discord_roles

roles.where(id: params[:default_role_ids]).update_all(default: true) # rubocop:disable Rails/SkipsModelValidations
roles
.where.not(id: params[:default_role_ids])
.where(default: true)
.update_all(default: false) # rubocop:disable Rails/SkipsModelValidations

flash[:success] = t(".updated_default_roles")

redirect_to discord_server_roles_school_path
end

# GET /school/
def school_router
authorize current_school
render html: "", layout: "school_router"
end

private

def set_discord_roles
@discord_config = Schools::Configuration::Discord.new(current_school)
@discord_roles = transform_discord_roles
@school_logo_url =
if current_school.icon_on_light_bg.attached?
view_context.rails_public_blob_url(current_school.icon_variant(:thumb))
else
"/favicon.png"
end
end

def transform_discord_roles
db_roles =
current_school.discord_roles.includes(:users).order(position: :desc)
default_role_ids = current_school.default_discord_role_ids || []

db_roles.map do |role|
OpenStruct.new(
{
id: role.id,
name: role.name,
color_hex: role.color_hex,
is_default: role.discord_id.in?(default_role_ids),
member_count: role.users.size
}
)
end
end
end
62 changes: 62 additions & 0 deletions app/forms/schools/discord_configuration_form.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
module Schools
class DiscordConfigurationForm < Reform::Form
attr_accessor :current_school

property :server_id
property :bot_user_id
property :bot_token

validate :validate_server_id
validate :validate_bot_user_id
validate :validate_bot_token

def save
config = current_school.configuration.fetch("discord", {})

config["server_id"] = server_id
config["bot_user_id"] = bot_user_id
config["bot_token"] = bot_token.presence || config.fetch("bot_token")

current_school.update!(
configuration: current_school.configuration.merge("discord" => config)
)
end

private

def validate_server_id
return if server_id.blank?

unless server_id.match?(/^\d+\z/)
errors.add(
:server_id,
I18n.t("schools.discord_configuration_form.invalid_server_id")
)
end
end

def validate_bot_user_id
return if bot_user_id.blank?

unless bot_user_id.match?(/^\d+\z/)
errors.add(
:bot_user_id,
I18n.t("schools.discord_configuration_form.invalid_bot_user_id")
)
end
end

def validate_bot_token
return if bot_token.blank?

unless bot_token.match?(
/^[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+$/
)
errors.add(
:bot_token,
I18n.t("schools.discord_configuration_form.invalid_bot_token")
)
end
end
end
end
6 changes: 6 additions & 0 deletions app/frontend/layouts/school_router/SchoolRouter.res
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ let make = (~school, ~courses, ~currentUser) => {
}, [url])

let (selectedPage: Page.t, component) = switch url.path {
| list{"school", "users"} => (Users, None)
| list{"school", "users", _userId} => (Users, None)
| list{"school", "users", _userId, "edit"} => (Users, None)
| list{"school", "coaches"} => (SchoolCoaches, None)
| list{"school", "customize"} => (Settings(Customization), None)
| list{"school", "communities"} => (Communities, None)
| list{"school", "admins"} => (Settings(Admins), None)
| list{"school", "standing"} => (Settings(Standing), None)
| list{"school", "code_of_conduct"} => (Settings(Standing), None)
| list{"school", "standings", ..._tail} => (Settings(Standing), None)
| list{"school", "discord_configuration"} => (Settings(Discord), None)
| list{"school", "discord_server_roles"} => (Settings(Discord), None)
| list{"school", "discord_sync_roles"} => (Settings(Discord), None)
| list{"school"}
| list{"school", "courses"}
| list{"school", "courses", "new"} => (Courses, Some(<CourseEditor__Root school />))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ let make = (~school, ~courses, ~selectedPage, ~currentUser) => {
</div>
{ReactUtils.nullIf(
<ul>
{[Page.Courses, SchoolCoaches, Communities, Settings(Customization)]
{[Page.Courses, Users, SchoolCoaches, Communities, Settings(Customization)]
->Js.Array2.map(page =>
<li key={Page.primaryNavName(page)}>
{topLink(selectedPage, selectedCourse, page)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ let make = (~selectedPage, ~selectedCourse, ~currentUser) =>
{secondaryNavOption(selectedPage, selectedCourse, Page.Settings(Customization))}
{secondaryNavOption(selectedPage, selectedCourse, Page.Settings(Admins))}
{secondaryNavOption(selectedPage, selectedCourse, Page.Settings(Standing))}
{secondaryNavOption(selectedPage, selectedCourse, Page.Settings(Discord))}
</div>
</div>
| SelectedCourse(_courseSelection) =>
Expand Down
Loading

0 comments on commit 050625d

Please sign in to comment.