diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 45ac849fb..abe7ef787 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -2,6 +2,7 @@ class Assignment < ApplicationRecord include MetricHelper + include DueDateActions has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'parent_id', dependent: :destroy has_many :users, through: :participants, inverse_of: :assignment has_many :teams, class_name: 'AssignmentTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :assignment @@ -11,8 +12,7 @@ class Assignment < ApplicationRecord has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy + has_many :due_dates, as: :parent, dependent: :destroy belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments @@ -118,17 +118,20 @@ def copy # Save the copied assignment to the database copied_assignment.save + # Copy all due dates to the new assignment + copy_due_dates_to(copied_assignment) + copied_assignment end def is_calibrated? is_calibrated end - + def pair_programming_enabled? enable_pair_programming end - + def has_badge? has_badge end diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb new file mode 100644 index 000000000..ae0111e8a --- /dev/null +++ b/app/models/concerns/due_date_actions.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module DueDateActions + # Generic activity permission checker that determines if an activity is permissible + # based on the current deadline state for this parent object + def activity_permissible?(activity) + current_deadline = next_due_date + return false unless current_deadline + + current_deadline.activity_permissible?(activity) + end + + # Activity permission checker for a specific deadline date (not current date) + def activity_permissible_for?(activity, deadline_date) + # Find the most recent due date that has passed by the given deadline_date + deadline = due_dates.where('due_at <= ?', deadline_date).order(:due_at).last + return false unless deadline + + deadline.activity_permissible?(activity) + end + + # Syntactic sugar methods for common activities + # These provide clean, readable method names while avoiding DRY violations + def submission_permissible? + activity_permissible?(:submission) + end + + def review_permissible? + activity_permissible?(:review) + end + + def teammate_review_permissible? + activity_permissible?(:teammate_review) + end + + def quiz_permissible? + activity_permissible?(:quiz) + end + + def team_formation_permissible? + activity_permissible?(:team_formation) + end + + def signup_permissible? + activity_permissible?(:signup) + end + + def drop_topic_permissible? + activity_permissible?(:drop_topic) + end + + # Return the next upcoming due date. If topic_id is given and topic-specific + # deadlines exist, prefer that topic's next deadline; otherwise fall back to + # assignment-level deadlines. + # + # @param topic_id [Integer, nil] optional topic ID to prefer topic-specific deadlines + # @return [DueDate, nil] next upcoming due date or nil if none exist + def next_due_date(topic_id = nil) + # If a topic is specified and this assignment has topic-specific deadlines, + # look for topic due dates first + if topic_id && has_topic_specific_deadlines? + topic_deadline = due_dates.where(parent_id: topic_id, parent_type: 'ProjectTopic').upcoming.first + return topic_deadline if topic_deadline + end + + # Fall back to assignment-level due dates + # This handles both cases: + # 1. No topic specified + # 2. Topic specified but no topic-specific deadlines found + due_dates.upcoming.first + end + + # Get the current stage name for display purposes + def current_stage + deadline = next_due_date + return 'finished' unless deadline + + deadline.deadline_type&.name || 'unknown' + end + + # Check if assignment has topic-specific deadlines + def has_topic_specific_deadlines? + staggered_deadline || due_dates.where(parent_type: 'ProjectTopic').exists? + end + + # Copy due dates to a new parent object + def copy_due_dates_to(new_parent) + due_dates.find_each do |due_date| + new_due_date = due_date.dup + new_due_date.parent = new_parent + new_due_date.save! + end + end + + # Shift deadlines of a specific type by a time interval + def shift_deadlines_of_type(deadline_type_name, days) + due_dates.joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .update_all("due_at = due_at + INTERVAL #{days.to_i} DAY") + end + + # Check if deadlines are in proper chronological order + def deadlines_properly_ordered? + previous_due = nil + due_dates.order(:id).each do |deadline| + return false if previous_due && deadline.due_at < previous_due + + previous_due = deadline.due_at + end + true + end +end diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb new file mode 100644 index 000000000..74ee48a2c --- /dev/null +++ b/app/models/concerns/due_date_permissions.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module DueDatePermissions + # Permission checking methods that combine deadline-based and role-based logic + # + # As Ed explained, these methods must check both: + # 1. Whether the action is permitted by the current deadline (submission_allowed_id, review_allowed_id, etc.) + # 2. Whether the participant has the necessary permissions (can_submit, can_review, can_take_quiz fields) + # + # The participant object represents how a user is participating in the assignment. + # Not all participants can do all actions - some might only submit, others only review, + # and others might only take quizzes. The can_* fields control these permissions. + + # Check if submission is allowed based on both deadline and participant permissions + # @param participant [Participant, nil] The participant to check permissions for. + # If nil, only checks deadline-based permissions. + # @return [Boolean] true if submission is allowed, false otherwise + def can_submit?(participant = nil) + return false unless submission_allowed_id + return false if participant && !participant.can_submit + + deadline_right = DeadlineRight.find_by(id: submission_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Check if review is allowed based on both deadline and participant permissions + # @param participant [Participant, nil] The participant to check permissions for. + # If nil, only checks deadline-based permissions. + # @return [Boolean] true if review is allowed, false otherwise + def can_review?(participant = nil) + return false unless review_allowed_id + return false if participant && !participant.can_review + + deadline_right = DeadlineRight.find_by(id: review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Check if taking a quiz is allowed based on both deadline and participant permissions + # @param participant [Participant, nil] The participant to check permissions for. + # If nil, only checks deadline-based permissions. + # @return [Boolean] true if taking quiz is allowed, false otherwise + def can_take_quiz?(participant = nil) + return false unless quiz_allowed_id + return false if participant && !participant.can_take_quiz + + deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Check if teammate review is allowed based on both deadline and participant permissions + # Note: teammate review uses the can_review permission field + # @param participant [Participant, nil] The participant to check permissions for. + # If nil, only checks deadline-based permissions. + # @return [Boolean] true if teammate review is allowed, false otherwise + def can_review_teammate?(participant = nil) + return false unless teammate_review_allowed_id + return false if participant && !participant.can_review + + deadline_right = DeadlineRight.find_by(id: teammate_review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Generic permission checker + def activity_permissible?(activity) + permission_field = "#{activity}_allowed_id" + return false unless respond_to?(permission_field) + + allowed_id = public_send(permission_field) + return false unless allowed_id + + deadline_right = DeadlineRight.find_by(id: allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Get permission status for an action (OK, Late, No) + def permission_status_for(action) + permission_field = "#{action}_allowed_id" + return 'No' unless respond_to?(permission_field) + + allowed_id = public_send(permission_field) + return 'No' unless allowed_id + + deadline_right = DeadlineRight.find_by(id: allowed_id) + deadline_right&.name || 'No' + end +end diff --git a/app/models/deadline_right.rb b/app/models/deadline_right.rb new file mode 100644 index 000000000..8e668eaa7 --- /dev/null +++ b/app/models/deadline_right.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class DeadlineRight < ApplicationRecord + validates :name, presence: true, uniqueness: true + + # Permission checking methods + def allows_action? + %w[OK Late].include?(name) + end + + def denies_action? + name == 'No' + end + + def allows_with_penalty? + name == 'Late' + end + + def allows_without_penalty? + name == 'OK' + end + + # Display methods + def to_s + name + end + + def permission_level + case name + when 'No' + 0 + when 'Late' + 1 + when 'OK' + 2 + else + -1 + end + end + + def <=>(other) + return nil unless other.is_a?(DeadlineRight) + + permission_level <=> other.permission_level + end +end diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb new file mode 100644 index 000000000..63c11ae11 --- /dev/null +++ b/app/models/deadline_type.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class DeadlineType < ApplicationRecord + validates :name, presence: true, uniqueness: true + validates :description, presence: true + + has_many :due_dates, foreign_key: :deadline_type_id, dependent: :restrict_with_exception + + # Semantic helper methods for deadline type identification + def submission? + name == 'submission' + end + + def review? + name == 'review' + end + + def teammate_review? + name == 'teammate_review' + end + + def quiz? + name == 'quiz' + end + + def team_formation? + name == 'team_formation' + end + + def signup? + name == 'signup' + end + + def drop_topic? + name == 'drop_topic' + end + + # Display methods + def display_name + name.humanize + end + + def to_s + display_name + end + + +end diff --git a/app/models/due_date.rb b/app/models/due_date.rb index ed310bef5..ece872cf4 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -1,60 +1,108 @@ # frozen_string_literal: true class DueDate < ApplicationRecord - include Comparable - # Named constants for teammate review statuses - ALLOWED = 3 - LATE_ALLOWED = 2 - NOT_ALLOWED = 1 + include DueDatePermissions belongs_to :parent, polymorphic: true - validate :due_at_is_valid_datetime + belongs_to :deadline_type, foreign_key: :deadline_type_id + validates :due_at, presence: true + validates :deadline_type_id, presence: true + validates :parent, presence: true + validates :round, numericality: { greater_than: 0 }, allow_nil: true + validate :due_at_is_valid_datetime - attr_accessor :teammate_review_allowed, :submission_allowed, :review_allowed + # Scopes for common queries + scope :upcoming, -> { where('due_at > ?', Time.current).order(:due_at) } + scope :overdue, -> { where('due_at < ?', Time.current).order(:due_at) } + scope :for_round, ->(round_num) { where(round: round_num) } + scope :for_deadline_type, ->(type_name) { joins(:deadline_type).where(deadline_types: { name: type_name }) } - def due_at_is_valid_datetime - errors.add(:due_at, 'must be a valid datetime') unless due_at.is_a?(Time) + # Check if this deadline has passed + def overdue? + due_at < Time.current end - # Method to compare due dates - def <=>(other) - due_at <=> other.due_at + # Check if this deadline is upcoming + def upcoming? + due_at > Time.current end - # Return the set of due dates sorted by due_at - def self.sort_due_dates(due_dates) - due_dates.sort_by(&:due_at) + def set(deadline_type_id, parent_id, round) + self.deadline_type_id = deadline_type_id + self.parent_id = parent_id + self.round = round + save! end - # Fetches all due dates for the parent Assignment or Topic - def self.fetch_due_dates(parent_id) - due_dates = where('parent_id = ?', parent_id) - sort_due_dates(due_dates) + def copy(to_assignment_id) + to_assignment = Assignment.find(to_assignment_id) + new_due_date = dup + new_due_date.parent = to_assignment + new_due_date.save! + new_due_date end - # Class method to check if any due date is in the future - def self.any_future_due_dates?(due_dates) - due_dates.any? { |due_date| due_date.due_at > Time.zone.now } + # Get the deadline type name + def deadline_type_name + deadline_type&.name end - def set(deadline, assignment_id, max_round) - self.deadline_type_id = deadline - self.parent_id = assignment_id - self.round = max_round - save + # Check if this is the last deadline for the parent + def last_deadline? + parent.due_dates.where('due_at > ?', due_at).empty? end - # Fetches due dates from parent then selects the next upcoming due date - def self.next_due_date(parent_id) - due_dates = fetch_due_dates(parent_id) - due_dates.find { |due_date| due_date.due_at > Time.zone.now } + # Comparison method for sorting + def <=>(other) + return nil unless other.is_a?(DueDate) + + due_at <=> other.due_at end - # Creates duplicate due dates and assigns them to a new assignment - def copy(new_assignment_id) - new_due_date = dup - new_due_date.parent_id = new_assignment_id - new_due_date.save + # String representation + def to_s + "#{deadline_type_name} - Due #{due_at.strftime('%B %d, %Y at %I:%M %p')}" + end + + # Class methods for collection operations + class << self + # Sort a collection of due dates chronologically + def sort_due_dates(due_dates) + due_dates.sort_by(&:due_at) + end + + # Check if any due dates in the future exist for a collection + def any_future_due_dates?(due_dates) + due_dates.any?(&:upcoming?) + end + + def copy(from_assignment_id, to_assignment_id) + from_assignment = Assignment.find(from_assignment_id) + to_assignment = Assignment.find(to_assignment_id) + + from_assignment.due_dates.each do |due_date| + new_due_date = due_date.dup + new_due_date.parent = to_assignment + new_due_date.save! + end + end + end + + private + + def due_at_is_valid_datetime + return unless due_at.present? + + return if due_at.is_a?(Time) || due_at.is_a?(DateTime) + + errors.add(:due_at, 'must be a valid datetime') + end + + # Set default round if not specified + before_save :set_default_round + + def set_default_round + self.round ||= 1 end end diff --git a/app/models/sign_up_topic.rb b/app/models/sign_up_topic.rb index 1d89b687b..4dec21d82 100644 --- a/app/models/sign_up_topic.rb +++ b/app/models/sign_up_topic.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class SignUpTopic < ApplicationRecord + include DueDateActions has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy - has_many :due_dates, as: :parent,class_name: 'DueDate', dependent: :destroy + # Note: due_dates association is provided by DueDateActions mixin belongs_to :assignment end diff --git a/app/models/topic_due_date.rb b/app/models/topic_due_date.rb index 739e0ad0d..a26fff0a5 100644 --- a/app/models/topic_due_date.rb +++ b/app/models/topic_due_date.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true class TopicDueDate < DueDate - # overwrite super method with additional logic to check for topic first - def self.next_due_date(assignment_id, topic_id) - next_due_date = super(topic_id) - - next_due_date ||= DueDate.next_due_date(assignment_id) - - next_due_date - end + # TopicDueDate is a subclass of DueDate that uses single table inheritance + # The 'type' field in the database will be automatically set to 'TopicDueDate' + # when instances of this class are created. + # + # This class inherits all functionality from DueDate and doesn't need + # any additional methods since the parent class and DueDateActions concern + # already handle topic-specific due date logic properly. end diff --git a/db/migrate/20241201000001_create_deadline_types.rb b/db/migrate/20241201000001_create_deadline_types.rb new file mode 100644 index 000000000..e25e98104 --- /dev/null +++ b/db/migrate/20241201000001_create_deadline_types.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class CreateDeadlineTypes < ActiveRecord::Migration[7.0] + def change + create_table :deadline_types do |t| + t.string :name, null: false + t.text :description, null: false + + t.timestamps + end + + add_index :deadline_types, :name, unique: true + + change_column :due_dates, :deadline_type_id, :bigint + + # Add foreign key constraint to due_dates table + # add_foreign_key :due_dates, :deadline_types, column: :deadline_type_id + + # Seed canonical deadline type data + reversible do |dir| + dir.up do + deadline_types = [ + { id: 1, name: 'submission', description: 'Student work submission deadlines' }, + { id: 2, name: 'review', description: 'Peer review deadlines' }, + { id: 3, name: 'teammate_review', description: 'Team member evaluation deadlines' }, + { id: 5, name: 'metareview', description: 'Meta-review deadlines (kept for backward compatibility)' }, + { id: 6, name: 'drop_topic', description: 'Topic drop deadlines' }, + { id: 7, name: 'signup', description: 'Course/assignment signup deadlines' }, + { id: 8, name: 'team_formation', description: 'Team formation deadlines' }, + { id: 11, name: 'quiz', description: 'Quiz completion deadlines' } + ] + + deadline_types.each do |type_attrs| + execute <<~SQL + INSERT INTO deadline_types (id, name, description, created_at, updated_at) + VALUES (#{type_attrs[:id]}, '#{type_attrs[:name]}', '#{type_attrs[:description]}', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW() + SQL + end + + # Clean up any duplicate team_formation entries (keeping ID 8) + execute <<~SQL + DELETE FROM deadline_types + WHERE name = 'team_formation' AND id != 8 + SQL + + # Update any due_dates that might reference the duplicate ID + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 8 + WHERE deadline_type_id IN ( + SELECT id FROM deadline_types + WHERE name = 'team_formation' AND id != 8 + ) + SQL + end + + dir.down do + # Remove foreign key constraint before dropping table + remove_foreign_key :due_dates, :deadline_types + end + end + end +end diff --git a/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb b/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb new file mode 100644 index 000000000..fadb0b506 --- /dev/null +++ b/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class AddDeadlineTypeForeignKeyToDueDates < ActiveRecord::Migration[7.0] + def change + # Ensure deadline_type_id column exists and is properly typed + unless column_exists?(:due_dates, :deadline_type_id) + add_column :due_dates, :deadline_type_id, :integer, null: false + end + + # Clean up any invalid deadline_type_id references before adding foreign key + reversible do |dir| + dir.up do + # Update any due_dates with invalid deadline_type_id to use submission (ID: 1) + # This handles orphaned records that might reference non-existent deadline types + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 1 + WHERE deadline_type_id NOT IN (1, 2, 3, 5, 6, 7, 8, 11) + OR deadline_type_id IS NULL + SQL + + # Clean up any duplicate team_formation references (use canonical ID 8) + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 8 + WHERE deadline_type_id = 10 + SQL + end + end + + # Add foreign key constraint to ensure referential integrity + add_foreign_key :due_dates, :deadline_types, column: :deadline_type_id, + on_delete: :restrict, on_update: :cascade + + # Add index for better query performance + add_index :due_dates, :deadline_type_id unless index_exists?(:due_dates, :deadline_type_id) + + # Add composite index for common query patterns + add_index :due_dates, [:parent_type, :parent_id, :deadline_type_id], + name: 'index_due_dates_on_parent_and_deadline_type' unless + index_exists?(:due_dates, [:parent_type, :parent_id, :deadline_type_id]) + end +end diff --git a/db/migrate/20241201000003_create_deadline_rights.rb b/db/migrate/20241201000003_create_deadline_rights.rb new file mode 100644 index 000000000..5afc6732f --- /dev/null +++ b/db/migrate/20241201000003_create_deadline_rights.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CreateDeadlineRights < ActiveRecord::Migration[7.0] + def change + create_table :deadline_rights do |t| + t.string :name, null: false + t.text :description, null: false + + t.timestamps + end + + add_index :deadline_rights, :name, unique: true + + # Seed the deadline rights with canonical data + reversible do |dir| + dir.up do + deadline_rights = [ + { id: 1, name: 'No', description: 'Action is not allowed' }, + { id: 2, name: 'Late', description: 'Action is allowed with late penalty' }, + { id: 3, name: 'OK', description: 'Action is allowed without penalty' } + ] + + deadline_rights.each do |right_attrs| + execute <<~SQL + INSERT INTO deadline_rights (id, name, description, created_at, updated_at) + VALUES (#{right_attrs[:id]}, '#{right_attrs[:name]}', '#{right_attrs[:description]}', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW() + SQL + end + end + + dir.down do + # Remove all deadline rights + execute "DELETE FROM deadline_rights" + end + end + end +end diff --git a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb index cd2fb7094..8e7a1f18f 100644 --- a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb +++ b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb @@ -6,6 +6,8 @@ def change remove_reference :teams, :assignment, foreign_key: true # Add polymorphic association fields (type column already exists) + return if column_exists?(:teams, :parent_id) + add_column :teams, :parent_id, :integer, null: false end end diff --git a/db/migrate/20250626161114_add_name_to_teams.rb b/db/migrate/20250626161114_add_name_to_teams.rb index d18f94bcd..546b4d06e 100644 --- a/db/migrate/20250626161114_add_name_to_teams.rb +++ b/db/migrate/20250626161114_add_name_to_teams.rb @@ -1,5 +1,5 @@ class AddNameToTeams < ActiveRecord::Migration[8.0] def change - add_column :teams, :name, :string + # add_column :teams, :name, :string end end diff --git a/db/schema.rb b/db/schema.rb index d3a15fcfa..bb89e02be 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_29_071649) do +ActiveRecord::Schema[8.0].define(version: 2025_07_27_170825) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -125,11 +125,6 @@ t.datetime "updated_at", null: false end - create_table "cakes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "directory_path" @@ -144,9 +139,25 @@ t.index ["instructor_id"], name: "index_courses_on_instructor_id" end + create_table "deadline_rights", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.text "description", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_deadline_rights_on_name", unique: true + end + + create_table "deadline_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.text "description", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_deadline_types_on_name", unique: true + end + create_table "due_dates", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "due_at", null: false - t.integer "deadline_type_id", null: false + t.bigint "deadline_type_id", null: false t.string "parent_type", null: false t.bigint "parent_id", null: false t.integer "submission_allowed_id", null: false @@ -165,6 +176,8 @@ t.integer "review_of_review_allowed_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["deadline_type_id"], name: "fk_rails_ee95f82c0c" + t.index ["parent_type", "parent_id", "deadline_type_id"], name: "index_due_dates_on_parent_and_deadline_type" t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end @@ -176,16 +189,14 @@ create_table "invitations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" + t.integer "from_id" + t.integer "to_id" t.string "reply_status", limit: 1 t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "from_id", null: false - t.bigint "to_id", null: false - t.bigint "participant_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "index_invitations_on_from_id" - t.index ["participant_id"], name: "index_invitations_on_participant_id" - t.index ["to_id"], name: "index_invitations_on_to_id" + t.index ["from_id"], name: "fk_invitationfrom_users" + t.index ["to_id"], name: "fk_invitationto_users" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -211,7 +222,7 @@ t.integer "participant_id" t.integer "team_id" t.text "comments" - t.string "reply_status" + t.string "status" end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -295,7 +306,6 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "type" t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -342,8 +352,6 @@ t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "comments_for_advertisement" - t.boolean "advertise_for_partner" t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -360,12 +368,17 @@ create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false + t.integer "parent_id" t.string "type", null: false + t.boolean "advertise_for_partner", default: false, null: false + t.text "submitted_hyperlinks" + t.integer "directory_num" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "parent_id", null: false t.integer "grade_for_submission" t.string "comment_for_submission" + t.index ["parent_id"], name: "index_teams_on_parent_id" + t.index ["type"], name: "index_teams_on_type" end create_table "teams_participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -385,6 +398,7 @@ t.bigint "user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["team_id", "user_id"], name: "index_teams_users_on_team_id_and_user_id", unique: true t.index ["team_id"], name: "index_teams_users_on_team_id" t.index ["user_id"], name: "index_teams_users_on_user_id" end @@ -422,8 +436,7 @@ add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "invitations", "participants", column: "from_id" - add_foreign_key "invitations", "participants", column: "to_id" + add_foreign_key "due_dates", "deadline_types", on_update: :cascade add_foreign_key "items", "questionnaires" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" diff --git a/lib/tasks/deadline_demo.rake b/lib/tasks/deadline_demo.rake new file mode 100644 index 000000000..12662c314 --- /dev/null +++ b/lib/tasks/deadline_demo.rake @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +namespace :deadline do + desc "Demonstrate the simplified due date functionality" + task demo: :environment do + puts "=" * 80 + puts "Simplified Due Date System Demo" + puts "=" * 80 + puts + + puts "1. Creating demo assignment..." + assignment = Assignment.create!( + name: "Demo Assignment - Simplified DueDate", + description: "Demonstration of the simplified deadline system", + max_team_size: 3, + instructor: User.find_by(role: Role.find_by(name: 'Instructor')) || User.first + ) + + puts "Created assignment: #{assignment.name} (ID: #{assignment.id})" + puts + + puts "2. Creating due dates..." + + submission_deadline = DueDate.create!( + parent: assignment, + deadline_type_id: 1, + due_at: 2.weeks.from_now, + submission_allowed_id: 3, + review_allowed_id: 1, + teammate_review_allowed_id: 1, + quiz_allowed_id: 1, + round: 1 + ) + + DueDate.create!( + parent: assignment, + deadline_type_id: 2, + due_at: 3.weeks.from_now, + submission_allowed_id: 2, + review_allowed_id: 3, + teammate_review_allowed_id: 1, + quiz_allowed_id: 1, + round: 1 + ) + + DueDate.create!( + parent: assignment, + deadline_type_id: 3, + due_at: 4.weeks.from_now, + submission_allowed_id: 1, + review_allowed_id: 2, + teammate_review_allowed_id: 3, + quiz_allowed_id: 1, + round: 1 + ) + + puts "Created #{assignment.due_dates.count} deadlines for the assignment" + puts + + puts "3. Permission checking demo:" + puts "Can submit: #{assignment.submission_permissible?}" + puts "Can review: #{assignment.review_permissible?}" + puts "Can teammate review: #{assignment.teammate_review_permissible?}" + puts "Can take quiz: #{assignment.quiz_permissible?}" + puts + + puts "4. Deadline query methods demo:" + next_deadline = assignment.next_due_date + puts "Next due date: #{next_deadline&.deadline_type_name || 'None'}" + puts "Current stage: #{assignment.current_stage}" + puts "Has topic specific deadlines: #{assignment.has_topic_specific_deadlines?}" + puts + + puts "5. Due date properties demo:" + assignment.due_dates.each do |due_date| + puts "#{due_date.deadline_type_name.ljust(15)} | #{due_date.overdue? ? 'Overdue' : 'Upcoming'} | #{due_date}" + end + puts + + puts "6. Deadline copying demo..." + new_assignment = Assignment.create!( + name: "Copied Assignment", + description: "Copy of demo assignment", + max_team_size: 3, + instructor: assignment.instructor + ) + + assignment.copy_due_dates_to(new_assignment) + puts "Original assignment deadlines: #{assignment.due_dates.count}" + puts "Copied assignment deadlines: #{new_assignment.due_dates.count}" + puts "Copy successful: #{assignment.due_dates.count == new_assignment.due_dates.count}" + puts + + puts "7. Class method demos:" + all_due_dates = assignment.due_dates.to_a + sorted_dates = DueDate.sort_due_dates(all_due_dates) + puts "Sorted #{sorted_dates.count} due dates chronologically" + puts "Any future due dates: #{DueDate.any_future_due_dates?(all_due_dates)}" + puts + + puts "8. Deadline ordering demo:" + puts "Deadlines properly ordered: #{assignment.deadlines_properly_ordered?}" + puts + + puts "9. Cleaning up demo data..." + new_assignment.destroy + assignment.destroy + puts "Demo completed successfully!" + puts + puts "=" * 80 + puts "Summary of Features Demonstrated:" + puts "- Basic permission checking methods" + puts "- Next due date functionality" + puts "- Due date copying between assignments" + puts "- Chronological date sorting" + puts "- Class methods for date management" + puts "=" * 80 + end + + desc "Show deadline statistics" + task stats: :environment do + puts "Deadline System Statistics" + puts "=" * 50 + puts "DeadlineTypes: #{DeadlineType.count}" + DeadlineType.all.each do |dt| + puts " #{dt.name}: #{dt.due_dates.count} due dates" + end + puts + puts "DeadlineRights: #{DeadlineRight.count}" + DeadlineRight.all.each do |dr| + puts " #{dr.name}: used in system" + end + puts + puts "DueDates: #{DueDate.count}" + puts " Upcoming: #{DueDate.upcoming.count}" + puts " Overdue: #{DueDate.overdue.count}" + puts + end +end diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index 58fd6a45f..52e7f64df 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true - require 'rails_helper' RSpec.describe Assignment, type: :model do - - let(:team) {Team.new} + let(:team) { Team.new } let(:assignment) { Assignment.new(id: 1, name: 'Test Assignment') } let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } let(:answer) { Answer.new(answer: 1, comments: 'Answer text', item_id: 1) } @@ -31,8 +29,174 @@ describe '.volume_of_review_comments' do it 'returns volumes of review comments in each round' do allow(assignment).to receive(:get_all_review_comments).with(1) - .and_return([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) + .and_return([ + [nil, 'Answer text', 'Answer textLGTM', + ''], [nil, 1, 1, 0] + ]) expect(assignment.volume_of_review_comments(1)).to eq([1, 2, 2, 0]) end end + + # Create deadline types for testing + let!(:submission_type) { DeadlineType.create(name: 'submission', description: 'Submission deadline') } + let!(:review_type) { DeadlineType.create(name: 'review', description: 'Review deadline') } + let!(:quiz_type) { DeadlineType.create(name: 'quiz', description: 'Quiz deadline') } + + # Create deadline rights for testing + let!(:ok_right) { DeadlineRight.create(name: 'OK', description: '') } + let!(:late_right) { DeadlineRight.create(name: 'Late', description: '') } + let!(:no_right) { DeadlineRight.create(name: 'No', description: '') } + + describe '#activity_permissible?' do + context 'when next_due_date allows the activity' do + let!(:due_date) do + DueDate.create!( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: late_right.id + ) + end + + it 'returns true for allowed activity' do + expect(assignment.activity_permissible?(:submission)).to eq(true) + end + end + + context 'when no upcoming due_date exists' do + it 'returns false' do + expect(assignment.activity_permissible?(:submission)).to eq(false) + end + end + end + + describe '#submission_permissible?' do + it 'delegates to activity_permissible?' do + allow(assignment).to receive(:activity_permissible?).with(:submission).and_return(true) + expect(assignment.submission_permissible?).to eq(true) + end + end + + describe '#activity_permissible_for?' do + include ActiveSupport::Testing::TimeHelpers + + let!(:past_due_date) do + DueDate.create!( + parent: assignment, + due_at: 2.days.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, # submission = true + review_allowed_id: no_right.id + ) + end + + let!(:future_due_date) do + DueDate.create!( + parent: assignment, + due_at: 3.days.from_now, + deadline_type: submission_type, + submission_allowed_id: no_right.id, # submission = false + review_allowed_id: ok_right.id + ) + end + + it 'returns permission of the most recent past deadline' do + result = assignment.activity_permissible_for?(:submission, Time.current) + expect(result).to eq(true) # OK from past_due_date + end + + it 'returns false when the most recent past deadline forbids the activity' do + # past due date forbids submission now + past_due_date.update!(submission_allowed_id: no_right.id) + + result = assignment.activity_permissible_for?(:submission, Time.current) + expect(result).to eq(false) + end + + it 'ignores future deadlines when evaluating permissions' do + # Even though future due date forbids submission, + # the past due date is still used because deadline_date = Time.now + expect(assignment.activity_permissible_for?(:submission, Time.current)).to eq(true) + end + + it 'uses a future-point-in-time to select future deadline' do + travel_to(4.days.from_now) do + result = assignment.activity_permissible_for?(:submission, Time.current) + expect(result).to eq(false) # now future_due_date becomes the "past" one + end + end + + it 'returns false when no deadlines exist before the given time' do + travel_to(3.days.ago - 1.hour) do + result = assignment.activity_permissible_for?(:submission, Time.current) + expect(result).to eq(false) + end + end + end + + describe '#next_due_date' do + it 'returns the earliest upcoming due date' do + due1 = DueDate.create!( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: no_right.id + ) + due2 = DueDate.create!( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: no_right.id + ) + + expect(assignment.next_due_date).to eq(due1) + end + + it 'returns nil when no upcoming due dates' do + expect(assignment.next_due_date).to eq(nil) + end + end + + describe '#deadlines_properly_ordered?' do + it 'returns true when chronological' do + DueDate.create!( + parent: assignment, + due_at: 1.day.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: no_right.id + ) + DueDate.create!( + parent: assignment, + due_at: 3.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: no_right.id + ) + + expect(assignment.deadlines_properly_ordered?).to eq(true) + end + + it 'returns false when ordering wrong' do + DueDate.create!( + parent: assignment, + due_at: 3.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + DueDate.create!( + parent: assignment, + due_at: 1.day.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + + expect(assignment.deadlines_properly_ordered?).to eq(false) + end + end end diff --git a/spec/models/due_date_spec.rb b/spec/models/due_date_spec.rb index 510a583a5..2b59b05b2 100644 --- a/spec/models/due_date_spec.rb +++ b/spec/models/due_date_spec.rb @@ -1,208 +1,919 @@ # frozen_string_literal: true -# spec/models/due_date_spec.rb require 'rails_helper' RSpec.describe DueDate, type: :model do - let(:role) {Role.create(name: 'Instructor', parent_id: nil, id: 2, default_page_id: nil)} - let(:instructor) { Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role: role) } - - describe '.fetch_due_dates' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - - it 'fetches all the due_dates from a due_date\'s parent' do - due_date1 = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date2 = DueDate.create(parent: assignment, due_at: 3.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date3 = DueDate.create(parent: assignment, due_at: 4.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date4 = DueDate.create(parent: assignment, due_at: 2.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date5 = DueDate.create(parent: assignment, due_at: 3.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_dates = [due_date1, due_date2, due_date3, due_date4, due_date5] - fetched_due_dates = DueDate.fetch_due_dates(due_date3.parent_id) - - fetched_due_dates.each { |due_date| expect(due_dates).to include(due_date) } - end + let(:role) { Role.create(name: 'Instructor', parent_id: nil, id: 2, default_page_id: nil) } + let(:instructor) do + Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', + role: role) end + let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor: instructor) } + let(:assignment2) { Assignment.create(id: 2, name: 'Test Assignment2', instructor: instructor) } + + # Create deadline types for testing + let!(:submission_type) { DeadlineType.create(name: 'submission', description: 'Submission deadline') } + let!(:review_type) { DeadlineType.create(name: 'review', description: 'Review deadline') } + let!(:quiz_type) { DeadlineType.create(name: 'quiz', description: 'Quiz deadline') } + + # Create deadline rights for testing + let!(:ok_right) { DeadlineRight.create(name: 'OK', description: '') } + let!(:late_right) { DeadlineRight.create(name: 'Late', description: '') } + let!(:no_right) { DeadlineRight.create(name: 'No', description: '') } + + describe 'validations' do + context 'when parent is missing' do + let(:due_date) do + DueDate.new( + parent: nil, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'is invalid' do + expect(due_date).to be_invalid + end - describe '.sort_due_dates' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - it 'sorts a list of due dates from earliest to latest' do - due_date1 = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date2 = DueDate.create(parent: assignment, due_at: 3.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date3 = DueDate.create(parent: assignment, due_at: 4.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date4 = DueDate.create(parent: assignment, due_at: 2.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date5 = DueDate.create(parent: assignment, due_at: 3.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - sorted_dates = DueDate.sort_due_dates([due_date1, due_date2, due_date3, due_date4, due_date5]) - - expect(sorted_dates).to eq([due_date5, due_date4, due_date1, due_date2, due_date3]) + it 'has error on parent field' do + due_date.valid? + expect(due_date.errors[:parent]).to include('must exist') + end end - end - describe '.any_future_due_dates?' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - it 'returns true when a future due date exists' do - due_date1 = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date2 = DueDate.create(parent: assignment, due_at: 3.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date3 = DueDate.create(parent: assignment, due_at: 4.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_dates = [due_date1, due_date2, due_date3] - expect(DueDate.any_future_due_dates?(due_dates)).to(be true) - end - - it 'returns true when a no future due dates exist' do - due_date1 = DueDate.create(parent: assignment, due_at: 2.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date2 = DueDate.create(parent: assignment, due_at: 3.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date3 = DueDate.create(parent: assignment, due_at: 4.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_dates = [due_date1, due_date2, due_date3] - expect(DueDate.any_future_due_dates?(due_dates)).to(be false) + context 'when due_at is missing' do + let(:due_date) do + DueDate.new( + parent: assignment, + due_at: nil, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'is invalid' do + expect(due_date).to be_invalid + end + + it 'has error on due_at field' do + due_date.valid? + expect(due_date.errors[:due_at]).to include("can't be blank") + end end - end - describe '.set' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - let(:assignment2) { Assignment.create(id: 2, name: 'Test Assignment2', instructor:) } - it 'returns true when a future due date exists' do - due_date = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - expect(due_date.deadline_type_id).to(be 3) - expect(due_date.parent).to(be assignment) - expect(due_date.round).to(be nil) + context 'when deadline_type_id is missing' do + let(:due_date) do + DueDate.new( + parent: assignment, + due_at: 2.days.from_now, + deadline_type_id: nil, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end - due_date.set(1, assignment2.id, 1) + it 'is invalid' do + expect(due_date).to be_invalid + end - expect(due_date.deadline_type_id).to(be 1) - expect(due_date.parent).to eq(assignment2) - expect(due_date.round).to(be 1) + it 'has error on deadline_type_id field' do + due_date.valid? + expect(due_date.errors[:deadline_type_id]).to include("can't be blank") + end + end + + context 'when all required fields are present' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'is valid' do + expect(due_date).to be_valid + end + + it 'persists to database' do + expect(due_date).to be_persisted + end + end + + context 'when round validation' do + context 'with round value of 0' do + let(:due_date) do + DueDate.new( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 0 + ) + end + + it 'is invalid' do + expect(due_date).to be_invalid + end + + it 'has error on round field' do + due_date.valid? + expect(due_date.errors[:round]).to be_present + end + end + + context 'with negative round value' do + let(:due_date) do + DueDate.new( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: -1 + ) + end + + it 'is invalid' do + expect(due_date).to be_invalid + end + end + + context 'with nil round' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: nil + ) + end + + it 'is valid' do + expect(due_date).to be_valid + end + + it 'sets default round to 1' do + expect(due_date.round).to eq(1) + end + end + + context 'with positive round value' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 3 + ) + end + + it 'is valid' do + expect(due_date).to be_valid + end + + it 'preserves the round value' do + expect(due_date.round).to eq(3) + end + end end end - describe '.copy' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - let(:assignment2) { Assignment.create(id: 2, name: 'Test Assignment2', instructor:) } - it 'copies the due dates from one assignment to another' do - due_date1 = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date2 = DueDate.create(parent: assignment, due_at: 3.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - due_date3 = DueDate.create(parent: assignment, due_at: 3.days.ago, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) + describe 'scopes' do + let!(:past_due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + let!(:upcoming_due_date1) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + let!(:upcoming_due_date2) do + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: quiz_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + describe '.upcoming' do + it 'returns only future due dates' do + upcoming = DueDate.upcoming + expect(upcoming).to contain_exactly(upcoming_due_date1, upcoming_due_date2) + end + + it 'orders results by due_at ascending' do + upcoming = DueDate.upcoming + expect(upcoming).to eq([upcoming_due_date1, upcoming_due_date2]) + end + + it 'excludes past due dates' do + upcoming = DueDate.upcoming + expect(upcoming).not_to include(past_due_date) + end + + context 'when all due dates are in the past' do + before do + DueDate.destroy_all + DueDate.create( + parent: assignment, + due_at: 3.days.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns empty collection' do + expect(DueDate.upcoming).to be_empty + end + end + end + + describe '.overdue' do + it 'returns only past due dates' do + overdue = DueDate.overdue + expect(overdue).to contain_exactly(past_due_date) + end + + it 'orders results by due_at ascending' do + past_due_date2 = DueDate.create( + parent: assignment, + due_at: 5.days.ago, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + overdue = DueDate.overdue + expect(overdue.first).to eq(past_due_date2) + end + + it 'excludes future due dates' do + overdue = DueDate.overdue + expect(overdue).not_to include(upcoming_due_date1) + end + + context 'when all due dates are in the future' do + before do + DueDate.destroy_all + DueDate.create( + parent: assignment, + due_at: 3.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns empty collection' do + expect(DueDate.overdue).to be_empty + end + end + end + + describe '.for_round' do + let!(:round1_due_date) do + DueDate.create( + parent: assignment, + due_at: 3.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 1 + ) + end - assign1_due_dates = DueDate.fetch_due_dates(assignment.id) - assign1_due_dates.each { |due_date| due_date.copy(assignment2.id) } - assign2_due_dates = DueDate.fetch_due_dates(assignment2.id) + let!(:round2_due_date) do + DueDate.create( + parent: assignment, + due_at: 4.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 2 + ) + end - excluded_attributes = %w[id created_at updated_at parent parent_id] + it 'returns due dates for specified round' do + expect(DueDate.for_round(1)).to include(round1_due_date) + end - assign1_due_dates.zip(assign2_due_dates).each do |original, copy| - original_attributes = original.attributes.except(*excluded_attributes) - copied_attributes = copy.attributes.except(*excluded_attributes) + it 'excludes due dates from other rounds' do + expect(DueDate.for_round(1)).not_to include(round2_due_date) + end - expect(copied_attributes).to eq(original_attributes) + context 'when round has no due dates' do + it 'returns empty collection' do + expect(DueDate.for_round(999)).to be_empty + end + end + end + + describe '.for_deadline_type' do + it 'returns due dates for specified deadline type' do + submission_dates = DueDate.for_deadline_type('submission') + expect(submission_dates).to include(past_due_date) + end + + it 'excludes due dates with different deadline types' do + submission_dates = DueDate.for_deadline_type('submission') + expect(submission_dates).not_to include(upcoming_due_date1) + end + + context 'when deadline type has no due dates' do + it 'returns empty collection' do + expect(DueDate.for_deadline_type('nonexistent')).to be_empty + end end end end - describe '.next_due_date' do - context 'when parent_type is Assignment' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } - let!(:assignment_due_date1) do - DueDate.create(parent: assignment, due_at: 2.days.from_now, - submission_allowed_id: 3, review_allowed_id: 3, deadline_type_id: 3) + describe 'instance methods' do + describe '#overdue?' do + context 'when due date is in the past' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 1.day.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns true' do + expect(due_date.overdue?).to be true + end end - let!(:assignment_due_date2) do - DueDate.create(parent: assignment, due_at: 3.days.from_now, - submission_allowed_id: 3, review_allowed_id: 3, deadline_type_id: 3) + + context 'when due date is in the future' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 1.day.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns false' do + expect(due_date.overdue?).to be false + end end - let!(:assignment_past_due_date) do - DueDate.create(parent: assignment, due_at: 1.day.ago, - submission_allowed_id: 3, review_allowed_id: 3, deadline_type_id: 3) + + context 'when due date is exactly now' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: Time.current + 1.seconds, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns false' do + expect(due_date.overdue?).to be false + end end + end + + describe '#upcoming?' do + context 'when due date is in the future' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 1.day.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns true' do + expect(due_date.upcoming?).to be true + end + end + + context 'when due date is in the past' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 1.day.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end - it 'returns the next upcoming due date' do - result = DueDate.next_due_date(assignment_due_date2.parent_id) - expect(result).to eq(assignment_due_date1) + it 'returns false' do + expect(due_date.upcoming?).to be false + end end end - context 'when parent_type is SignUpTopic' do - let!(:assignment) { Assignment.create!(id: 2, name: 'Test Assignment', instructor:) } - let!(:assignment2) { Assignment.create(id: 6, name: 'Test Assignment2', instructor:) } - let!(:topic1) { SignUpTopic.create!(id: 2, topic_name: 'Test Topic', assignment:) } - let!(:topic2) { SignUpTopic.create(id: 4, topic_name: 'Test Topic2', assignment: assignment2) } - let!(:topic3) { SignUpTopic.create(id: 5, topic_name: 'Test Topic2', assignment: assignment2) } - let!(:topic_due_date1) do - DueDate.create(parent: topic1, due_at: 2.days.from_now, submission_allowed_id: 3, review_allowed_id: 3, - deadline_type_id: 3, type: 'TopicDueDate') + describe '#set' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) end - let!(:topic_due_date2) do - DueDate.create(parent: topic1, due_at: 3.days.from_now, submission_allowed_id: 3, review_allowed_id: 3, - deadline_type_id: 3, type: 'TopicDueDate') + + context 'when updating all fields' do + before do + due_date.set(review_type.id, assignment2.id, 2) + due_date.reload + end + + it 'updates deadline_type_id' do + expect(due_date.deadline_type_id).to eq(review_type.id) + end + + it 'updates parent_id' do + expect(due_date.parent_id).to eq(assignment2.id) + end + + it 'updates round' do + expect(due_date.round).to eq(2) + end + + it 'persists changes to database' do + expect(due_date.reload.round).to eq(2) + end end - let!(:past_topic_due_date) do - DueDate.create(parent: topic1, due_at: 1.day.ago, submission_allowed_id: 3, review_allowed_id: 3, - deadline_type_id: 3, type: 'TopicDueDate') + + context 'when called with invalid data' do + it 'raises error for non-existent deadline_type_id' do + expect do + due_date.set(99_999, assignment2.id, 1) + end.to raise_error(ActiveRecord::RecordInvalid) + end end - let!(:past_topic_due_date2) do - DueDate.create(parent: topic2, due_at: 2.day.ago, submission_allowed_id: 3, review_allowed_id: 3, - deadline_type_id: 3, type: 'TopicDueDate') + end + + describe '#copy' do + let(:original) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: late_right.id, + round: 1 + ) + end + + let(:copied) { original.copy(assignment2.id) } + + it 'creates a new persisted record' do + expect(copied).to be_persisted + end + + it 'has different id from original' do + expect(copied.id).not_to eq(original.id) + end + + it 'copies to new parent' do + expect(copied.parent_id).to eq(assignment2.id) + end + + it 'preserves due_at' do + expect(copied.due_at).to eq(original.due_at) + end + + it 'preserves deadline_type_id' do + expect(copied.deadline_type_id).to eq(original.deadline_type_id) + end + + it 'preserves submission_allowed_id' do + expect(copied.submission_allowed_id).to eq(original.submission_allowed_id) + end + + it 'preserves review_allowed_id' do + expect(copied.review_allowed_id).to eq(original.review_allowed_id) + end + + it 'preserves round' do + expect(copied.round).to eq(original.round) end - let!(:assignment_due_date) do - DueDate.create(parent: assignment2, due_at: 2.days.from_now, submission_allowed_id: 3, review_allowed_id: 3, - deadline_type_id: 3, type: 'AssignmentDueDate') + + context 'when copying to non-existent assignment' do + it 'raises error' do + expect do + original.copy(99_999) + end.to raise_error(ActiveRecord::RecordNotFound) + end end + end - it 'calls TopicDueDate.next_due_date' do - expect(TopicDueDate).to receive(:next_due_date).with(topic_due_date1.parent.assignment.id, - topic_due_date1.parent_id) - TopicDueDate.next_due_date(topic_due_date1.parent.assignment.id, topic_due_date1.parent_id) + describe '#deadline_type_name' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) end - it 'returns the next upcoming due date for topics' do - result = TopicDueDate.next_due_date(topic_due_date2.parent.assignment.id, topic_due_date2.parent_id) - expect(result).to eq(topic_due_date1) + it 'returns the name of associated deadline type' do + expect(due_date.deadline_type_name).to eq('submission') end - it 'returns the next assignment due date when topic has no upcoming due dates' do - result = TopicDueDate.next_due_date(past_topic_due_date2.parent.assignment.id, past_topic_due_date2.parent_id) + context 'when deadline_type is nil' do + before { allow(due_date).to receive(:deadline_type).and_return(nil) } - expect(result).to eq(assignment_due_date) + it 'returns nil' do + expect(due_date.deadline_type_name).to be_nil + end + end + end + + describe '#last_deadline?' do + context 'when this is the last deadline' do + let(:last_deadline) do + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns true' do + expect(last_deadline.last_deadline?).to be true + end + end + + context 'when there are later deadlines' do + let!(:earlier_deadline) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + let!(:later_deadline) do + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns false' do + expect(earlier_deadline.last_deadline?).to be false + end + end + + context 'when parent has no other due dates' do + let(:only_deadline) do + DueDate.create( + parent: assignment2, + due_at: 3.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns true' do + expect(only_deadline.last_deadline?).to be true + end + end + end + + describe '#<=>' do + let(:earlier) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + let(:later) do + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'returns -1 when comparing earlier to later' do + expect(earlier <=> later).to eq(-1) + end + + it 'returns 1 when comparing later to earlier' do + expect(later <=> earlier).to eq(1) + end + + it 'returns 0 when comparing to itself' do + expect(earlier <=> earlier).to eq(0) + end + + context 'when comparing with non-DueDate object' do + it 'returns nil' do + expect(earlier <=> 'not a due date').to be_nil + end end end end - describe 'validation' do - let(:assignment) { Assignment.create(id: 1, name: 'Test Assignment', instructor:) } + describe 'class methods' do + describe '.sort_due_dates' do + let!(:due_date1) do + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end - it 'is invalid without a parent' do - due_date = DueDate.create(parent: nil, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - expect(due_date).to be_invalid + let!(:due_date2) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + let!(:due_date3) do + DueDate.create( + parent: assignment, + due_at: 1.day.ago, + deadline_type: quiz_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'sorts due dates from earliest to latest' do + sorted = DueDate.sort_due_dates([due_date1, due_date2, due_date3]) + expect(sorted).to eq([due_date3, due_date2, due_date1]) + end + + context 'when array is empty' do + it 'returns empty array' do + expect(DueDate.sort_due_dates([])).to eq([]) + end + end + + context 'when array has single element' do + it 'returns array with single element' do + expect(DueDate.sort_due_dates([due_date1])).to eq([due_date1]) + end + end end - it 'is invalid without a due_at' do - due_date = DueDate.create(parent: assignment, due_at: nil, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - expect(due_date).to be_invalid + describe '.any_future_due_dates?' do + context 'when future due dates exist' do + let(:due_dates) do + [ + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ), + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + ] + end + + it 'returns true' do + expect(DueDate.any_future_due_dates?(due_dates)).to be true + end + end + + context 'when no future due dates exist' do + let(:due_dates) do + [ + DueDate.create( + parent: assignment, + due_at: 2.days.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ), + DueDate.create( + parent: assignment, + due_at: 5.days.ago, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + ] + end + + it 'returns false' do + expect(DueDate.any_future_due_dates?(due_dates)).to be false + end + end + + context 'when array is empty' do + it 'returns false' do + expect(DueDate.any_future_due_dates?([])).to be false + end + end + + context 'when mix of past and future dates' do + let(:due_dates) do + [ + DueDate.create( + parent: assignment, + due_at: 2.days.ago, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ), + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + ] + end + + it 'returns true' do + expect(DueDate.any_future_due_dates?(due_dates)).to be true + end + end + end + + describe '.copy' do + before do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 1 + ) + + DueDate.create( + parent: assignment, + due_at: 5.days.from_now, + deadline_type: review_type, + review_allowed_id: late_right.id, + submission_allowed_id: ok_right.id, + round: 1 + ) + end + + let(:original_count) { assignment.due_dates.count } + + it 'creates due dates for target assignment' do + expect { DueDate.copy(assignment.id, assignment2.id) } + .to change { assignment2.due_dates.count }.from(0).to(original_count) + end + + it 'preserves original assignment due dates' do + expect { DueDate.copy(assignment.id, assignment2.id) } + .not_to(change { assignment.due_dates.count }) + end + + it 'copies all attributes correctly' do + DueDate.copy(assignment.id, assignment2.id) + + assignment.due_dates.each_with_index do |original, index| + copied = assignment2.due_dates[index] + expect(copied.due_at).to eq(original.due_at) + expect(copied.deadline_type_id).to eq(original.deadline_type_id) + expect(copied.round).to eq(original.round) + end + end + + context 'when source assignment has no due dates' do + before { assignment.due_dates.destroy_all } + + it 'does not create any due dates' do + expect { DueDate.copy(assignment.id, assignment2.id) } + .not_to(change { assignment2.due_dates.count }) + end + end + + context 'when source assignment does not exist' do + it 'raises error' do + expect { DueDate.copy(99_999, assignment2.id) } + .to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when target assignment does not exist' do + it 'raises error' do + expect { DueDate.copy(assignment.id, 99_999) } + .to raise_error(ActiveRecord::RecordNotFound) + end + end end + end + + describe 'callbacks' do + describe 'before_save :set_default_round' do + context 'when round is not specified' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + end + + it 'sets round to 1' do + expect(due_date.round).to eq(1) + end + end - it 'is valid with required fields' do - due_date = DueDate.create(parent: assignment, due_at: 2.days.from_now, submission_allowed_id: 3, - review_allowed_id: 3, deadline_type_id: 3) - expect(due_date).to be_valid + context 'when round is explicitly set' do + let(:due_date) do + DueDate.create( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: 3 + ) + end + + it 'does not override the value' do + expect(due_date.round).to eq(3) + end + end + + context 'when round is set to nil explicitly' do + let(:due_date) do + DueDate.new( + parent: assignment, + due_at: 2.days.from_now, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id, + round: nil + ) + end + + it 'sets default value before save' do + due_date.save + expect(due_date.round).to eq(1) + end + end end end end