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,33 @@
# frozen_string_literal: true
class Announcement < ApplicationRecord
has_rich_text :content
validates :title, presence: true, length: { maximum: 255 }
validates :content, presence: true
before_save :unfeature_other_announcements, if: :will_save_change_to_featured?
scope :latest, -> { order(created_at: :desc) }
def self.current
find_by(featured: true)
end
def excerpt(limit: 150)
content&.to_plain_text&.truncate(limit) || ""
end
def published_at
created_at # Alias for semantic clarity
end
private
def unfeature_other_announcements
return unless featured? && will_save_change_to_featured?(to: true)
currently_featured = self.class.where(featured: true).where.not(id: id).first
currently_featured&.update(featured: false)
end
end

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

@@ -0,0 +1,125 @@
# frozen_string_literal: true
class Classroom < ApplicationRecord
MIN_GRADE = 5
MAX_GRADE = 8
GRADE_RANGE = (MIN_GRADE..MAX_GRADE).to_a.freeze
belongs_to :school_year
has_one :school, through: :school_year
has_one :year, through: :school_year
delegate :name, to: :school, prefix: :school
delegate :name, to: :year, prefix: :year
has_many :users, dependent: :nullify
has_many :teacher_classrooms, dependent: :destroy
has_many :teachers, through: :teacher_classrooms
has_many :classroom_enrollments, dependent: :destroy
has_many :enrolled_students, through: :classroom_enrollments, source: :student
has_many :students, -> { kept }, class_name: "Student", inverse_of: :classroom, dependent: :nullify
has_many :classroom_grades, dependent: :destroy
has_many :grades, through: :classroom_grades
has_many :grade_books, dependent: :destroy
validates :name, presence: true
validate :school_year_presence
validate :grade_level
after_create :create_gradebooks_for_quarters
scope :active, -> { where(archived: false) }
scope :archived, -> { where(archived: true) }
scope :order_by_name, ->(direction = :asc) { reorder(name: direction) }
scope :order_by_student_count, lambda { |direction = :asc|
dir = direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
joins("LEFT OUTER JOIN users ON users.classroom_id = classrooms.id AND users.type = 'Student'")
.group(:id)
.order(Arel.sql("COUNT(users.id) #{dir}"))
}
scope :order_by_total_earnings, lambda { |direction = :asc|
dir = direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
joins("LEFT OUTER JOIN users ON users.classroom_id = classrooms.id AND users.type = 'Student'")
.joins("LEFT OUTER JOIN portfolios ON portfolios.user_id = users.id")
.joins("LEFT OUTER JOIN portfolio_transactions ON portfolio_transactions.portfolio_id = portfolios.id")
.group(:id)
.order(Arel.sql("COALESCE(SUM(portfolio_transactions.amount_cents), 0) #{dir}"))
}
# Apply sorting based on sort column param
# @param collection [ActiveRecord::Relation] The collection to sort (optional)
# @param sort_column [String] The column to sort by
# @param direction [Symbol] :asc or :desc
# @return [ActiveRecord::Relation] The sorted collection
def self.apply_sorting(collection = nil, sort_column = nil, direction = :asc)
base_scope = collection || all
case sort_column
when "name"
base_scope.order_by_name(direction)
when "student_count"
base_scope.order_by_student_count(direction)
when "total_earnings"
base_scope.order_by_total_earnings(direction)
else
base_scope.order_by_name(:asc)
end
end
# Get currently enrolled students (students with active enrollments)
#
# @return [ActiveRecord::Relation<Student>]
def current_students
enrolled_students.joins(:classroom_enrollments)
.merge(classroom_enrollments.current)
.distinct
end
# Get historically enrolled students (students with past enrollments)
#
# @return [ActiveRecord::Relation<Student>]
def historical_students
enrolled_students.joins(:classroom_enrollments)
.merge(classroom_enrollments.historical)
.distinct
end
def grades_display
values = classroom_grades
.joins(:grade)
.pluck("grades.level")
.uniq
.sort
return if values.empty?
if values.one?
values.first.ordinalize
elsif continuous?(values)
"#{values.first.ordinalize}-#{values.last.ordinalize}"
else
values.map(&:ordinalize).join(", ")
end
end
def continuous?(values)
values.each_cons(2).all? { |a, b| b == a + 1 }
end
private
def create_gradebooks_for_quarters
school_year.quarters.each do |quarter|
GradeBook.find_or_create_by!(quarter: quarter, classroom: self)
end
end
def grade_level
errors.add(:grades, "must have at least one grade") if grades.empty?
end
def school_year_presence
errors.add(:school_year_id, :blank) if school_year_id.blank? && school_year.nil?
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
# Represents a student's enrollment in a classroom, supporting multiple
# enrollments over time with historical tracking.
#
# A student can have multiple enrollment records for the same classroom
# (e.g., enrolled in Fall 2023, then again in Spring 2024), and can be
# enrolled in multiple classrooms simultaneously.
#
# @example Find a student's current enrollments
# student.classroom_enrollments.current
#
# @example Find a student's enrollment history
# student.classroom_enrollments.historical
#
# @example Get a student's primary classroom
# student.primary_enrollment&.classroom
class ClassroomEnrollment < ApplicationRecord
belongs_to :student, class_name: "Student"
belongs_to :classroom
validates :enrolled_at, presence: true
validate :unenrolled_at_after_enrolled_at
validate :only_one_primary_per_student, if: :primary?
scope :current, -> { where(unenrolled_at: nil) }
scope :historical, -> { where.not(unenrolled_at: nil) }
scope :primary_enrollment, -> { where(primary: true) }
scope :for_student, ->(student) { where(student: student) }
scope :for_classroom, ->(classroom) { where(classroom: classroom) }
# Mark this enrollment as the primary enrollment for the student
# and demote any other primary enrollments
#
# @return [self]
def make_primary!
transaction do
ClassroomEnrollment.where(student: student, primary: true)
.where.not(id: id)
.find_each { |enrollment| enrollment.update!(primary: false) }
update!(primary: true)
end
self
end
# Unenroll the student from this classroom
#
# @param at [DateTime] when the student was unenrolled (defaults to now)
# @return [self]
def unenroll!(at: Time.current)
update!(unenrolled_at: at, primary: false)
self
end
# Check if this enrollment is currently active
#
# @return [Boolean]
def current?
unenrolled_at.nil?
end
# Check if this enrollment is historical (no longer active)
#
# @return [Boolean]
def historical?
!current?
end
private
def unenrolled_at_after_enrolled_at
return if unenrolled_at.blank? || enrolled_at.blank?
return if unenrolled_at >= enrolled_at
errors.add(:unenrolled_at, "must be after enrolled_at")
end
def only_one_primary_per_student
return if student_id.blank?
existing = ClassroomEnrollment.where(student_id: student_id, primary: true)
.where.not(id: id)
.exists?
errors.add(:primary, "student can only have one primary enrollment") if existing
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class ClassroomGrade < ApplicationRecord
belongs_to :classroom
belongs_to :grade
validates :grade_id, uniqueness: { scope: :classroom_id }
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
module UrlHelpers
extend ActiveSupport::Concern
included do
include Rails.application.routes.url_helpers
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
class EarningsSummary
attr_reader :portfolio
def initialize(portfolio)
@portfolio = portfolio
end
def attendance_earnings_cents
sum_by_reason(:attendance_earnings)
end
def reading_earnings_cents
sum_by_reason(:reading_earnings)
end
def math_earnings_cents
sum_by_reason(:math_earnings)
end
def awards_cents
sum_by_reason(:awards)
end
def total_earnings_cents
attendance_earnings_cents + reading_earnings_cents + math_earnings_cents + awards_cents
end
def transaction_fees_cents
sum_by_reason(:transaction_fees)
end
private
def sum_by_reason(reason)
portfolio.portfolio_transactions
.deposits
.where(reason: reason)
.sum(:amount_cents)
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
class Grade < ApplicationRecord
has_many :classroom_grades, dependent: :restrict_with_error
has_many :classrooms, through: :classroom_grades
validates :name, presence: true, uniqueness: { case_sensitive: false }
validates :level, presence: true, uniqueness: true
end

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class GradeBook < ApplicationRecord
belongs_to :quarter
belongs_to :classroom
has_many :grade_entries, dependent: :destroy
enum :status, {
draft: "draft",
verified: "verified",
completed: "completed"
}
end

View File

@@ -0,0 +1,68 @@
# frozen_string_literal: true
class GradeEntry < ApplicationRecord
belongs_to :grade_book
belongs_to :user
delegate :username, to: :user
EARNINGS_PER_DAY_ATTENDANCE = 20
EARNINGS_FOR_A_GRADE = 3_00
EARNINGS_FOR_B_GRADE = 2_00
EARNINGS_FOR_IMPROVED_GRADE = 2_00
EARNINGS_FOR_PERFECT_ATTENDANCE = 1_00
GRADE_OPTIONS = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D", "F"].freeze
def earnings_for_attendance
return 0 if attendance_days.blank?
attendance_days * EARNINGS_PER_DAY_ATTENDANCE
end
def earnings_for_math
grade_based_earnings(math_grade)
end
def earnings_for_reading
grade_based_earnings(reading_grade)
end
def attendance_perfect_earnings
return 0 unless is_perfect_attendance
EARNINGS_FOR_PERFECT_ATTENDANCE
end
def math_improvement_earnings(previous_entry)
return 0 unless previous_entry
return 0 if math_grade.blank? || previous_entry.math_grade.blank?
improved_grade?(math_grade, previous_entry.math_grade) ? EARNINGS_FOR_IMPROVED_GRADE : 0
end
def reading_improvement_earnings(previous_entry)
return 0 unless previous_entry
return 0 if reading_grade.blank? || previous_entry.reading_grade.blank?
improved_grade?(reading_grade, previous_entry.reading_grade) ? EARNINGS_FOR_IMPROVED_GRADE : 0
end
private
def grade_based_earnings(grade)
case grade
when "A+", "A", "A-"
EARNINGS_FOR_A_GRADE
when "B+", "B", "B-"
EARNINGS_FOR_B_GRADE
else
0
end
end
def improved_grade?(current_grade,
previous_grade)
GRADE_OPTIONS.index(current_grade) < GRADE_OPTIONS.index(previous_grade)
end
end

View File

@@ -0,0 +1,208 @@
# frozen_string_literal: true
class Order < ApplicationRecord
include ApplicationHelper
belongs_to :user
belongs_to :stock
belongs_to :portfolio_stock, optional: true
belongs_to :portfolio_transaction, optional: true
enum :status, { pending: 0, completed: 1, canceled: 2 }, default: :pending
enum :action, { buy: "buy", sell: "sell" }
validates :shares, presence: true, numericality: { greater_than: 0 }
validate :sufficient_funds_for_buy_when_update, on: :update, if: -> { buy? }
validate :order_is_pending, on: :update
# Now runs when creating orders or when user changes the share amount.
# Skips during status changes (pending→completed) to avoid false errors.
validate :sufficient_shares_for_sell,
if: -> { sell? && validate_share_availability? },
on: %i[create update]
validate :sufficient_funds_for_buy, if: -> { buy? }, on: :create
validate :prevent_archived_stock_purchase, if: -> { buy? }, on: %i[create update]
validate :trading_enabled_for_classroom, on: :create
after_update :update_portfolio_transaction_for_pending_order
delegate :portfolio, to: :user
scope :buy, -> { where(action: :buy) }
scope :sell, -> { where(action: :sell) }
scope :pending, -> { where(status: :pending) }
scope :completed, -> { where(status: :completed) }
scope :canceled, -> { where(status: :canceled) }
scope :for_student, ->(student) { where(user: student) }
scope :for_teacher, lambda { |teacher|
joins(user: :classroom).where(users: { classroom: teacher.classrooms })
}
scope :order_by_created_at, lambda { |direction = :asc|
reorder(created_at: safe_direction(direction))
}
scope :order_by_username, lambda { |direction = :asc|
joins(:user).reorder(users: { username: safe_direction(direction) })
}
scope :order_by_classroom, lambda { |direction = :asc|
joins(user: :classroom)
.reorder(classrooms: { name: safe_direction(direction) })
}
scope :order_by_stock, lambda { |direction = :asc|
joins(:stock).reorder(stocks: { ticker: safe_direction(direction) })
}
scope :order_by_price_per_share, lambda { |direction = :asc|
joins(:stock)
.reorder(stocks: { price_cents: safe_direction(direction) })
}
scope :order_by_shares, lambda { |direction = :asc|
reorder(shares: safe_direction(direction))
}
scope :order_by_status, lambda { |direction = :asc|
reorder(status: safe_direction(direction))
}
scope :order_by_total_cost, lambda { |direction = :asc|
dir = safe_direction(direction)
expression = Arel.sql("stocks.price_cents * orders.shares")
joins(:stock)
.reorder(dir == :desc ? expression.desc : expression.asc)
}
# Apply sorting based on sort column param
# @param collection [ActiveRecord::Relation] The collection to sort (optional)
# @param sort_column [String] The column to sort by
# @param direction [Symbol] :asc or :desc
# @return [ActiveRecord::Relation] The sorted collection
def self.apply_sorting(collection = nil, sort_column = nil, direction = :asc)
base_scope = collection || all
sorting_method = SORTING_METHODS[sort_column]
return base_scope.order_by_created_at(:desc) unless sorting_method
base_scope.send(sorting_method, direction)
end
# Map of sort column names to scope method names
SORTING_METHODS = {
"created_at" => :order_by_created_at,
"username" => :order_by_username,
"classroom" => :order_by_classroom,
"stock" => :order_by_stock,
"price_per_share" => :order_by_price_per_share,
"shares" => :order_by_shares,
"status" => :order_by_status,
"total_cost" => :order_by_total_cost
}.freeze
def cancel!
update(status: :canceled)
end
def purchase_cost
stock.price_cents * shares
end
def existing_transaction_type
order_transaction_type = portfolio_transaction&.transaction_type&.to_sym
order_transaction_type == :debit ? "buy" : "sell"
end
def self.safe_direction(direction)
direction.to_s.downcase == "desc" ? :desc : :asc
end
private
def number_of_shares_valid_for_calculations?
shares.is_a?(Numeric) && shares.positive?
end
def sufficient_shares_for_sell
return unless number_of_shares_valid_for_calculations?
current_shares = user.portfolio&.shares_owned(stock_id) || 0
return unless shares > current_shares
formatted_shares = (current_shares % 1).zero? ? current_shares.to_i : current_shares
errors.add(:base, "Cannot sell more shares than you own (#{formatted_shares} available)")
end
def sufficient_funds_for_buy
return unless number_of_shares_valid_for_calculations?
current_balance_cents = (user.portfolio&.cash_balance || 0) * 100
total_cost = purchase_cost + transaction_fee
return if (current_balance_cents - total_cost) >= 0
formatted_balance = format_money(current_balance_cents)
formatted_cost = format_money(total_cost)
errors.add(:shares, "Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}")
end
def transaction_fee
user.orders.pending.where.not(id:).exists? ? 0 : PortfolioTransaction::TRANSACTION_FEE_CENTS
end
def sufficient_funds_for_buy_when_update
return unless number_of_shares_valid_for_calculations?
return unless shares_changed?
previous_shares = shares_was
refund = (stock.price_cents * previous_shares) + PortfolioTransaction::TRANSACTION_FEE_CENTS
current_balance_cents = ((user.portfolio&.cash_balance || 0) * 100) + refund
total_cost = purchase_cost + transaction_fee
return if (current_balance_cents - total_cost) >= 0
formatted_balance = format_money(current_balance_cents)
formatted_cost = format_money(total_cost)
errors.add(
:shares,
"Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}"
)
end
def order_is_pending
prev_status, = status_change_to_be_saved
return if pending? || prev_status.nil? || prev_status == "pending"
errors.add(:base, "Cannot update non-pending orders")
end
def update_portfolio_transaction_for_pending_order
return unless pending? && portfolio_transaction.present?
portfolio_transaction.amount_cents = purchase_cost
portfolio_transaction.transaction_type = translated_transaction_type
portfolio_transaction.save
end
def translated_transaction_type
buy? ? :debit : :credit
end
def prevent_archived_stock_purchase
return unless stock&.archived?
errors.add(:stock, "Cannot purchase shares of archived stocks")
end
# Only validate share availability when shares quantity changes,
# not during status-only updates (pending → completed)
def validate_share_availability?
return true if new_record?
shares_changed?
end
def trading_enabled_for_classroom
return if user&.classroom&.trading_enabled?
errors.add(:base, "Trading is currently disabled for your classroom")
end
end

View File

@@ -0,0 +1,105 @@
# frozen_string_literal: true
class Portfolio < ApplicationRecord
include ::UrlHelpers
belongs_to :user
validate :user_must_be_student
delegate :username, :student?, :school_name, :trading_enabled?, to: :user
has_many :portfolio_transactions, dependent: :destroy
has_many :portfolio_stocks, dependent: :destroy
has_many :stocks, through: :portfolio_stocks
has_many :portfolio_snapshots, dependent: :destroy
def cash_balance
cash_on_hand
end
def path
portfolio_path(self)
end
def shares_owned(stock_id)
portfolio_stocks.where(stock_id: stock_id).sum(:shares)
end
def positions
PortfolioPosition.for_portfolio(self)
end
def calculate_total_value_cents
cash_on_hand_in_cents + holdings_value_cents
end
def calculate_total_value
calculate_total_value_cents / 100.0
end
def total_portfolio_worth
calculate_total_value
end
def holdings_value
holdings_value_cents / 100.0
end
def holdings_value_cents
portfolio_stocks.joins(:stock).sum(
"portfolio_stocks.shares * stocks.price_cents"
)
end
def chart_data
snapshots = portfolio_snapshots.order(date: :asc).last(12)
snapshots.map do |snapshot|
{
label: snapshot.date.strftime("%b %Y"),
value: snapshot.current_worth
}
end
end
private
def cash_on_hand
cash_on_hand_in_cents / 100.0
end
def cash_on_hand_in_cents
credits = total_credits + total_deposits
debits = total_debits + total_withdrawals + total_fees + pending_transaction_fee
credits - debits
end
def total_withdrawals
portfolio_transactions.withdrawals.sum(:amount_cents)
end
def total_deposits
portfolio_transactions.deposits.sum(:amount_cents)
end
def total_fees
portfolio_transactions.fees.sum(:amount_cents)
end
def total_credits
portfolio_transactions.credits.sum(:amount_cents)
end
def total_debits
pending_orders_amount = user.orders.pending.buy.sum(&:purchase_cost) || 0
portfolio_transactions.debits.sum(:amount_cents) + pending_orders_amount
end
def pending_transaction_fee
user.orders.pending.exists? ? PortfolioTransaction::TRANSACTION_FEE_CENTS : 0
end
def user_must_be_student
errors.add(:user, "must be a student") unless user&.student?
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
class PortfolioPosition
attr_reader :stock, :shares, :portfolio, :change_amount, :total_return_amount
delegate :current_price, :price_cents, :ticker, to: :stock, prefix: :stock
def initialize(stock:, shares:, portfolio: nil, financial_data: {})
@stock = stock
@shares = shares
@portfolio = portfolio
@change_amount = financial_data[:change_amount]
@total_return_amount = financial_data[:total_return_amount]
end
def current_value
shares * stock_current_price
end
def current_value_cents
shares * stock_price_cents
end
def self.for_portfolio(portfolio)
Stock
.joins(:portfolio_stocks)
.where(portfolio_stocks: { portfolio_id: portfolio.id })
.group("stocks.id")
.having("SUM(portfolio_stocks.shares) > 0")
.select(
<<~SQL.squish
stocks.*,
SUM(portfolio_stocks.shares) AS total_shares,
(stocks.price_cents / 100.0) * SUM(portfolio_stocks.shares) -
SUM(portfolio_stocks.purchase_price * portfolio_stocks.shares) AS aggregated_change_amount,
(stocks.price_cents / 100.0) * SUM(portfolio_stocks.shares) AS aggregated_total_return
SQL
)
.map { |result| build_position(result, portfolio) }
end
def self.build_position(result, portfolio)
new(
stock: result,
shares: result.total_shares,
portfolio: portfolio,
financial_data: {
change_amount: result.aggregated_change_amount,
total_return_amount: result.aggregated_total_return
}
)
end
private_class_method :build_position
end

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class PortfolioSnapshot < ApplicationRecord
belongs_to :portfolio
validates :date, presence: true
validates :worth_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :portfolio_id, uniqueness: { scope: :date }
def current_worth
worth_cents / 100.0
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
# NOTE: for calculations, use `PortfolioPosition` as an aggregate.
class PortfolioStock < ApplicationRecord
belongs_to :portfolio
belongs_to :stock
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class PortfolioTransaction < ApplicationRecord
TRANSACTION_FEE_CENTS = 1_00
# deposit/witdrawal is for cash transactions ie grades and attendance
# credit/debit is for stock transactions ie buy/sell stocks
enum :transaction_type, { deposit: 0, withdrawal: 1, credit: 2, debit: 3, fee: 4 }
enum :reason, {
math_earnings: 0,
reading_earnings: 1,
attendance_earnings: 2,
grade_earnings: 3, # Deprecated, will be removed in future
transaction_fees: 4,
awards: 5,
administrative_adjustments: 6
}, allow_nil: true
belongs_to :portfolio
has_one :order, dependent: :destroy
scope :deposits, -> { where(transaction_type: :deposit) }
scope :debits, -> { where(transaction_type: :debit) }
scope :credits, -> { where(transaction_type: :credit) }
scope :withdrawals, -> { where(transaction_type: :withdrawal) }
scope :fees, -> { where(transaction_type: :fee) }
end

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
class Quarter < ApplicationRecord
belongs_to :school_year
has_many :grade_books, dependent: :restrict_with_error
validates :number,
presence: true,
inclusion: { in: 1..4 },
uniqueness: { scope: :school_year_id }
scope :ordered, -> { order(:number) }
def next
return first_quarter_of_next_year if number == 4
school_year.quarters.ordered.find_by(number: number + 1)
end
def previous
return last_quarter_of_previous_year if number == 1
school_year.quarters.ordered.find_by(number: number - 1)
end
private
def first_quarter_of_next_year
@first_quarter_of_next_year ||= begin
next_year = school_year.year.next_year
if next_year
Quarter.joins(school_year: %i[school year])
.where(
school_years: { school: school_year.school },
years: { id: next_year.id },
number: 1
)
.first
end
end
end
def last_quarter_of_previous_year
@last_quarter_of_previous_year ||= begin
previous_year = school_year.year.previous_year
if previous_year
Quarter.joins(school_year: %i[school year])
.where(
school_years: { school: school_year.school },
years: { id: previous_year.id },
number: 4
)
.first
end
end
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class School < ApplicationRecord
has_many :school_years, dependent: :restrict_with_error
has_many :years, through: :school_years
validates :name, presence: true
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
class SchoolYear < ApplicationRecord
belongs_to :school
belongs_to :year
has_many :classrooms, dependent: :restrict_with_error
has_many :quarters, dependent: :destroy
delegate :name, to: :school, prefix: :school
delegate :name, to: :year, prefix: :year
after_create :create_quarters
def name
"#{school_name} (#{year_name})"
end
private
def create_quarters
(1..4).each do |n|
quarters.create!(name: "Quarter #{n}", number: n)
end
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
class Stock < ApplicationRecord
has_many :portfolio_stocks, dependent: :restrict_with_error
has_many :orders, dependent: :restrict_with_error
validates :ticker, presence: true
validates(
:company_website,
format: {
with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
allow_blank: true
}
)
scope :active, -> { where(archived: false) }
scope :archived, -> { where(archived: true) }
def current_price
price_cents.to_f / 100
end
def yesterday_price
return current_price if yesterday_price_cents.nil?
yesterday_price_cents.to_f / 100
end
def percentage_change
return 0.0 if yesterday_price_cents.nil? || yesterday_price_cents.zero?
((current_price - yesterday_price) / yesterday_price) * 100
end
def percentage_change_formatted
return "0.00%" if percentage_change.zero?
formatted = format("%.2f%%", percentage_change.abs)
percentage_change.positive? ? "+#{formatted}" : "-#{formatted}"
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
class Student < User
has_many :classroom_enrollments, dependent: :destroy
has_many :classrooms, through: :classroom_enrollments
# Ensure students have nil email by default (not empty string)
after_initialize :set_default_email, if: :new_record?
after_create :ensure_portfolio
after_create :create_initial_enrollment, if: -> { classroom_id.present? }
delegate :path, to: :portfolio, prefix: true, allow_nil: true
# Get the student's current (active) enrollments
#
# @return [ActiveRecord::Relation<ClassroomEnrollment>]
def current_enrollments
classroom_enrollments.current
end
# Get the student's current (active) classrooms
#
# @return [ActiveRecord::Relation<Classroom>]
def current_classrooms
classrooms.joins(:classroom_enrollments)
.merge(classroom_enrollments.current)
.distinct
end
# Get the student's primary enrollment
#
# @return [ClassroomEnrollment, nil]
def primary_enrollment
classroom_enrollments.primary_enrollment.current.first
end
# Get the student's primary classroom (for backward compatibility)
# Falls back to classroom_id if no primary enrollment exists
#
# @return [Classroom, nil]
def primary_classroom
primary_enrollment&.classroom || classroom
end
# Enroll the student in a classroom
#
# @param classroom [Classroom] the classroom to enroll in
# @param enrolled_at [DateTime] when the student enrolled (defaults to now)
# @param primary [Boolean] whether this should be the primary enrollment
# @return [ClassroomEnrollment]
def enroll_in!(classroom, enrolled_at: Time.current, primary: false)
enrollment = classroom_enrollments.create!(
classroom: classroom,
enrolled_at: enrolled_at,
primary: false
)
enrollment.make_primary! if primary
enrollment
end
# Unenroll the student from a classroom
#
# @param classroom [Classroom] the classroom to unenroll from
# @param unenrolled_at [DateTime] when the student was unenrolled (defaults to now)
# @return [void]
def unenroll_from!(classroom, unenrolled_at: Time.current)
current_enrollments.for_classroom(classroom).each do |enrollment|
enrollment.unenroll!(at: unenrolled_at)
end
end
private
def set_default_email
self.email = nil if email.blank?
end
def ensure_portfolio
create_portfolio! if portfolio.blank?
end
def create_initial_enrollment
# Create initial enrollment based on classroom_id
# Set as primary since it's the first enrollment
enroll_in!(classroom, enrolled_at: created_at, primary: true)
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
class Teacher < User
attr_accessor :school_id
has_many :teacher_classrooms, dependent: :destroy
has_many :classrooms, through: :teacher_classrooms
before_validation :sync_username_from_email
def display_name
name.presence || email&.split("@")&.first || "Teacher"
end
private
def sync_username_from_email
self.username = email if email.present?
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class TeacherClassroom < ApplicationRecord
belongs_to :teacher, class_name: "Teacher"
belongs_to :classroom
validates :teacher_id, uniqueness: { scope: :classroom_id }
end

View File

@@ -0,0 +1,83 @@
# frozen_string_literal: true
class User < ApplicationRecord
include Discard::Model
def destroy(*)
soft_delete_guard
discard
end
def destroy!(*)
soft_delete_guard
discard
end
def really_destroy!
ActiveRecord::Base.instance_method(:destroy).bind(self).call
end
belongs_to :classroom, optional: true
# Allow calling `user.school` (used in portfolio view) via the classroom's associated school.
# This prevents undefined method errors for Student records without directly adding a belongs_to.
delegate :school, to: :classroom, allow_nil: true
delegate :name, to: :school, prefix: :school, allow_nil: true
delegate :trading_enabled?, to: :classroom, allow_nil: true
has_one :portfolio, dependent: :destroy
accepts_nested_attributes_for :portfolio
has_many :orders, dependent: :destroy
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
validates :email, uniqueness: true, presence: false, allow_blank: true
validates :username, presence: true, uniqueness: true
validates :type, inclusion: { in: %w[User Student Teacher] }
scope :students, -> { where(type: "Student") }
scope :teachers, -> { where(type: "Teacher") }
scope :admins, -> { where(admin: true) }
def student?
type == "Student"
end
def teacher?
type == "Teacher"
end
def teacher_or_admin?
teacher? || admin?
end
def display_name
username.presence || email&.split("@")&.first || "User"
end
def email_required?
teacher? || admin?
end
def email_changed?
false
end
def holding?(stock)
portfolio&.shares_owned(stock.id)&.positive?
end
private
def soft_delete_guard
return if Rails.env.production?
raise <<~MSG.squish
❌ Hard delete attempted on #{self.class}. Use #discard instead,
or #really_destroy! if you are ABSOLUTELY sure you need a hard delete.
MSG
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
class Year < ApplicationRecord
has_many :school_years, dependent: :destroy
has_many :schools, through: :school_years
has_many :classrooms, through: :school_years
validates :name, presence: true, uniqueness: true
scope :ordered_by_start_year, -> { order(Arel.sql("CAST(SUBSTRING(name FROM 1 FOR 4) AS INTEGER) DESC")) }
def self.current_school_year(date = Date.current)
name = if date.month <= 6
"#{date.year - 1} - #{date.year}"
else
"#{date.year} - #{date.year + 1}"
end
where(name: name)
end
def previous_year
@previous_year || Year.find_by(name: "#{start_year_value - 1} - #{start_year_value}")
end
def next_year
@next_year || Year.find_by(name: "#{end_year_value} - #{end_year_value + 1}")
end
private
def start_year_value
@start_year_value ||= parsed_year_values.first
end
def end_year_value
@end_year_value ||= parsed_year_values.last
end
def parsed_year_values
@parsed_year_values ||= name.split(" - ").map(&:to_i)
end
end