chore: init commit

in worker.../repo/GITFOLDER.zip is the .git folder.
This commit is contained in:
2026-08-11 14:44:09 -04:00
parent 0012380fd3
commit 392781f7aa
781 changed files with 65944 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# frozen_string_literal: true
module Admin
class AnnouncementsController < BaseController
before_action :set_announcement, only: %i[show edit update destroy]
def index
sort_column = params[:sort].presence || "created_at"
sort_direction = params[:direction] == "desc" ? :desc : :asc
@announcements = Announcement.reorder(sort_column => sort_direction)
@breadcrumbs = [
{ label: "Announcements" }
]
end
def show
@breadcrumbs = [
{ label: "Announcements", path: admin_announcements_path },
{ label: @announcement.title }
]
end
def new
@announcement = Announcement.new
@breadcrumbs = [
{ label: "Announcements", path: admin_announcements_path },
{ label: "New Announcement" }
]
end
def edit
@breadcrumbs = [
{ label: "Announcements", path: admin_announcements_path },
{ label: @announcement.title, path: admin_announcement_path(@announcement) },
{ label: "Edit" }
]
end
def create
@announcement = Announcement.new(announcement_params)
if @announcement.save
redirect_to admin_announcement_path(@announcement), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Announcements", path: admin_announcements_path },
{ label: "New Announcement" }
]
render :new, status: :unprocessable_content
end
end
def update
if @announcement.update(announcement_params)
redirect_to admin_announcement_path(@announcement), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Announcements", path: admin_announcements_path },
{ label: @announcement.title, path: admin_announcement_path(@announcement) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
@announcement.destroy
redirect_to admin_announcements_path, notice: t(".notice")
end
private
def set_announcement
@announcement = Announcement.find(params.expect(:id))
end
def announcement_params
params.expect(announcement: %i[title content featured])
end
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
# Base controller for in-house admin.
# All Admin controllers inherit from this controller.
module Admin
class BaseController < ApplicationController
layout "admin"
before_action :authenticate_admin
private
def authenticate_admin
redirect_to root_path, alert: t("application.access_denied.admin_required") unless current_user&.admin?
end
# Apply sorting to a collection based on params
# @param collection [ActiveRecord::Relation] The base collection to sort
# @param default [String] The default column to sort by
# @return [ActiveRecord::Relation] The sorted collection
def apply_sorting(collection, default:)
sort_column = params[:sort].presence || default
sort_direction = params[:direction] == "desc" ? :desc : :asc
collection.reorder(sort_column => sort_direction)
end
end
end

View File

@@ -0,0 +1,83 @@
# frozen_string_literal: true
module Admin
class ClassroomsController < BaseController
before_action :set_classroom, only: %i[show edit update toggle_archive]
def index
@classrooms = apply_sorting(Classroom.all, default: "name")
@breadcrumbs = [
{ label: "Classrooms" }
]
end
def show
@breadcrumbs = [
{ label: "Classrooms", path: admin_classrooms_path },
{ label: @classroom.name }
]
end
def new
@classroom = Classroom.new
@breadcrumbs = [
{ label: "Classrooms", path: admin_classrooms_path },
{ label: "New Classroom" }
]
end
def edit
@breadcrumbs = [
{ label: "Classrooms", path: admin_classrooms_path },
{ label: @classroom.name, path: admin_classroom_path(@classroom) },
{ label: "Edit" }
]
end
def create
@classroom = Classroom.new(classroom_params)
if @classroom.save
redirect_to admin_classroom_path(@classroom), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Classrooms", path: admin_classrooms_path },
{ label: "New Classroom" }
]
render :new, status: :unprocessable_content
end
end
def update
if @classroom.update(classroom_params)
redirect_to admin_classroom_path(@classroom), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Classrooms", path: admin_classrooms_path },
{ label: @classroom.name, path: admin_classroom_path(@classroom) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def toggle_archive
authorize @classroom, :toggle_archive?
@classroom.update!(archived: !@classroom.archived)
flash[:notice] = @classroom.archived? ? "Classroom has been archived." : "Classroom has been activated."
redirect_to admin_classroom_path(@classroom)
end
private
def set_classroom
@classroom = Classroom.find(params.expect(:id))
end
def classroom_params
params.expect(classroom: [:name, :trading_enabled, :school_year_id, { grade_ids: [] }])
end
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
module Admin
class ComponentDemoController < BaseController
# rubocop:disable Metrics/MethodLength
def index
# Demo data for table component - apply sorting before limit
@users = User.all
# Apply sorting if params present
if params[:sort].present?
direction = params[:direction] == "desc" ? :desc : :asc
@users = @users.order(params[:sort] => direction)
end
@users = @users.limit(10)
# Demo data for show component
@sample_user = User.first || User.new(
email: "demo@example.com",
name: "Demo User",
admin: true,
created_at: Time.current
)
# Demo breadcrumbs
@breadcrumbs = [
{ label: "Components", path: admin_component_demo_index_path },
{ label: "Demo" }
]
# Demo table columns
@columns = [
{ attribute: :id, label: "ID", sortable: true },
{ attribute: :email, label: "Email", sortable: true },
{ attribute: :name, label: "Name", sortable: true },
{ attribute: :admin, label: "Admin", sortable: true },
{ attribute: :created_at, label: "Created", sortable: true }
]
# Demo filters
@filters = [
{
name: :type,
label: "User Type",
options: [["All", ""], ["Admin", "admin"], ["Teacher", "teacher"], ["Student", "student"]]
},
{
name: :status,
label: "Status",
options: [["All", ""], ["Active", "active"], ["Inactive", "inactive"]]
}
]
end
# rubocop:enable Metrics/MethodLength
def show
@user = User.find(params.expect(:id))
@breadcrumbs = [
{ label: "Components", path: admin_component_demo_index_path },
{ label: "User Details" }
]
end
def form
@user = User.first || User.new(email: "", name: "", admin: false)
# Add validation errors for demo purposes if requested
if params[:show_errors] == "true"
@user.errors.add(:email, "can't be blank")
@user.errors.add(:name, "is too short (minimum is 3 characters)")
end
@breadcrumbs = [
{ label: "Components", path: admin_component_demo_index_path },
{ label: "Form Builder Demo" }
]
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
module Admin
class DashboardController < BaseController
def index
# Dashboard will show overview of admin resources
end
end
end

View File

@@ -0,0 +1,90 @@
# frozen_string_literal: true
module Admin
class PortfolioTransactionsController < BaseController
before_action :set_portfolio_transaction, only: %i[show edit update destroy]
def show
# TODO: FIX
# authorize @portfolio_transaction
@breadcrumbs = [
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}" }
]
end
def new
@portfolio_transaction = PortfolioTransaction.new
# TODO: FIX
# authorize @portfolio_transaction
@breadcrumbs = [
{ label: "Portfolio Transactions", path: "#" },
{ label: "New" }
]
end
def edit
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
# authorize @portfolio_transaction
@breadcrumbs = [
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}",
path: admin_portfolio_transaction_path(@portfolio_transaction) },
{ label: "Edit" }
]
end
def create
@portfolio_transaction = PortfolioTransaction.new(portfolio_transaction_params)
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
# authorize @portfolio_transaction
if @portfolio_transaction.save
redirect_to admin_portfolio_transaction_path(@portfolio_transaction),
notice: t(".notice")
else
@breadcrumbs = [
{ label: "Portfolio Transactions", path: "#" },
{ label: "New" }
]
render :new, status: :unprocessable_content
end
end
def update
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
# authorize @portfolio_transaction
if @portfolio_transaction.update(portfolio_transaction_params)
redirect_to admin_portfolio_transaction_path(@portfolio_transaction),
notice: t(".notice")
else
@breadcrumbs = [
{ label: "Portfolio Transaction ##{@portfolio_transaction.id}",
path: admin_portfolio_transaction_path(@portfolio_transaction) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
# TODO: Determine if explicit authorization is needed since BaseController already restricts to admins
# authorize @portfolio_transaction
@portfolio_transaction.destroy
redirect_to admin_root_path, notice: t(".notice")
end
private
def set_portfolio_transaction
@portfolio_transaction = PortfolioTransaction.find(params.expect(:id))
end
def portfolio_transaction_params
params.expect(portfolio_transaction: %i[portfolio_id transaction_type reason description amount_cents])
end
end
end

View File

@@ -0,0 +1,111 @@
# frozen_string_literal: true
module Admin
class SchoolYearsController < BaseController
before_action :set_school_year, only: %i[show edit update destroy]
def index
@school_years = apply_sorting(SchoolYear.includes(:school, :year), default: "id")
@breadcrumbs = [
{ label: "School Years" }
]
end
def show
@breadcrumbs = [
{ label: "School Years", path: admin_school_years_path },
{ label: @school_year.to_s }
]
end
def new
@school_year = SchoolYear.new
@breadcrumbs = [
{ label: "School Years", path: admin_school_years_path },
{ label: "New School Year" }
]
end
def edit
@breadcrumbs = [
{ label: "School Years", path: admin_school_years_path },
{ label: @school_year.to_s, path: admin_school_year_path(@school_year) },
{ label: "Edit" }
]
end
def create
school = School.find_by(id: school_year_params[:school_id])
year = Year.find_by(id: school_year_params[:year_id])
unless school && year
@school_year = SchoolYear.new(school_year_params)
@school_year.valid?
return render_new_with_errors
end
@school_year = SchoolYear.create!(school:, year:)
if @school_year.persisted?
redirect_to admin_school_year_path(@school_year), notice: t(".notice")
else
render_new_with_errors
end
rescue ActiveRecord::RecordNotUnique
save_error(school_year_params)
render_new_with_errors
rescue ActiveRecord::RecordInvalid => e
@school_year = e.record
render_new_with_errors
end
def update
if @school_year.update(school_year_params)
redirect_to admin_school_year_path(@school_year), notice: t(".notice")
else
@breadcrumbs = [
{ label: "School Years", path: admin_school_years_path },
{ label: @school_year.to_s, path: admin_school_year_path(@school_year) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
if @school_year.destroy
redirect_to admin_school_years_path, notice: t(".notice")
else
redirect_to admin_school_years_path, alert: t(".delete_restricted")
end
rescue ActiveRecord::DeleteRestrictionError
redirect_to admin_school_years_path, alert: t(".delete_restricted")
end
private
def set_school_year
@school_year = SchoolYear.find(params.expect(:id))
end
def school_year_params
params.expect(school_year: %i[school_id year_id])
end
def render_new_with_errors
@breadcrumbs = [
{ label: "School Years", path: admin_school_years_path },
{ label: "New School Year" }
]
render :new, status: :unprocessable_content
end
def save_error(params)
@school_year = SchoolYear.new(params)
@school_year.valid?
@school_year.errors.add(:base, "A School year with this school and year already exists.")
end
end
end

View File

@@ -0,0 +1,97 @@
# frozen_string_literal: true
module Admin
class SchoolsController < BaseController
before_action :set_school, only: %i[show edit update destroy]
def index
sort_column = params[:sort].presence || "name"
sort_direction = params[:direction] == "desc" ? :desc : :asc
@schools = School.reorder(sort_column => sort_direction)
@breadcrumbs = [
{ label: "Schools" }
]
end
def show
@breadcrumbs = [
{ label: "Schools", path: admin_schools_path },
{ label: @school.name }
]
end
def new
@school = School.new
set_form_data
@breadcrumbs = [
{ label: "Schools", path: admin_schools_path },
{ label: "New School" }
]
end
def edit
set_form_data
@breadcrumbs = [
{ label: "Schools", path: admin_schools_path },
{ label: @school.name, path: admin_school_path(@school) },
{ label: "Edit" }
]
end
def create
year_ids = school_params[:year_ids]&.reject(&:blank?)
@school = School.new(school_params.except(:year_ids))
@school.year_ids = year_ids if year_ids.present?
if @school.save
redirect_to admin_school_path(@school), notice: t(".notice")
else
set_form_data
@breadcrumbs = [
{ label: "Schools", path: admin_schools_path },
{ label: "New School" }
]
render :new, status: :unprocessable_content
end
end
def update
year_ids = school_params[:year_ids]&.reject(&:blank?)
update_params = school_params.except(:year_ids)
update_params[:year_ids] = year_ids || []
if @school.update(update_params)
redirect_to admin_school_path(@school), notice: t(".notice")
else
set_form_data
@breadcrumbs = [
{ label: "Schools", path: admin_schools_path },
{ label: @school.name, path: admin_school_path(@school) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
@school.destroy
redirect_to admin_schools_path, notice: t(".notice")
end
private
def set_school
@school = School.find(params.expect(:id))
end
def set_form_data
@years = Year.ordered_by_start_year
end
def school_params
params.expect(school: [:name, { year_ids: [] }])
end
end
end

View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
module Admin
class StocksController < BaseController
before_action :set_stock, only: %i[show edit update destroy]
def index
@stocks = apply_sorting(Stock.all, default: "ticker")
@breadcrumbs = [
{ label: "Stocks" }
]
end
def show
@breadcrumbs = [
{ label: "Stocks", path: admin_stocks_path },
{ label: @stock.ticker }
]
end
def new
@stock = Stock.new
@breadcrumbs = [
{ label: "Stocks", path: admin_stocks_path },
{ label: "New Stock" }
]
end
def edit
@breadcrumbs = [
{ label: "Stocks", path: admin_stocks_path },
{ label: @stock.ticker, path: admin_stock_path(@stock) },
{ label: "Edit" }
]
end
def create
@stock = Stock.new(stock_params)
if @stock.save
redirect_to admin_stock_path(@stock), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Stocks", path: admin_stocks_path },
{ label: "New Stock" }
]
render :new, status: :unprocessable_content
end
end
def update
if @stock.update(stock_params)
redirect_to admin_stock_path(@stock), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Stocks", path: admin_stocks_path },
{ label: @stock.ticker, path: admin_stock_path(@stock) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
@stock.destroy
redirect_to admin_stocks_path, notice: t(".notice")
rescue ActiveRecord::DeleteRestrictionError => e
redirect_to admin_stocks_path, alert: "Cannot delete stock: #{e.message}"
end
private
def set_stock
@stock = Stock.find(params.expect(:id))
end
# rubocop:disable Metrics/MethodLength
def stock_params
params.expect(
stock: %i[
ticker
company_name
company_website
stock_exchange
description
price_cents
yesterday_price_cents
employees
industry
cash_flow
debt
debt_to_equity
profit_margin
sales_growth
industry_avg_debt_to_equity
industry_avg_profit_margin
industry_avg_sales_growth
management
competitor_names
archived
]
)
end
# rubocop:enable Metrics/MethodLength
end
end

View File

@@ -0,0 +1,235 @@
# frozen_string_literal: true
module Admin
# rubocop:disable Metrics/ClassLength
class StudentsController < BaseController
include SoftDeletableFiltering
before_action :set_student, only: %i[show edit update destroy add_transaction]
before_action :set_discarded_student, only: %i[restore]
def index
@students = apply_sorting(scoped_by_discard_status(Student), default: "username")
@breadcrumbs = [
{ label: "Students" }
]
end
def show
@attendance_entries = AttendanceEntryPresenter.for_student(@student)
@earnings_summary = EarningsSummary.new(@student.portfolio) if @student.portfolio.present?
@breadcrumbs = [
{ label: "Students", path: admin_students_path },
{ label: @student.username }
]
end
def new
@student = Student.new
@breadcrumbs = [
{ label: "Students", path: admin_students_path },
{ label: "New Student" }
]
end
def edit
@breadcrumbs = [
{ label: "Students", path: admin_students_path },
{ label: @student.username, path: admin_student_path(@student) },
{ label: "Edit" }
]
end
def create
@student = Student.new(student_params)
# Generate a memorable password if not provided
if @student.password.blank?
generated_password = MemorablePasswordGenerator.generate
@student.password = generated_password
@student.password_confirmation = generated_password
else
generated_password = @student.password
end
if @student.save
redirect_to admin_student_path(@student),
notice: t(".notice", username: @student.username, password: generated_password)
else
@breadcrumbs = [
{ label: "Students", path: admin_students_path },
{ label: "New Student" }
]
render :new, status: :unprocessable_content
end
end
def update
if @student.update(student_params)
redirect_to admin_student_path(@student), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Students", path: admin_students_path },
{ label: @student.username, path: admin_student_path(@student) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
username = @student.username
@student.discard
redirect_to admin_students_path, notice: t(".notice", username: username)
end
def restore
username = @student.username
@student.undiscard
redirect_to admin_students_path(discarded: true), notice: t(".notice", username: username)
end
def add_transaction
errors = validate_transaction_params
if errors.present?
redirect_to edit_admin_student_path(@student), alert: errors.join(", ")
else
transaction = PortfolioTransaction.new(
portfolio: @student.portfolio,
amount_cents: transaction_amount_cents,
transaction_type: transaction_type,
reason: transaction_reason,
description: transaction_description
)
if transaction.save
redirect_to admin_student_path(@student), notice: t(".notice")
else
redirect_to edit_admin_student_path(@student), alert: transaction.errors.full_messages.join(", ")
end
end
end
def import
return redirect_with_missing_file_error if params[:csv_file].blank?
begin
results = BulkStudentImportService.import_from_csv(params.expect(:csv_file).path)
redirect_with_import_results(results)
rescue CSV::MalformedCSVError => e
redirect_to admin_students_path, alert: "Invalid CSV format: #{e.message}"
end
end
def template
send_data BulkStudentImportService.generate_csv_template,
filename: "student_import_template.csv",
type: "text/csv",
disposition: "attachment"
end
private
def set_discarded_student
@student = Student.with_discarded.find(params.expect(:id))
end
def set_student
@student = Student.find(params.expect(:id))
end
def student_params
params.expect(student: %i[username classroom_id password password_confirmation])
end
def transaction_params
params.permit(:add_fund_amount, :transaction_type, :transaction_reason, :transaction_description)
end
def transaction_amount_cents
amount = transaction_params[:add_fund_amount]
amount.present? ? (amount.to_f * 100).to_i : nil
end
def transaction_type
transaction_params[:transaction_type]
end
def transaction_reason
transaction_params[:transaction_reason]
end
def transaction_description
transaction_params[:transaction_description]
end
def validate_transaction_params
errors = []
errors << t("admin.students.add_transaction.errors.transaction_type_blank") if transaction_type.blank?
errors << t("admin.students.add_transaction.errors.amount_blank") if transaction_amount_cents.blank?
errors << t("admin.students.add_transaction.errors.reason_blank") if transaction_reason.blank?
errors
end
def redirect_with_missing_file_error
redirect_to admin_students_path, alert: t(".errors.no_file")
end
def redirect_with_import_results(results)
return redirect_with_no_results_error if results.empty?
created, skipped, failed = partition_results(results)
success_messages = build_success_messages(created, skipped)
if failed.any?
redirect_with_mixed_results(success_messages, failed)
else
redirect_to admin_students_path, notice: success_messages.join(". ")
end
end
def redirect_with_no_results_error
redirect_to admin_students_path, alert: t(".errors.no_students")
end
def partition_results(results)
[
results.select(&:created?),
results.select(&:skipped?),
results.select(&:failed?)
]
end
def build_success_messages(created, skipped)
messages = []
messages << build_created_message(created) if created.any?
messages << build_skipped_message(skipped) if skipped.any?
messages
end
def build_created_message(created)
usernames = created.map { |item| item.student.username }
"Successfully created #{created.count} students: #{usernames.join(', ')}"
end
def build_skipped_message(skipped)
"Skipped #{skipped.count} existing usernames"
end
def redirect_with_mixed_results(success_messages, failed)
error_messages = failed.map { |item| "Row #{item.line_number}: #{item.error_message}" }
alert_message = "#{failed.count} errors occurred: #{error_messages.join(', ')}"
if success_messages.any?
redirect_to admin_students_path, notice: success_messages.join(". "), alert: alert_message
else
redirect_to admin_students_path, alert: alert_message
end
end
end
# rubocop:enable Metrics/ClassLength
end

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Admin
module Teachers
class DeactivationsController < BaseController
before_action :set_teacher
def create
username = @teacher.username
@teacher.discard
redirect_back_or_to admin_teachers_path, notice: t(".notice", username: username)
end
private
def set_teacher
@teacher = Teacher.find(params.expect(:teacher_id))
end
end
end
end

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Admin
module Teachers
class ReactivationsController < BaseController
before_action :set_teacher
def create
username = @teacher.username
@teacher.undiscard
redirect_back_or_to admin_teachers_path, notice: t(".notice", username: username)
end
private
def set_teacher
@teacher = Teacher.with_discarded.find(params.expect(:teacher_id))
end
end
end
end

View File

@@ -0,0 +1,115 @@
# frozen_string_literal: true
module Admin
class TeachersController < BaseController
include SoftDeletableFiltering
before_action :set_teacher, only: %i[show edit update destroy]
before_action :require_deactivated, only: %i[destroy]
def index
@teachers = apply_sorting(scoped_by_discard_status(Teacher), default: "username")
@breadcrumbs = [
{ label: "Teachers" }
]
end
def show
@breadcrumbs = [
{ label: "Teachers", path: admin_teachers_path },
{ label: @teacher.username }
]
end
def new
@teacher = Teacher.new
set_form_data
@breadcrumbs = [
{ label: "Teachers", path: admin_teachers_path },
{ label: "New Teacher" }
]
end
def edit
set_form_data
@breadcrumbs = [
{ label: "Teachers", path: admin_teachers_path },
{ label: @teacher.username, path: admin_teacher_path(@teacher) },
{ label: "Edit" }
]
end
def create
temp_password = Devise.friendly_token.first(20)
classroom_ids = teacher_params[:classroom_ids]&.reject(&:blank?)
@teacher = Teacher.new(teacher_params.except(:classroom_ids).merge(password: temp_password))
@teacher.classroom_ids = classroom_ids if classroom_ids.present?
if @teacher.save
@teacher.send_reset_password_instructions
redirect_to admin_teacher_path(@teacher), notice: t(".notice")
else
set_form_data
@breadcrumbs = [
{ label: "Teachers", path: admin_teachers_path },
{ label: "New Teacher" }
]
render :new, status: :unprocessable_content
end
end
def update
classroom_ids = teacher_params[:classroom_ids]&.reject(&:blank?)
update_params = teacher_params.except(:classroom_ids)
update_params[:classroom_ids] = classroom_ids if classroom_ids.present?
if @teacher.update(update_params)
redirect_to admin_teacher_path(@teacher), notice: t(".notice")
else
set_form_data
@breadcrumbs = [
{ label: "Teachers", path: admin_teachers_path },
{ label: @teacher.username, path: admin_teacher_path(@teacher) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
username = @teacher.username
@teacher.really_destroy!
redirect_to admin_teachers_path, notice: t(".notice", username: username)
end
private
def set_teacher
@teacher = Teacher.find(params.expect(:id))
end
def require_deactivated
return if @teacher.discarded?
redirect_to edit_admin_teacher_path(@teacher), alert: t("admin.teachers.destroy.must_be_deactivated")
end
def teacher_params
params.expect(teacher: [:email, :name, { classroom_ids: [] }])
end
def set_form_data
active_years = Year.current_school_year(Date.current)
@schools = School.joins(:school_years).where(school_years: { year_id: active_years.ids }).distinct.order(:name)
@selected_school_id = params[:school_id] || @teacher.classrooms.first&.school&.id
@classrooms = Classroom.active.joins(:school_year).where(school_years: { year_id: active_years.ids })
@classrooms = @classrooms.where(school_years: { school_id: @selected_school_id }) if @selected_school_id.present?
@classrooms = @classrooms.order(:name)
end
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Admin
class UsersController < BaseController
before_action :set_user, only: %i[show edit update destroy]
def index
@users = apply_sorting(User.all, default: "username")
@breadcrumbs = [
{ label: "Users" }
]
end
def show
@breadcrumbs = [
{ label: "Users", path: admin_users_path },
{ label: @user.username }
]
end
def new
@user = User.new
@breadcrumbs = [
{ label: "Users", path: admin_users_path },
{ label: "New User" }
]
end
def edit
@breadcrumbs = [
{ label: "Users", path: admin_users_path },
{ label: @user.username, path: admin_user_path(@user) },
{ label: "Edit" }
]
end
def create
@user = User.new(user_params)
if @user.save
redirect_to admin_user_path(@user), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Users", path: admin_users_path },
{ label: "New User" }
]
render :new, status: :unprocessable_content
end
end
def update
if @user.update(user_params)
redirect_to admin_user_path(@user), notice: t(".notice")
else
@breadcrumbs = [
{ label: "Users", path: admin_users_path },
{ label: @user.username, path: admin_user_path(@user) },
{ label: "Edit" }
]
render :edit, status: :unprocessable_content
end
end
def destroy
@user.destroy
redirect_to admin_users_path, notice: t(".notice")
end
private
def set_user
@user = User.find(params.expect(:id))
end
def user_params
params.expect(user: %i[username email type admin classroom_id password password_confirmation])
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AnnouncementsController < ApplicationController
before_action :set_announcement, only: %i[show]
def show; end
private
def set_announcement
@announcement = Announcement.find(params.expect(:id))
rescue ActiveRecord::RecordNotFound
redirect_to root_path, alert: t("announcements.not_found")
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
class ApplicationController < ActionController::Base
include Pundit::Authorization
before_action :authenticate_user!
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :set_navbar_stocks
protected
def ensure_teacher_or_admin
authorize :application, :teacher_or_admin_required?
end
def ensure_admin
authorize :application, :admin_required?
end
private
def set_navbar_stocks
@navbar_stocks = policy_scope(Stock).active
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: %i[username type classroom_id])
devise_parameter_sanitizer.permit(:account_update, keys: %i[username email])
end
rescue_from Pundit::NotAuthorizedError do
if current_user.nil?
# go to the login page
redirect_to new_user_session_path, alert: t("devise.failure.unauthenticated")
elsif current_user.student?
redirect_to current_user&.portfolio_path, alert: t("application.access_denied.no_access")
else
redirect_to root_url, alert: t("application.access_denied.no_access")
end
end
end

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
# Manages student enrollments in classrooms
class ClassroomEnrollmentsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_teacher_or_admin
before_action :set_classroom
before_action :set_enrollment, only: %i[destroy unenroll]
# POST /classrooms/:classroom_id/classroom_enrollments
def create
student = Student.find(enrollment_params[:student_id])
enrollment = student.enroll_in!(
@classroom,
primary: enrollment_params[:primary] == "true"
)
if enrollment.persisted?
redirect_to classroom_path(@classroom),
notice: "#{student.username} enrolled in #{@classroom.name}"
else
redirect_to classroom_path(@classroom),
alert: "Failed to enroll student: #{enrollment.errors.full_messages.join(', ')}"
end
end
# DELETE /classrooms/:classroom_id/classroom_enrollments/:id
def destroy
student = @enrollment.student
@enrollment.destroy
redirect_to classroom_path(@classroom),
notice: "Removed #{student.username}'s enrollment from #{@classroom.name}"
end
# PATCH /classrooms/:classroom_id/classroom_enrollments/:id/unenroll
def unenroll
student = @enrollment.student
@enrollment.unenroll!
redirect_to classroom_path(@classroom),
notice: "#{student.username} unenrolled from #{@classroom.name}"
end
private
def set_classroom
@classroom = Classroom.find(params.expect(:classroom_id))
end
def set_enrollment
@enrollment = @classroom.classroom_enrollments.find(params.expect(:id))
end
def enrollment_params
params.expect(classroom_enrollment: %i[student_id primary])
end
end

View File

@@ -0,0 +1,110 @@
# frozen_string_literal: true
class ClassroomsController < ApplicationController
before_action :set_classroom, only: %i[show edit update toggle_trading]
before_action :authorize_classroom, except: %i[edit update toggle_trading]
before_action :authorize_classroom_instance, only: %i[edit update toggle_trading]
before_action :authenticate_user!
before_action :ensure_teacher_or_admin, except: %i[index show]
before_action :check_classroom_eligibility, only: :show
def index
@classrooms = policy_scope(Classroom).includes(:teachers, students: :portfolio)
@classrooms = Classroom.apply_sorting(@classrooms, params[:sort], params[:direction])
end
def show
facade = ClassroomFacade.new(@classroom)
@students = facade.students
@can_manage_students = current_user.teacher_or_admin?
@classroom_stats = facade.stats if @can_manage_students
end
def new
@classroom = Classroom.new
dropdown_data
end
def edit
dropdown_data
end
def create
@classroom = Classroom.new(classroom_params.except(:school_id, :year_id))
assign_school_year_to_classroom
if @classroom.save
redirect_to classroom_url(@classroom), notice: t(".notice")
else
dropdown_data
render :new, status: :unprocessable_content
end
end
def update
assign_school_year_to_classroom
if @classroom.update(classroom_params.except(:school_id, :year_id))
redirect_to classroom_url(@classroom), notice: t(".notice")
else
dropdown_data
render :edit, status: :unprocessable_content
end
end
def toggle_trading
if @classroom.update(trading_enabled: !@classroom.trading_enabled)
notice_key = @classroom.trading_enabled? ? :enabled : :disabled
redirect_to classroom_url(@classroom), notice: t(".#{notice_key}")
else
redirect_to classroom_url(@classroom), alert: t(".alert")
end
end
private
def set_classroom
@classroom = Classroom.includes(users: :portfolio).find(params.expect(:id).to_i)
end
def authorize_classroom
authorize Classroom
end
def authorize_classroom_instance
authorize @classroom
end
def classroom_params
params.expect(classroom: [:name, :trading_enabled, :school_id, :year_id, { grade_ids: [] }, { teacher_ids: [] }])
end
def dropdown_data
@schools = School.order(:name)
@years = Year.order(:name)
@teachers = Teacher.all.sort_by(&:display_name)
end
def assign_school_year_to_classroom
return unless classroom_params[:school_id].present? && classroom_params[:year_id].present?
school = School.find(classroom_params[:school_id])
year = Year.find(classroom_params[:year_id])
school_year = SchoolYear.find_or_create_by!(school: school, year: year)
@classroom.school_year = school_year
end
def check_classroom_eligibility
# Redirect if classroom is archived and user is not an admin
if @classroom.archived? && !current_user.admin?
redirect_to root_path, alert: t("classrooms.archived.alert")
return
end
return if current_user.admin? ||
(current_user.teacher? &&
@classroom.teachers.include?(current_user))
redirect_to root_path, alert: t("application.access_denied.no_access")
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
module SoftDeletableFiltering
extend ActiveSupport::Concern
private
# Scopes a resource class based on discard status query parameters
#
# @param resource_class [ActiveRecord::Base] The model class to scope
# @return [ActiveRecord::Relation] Scoped collection based on params
#
# Query parameters:
# - discarded (any value): Returns only discarded records
# - all (any value): Returns all records (including discarded)
# - (none): Returns only kept (non-discarded) records
def scoped_by_discard_status(resource_class)
if params[:discarded].present?
resource_class.discarded
elsif params[:all].present?
resource_class.with_discarded
else
resource_class.kept
end
end
end

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
# app/controllers/grade_books_controller.rb
class GradeBooksController < ApplicationController
before_action :set_classroom_and_grade_book
before_action :authorize_grade_book
def show; end
def update
GradeEntry.transaction do
grade_entry_params.each do |id, attrs|
entry = @grade_book.grade_entries.find(id)
entry.update!(attrs)
end
end
@grade_book.grade_entries.reload
respond_to do |format|
format.html { redirect_to classroom_grade_book_path(@classroom, @grade_book), notice: t(".notice") }
format.turbo_stream
end
end
def finalize
if @grade_book.completed?
redirect_to classroom_grade_book_path(@classroom, @grade_book),
alert: t(".already_completed")
else
@grade_book.verified!
DistributeEarnings.execute(@grade_book)
redirect_to classroom_grade_book_path(@classroom, @grade_book),
notice: t(".notice")
end
end
private
def authorize_grade_book
authorize @grade_book
end
def set_classroom_and_grade_book
@classroom = Classroom.find(params.expect(:classroom_id))
@grade_book = @classroom.grade_books.includes(grade_entries: :user).find(params.expect(:id))
# Redirect if classroom is archived and user is not an admin
return unless @classroom.archived? && !current_user.admin?
redirect_to root_path, alert: t("classrooms.archived.alert")
end
def grade_entry_params
params.require(:grade_entries).transform_values do |entry|
entry.permit(:math_grade, :reading_grade, :attendance_days, :is_perfect_attendance)
end
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class HomeController < ApplicationController
def index
@current_announcement = Announcement.current
end
end

View File

@@ -0,0 +1,113 @@
# frozen_string_literal: true
class OrdersController < ApplicationController
before_action :set_order, only: %i[edit update cancel]
before_action :set_stock, only: %i[new]
before_action :set_shared_owned, only: %i[new edit]
before_action :authenticate_user!
def index
@orders = policy_scope(Order)
@orders = Order.apply_sorting(@orders, params[:sort], params[:direction])
end
def new
@order = Order.new(action: params[:transaction_type], stock: @stock)
end
def edit; end
def create
@order = Order.new(order_params.merge(user: current_user))
respond_to do |format|
if @order.save
format.html { redirect_to orders_url, notice: t(".notice") }
format.turbo_stream { redirect_to orders_url, notice: t(".notice") }
format.json { render :show, status: :created, location: @order }
else
setup_error_data
format.html { render :new, status: :unprocessable_content }
format.turbo_stream { render :new, status: :unprocessable_content }
format.json { render json: @order.errors, status: :unprocessable_content }
end
end
end
def update
respond_to do |format|
if @order.update(order_params)
format.html { redirect_to orders_url, notice: t(".notice") }
format.json { render :show, status: :ok, location: @order }
else
format.html { render :edit, status: :unprocessable_content }
format.json { render json: @order.errors, status: :unprocessable_content }
end
end
end
def cancel
authorize @order
if @order.pending?
cancel_order
else
invalid_order_response
end
end
private
# Strong parameters
def order_params
params.expect(order: %i[stock_id shares action])
end
def set_order
@order = Order.find(params.expect(:id))
end
def set_stock
@stock = Stock.find(params.expect(:stock_id))
end
def set_shared_owned
@shares_owned = current_user.portfolio&.shares_owned(@stock&.id)
end
def setup_error_data
@stock = @order.stock
@shares_owned = current_user.portfolio&.shares_owned(@stock&.id)
end
def unauthorized_response
respond_to do |format|
format.html { redirect_to orders_url, alert: t(".unauthorized") }
format.json { render json: { error: t(".unauthorized") }, status: :forbidden }
end
end
def cancel_order
@order.cancel!
respond_to do |format|
format.html { redirect_to orders_url, notice: t(".success") }
format.json { head :no_content }
end
end
def invalid_order_response
respond_to do |format|
format.html { redirect_to orders_url, alert: t(".pending_only") }
format.json { render json: { error: t(".pending_only") }, status: :unprocessable_content }
end
end
def destroy
@order.destroy!
respond_to do |format|
format.html { redirect_to orders_url, notice: t(".notice") }
format.json { head :no_content }
end
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class PortfoliosController < ApplicationController
before_action :set_portfolio
before_action :authenticate_user!
def show
authorize @portfolio
@stocks = @portfolio.stocks
@earnings_summary = EarningsSummary.new(@portfolio)
end
private
def set_portfolio
@portfolio = Portfolio.find(params.expect(:id))
end
end

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
class SchoolsController < ApplicationController
before_action :set_school, only: %i[show edit update destroy]
before_action :authenticate_user!
# GET /schools or /schools.json
def index
@schools = School.all
end
# GET /schools/1 or /schools/1.json
def show; end
# GET /schools/new
def new
@school = School.new
end
# GET /schools/1/edit
def edit; end
# POST /schools or /schools.json
def create
@school = School.new(school_params)
respond_to do |format|
if @school.save
format.html { redirect_to school_url(@school), notice: t(".notice") }
format.json { render :show, status: :created, location: @school }
else
format.html { render :new, status: :unprocessable_content }
format.json { render json: @school.errors, status: :unprocessable_content }
end
end
end
# PATCH/PUT /schools/1 or /schools/1.json
def update
respond_to do |format|
if @school.update(school_params)
format.html { redirect_to school_url(@school), notice: t(".notice") }
format.json { render :show, status: :ok, location: @school }
else
format.html { render :edit, status: :unprocessable_content }
format.json { render json: @school.errors, status: :unprocessable_content }
end
end
end
# DELETE /schools/1 or /schools/1.json
def destroy
@school.destroy!
respond_to do |format|
format.html { redirect_to schools_url, notice: t(".notice") }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_school
@school = School.find(params.expect(:id))
end
# Only allow a list of trusted parameters through.
def school_params
params.expect(school: [:name])
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
class StocksController < ApplicationController
before_action :set_stock, only: %i[show]
before_action :authenticate_user!
before_action :set_portfolio
def index
@stocks = policy_scope(Stock).includes(portfolio_stocks: :portfolio)
@portfolio = current_user.portfolio if current_user.student?
end
def show
authorize @stock
end
private
def set_portfolio
@portfolio = current_user.portfolio
end
def set_stock
@stock = Stock.find(params.expect(:id))
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
class StudentsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_teacher_or_admin
before_action :set_classroom
before_action :set_student, except: %i[new create]
def new
@student = Student.new(classroom: @classroom)
end
def edit; end
def create
@student = Student.new(student_params)
@student.classroom = @classroom
@student.password = generate_memorable_password
if @student.save
redirect_to classroom_path(@classroom),
notice: t(".notice", username: @student.username, password: @student.password)
else
render :new, status: :unprocessable_content
end
end
def update
if @student.update(student_params)
redirect_to classroom_path(@classroom), notice: t(".notice")
else
render :edit, status: :unprocessable_content
end
end
def destroy
username = @student.username
@student.discard
redirect_to classroom_path(@classroom), notice: t(".notice", username: username)
end
def reset_password
new_password = generate_memorable_password
@student.update!(password: new_password)
redirect_to classroom_path(@classroom),
notice: t(".notice", username: @student.username, password: new_password)
end
def generate_password
new_password = generate_memorable_password
@student.update!(password: new_password)
redirect_to classroom_path(@classroom),
notice: t(".notice", username: @student.username, password: new_password)
end
private
def set_classroom
@classroom = Classroom.find(params.expect(:classroom_id))
# Redirect if classroom is archived and user is not an admin
return unless @classroom.archived? && !current_user.admin?
redirect_to root_path, alert: t("classrooms.archived.alert")
end
def set_student
@student = @classroom.users.students.kept.find(params.expect(:id))
rescue ActiveRecord::RecordNotFound
redirect_to classroom_path(@classroom), alert: t("students.not_found")
end
def student_params
params.expect(student: %i[username email])
end
def generate_memorable_password
MemorablePasswordGenerator.generate
end
end