From e3d03c93554f907c993cfca60ab6dace29eacb2d Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:20:20 -0500 Subject: [PATCH 01/17] Pushing (Local) Changes to the Task1 and Task2 for Completion checks. --- .DS_Store | Bin 0 -> 6148 bytes app/models/assignment.rb | 11 +- app/models/concerns/due_date_actions.rb | 300 ++++++++++++++++ app/models/concerns/due_date_permissions.rb | 199 +++++++++++ app/models/concerns/due_date_queries.rb | 291 +++++++++++++++ app/models/deadline_right.rb | 225 ++++++++++++ app/models/deadline_type.rb | 302 ++++++++++++++++ app/models/due_date.rb | 337 ++++++++++++++++-- app/models/sign_up_topic.rb | 3 +- .../20241201000001_create_deadline_types.rb | 65 ++++ ..._deadline_type_foreign_key_to_due_dates.rb | 43 +++ .../20241201000003_create_deadline_rights.rb | 41 +++ lib/tasks/deadline_demo.rake | 280 +++++++++++++++ 13 files changed, 2058 insertions(+), 39 deletions(-) create mode 100644 .DS_Store create mode 100644 app/models/concerns/due_date_actions.rb create mode 100644 app/models/concerns/due_date_permissions.rb create mode 100644 app/models/concerns/due_date_queries.rb create mode 100644 app/models/deadline_right.rb create mode 100644 app/models/deadline_type.rb create mode 100644 db/migrate/20241201000001_create_deadline_types.rb create mode 100644 db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb create mode 100644 db/migrate/20241201000003_create_deadline_rights.rb create mode 100644 lib/tasks/deadline_demo.rake diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 next_deadline.deadline_type.workflow_position + violations << { + earlier_deadline: deadline, + later_deadline: next_deadline, + issue: "#{next_deadline.deadline_type_name} should come after #{deadline.deadline_type_name}" + } + end + end + + violations + end + + # Validate that all required deadline types are present + def has_required_deadlines?(required_types = ['submission']) + required_types.all? { |type| has_deadline_type?(type) } + end + + # Get missing required deadline types + def missing_required_deadlines(required_types = ['submission']) + required_types.reject { |type| has_deadline_type?(type) } + end + + # Check if this object has a complete deadline schedule + def has_complete_deadline_schedule? + has_deadline_type?('submission') && + (has_deadline_type?('review') || has_deadline_type?('quiz')) + end + + # Get the workflow stage based on current time and deadlines + def current_workflow_stage + current_deadline = next_due_date + return 'inactive' unless current_deadline + + if current_deadline.overdue? + previous = current_deadline.previous_deadline + return previous ? previous.deadline_type_name : 'pre-submission' + else + current_deadline.deadline_type_name + end + end + + # Check if object is in a specific workflow stage + def in_stage?(stage_name) + current_workflow_stage == stage_name + end + + # Get all stages this object will go through + def workflow_stages + used_deadline_types.sort_by(&:workflow_position).map(&:name) + end + + # Check if a stage has been completed + def stage_completed?(stage_name) + deadline = find_deadline(stage_name) + return false unless deadline + + deadline.overdue? + end + + # Get completion status for all stages + def stage_completion_status + workflow_stages.map do |stage| + { + stage: stage, + completed: stage_completed?(stage), + deadline: find_deadline(stage) + } + end + 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..69e39538c --- /dev/null +++ b/app/models/concerns/due_date_permissions.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +module DueDatePermissions + extend ActiveSupport::Concern + + # Permission checking methods that combine deadline-based and role-based logic + # These methods provide a unified interface for checking if actions are allowed + + def can_submit? + return false unless submission_allowed_id + + deadline_right = DeadlineRight.find_by(id: submission_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_review? + return false unless review_allowed_id + + deadline_right = DeadlineRight.find_by(id: review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_take_quiz? + return false unless quiz_allowed_id + + deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_teammate_review? + return false unless teammate_review_allowed_id + + deadline_right = DeadlineRight.find_by(id: teammate_review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_metareview? + return false unless respond_to?(:metareview_allowed_id) && metareview_allowed_id + + deadline_right = DeadlineRight.find_by(id: metareview_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Generic permission checker that can be extended for any action + 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 + + # Syntactic sugar methods for common activities + 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 metareview_permissible? + activity_permissible?(:metareview) if respond_to?(:metareview_allowed_id) + end + + # Check if deadline allows late submissions + def allows_late_submission? + return false unless submission_allowed_id + + deadline_right = DeadlineRight.find_by(id: submission_allowed_id) + deadline_right&.name == 'Late' + end + + def allows_late_review? + return false unless review_allowed_id + + deadline_right = DeadlineRight.find_by(id: review_allowed_id) + deadline_right&.name == 'Late' + end + + def allows_late_quiz? + return false unless quiz_allowed_id + + deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) + deadline_right&.name == 'Late' + end + + # Check if any activity is currently allowed + def has_any_permission? + can_submit? || can_review? || can_take_quiz? || can_teammate_review? || + (respond_to?(:can_metareview?) && can_metareview?) + end + + # Get list of currently allowed activities + def allowed_activities + activities = [] + activities << 'submission' if can_submit? + activities << 'review' if can_review? + activities << 'quiz' if can_take_quiz? + activities << 'teammate_review' if can_teammate_review? + activities << 'metareview' if respond_to?(:can_metareview?) && can_metareview? + activities + end + + # Check if this deadline is currently active (allows some action) + def active? + has_any_permission? + end + + # Check if this deadline is completely closed (no actions allowed) + def closed? + !active? + end + + # Check permissions for deadline type compatibility + def deadline_type_permits_action?(action) + return false unless deadline_type + + case action.to_s.downcase + when 'submit', 'submission' + deadline_type.allows_submission? + when 'review' + deadline_type.allows_review? + when 'quiz' + deadline_type.allows_quiz? + when 'teammate_review' + deadline_type.allows_review? + when 'metareview' + deadline_type.allows_review? + else + false + end + end + + # Comprehensive permission check combining deadline type and deadline rights + def permits_action?(action) + deadline_type_permits_action?(action) && activity_permissible?(action) + 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 + + # Check if deadline is in grace period (allows late submissions) + def in_grace_period_for?(action) + permission_status_for(action) == 'Late' + end + + # Check if deadline is fully open for action + def fully_open_for?(action) + permission_status_for(action) == 'OK' + end + + # Get human-readable permission description + def permission_description_for(action) + status = permission_status_for(action) + case status + when 'OK' + "#{action.to_s.humanize} is allowed" + when 'Late' + "#{action.to_s.humanize} is allowed with late penalty" + when 'No' + "#{action.to_s.humanize} is not allowed" + else + "#{action.to_s.humanize} status unknown" + end + end + + # Get a summary of all permissions for this deadline + def permissions_summary + { + submission: permission_status_for(:submission), + review: permission_status_for(:review), + quiz: permission_status_for(:quiz), + teammate_review: permission_status_for(:teammate_review), + active: active?, + closed: closed? + } + end +end diff --git a/app/models/concerns/due_date_queries.rb b/app/models/concerns/due_date_queries.rb new file mode 100644 index 000000000..25901ab3c --- /dev/null +++ b/app/models/concerns/due_date_queries.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +module DueDateQueries + extend ActiveSupport::Concern + + included do + # Scopes for common deadline queries + scope :upcoming, -> { where('due_at > ?', Time.current).order(:due_at) } + scope :overdue, -> { where('due_at < ?', Time.current).order(:due_at) } + scope :today, -> { where(due_at: Time.current.beginning_of_day..Time.current.end_of_day) } + scope :this_week, -> { where(due_at: Time.current.beginning_of_week..Time.current.end_of_week) } + scope :for_deadline_type, ->(type_name) { joins(:deadline_type).where(deadline_types: { name: type_name }) } + scope :for_round, ->(round_num) { where(round: round_num) } + scope :active_deadlines, -> { where('due_at > ?', Time.current) } + scope :by_deadline_type, -> { joins(:deadline_type).order('deadline_types.name') } + end + + class_methods do + # Find next upcoming deadline for any parent + def next_deadline + upcoming.first + end + + # Find deadlines by type name + def of_type(deadline_type_name) + joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) + end + + # Get deadline statistics + def deadline_stats + { + total: count, + upcoming: upcoming.count, + overdue: overdue.count, + today: today.count, + this_week: this_week.count + } + end + + # Find deadlines within a date range + def between_dates(start_date, end_date) + where(due_at: start_date..end_date) + end + + # Find deadlines for specific actions + def for_submission + of_type('submission') + end + + def for_review + of_type('review') + end + + def for_quiz + of_type('quiz') + end + + def for_teammate_review + of_type('teammate_review') + end + + def for_metareview + of_type('metareview') + end + + # Find deadlines that allow specific actions + def allowing_submission + where(submission_allowed_id: [2, 3]) # Late and OK + end + + def allowing_review + where(review_allowed_id: [2, 3]) # Late and OK + end + + def allowing_quiz + where(quiz_allowed_id: [2, 3]) # Late and OK + end + + # Get deadlines grouped by type + def grouped_by_type + joins(:deadline_type) + .group('deadline_types.name') + .order('deadline_types.name') + end + end + + # Instance methods for parent objects (Assignment, SignUpTopic) + # These methods should be included in Assignment and SignUpTopic models + + # Get next due date for this parent + def next_due_date + due_dates.upcoming.first + end + + # Get the most recently passed deadline + def last_due_date + due_dates.overdue.order(due_at: :desc).first + end + + # Find current stage/deadline for a specific action + def current_deadline_for(action) + deadline_type_name = map_action_to_deadline_type(action) + return nil unless deadline_type_name + + # First try to find an active deadline for this action + current = due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + + # If no future deadline, get the most recent past deadline + current ||= due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .order(due_at: :desc) + .first + + current + end + + # Get upcoming deadlines with limit + def upcoming_deadlines(limit: 5) + due_dates.upcoming.limit(limit) + end + + # Get overdue deadlines + def overdue_deadlines + due_dates.overdue + end + + # Check if there are any future deadlines + def has_future_deadlines? + due_dates.upcoming.exists? + end + + # Get deadlines for a specific round + def deadlines_for_round(round_number) + due_dates.where(round: round_number).order(:due_at) + end + + # Find deadline by type and round + def find_deadline(deadline_type_name, round_number = nil) + query = due_dates.joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) + query = query.where(round: round_number) if round_number + query.order(:due_at).first + end + + # Get all deadline types used by this object + def used_deadline_types + due_dates + .joins(:deadline_type) + .select('DISTINCT deadline_types.*') + .map(&:deadline_type) + end + + # Check if this object has a specific type of deadline + def has_deadline_type?(deadline_type_name) + due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .exists? + end + + # Get deadlines that are currently active (allowing some action) + def active_deadlines + due_dates.select(&:active?) + end + + # Get deadline summary for display + def deadline_summary + { + total_deadlines: due_dates.count, + upcoming_count: upcoming_deadlines.count, + overdue_count: overdue_deadlines.count, + deadline_types: used_deadline_types.map(&:name), + next_deadline: next_due_date, + has_active_deadlines: active_deadlines.any? + } + end + + # Find the current stage for topic-specific deadlines + def current_stage_for_topic(topic_id, action) + deadline_type_name = map_action_to_deadline_type(action) + return nil unless deadline_type_name + + # Try topic-specific deadline first + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type_name }) + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + + # Fall back to assignment-level deadline + topic_deadline || current_deadline_for(action) + end + + # Get all deadlines affecting a specific topic + def deadlines_for_topic(topic_id) + assignment_deadlines = due_dates.where(parent_type: 'Assignment') + topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') + + (assignment_deadlines + topic_deadlines).sort_by(&:due_at) + end + + # Check if assignment has topic-specific overrides + def has_topic_deadline_overrides? + due_dates.where(parent_type: 'SignUpTopic').exists? + end + + # Get deadline comparison between assignment and topic + def deadline_comparison_for_topic(topic_id) + assignment_deadlines = due_dates.where(parent_type: 'Assignment').includes(:deadline_type) + topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic').includes(:deadline_type) + + { + assignment_deadlines: assignment_deadlines, + topic_deadlines: topic_deadlines, + has_overrides: topic_deadlines.any? + } + end + + # Find conflicts between assignment and topic deadlines + def deadline_conflicts_for_topic(topic_id) + conflicts = [] + + used_deadline_types.each do |deadline_type| + assignment_deadline = find_deadline(deadline_type.name) + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type.name }) + .first + + if assignment_deadline && topic_deadline + if assignment_deadline.due_at != topic_deadline.due_at + conflicts << { + deadline_type: deadline_type.name, + assignment_due: assignment_deadline.due_at, + topic_due: topic_deadline.due_at, + difference: topic_deadline.due_at - assignment_deadline.due_at + } + end + end + end + + conflicts + end + + # Get the effective deadline for a topic (topic-specific or assignment fallback) + def effective_deadline_for_topic(topic_id, deadline_type_name) + # First check for topic-specific deadline + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type_name }) + .first + + # Fall back to assignment deadline + topic_deadline || find_deadline(deadline_type_name) + end + + private + + # Map action names to deadline type names + def map_action_to_deadline_type(action) + case action.to_s.downcase + when 'submit', 'submission' + 'submission' + when 'review', 'peer_review' + 'review' + when 'teammate_review' + 'teammate_review' + when 'metareview', 'meta_review' + 'metareview' + when 'quiz' + 'quiz' + when 'team_formation' + 'team_formation' + when 'signup' + 'signup' + when 'drop_topic' + 'drop_topic' + else + nil + end + end +end diff --git a/app/models/deadline_right.rb b/app/models/deadline_right.rb new file mode 100644 index 000000000..66121a832 --- /dev/null +++ b/app/models/deadline_right.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +class DeadlineRight < ApplicationRecord + # Constants for deadline right IDs + NO = 1 + LATE = 2 + OK = 3 + + validates :name, presence: true, uniqueness: true + validates :description, presence: true + + # Scopes for different permission levels + scope :allowing, -> { where(name: %w[OK Late]) } + scope :denying, -> { where(name: 'No') } + scope :with_penalty, -> { where(name: 'Late') } + scope :without_penalty, -> { where(name: 'OK') } + + # Class methods for finding deadline rights + def self.find_by_name(name) + find_by(name: name.to_s) + end + + def self.no + find_by_name('No') + end + + def self.late + find_by_name('Late') + end + + def self.ok + find_by_name('OK') + end + + # 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 + + # Semantic helper methods + def no? + name == 'No' + end + + def late? + name == 'Late' + end + + def ok? + name == 'OK' + end + + # Display methods + def display_name + name + end + + def display_description + description + end + + def to_s + name + end + + def css_class + case name + when 'OK' + 'deadline-allowed' + when 'Late' + 'deadline-late' + when 'No' + 'deadline-denied' + else + 'deadline-unknown' + end + end + + def icon + case name + when 'OK' + 'check-circle' + when 'Late' + 'clock' + when 'No' + 'x-circle' + else + 'question-circle' + end + end + + # Method to get human-readable status with context + def status_with_context(action) + case name + when 'OK' + "#{action.to_s.humanize} is allowed" + when 'Late' + "#{action.to_s.humanize} is allowed with late penalty" + when 'No' + "#{action.to_s.humanize} is not allowed" + else + "#{action.to_s.humanize} status unknown" + end + end + + # Comparison methods + def more_permissive_than?(other) + return false unless other.is_a?(DeadlineRight) + + permission_level > other.permission_level + end + + def less_permissive_than?(other) + return false unless other.is_a?(DeadlineRight) + + permission_level < other.permission_level + 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 + + # Method to seed the deadline rights (for use in migrations/seeds) + def self.seed_deadline_rights! + deadline_rights = [ + { id: NO, name: 'No', description: 'Action is not allowed' }, + { id: LATE, name: 'Late', description: 'Action is allowed with late penalty' }, + { id: OK, name: 'OK', description: 'Action is allowed without penalty' } + ] + + deadline_rights.each do |right_attrs| + find_or_create_by(id: right_attrs[:id]) do |dr| + dr.name = right_attrs[:name] + dr.description = right_attrs[:description] + end + end + end + + # Validation methods + def self.valid_right_names + %w[No Late OK] + end + + def self.validate_right_name(name) + valid_right_names.include?(name.to_s) + end + + # Statistics methods + def usage_count + # Count how many due_dates reference this deadline right + # This is a general count across all permission fields + count = 0 + + # Check submission permissions + count += DueDate.where(submission_allowed_id: id).count + + # Check review permissions + count += DueDate.where(review_allowed_id: id).count + + # Check quiz permissions + count += DueDate.where(quiz_allowed_id: id).count + + # Check teammate review permissions + count += DueDate.where(teammate_review_allowed_id: id).count + + # Check other permission fields if they exist + if DueDate.column_names.include?('resubmission_allowed_id') + count += DueDate.where(resubmission_allowed_id: id).count + end + + if DueDate.column_names.include?('rereview_allowed_id') + count += DueDate.where(rereview_allowed_id: id).count + end + + if DueDate.column_names.include?('review_of_review_allowed_id') + count += DueDate.where(review_of_review_allowed_id: id).count + end + + count + end + + # Check if this deadline right is being used + def in_use? + usage_count > 0 + end + + private + + # Prevent deletion if deadline right is in use + def cannot_delete_if_in_use + return unless in_use? + + errors.add(:base, 'Cannot delete deadline right that is being used by due dates') + throw :abort + end + + before_destroy :cannot_delete_if_in_use +end diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb new file mode 100644 index 000000000..bf490f4a4 --- /dev/null +++ b/app/models/deadline_type.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# DeadlineType serves as the canonical source of truth for all deadline categories. +# It replaces hard-coded deadline_type_id comparisons with semantic helper methods. +class DeadlineType < ApplicationRecord + # Constants for deadline type IDs (for backward compatibility) + SUBMISSION = 1 + REVIEW = 2 + TEAMMATE_REVIEW = 3 + METAREVIEW = 5 + DROP_TOPIC = 6 + SIGNUP = 7 + TEAM_FORMATION = 8 + QUIZ = 11 + + validates :name, presence: true, uniqueness: true + validates :description, presence: true + + has_many :due_dates, foreign_key: :deadline_type_id, dependent: :restrict_with_exception + + # Scopes for categorizing deadline types + scope :submission_types, -> { where(name: ['submission']) } + scope :review_types, -> { where(name: ['review', 'metareview', 'teammate_review']) } + scope :quiz_types, -> { where(name: ['quiz']) } + scope :administrative_types, -> { where(name: ['drop_topic', 'signup', 'team_formation']) } + + # Class methods for finding deadline types + def self.find_by_name(name) + find_by(name: name.to_s) + end + + def self.submission + find_by_name('submission') + end + + def self.review + find_by_name('review') + end + + def self.teammate_review + find_by_name('teammate_review') + end + + def self.metareview + find_by_name('metareview') + end + + def self.drop_topic + find_by_name('drop_topic') + end + + def self.signup + find_by_name('signup') + end + + def self.team_formation + find_by_name('team_formation') + end + + def self.quiz + find_by_name('quiz') + end + + # Dynamic method to find deadline type for action + def self.for_action(action_name) + case action_name.to_s.downcase + when 'submit', 'submission' then submission + when 'review' then review + when 'teammate_review' then teammate_review + when 'metareview' then metareview + when 'quiz' then quiz + when 'team_formation' then team_formation + when 'signup' then signup + when 'drop_topic' then drop_topic + else nil + end + end + + # Semantic helper methods for deadline type identification + def submission? + name == 'submission' + end + + def review? + %w[review metareview teammate_review].include?(name) + end + + def teammate_review? + name == 'teammate_review' + end + + def metareview? + name == 'metareview' + end + + def quiz? + name == 'quiz' + end + + def administrative? + %w[drop_topic signup team_formation].include?(name) + end + + def team_formation? + name == 'team_formation' + end + + def signup? + name == 'signup' + end + + def drop_topic? + name == 'drop_topic' + end + + # Permission checking helper methods + def allows_submission? + submission? + end + + def allows_review? + review? + end + + def allows_quiz? + quiz? + end + + def allows_team_formation? + team_formation? + end + + def allows_signup? + signup? + end + + def allows_topic_drop? + drop_topic? + end + + # Category checking methods + def workflow_deadline? + %w[submission review teammate_review metareview].include?(name) + end + + def assessment_deadline? + %w[review metareview teammate_review quiz].include?(name) + end + + def student_action_deadline? + %w[submission quiz signup team_formation drop_topic].include?(name) + end + + # Display methods + def display_name + name.humanize + end + + def to_s + display_name + end + + # Method to seed the deadline types (for use in migrations/seeds) + def self.seed_deadline_types! + deadline_types = [ + { id: SUBMISSION, name: 'submission', description: 'Student work submission deadlines' }, + { id: REVIEW, name: 'review', description: 'Peer review deadlines' }, + { id: TEAMMATE_REVIEW, name: 'teammate_review', description: 'Team member evaluation deadlines' }, + { id: METAREVIEW, name: 'metareview', description: 'Meta-review deadlines' }, + { id: DROP_TOPIC, name: 'drop_topic', description: 'Topic drop deadlines' }, + { id: SIGNUP, name: 'signup', description: 'Course/assignment signup deadlines' }, + { id: TEAM_FORMATION, name: 'team_formation', description: 'Team formation deadlines' }, + { id: QUIZ, name: 'quiz', description: 'Quiz completion deadlines' } + ] + + deadline_types.each do |type_attrs| + find_or_create_by(id: type_attrs[:id]) do |dt| + dt.name = type_attrs[:name] + dt.description = type_attrs[:description] + end + end + end + + # Method to clean up duplicate entries + def self.cleanup_duplicates! + # Remove any duplicate team_formation entries (keep canonical ID 8) + where(name: 'team_formation').where.not(id: TEAM_FORMATION).destroy_all + + # Update any due_dates that reference deleted duplicates + ActiveRecord::Base.connection.execute(<<~SQL) + UPDATE due_dates + SET deadline_type_id = #{TEAM_FORMATION} + WHERE deadline_type_id NOT IN (#{all.pluck(:id).join(',')}) + AND deadline_type_id IS NOT NULL + SQL + end + + # Validation methods + def self.valid_deadline_names + %w[submission review teammate_review metareview drop_topic signup team_formation quiz] + end + + def self.validate_deadline_name(name) + valid_deadline_names.include?(name.to_s) + end + + # Query helpers for associations + def self.used_in_assignments + joins(:due_dates) + .where(due_dates: { parent_type: 'Assignment' }) + .distinct + end + + def self.used_in_topics + joins(:due_dates) + .where(due_dates: { parent_type: 'SignUpTopic' }) + .distinct + end + + # Statistics methods + def due_dates_count + due_dates.count + end + + def active_due_dates_count + due_dates.where('due_at > ?', Time.current).count + end + + def overdue_count + due_dates.where('due_at < ?', Time.current).count + end + + # Comparison and ordering + def <=>(other) + return nil unless other.is_a?(DeadlineType) + + id <=> other.id + end + + # Class method to get deadline type hierarchy for workflow + def self.workflow_order + %w[signup team_formation submission review teammate_review metareview quiz drop_topic] + end + + def workflow_position + self.class.workflow_order.index(name) || Float::INFINITY + end + + # Method to check if this deadline type typically comes before another + def comes_before?(other_type) + return false unless other_type.is_a?(DeadlineType) + + workflow_position < other_type.workflow_position + end + + # Method to get the next logical deadline type in workflow + def next_in_workflow + current_pos = workflow_position + return nil if current_pos == Float::INFINITY + + next_name = self.class.workflow_order[current_pos + 1] + return nil unless next_name + + self.class.find_by_name(next_name) + end + + # Method to get the previous logical deadline type in workflow + def previous_in_workflow + current_pos = workflow_position + return nil if current_pos <= 0 + + prev_name = self.class.workflow_order[current_pos - 1] + return nil unless prev_name + + self.class.find_by_name(prev_name) + end + + # Method for dynamic permission checking based on action + def allows_action?(action) + case action.to_s.downcase + when 'submit', 'submission' then allows_submission? + when 'review' then allows_review? + when 'quiz' then allows_quiz? + when 'team_formation' then allows_team_formation? + when 'signup' then allows_signup? + when 'drop_topic' then allows_topic_drop? + else false + end + end + + private + + # Ensure we maintain referential integrity + def cannot_delete_if_has_due_dates + return unless due_dates.exists? + + errors.add(:base, 'Cannot delete deadline type that has associated due dates') + throw :abort + end + + before_destroy :cannot_delete_if_has_due_dates +end diff --git a/app/models/due_date.rb b/app/models/due_date.rb index ed310bef5..4637dac53 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -2,59 +2,328 @@ 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 :round, presence: true, numericality: { greater_than: 0 } + 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 }) } + scope :active, -> { where('due_at > ?', Time.current) } - def due_at_is_valid_datetime - errors.add(:due_at, 'must be a valid datetime') unless due_at.is_a?(Time) + # Instance methods for individual due date operations + + # Create a copy of this due date for a new parent + def copy_to(new_parent) + new_due_date = dup + new_due_date.parent = new_parent + new_due_date.save! + new_due_date + end + + # Duplicate this due date with different attributes + def duplicate_with_changes(changes = {}) + new_due_date = dup + changes.each { |attr, value| new_due_date.public_send("#{attr}=", value) } + new_due_date.save! + new_due_date + end + + # Check if this deadline has passed + def overdue? + due_at < Time.current + end + + # Check if this deadline is upcoming + def upcoming? + due_at > Time.current + end + + # Check if this deadline is today + def due_today? + due_at.to_date == Time.current.to_date + end + + # Check if this deadline is this week + def due_this_week? + due_at >= Time.current.beginning_of_week && due_at <= Time.current.end_of_week + end + + # Time remaining until deadline (returns nil if overdue) + def time_remaining + return nil if overdue? + + due_at - Time.current + end + + # Time since deadline passed (returns nil if not overdue) + def time_overdue + return nil unless overdue? + + Time.current - due_at + end + + # Get human-readable time description + def time_description + if due_today? + "Due today at #{due_at.strftime('%I:%M %p')}" + elsif overdue? + days_overdue = (Time.current.to_date - due_at.to_date).to_i + "#{days_overdue} day#{'s' if days_overdue != 1} overdue" + elsif upcoming? + days_until = (due_at.to_date - Time.current.to_date).to_i + if days_until == 0 + "Due today" + elsif days_until == 1 + "Due tomorrow" + else + "Due in #{days_until} days" + end + else + "Due #{due_at.strftime('%B %d, %Y')}" + end + end + + # Check if this deadline is for a specific type of activity + def for_submission? + deadline_type&.submission? + end + + def for_review? + deadline_type&.review? + end + + def for_quiz? + deadline_type&.quiz? + end + + def for_teammate_review? + deadline_type&.teammate_review? + end + + def for_metareview? + deadline_type&.metareview? + end + + def for_team_formation? + deadline_type&.team_formation? + end + + def for_signup? + deadline_type&.signup? + end + + def for_topic_drop? + deadline_type&.drop_topic? + end + + # Get the deadline type name + def deadline_type_name + deadline_type&.name + end + + # Get human-readable deadline type + def deadline_type_display + deadline_type&.display_name || 'Unknown' + end + + # Check if this deadline allows late submissions + def allows_late_work? + allows_late_submission? || allows_late_review? || allows_late_quiz? + end + + # Get status description + def status_description + if overdue? + allows_late_work? ? 'Overdue (Late work accepted)' : 'Closed' + elsif due_today? + 'Due today' + elsif upcoming? + time_description + else + 'Unknown status' + end + end + + # Check if this deadline is currently in effect + def currently_active? + active? && (upcoming? || (overdue? && allows_late_work?)) + end + + # Get the next deadline after this one (for the same parent) + def next_deadline + parent.due_dates + .where('due_at > ? OR (due_at = ? AND id > ?)', due_at, due_at, id) + .order(:due_at, :id) + .first + end + + # Get the previous deadline before this one (for the same parent) + def previous_deadline + parent.due_dates + .where('due_at < ? OR (due_at = ? AND id < ?)', due_at, due_at, id) + .order(due_at: :desc, id: :desc) + .first end - # Method to compare due dates + # Check if this is the last deadline for the parent + def last_deadline? + next_deadline.nil? + end + + # Check if this is the first deadline for the parent + def first_deadline? + previous_deadline.nil? + end + + # Comparison method for sorting def <=>(other) - due_at <=> other.due_at + return nil unless other.is_a?(DueDate) + + # Primary sort: due_at + comparison = due_at <=> other.due_at + return comparison unless comparison.zero? + + # Secondary sort: deadline type workflow order + if deadline_type && other.deadline_type + workflow_comparison = deadline_type.workflow_position <=> other.deadline_type.workflow_position + return workflow_comparison unless workflow_comparison.zero? + end + + # Tertiary sort: id for consistency + id <=> other.id end - # Return the set of due dates sorted by due_at - def self.sort_due_dates(due_dates) - due_dates.sort_by(&:due_at) + # Get all due dates for the same round and parent + def round_siblings + parent.due_dates.where(round: round).where.not(id: id).order(:due_at) 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) + # Check if this deadline conflicts with others in the same round + def has_round_conflicts? + round_siblings.where(deadline_type_id: deadline_type_id).exists? 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 summary information about this deadline + def summary + { + id: id, + deadline_type: deadline_type_name, + due_at: due_at, + round: round, + overdue: overdue?, + upcoming: upcoming?, + currently_active: currently_active?, + time_description: time_description, + status: status_description, + permissions: permissions_summary + } end - def set(deadline, assignment_id, max_round) - self.deadline_type_id = deadline - self.parent_id = assignment_id - self.round = max_round - save + # String representation + def to_s + "#{deadline_type_display} - #{time_description}" 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 } + # Detailed string representation + def inspect_details + "DueDate(id: #{id}, type: #{deadline_type_name}, due: #{due_at}, " \ + "round: #{round}, parent: #{parent_type}##{parent_id})" 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 + # Class methods for collection operations + class << self + # Sort a collection of due dates + def sort_by_due_date(due_dates) + due_dates.sort + end + + # Find the next upcoming due date from a collection + def next_from_collection(due_dates) + due_dates.select(&:upcoming?).min + end + + # Check if any due dates in collection allow late work + def any_allow_late_work?(due_dates) + due_dates.any?(&:allows_late_work?) + end + + # Get due dates grouped by deadline type + def group_by_type(due_dates) + due_dates.group_by(&:deadline_type_name) + end + + # Get due dates grouped by round + def group_by_round(due_dates) + due_dates.group_by(&:round) + end + + # Filter due dates that are currently actionable + def currently_actionable(due_dates) + due_dates.select(&:currently_active?) + end + + # Get statistics for a collection of due dates + def collection_stats(due_dates) + { + total: due_dates.count, + upcoming: due_dates.count(&:upcoming?), + overdue: due_dates.count(&:overdue?), + due_today: due_dates.count(&:due_today?), + active: due_dates.count(&:currently_active?), + types: due_dates.map(&:deadline_type_name).uniq.compact.sort + } + end + + # Find deadline conflicts in a collection + def find_conflicts(due_dates) + conflicts = [] + + due_dates.group_by(&:round).each do |round, round_deadlines| + round_deadlines.group_by(&:deadline_type_name).each do |type, type_deadlines| + if type_deadlines.count > 1 + conflicts << { + round: round, + deadline_type: type, + conflicting_deadlines: type_deadlines.map(&:id) + } + end + end + end + + conflicts + end + + # Get upcoming deadlines across all due dates + def upcoming_across_all(limit: 10) + upcoming.limit(limit).includes(:deadline_type, :parent) + end + + # Get overdue deadlines across all due dates + def overdue_across_all(limit: 10) + overdue.limit(limit).includes(:deadline_type, :parent) + end + end + + private + + def due_at_is_valid_datetime + return unless due_at.present? + + unless due_at.is_a?(Time) || due_at.is_a?(DateTime) || due_at.is_a?(Date) + errors.add(:due_at, 'must be a valid datetime') + end + + if due_at.is_a?(Date) + errors.add(:due_at, 'should include time information, not just date') + end 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/db/migrate/20241201000001_create_deadline_types.rb b/db/migrate/20241201000001_create_deadline_types.rb new file mode 100644 index 000000000..81db538b6 --- /dev/null +++ b/db/migrate/20241201000001_create_deadline_types.rb @@ -0,0 +1,65 @@ +# 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 + + # 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/lib/tasks/deadline_demo.rake b/lib/tasks/deadline_demo.rake new file mode 100644 index 000000000..af5dda5e0 --- /dev/null +++ b/lib/tasks/deadline_demo.rake @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +namespace :deadline do + desc "Demonstrate the new DeadlineType and DueDate functionality" + task demo: :environment do + puts "=" * 80 + puts "DeadlineType and DueDate Refactoring Demo" + puts "=" * 80 + puts + + # Step 1: Seed deadline types and rights + puts "1. Setting up DeadlineTypes and DeadlineRights..." + DeadlineType.seed_deadline_types! + DeadlineRight.seed_deadline_rights! + DeadlineType.cleanup_duplicates! + + puts " Created #{DeadlineType.count} deadline types:" + DeadlineType.all.order(:id).each do |dt| + puts " - #{dt.name} (ID: #{dt.id}): #{dt.description}" + end + puts + + puts " Created #{DeadlineRight.count} deadline rights:" + DeadlineRight.all.order(:id).each do |dr| + puts " - #{dr.name} (ID: #{dr.id}): #{dr.description}" + end + puts + + # Step 2: Create a demo assignment + puts "2. Creating demo assignment..." + assignment = Assignment.create!( + name: "Demo Assignment - DueDate Refactor", + description: "Demonstration of the new 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 + + # Step 3: Create due dates using the new API + puts "3. Creating due dates using new DeadlineType integration..." + + submission_deadline = assignment.create_due_date( + 'submission', + 2.weeks.from_now, + submission_allowed_id: DeadlineRight::OK, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + review_deadline = assignment.create_due_date( + 'review', + 3.weeks.from_now, + submission_allowed_id: DeadlineRight::LATE, + review_allowed_id: DeadlineRight::OK, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + teammate_review_deadline = assignment.create_due_date( + 'teammate_review', + 4.weeks.from_now, + submission_allowed_id: DeadlineRight::NO, + review_allowed_id: DeadlineRight::LATE, + teammate_review_allowed_id: DeadlineRight::OK, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + quiz_deadline = assignment.create_due_date( + 'quiz', + 1.week.from_now, + submission_allowed_id: DeadlineRight::NO, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::OK, + round: 1 + ) + + puts " Created #{assignment.due_dates.count} deadlines for the assignment" + puts + + # Step 4: Demonstrate DeadlineType semantic methods + puts "4. DeadlineType semantic methods demo:" + puts " submission.submission? = #{DeadlineType.submission.submission?}" + puts " submission.review? = #{DeadlineType.submission.review?}" + puts " review.review? = #{DeadlineType.review.review?}" + puts " teammate_review.review? = #{DeadlineType.teammate_review.review?}" + puts " quiz.allows_quiz? = #{DeadlineType.quiz.allows_quiz?}" + puts " DeadlineType.for_action('submit') = #{DeadlineType.for_action('submit')&.name}" + puts " DeadlineType.for_action('review') = #{DeadlineType.for_action('review')&.name}" + puts + + # Step 5: Demonstrate DueDate instance methods + puts "5. DueDate instance methods demo:" + assignment.due_dates.each do |due_date| + puts " #{due_date.deadline_type_name.ljust(15)} | #{due_date.time_description} | Status: #{due_date.status_description}" + end + puts + + # Step 6: Demonstrate permission checking + puts "6. Permission checking demo:" + puts " Assignment permissions summary:" + permissions = assignment.action_permissions_summary + permissions.each do |action, allowed| + status = allowed ? "✓ ALLOWED" : "✗ DENIED" + puts " #{action.to_s.ljust(15)} : #{status}" + end + puts + + # Step 7: Demonstrate deadline queries + puts "7. Deadline query methods demo:" + puts " Next due date: #{assignment.next_due_date&.summary&.dig(:deadline_type) || 'None'}" + puts " Upcoming deadlines: #{assignment.upcoming_deadlines.count}" + puts " Overdue deadlines: #{assignment.overdue_deadlines.count}" + puts " Has future deadlines: #{assignment.has_future_deadlines?}" + puts " Used deadline types: #{assignment.used_deadline_types.map(&:name).join(', ')}" + puts + + # Step 8: Demonstrate workflow stage tracking + puts "8. Workflow stage tracking demo:" + puts " Current workflow stage: #{assignment.current_workflow_stage}" + puts " Workflow stages: #{assignment.workflow_stages.join(' → ')}" + puts " Stage completion status:" + assignment.stage_completion_status.each do |stage_info| + status = stage_info[:completed] ? "✓ COMPLETED" : "○ PENDING" + puts " #{stage_info[:stage].ljust(15)} : #{status}" + end + puts + + # Step 9: Demonstrate deadline copying + puts "9. Deadline copying demo:" + copied_assignment = assignment.copy + puts " Original assignment deadlines: #{assignment.due_dates.count}" + puts " Copied assignment deadlines: #{copied_assignment.due_dates.count}" + puts " Copy successful: #{assignment.due_dates.count == copied_assignment.due_dates.count}" + puts + + # Step 10: Demonstrate topic-specific deadlines + puts "10. Topic-specific deadline demo..." + + # Create a sign up topic + topic = SignUpTopic.create!( + topic_name: "Demo Topic", + topic_identifier: "DEMO-001", + max_choosers: 5, + assignment: assignment + ) + + # Create topic-specific deadline that differs from assignment + topic_submission = topic.create_due_date( + 'submission', + 10.days.from_now, # Different from assignment deadline + submission_allowed_id: DeadlineRight::OK, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + puts " Created topic: #{topic.topic_name}" + puts " Topic submission deadline: #{topic_submission.due_at.strftime('%B %d, %Y')}" + puts " Assignment submission deadline: #{submission_deadline.due_at.strftime('%B %d, %Y')}" + puts " Topic has deadline overrides: #{assignment.has_topic_deadline_overrides?}" + + # Demonstrate topic deadline resolution + effective_deadline = assignment.effective_deadline_for_topic(topic.id, 'submission') + puts " Effective deadline for topic: #{effective_deadline.due_at.strftime('%B %d, %Y')} (#{effective_deadline.parent_type})" + puts + + # Step 11: Demonstrate deadline conflicts detection + puts "11. Deadline conflict detection demo:" + conflicts = assignment.deadline_conflicts_for_topic(topic.id) + if conflicts.any? + puts " Found #{conflicts.count} deadline conflicts:" + conflicts.each do |conflict| + puts " #{conflict[:deadline_type]}: Assignment (#{conflict[:assignment_due].strftime('%m/%d')}) vs Topic (#{conflict[:topic_due].strftime('%m/%d')})" + end + else + puts " No deadline conflicts detected" + end + puts + + # Step 12: Demonstrate deadline validation + puts "12. Deadline validation demo:" + puts " Deadlines properly ordered: #{assignment.deadlines_properly_ordered?}" + violations = assignment.deadline_ordering_violations + if violations.any? + puts " Ordering violations found:" + violations.each do |violation| + puts " #{violation[:issue]}" + end + else + puts " No ordering violations found" + end + puts + + # Step 13: Demonstrate permission status details + puts "13. Permission status details demo:" + assignment.due_dates.includes(:deadline_type).each do |due_date| + puts " #{due_date.deadline_type_name.ljust(15)}:" + puts " Submission: #{due_date.permission_description_for(:submission)}" + puts " Review: #{due_date.permission_description_for(:review)}" + puts " Quiz: #{due_date.permission_description_for(:quiz)}" + end + puts + + # Step 14: Demonstrate collection statistics + puts "14. Collection statistics demo:" + stats = DueDate.collection_stats(assignment.due_dates) + puts " Total deadlines: #{stats[:total]}" + puts " Upcoming: #{stats[:upcoming]}" + puts " Overdue: #{stats[:overdue]}" + puts " Due today: #{stats[:due_today]}" + puts " Currently active: #{stats[:active]}" + puts " Deadline types: #{stats[:types].join(', ')}" + puts + + # Step 15: Clean up + puts "15. Cleaning up demo data..." + copied_assignment.destroy + assignment.destroy + puts " Demo completed successfully!" + puts + puts "=" * 80 + puts "Summary of New Features Demonstrated:" + puts "- ✓ DeadlineType model as canonical source of truth" + puts "- ✓ Semantic helper methods for deadline types" + puts "- ✓ DueDate refactored with instance methods" + puts "- ✓ Permission checking through mixins" + puts "- ✓ Unified deadline querying" + puts "- ✓ Topic-specific deadline overrides" + puts "- ✓ Workflow stage tracking" + puts "- ✓ Deadline copying and duplication" + puts "- ✓ Conflict detection and validation" + puts "- ✓ Comprehensive permission status" + puts "=" * 80 + end + + desc "Clean up any duplicate deadline types" + task cleanup_duplicates: :environment do + puts "Cleaning up duplicate deadline types..." + DeadlineType.cleanup_duplicates! + puts "Cleanup completed." + end + + desc "Seed deadline types and rights" + task seed: :environment do + puts "Seeding deadline types..." + DeadlineType.seed_deadline_types! + puts "Seeding deadline rights..." + DeadlineRight.seed_deadline_rights! + puts "Seeding completed." + 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}: #{dr.usage_count} usages" + end + puts + puts "DueDates: #{DueDate.count}" + puts " Upcoming: #{DueDate.upcoming.count}" + puts " Overdue: #{DueDate.overdue.count}" + puts " Due today: #{DueDate.today.count}" + puts + end +end From 004974915225dd8d01e8e6963e20b1da92a40b58 Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:26:38 -0500 Subject: [PATCH 02/17] Updates to comments in pullrequest --- .DS_Store | Bin 6148 -> 8196 bytes app/.DS_Store | Bin 0 -> 8196 bytes app/models/.DS_Store | Bin 0 -> 6148 bytes app/models/concerns/due_date_actions.rb | 263 ++--------------- app/models/concerns/due_date_permissions.rb | 110 +------ app/models/concerns/due_date_queries.rb | 294 ++----------------- app/models/deadline_right.rb | 174 ----------- app/models/deadline_type.rb | 241 +--------------- app/models/due_date.rb | 301 ++++--------------- app/models/topic_due_date.rb | 13 +- app/views/.DS_Store | Bin 0 -> 6148 bytes config/.DS_Store | Bin 0 -> 6148 bytes lib/tasks/deadline_demo.rake | 302 +++++++------------- spec/.DS_Store | Bin 0 -> 8196 bytes test/.DS_Store | Bin 0 -> 6148 bytes 15 files changed, 215 insertions(+), 1483 deletions(-) create mode 100644 app/.DS_Store create mode 100644 app/models/.DS_Store create mode 100644 app/views/.DS_Store create mode 100644 config/.DS_Store create mode 100644 spec/.DS_Store create mode 100644 test/.DS_Store diff --git a/.DS_Store b/.DS_Store index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..b15468b1189813e95eeff407f027f15f498b81a2 100644 GIT binary patch literal 8196 zcmeHM&5qMB5FT$rDME-97j|!X0Fc-N;u@-Q;Jh5T!KMiXZQE3(yKq57;w8B82=EMC z;4u)Kcn2i-#-60MQ}1m7X>G|K%iqj=_R5Zo z+=W!Ir-Q>uJzwh4q^BJRVg_OcVg_OcVg_Oc{)Y_Uo6Thy`0g7twlM=S0~ay_d_QEU zEOU{Kk^1UDqn`kfDTZ}Hf0sLunaEsZVT!_ zvyvBz((Le`X?0S$k+F>#h#BZ-fZy&39nq4Ov#I@lfBSfY%s)_JK|iIJ@Kt%UN^kzOvA~QvTQ8sOY&zVT z$>1AY(`E3k5wV@@xxw>pI7?K;ndN}U7OdB`Wk_`Fq7ryjCd?9H=c&$NinO6 z=jKL|@!ssPKnxbXb$4{Q%lnCW6YyeJ*sQ7?-P#~F=ljq1PP?04+u8H&qc)%VAvZ7I zcGzFMhPE{r+w~uirDOZxfMYqw49IWvd;I=?5dQuD93zOXV+LXd{&faaIzAka5QV(0 zd-3dGBtw0Q%7yhBBee+{R^f5|9Ea@wFhoBSEEm}r2?XV@{}2$r|MB~;cizQr{s6?! B&_)0N delta 105 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGpJ516q~502;ws^0>w5KPGg_gAhnpCgF}!R pBnTAa1`@77th%xAJM(0I8AV3M$)+;eJWLRCKt?lcj^~-f3;-`E4{!hg diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4ec8001e5270a797f25053ed04e30aed838d8770 GIT binary patch literal 8196 zcmeHMu}&L75S=9kEZn3d-4!X*_zy0~lt__Wq&2n?I63DmFd$J~l_E-;CaLK18Fc(X z>L^vpd_vyrZq7G*yMYQ}F^g{Jbn|BS`R#D)Srd`jJD>K6c8REqi|z9sx=3R`U#+&` zpS*-Mh$s3$M^w;Raoiua(E30XPz6*0RX`O`1s;O}*t5BH6Z^jNYOe~Y0#8x_em`Wm z*yi3=&e~T8I$Hw3cJSCX+@lV#F`2iyx0SOt6l1#T!T3<)OAO<};m;Tjn|oV1>%z&n za58>o<0}+nXGdIS;bd}WwO0jHfwlr%yLV|qCv=GFy-~kU%K5^>f5kBKaxfSd<=FbD zy8dx^`#O%B-V3)l=?_m%4kagr;2_j#4THdprlSjp4o+a`Fh0mfJBvvR_=nX=Z?vb)!32j;hL_(@k7VL5rPQwKdXW$q-+oP8J zDpm*~Tk^b#W6!+2j4~M_a+Bx9m}o>q0i1Dggr>vzK6}dsK5`0lGR6yS=$_I%8z-%7 z3;aa|_}v9Gqepm7-`-zZ7AqwjoOR_CWgO4*axV9%nto<)m)&`bALXs?#>s9@UXS3F zQVC`$EolvW;&rvlxwz@dY4EyEA3U$dSVdXm)tgu6b;Z1nDZx5lL$M8*WmxgcCbz@) zzPtv%*N>U->m0Meac^#&XT&_sOaW8C6zEd`dp28eqG+utU<#N5TLt+05W*QFkCmc) zI?%`w060Wg41M``1BM&`Mjk6gL|{%!fm-VP5yNRY;-SxrJXVTYPEKaVIDTg5Pbf}i zM?92ta*?97rhq9>SKvS|N4)<}`_KRNBD*pLOo4x;fE&h1Ji#ORy|wjlyw`^CTR0o% mRf^jZbYv?=E^o!>a52O~?f@f?l_DZA`w+R%W_Hp literal 0 HcmV?d00001 diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index a625a41a3..f0fb4ce7f 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -1,11 +1,8 @@ # frozen_string_literal: true module DueDateActions - extend ActiveSupport::Concern - included do has_many :due_dates, as: :parent, dependent: :destroy - include DueDateQueries end # Generic activity permission checker that determines if an activity is permissible @@ -17,8 +14,15 @@ def activity_permissible?(activity) current_deadline.activity_permissible?(activity) end + # Activity permission checker for a specific deadline date (not current date) + def activity_permissible_for?(activity, 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 @@ -31,10 +35,6 @@ def teammate_review_permissible? activity_permissible?(:teammate_review) end - def metareview_permissible? - activity_permissible?(:metareview) - end - def quiz_permissible? activity_permissible?(:quiz) end @@ -51,250 +51,45 @@ def drop_topic_permissible? activity_permissible?(:drop_topic) end - # Check activity permissions for a specific deadline type - def activity_permissible_for_type?(activity, deadline_type_name) - deadline = find_deadline(deadline_type_name) - return false unless deadline - - deadline.activity_permissible?(activity) - end - - # Get the current stage/deadline for a specific action - def current_stage_for(action) - current_deadline_for(action) + # Get the next due date for this parent + def next_due_date + due_dates.where('due_at >= ?', Time.current).order(:due_at).first end - # Check if a specific action is currently allowed based on deadlines - def action_allowed?(action) - case action.to_s.downcase - when 'submit', 'submission' - submission_permissible? - when 'review' - review_permissible? - when 'teammate_review' - teammate_review_permissible? - when 'metareview' - metareview_permissible? - when 'quiz' - quiz_permissible? - when 'team_formation' - team_formation_permissible? - when 'signup' - signup_permissible? - when 'drop_topic' - drop_topic_permissible? - else - false - end - end - - # Get all currently allowed actions - def allowed_actions - actions = [] - actions << 'submission' if submission_permissible? - actions << 'review' if review_permissible? - actions << 'teammate_review' if teammate_review_permissible? - actions << 'metareview' if metareview_permissible? - actions << 'quiz' if quiz_permissible? - actions << 'team_formation' if team_formation_permissible? - actions << 'signup' if signup_permissible? - actions << 'drop_topic' if drop_topic_permissible? - actions - end - - # Check if any actions are currently allowed - def has_allowed_actions? - allowed_actions.any? - end + # Get the current stage name for display purposes + def current_stage + deadline = next_due_date + return 'finished' unless deadline - # Get permission summary for all actions - def action_permissions_summary - { - submission: submission_permissible?, - review: review_permissible?, - teammate_review: teammate_review_permissible?, - metareview: metareview_permissible?, - quiz: quiz_permissible?, - team_formation: team_formation_permissible?, - signup: signup_permissible?, - drop_topic: drop_topic_permissible?, - has_any_permissions: has_allowed_actions? - } + deadline.deadline_type&.name || 'unknown' end - # Topic-specific permission checking - def activity_permissible_for_topic?(activity, topic_id) - deadline = current_stage_for_topic(topic_id, activity) - return false unless deadline - - deadline.activity_permissible?(activity) - end - - # Topic-specific syntactic sugar methods - def submission_permissible_for_topic?(topic_id) - activity_permissible_for_topic?(:submission, topic_id) - end - - def review_permissible_for_topic?(topic_id) - activity_permissible_for_topic?(:review, topic_id) - end - - def quiz_permissible_for_topic?(topic_id) - activity_permissible_for_topic?(:quiz, topic_id) - end - - # Copy all due dates to a new parent object + # Copy due dates to a new parent object def copy_due_dates_to(new_parent) due_dates.find_each do |due_date| - due_date.copy_to(new_parent) - end - end - - # Duplicate due dates with modifications - def duplicate_due_dates_with_changes(new_parent, changes = {}) - due_dates.map do |due_date| - due_date.duplicate_with_changes(changes.merge(parent: new_parent)) - end - end - - # Create a new due date for this parent - def create_due_date(deadline_type_name, due_at, round: 1, **attributes) - deadline_type = DeadlineType.find_by_name(deadline_type_name) - raise ArgumentError, "Invalid deadline type: #{deadline_type_name}" unless deadline_type - - due_dates.create!( - deadline_type: deadline_type, - due_at: due_at, - round: round, - **attributes - ) - end - - # Update or create a due date for a specific type and round - def set_deadline(deadline_type_name, due_at, round: 1, **attributes) - deadline = find_deadline(deadline_type_name, round) - - if deadline - deadline.update!(due_at: due_at, **attributes) - deadline - else - create_due_date(deadline_type_name, due_at, round: round, **attributes) + new_due_date = due_date.dup + new_due_date.parent = new_parent + new_due_date.save! end end - # Remove due dates of a specific type - def remove_deadlines_of_type(deadline_type_name) - due_dates.joins(:deadline_type) - .where(deadline_types: { name: deadline_type_name }) - .destroy_all - end - - # Shift all deadlines by a certain amount of time - def shift_deadlines(time_delta) - due_dates.update_all("due_at = due_at + INTERVAL #{time_delta.to_i} SECOND") - end - - # Shift deadlines of a specific type - def shift_deadlines_of_type(deadline_type_name, time_delta) + # Shift deadlines of a specific type by a time interval + def shift_deadlines_of_type(deadline_type_name, time_interval) due_dates.joins(:deadline_type) .where(deadline_types: { name: deadline_type_name }) - .update_all("due_at = due_at + INTERVAL #{time_delta.to_i} SECOND") + .update_all("due_at = due_at + INTERVAL #{time_interval.to_i} SECOND") end - # Check if deadlines are properly ordered (submission before review, etc.) + # Check if deadlines are in proper chronological order def deadlines_properly_ordered? - workflow_deadlines = due_dates.joins(:deadline_type) - .where(deadline_types: { name: DeadlineType.workflow_order }) - .order(:due_at) + sorted_deadlines = due_dates.order(:due_at) + previous_date = nil - previous_position = -1 - workflow_deadlines.each do |deadline| - current_position = deadline.deadline_type.workflow_position - return false if current_position < previous_position - previous_position = current_position + sorted_deadlines.each do |deadline| + return false if previous_date && deadline.due_at < previous_date + previous_date = deadline.due_at end true end - - # Get deadline ordering violations - def deadline_ordering_violations - violations = [] - workflow_deadlines = due_dates.joins(:deadline_type) - .where(deadline_types: { name: DeadlineType.workflow_order }) - .order(:due_at) - - workflow_deadlines.each_with_index do |deadline, index| - next_deadline = workflow_deadlines[index + 1] - next unless next_deadline - - if deadline.deadline_type.workflow_position > next_deadline.deadline_type.workflow_position - violations << { - earlier_deadline: deadline, - later_deadline: next_deadline, - issue: "#{next_deadline.deadline_type_name} should come after #{deadline.deadline_type_name}" - } - end - end - - violations - end - - # Validate that all required deadline types are present - def has_required_deadlines?(required_types = ['submission']) - required_types.all? { |type| has_deadline_type?(type) } - end - - # Get missing required deadline types - def missing_required_deadlines(required_types = ['submission']) - required_types.reject { |type| has_deadline_type?(type) } - end - - # Check if this object has a complete deadline schedule - def has_complete_deadline_schedule? - has_deadline_type?('submission') && - (has_deadline_type?('review') || has_deadline_type?('quiz')) - end - - # Get the workflow stage based on current time and deadlines - def current_workflow_stage - current_deadline = next_due_date - return 'inactive' unless current_deadline - - if current_deadline.overdue? - previous = current_deadline.previous_deadline - return previous ? previous.deadline_type_name : 'pre-submission' - else - current_deadline.deadline_type_name - end - end - - # Check if object is in a specific workflow stage - def in_stage?(stage_name) - current_workflow_stage == stage_name - end - - # Get all stages this object will go through - def workflow_stages - used_deadline_types.sort_by(&:workflow_position).map(&:name) - end - - # Check if a stage has been completed - def stage_completed?(stage_name) - deadline = find_deadline(stage_name) - return false unless deadline - - deadline.overdue? - end - - # Get completion status for all stages - def stage_completion_status - workflow_stages.map do |stage| - { - stage: stage, - completed: stage_completed?(stage), - deadline: find_deadline(stage) - } - end - end end diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb index 69e39538c..9229dfbbe 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module DueDatePermissions - extend ActiveSupport::Concern - # Permission checking methods that combine deadline-based and role-based logic - # These methods provide a unified interface for checking if actions are allowed def can_submit? return false unless submission_allowed_id @@ -34,14 +31,7 @@ def can_teammate_review? deadline_right&.name&.in?(%w[OK Late]) end - def can_metareview? - return false unless respond_to?(:metareview_allowed_id) && metareview_allowed_id - - deadline_right = DeadlineRight.find_by(id: metareview_allowed_id) - deadline_right&.name&.in?(%w[OK Late]) - end - - # Generic permission checker that can be extended for any action + # Generic permission checker def activity_permissible?(activity) permission_field = "#{activity}_allowed_id" return false unless respond_to?(permission_field) @@ -53,27 +43,6 @@ def activity_permissible?(activity) deadline_right&.name&.in?(%w[OK Late]) end - # Syntactic sugar methods for common activities - 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 metareview_permissible? - activity_permissible?(:metareview) if respond_to?(:metareview_allowed_id) - end - # Check if deadline allows late submissions def allows_late_submission? return false unless submission_allowed_id @@ -96,56 +65,9 @@ def allows_late_quiz? deadline_right&.name == 'Late' end - # Check if any activity is currently allowed - def has_any_permission? - can_submit? || can_review? || can_take_quiz? || can_teammate_review? || - (respond_to?(:can_metareview?) && can_metareview?) - end - - # Get list of currently allowed activities - def allowed_activities - activities = [] - activities << 'submission' if can_submit? - activities << 'review' if can_review? - activities << 'quiz' if can_take_quiz? - activities << 'teammate_review' if can_teammate_review? - activities << 'metareview' if respond_to?(:can_metareview?) && can_metareview? - activities - end - # Check if this deadline is currently active (allows some action) def active? - has_any_permission? - end - - # Check if this deadline is completely closed (no actions allowed) - def closed? - !active? - end - - # Check permissions for deadline type compatibility - def deadline_type_permits_action?(action) - return false unless deadline_type - - case action.to_s.downcase - when 'submit', 'submission' - deadline_type.allows_submission? - when 'review' - deadline_type.allows_review? - when 'quiz' - deadline_type.allows_quiz? - when 'teammate_review' - deadline_type.allows_review? - when 'metareview' - deadline_type.allows_review? - else - false - end - end - - # Comprehensive permission check combining deadline type and deadline rights - def permits_action?(action) - deadline_type_permits_action?(action) && activity_permissible?(action) + can_submit? || can_review? || can_take_quiz? || can_teammate_review? end # Get permission status for an action (OK, Late, No) @@ -160,31 +82,6 @@ def permission_status_for(action) deadline_right&.name || 'No' end - # Check if deadline is in grace period (allows late submissions) - def in_grace_period_for?(action) - permission_status_for(action) == 'Late' - end - - # Check if deadline is fully open for action - def fully_open_for?(action) - permission_status_for(action) == 'OK' - end - - # Get human-readable permission description - def permission_description_for(action) - status = permission_status_for(action) - case status - when 'OK' - "#{action.to_s.humanize} is allowed" - when 'Late' - "#{action.to_s.humanize} is allowed with late penalty" - when 'No' - "#{action.to_s.humanize} is not allowed" - else - "#{action.to_s.humanize} status unknown" - end - end - # Get a summary of all permissions for this deadline def permissions_summary { @@ -192,8 +89,7 @@ def permissions_summary review: permission_status_for(:review), quiz: permission_status_for(:quiz), teammate_review: permission_status_for(:teammate_review), - active: active?, - closed: closed? + active: active? } end end diff --git a/app/models/concerns/due_date_queries.rb b/app/models/concerns/due_date_queries.rb index 25901ab3c..777163379 100644 --- a/app/models/concerns/due_date_queries.rb +++ b/app/models/concerns/due_date_queries.rb @@ -1,204 +1,20 @@ # frozen_string_literal: true module DueDateQueries - extend ActiveSupport::Concern - - included do - # Scopes for common deadline queries - scope :upcoming, -> { where('due_at > ?', Time.current).order(:due_at) } - scope :overdue, -> { where('due_at < ?', Time.current).order(:due_at) } - scope :today, -> { where(due_at: Time.current.beginning_of_day..Time.current.end_of_day) } - scope :this_week, -> { where(due_at: Time.current.beginning_of_week..Time.current.end_of_week) } - scope :for_deadline_type, ->(type_name) { joins(:deadline_type).where(deadline_types: { name: type_name }) } - scope :for_round, ->(round_num) { where(round: round_num) } - scope :active_deadlines, -> { where('due_at > ?', Time.current) } - scope :by_deadline_type, -> { joins(:deadline_type).order('deadline_types.name') } - end - - class_methods do - # Find next upcoming deadline for any parent - def next_deadline - upcoming.first - end - - # Find deadlines by type name - def of_type(deadline_type_name) - joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) - end - - # Get deadline statistics - def deadline_stats - { - total: count, - upcoming: upcoming.count, - overdue: overdue.count, - today: today.count, - this_week: this_week.count - } - end - - # Find deadlines within a date range - def between_dates(start_date, end_date) - where(due_at: start_date..end_date) - end - - # Find deadlines for specific actions - def for_submission - of_type('submission') - end - - def for_review - of_type('review') - end - - def for_quiz - of_type('quiz') - end - - def for_teammate_review - of_type('teammate_review') - end - - def for_metareview - of_type('metareview') - end - - # Find deadlines that allow specific actions - def allowing_submission - where(submission_allowed_id: [2, 3]) # Late and OK - end - - def allowing_review - where(review_allowed_id: [2, 3]) # Late and OK - end - - def allowing_quiz - where(quiz_allowed_id: [2, 3]) # Late and OK - end - - # Get deadlines grouped by type - def grouped_by_type - joins(:deadline_type) - .group('deadline_types.name') - .order('deadline_types.name') - end - end - - # Instance methods for parent objects (Assignment, SignUpTopic) - # These methods should be included in Assignment and SignUpTopic models - # Get next due date for this parent - def next_due_date - due_dates.upcoming.first - end - - # Get the most recently passed deadline - def last_due_date - due_dates.overdue.order(due_at: :desc).first - end - - # Find current stage/deadline for a specific action - def current_deadline_for(action) - deadline_type_name = map_action_to_deadline_type(action) - return nil unless deadline_type_name - - # First try to find an active deadline for this action - current = due_dates - .joins(:deadline_type) - .where(deadline_types: { name: deadline_type_name }) - .where('due_at >= ?', Time.current) - .order(:due_at) - .first - - # If no future deadline, get the most recent past deadline - current ||= due_dates - .joins(:deadline_type) - .where(deadline_types: { name: deadline_type_name }) - .order(due_at: :desc) - .first - - current - end - - # Get upcoming deadlines with limit - def upcoming_deadlines(limit: 5) - due_dates.upcoming.limit(limit) - end - - # Get overdue deadlines - def overdue_deadlines - due_dates.overdue - end - - # Check if there are any future deadlines - def has_future_deadlines? - due_dates.upcoming.exists? - end - - # Get deadlines for a specific round - def deadlines_for_round(round_number) - due_dates.where(round: round_number).order(:due_at) - end - - # Find deadline by type and round - def find_deadline(deadline_type_name, round_number = nil) - query = due_dates.joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) - query = query.where(round: round_number) if round_number - query.order(:due_at).first - end - - # Get all deadline types used by this object - def used_deadline_types - due_dates - .joins(:deadline_type) - .select('DISTINCT deadline_types.*') - .map(&:deadline_type) - end - - # Check if this object has a specific type of deadline - def has_deadline_type?(deadline_type_name) - due_dates - .joins(:deadline_type) - .where(deadline_types: { name: deadline_type_name }) - .exists? - end - - # Get deadlines that are currently active (allowing some action) - def active_deadlines - due_dates.select(&:active?) - end - - # Get deadline summary for display - def deadline_summary - { - total_deadlines: due_dates.count, - upcoming_count: upcoming_deadlines.count, - overdue_count: overdue_deadlines.count, - deadline_types: used_deadline_types.map(&:name), - next_deadline: next_due_date, - has_active_deadlines: active_deadlines.any? - } - end - - # Find the current stage for topic-specific deadlines - def current_stage_for_topic(topic_id, action) - deadline_type_name = map_action_to_deadline_type(action) - return nil unless deadline_type_name - - # Try topic-specific deadline first - topic_deadline = due_dates - .joins(:deadline_type) - .where(parent_id: topic_id, parent_type: 'SignUpTopic') - .where(deadline_types: { name: deadline_type_name }) - .where('due_at >= ?', Time.current) - .order(:due_at) - .first + def next_due_date(topic_id = nil) + if topic_id && has_topic_specific_deadlines? + topic_deadline = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + return topic_deadline if topic_deadline + end - # Fall back to assignment-level deadline - topic_deadline || current_deadline_for(action) + due_dates.where('due_at >= ?', Time.current).order(:due_at).first end - # Get all deadlines affecting a specific topic + # Get all deadlines for a topic (topic-specific + assignment fallback) def deadlines_for_topic(topic_id) assignment_deadlines = due_dates.where(parent_type: 'Assignment') topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') @@ -206,86 +22,24 @@ def deadlines_for_topic(topic_id) (assignment_deadlines + topic_deadlines).sort_by(&:due_at) end - # Check if assignment has topic-specific overrides - def has_topic_deadline_overrides? + # Check if assignment has topic-specific deadlines + def has_topic_specific_deadlines? due_dates.where(parent_type: 'SignUpTopic').exists? end - # Get deadline comparison between assignment and topic - def deadline_comparison_for_topic(topic_id) - assignment_deadlines = due_dates.where(parent_type: 'Assignment').includes(:deadline_type) - topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic').includes(:deadline_type) - - { - assignment_deadlines: assignment_deadlines, - topic_deadlines: topic_deadlines, - has_overrides: topic_deadlines.any? - } - end - - # Find conflicts between assignment and topic deadlines - def deadline_conflicts_for_topic(topic_id) - conflicts = [] - - used_deadline_types.each do |deadline_type| - assignment_deadline = find_deadline(deadline_type.name) - topic_deadline = due_dates - .joins(:deadline_type) - .where(parent_id: topic_id, parent_type: 'SignUpTopic') - .where(deadline_types: { name: deadline_type.name }) - .first - - if assignment_deadline && topic_deadline - if assignment_deadline.due_at != topic_deadline.due_at - conflicts << { - deadline_type: deadline_type.name, - assignment_due: assignment_deadline.due_at, - topic_due: topic_deadline.due_at, - difference: topic_deadline.due_at - assignment_deadline.due_at - } - end - end - end - - conflicts - end - - # Get the effective deadline for a topic (topic-specific or assignment fallback) - def effective_deadline_for_topic(topic_id, deadline_type_name) - # First check for topic-specific deadline - topic_deadline = due_dates - .joins(:deadline_type) - .where(parent_id: topic_id, parent_type: 'SignUpTopic') - .where(deadline_types: { name: deadline_type_name }) - .first - - # Fall back to assignment deadline - topic_deadline || find_deadline(deadline_type_name) - end - private - # Map action names to deadline type names - def map_action_to_deadline_type(action) - case action.to_s.downcase - when 'submit', 'submission' - 'submission' - when 'review', 'peer_review' - 'review' - when 'teammate_review' - 'teammate_review' - when 'metareview', 'meta_review' - 'metareview' - when 'quiz' - 'quiz' - when 'team_formation' - 'team_formation' - when 'signup' - 'signup' - when 'drop_topic' - 'drop_topic' - else - nil - end + # Map action names to deadline type names for lookup + def action_to_deadline_type(action) + { + 'submit' => 'submission', + 'submission' => 'submission', + 'review' => 'review', + 'teammate_review' => 'teammate_review', + 'quiz' => 'quiz', + 'team_formation' => 'team_formation', + 'signup' => 'signup', + 'drop_topic' => 'drop_topic' + }[action.to_s.downcase] end end diff --git a/app/models/deadline_right.rb b/app/models/deadline_right.rb index 66121a832..9e9ef8f03 100644 --- a/app/models/deadline_right.rb +++ b/app/models/deadline_right.rb @@ -1,37 +1,13 @@ # frozen_string_literal: true class DeadlineRight < ApplicationRecord - # Constants for deadline right IDs - NO = 1 - LATE = 2 - OK = 3 - validates :name, presence: true, uniqueness: true - validates :description, presence: true - - # Scopes for different permission levels - scope :allowing, -> { where(name: %w[OK Late]) } - scope :denying, -> { where(name: 'No') } - scope :with_penalty, -> { where(name: 'Late') } - scope :without_penalty, -> { where(name: 'OK') } # Class methods for finding deadline rights def self.find_by_name(name) find_by(name: name.to_s) end - def self.no - find_by_name('No') - end - - def self.late - find_by_name('Late') - end - - def self.ok - find_by_name('OK') - end - # Permission checking methods def allows_action? %w[OK Late].include?(name) @@ -49,85 +25,11 @@ def allows_without_penalty? name == 'OK' end - # Semantic helper methods - def no? - name == 'No' - end - - def late? - name == 'Late' - end - - def ok? - name == 'OK' - end - # Display methods - def display_name - name - end - - def display_description - description - end - def to_s name end - def css_class - case name - when 'OK' - 'deadline-allowed' - when 'Late' - 'deadline-late' - when 'No' - 'deadline-denied' - else - 'deadline-unknown' - end - end - - def icon - case name - when 'OK' - 'check-circle' - when 'Late' - 'clock' - when 'No' - 'x-circle' - else - 'question-circle' - end - end - - # Method to get human-readable status with context - def status_with_context(action) - case name - when 'OK' - "#{action.to_s.humanize} is allowed" - when 'Late' - "#{action.to_s.humanize} is allowed with late penalty" - when 'No' - "#{action.to_s.humanize} is not allowed" - else - "#{action.to_s.humanize} status unknown" - end - end - - # Comparison methods - def more_permissive_than?(other) - return false unless other.is_a?(DeadlineRight) - - permission_level > other.permission_level - end - - def less_permissive_than?(other) - return false unless other.is_a?(DeadlineRight) - - permission_level < other.permission_level - end - def permission_level case name when 'No' @@ -146,80 +48,4 @@ def <=>(other) permission_level <=> other.permission_level end - - # Method to seed the deadline rights (for use in migrations/seeds) - def self.seed_deadline_rights! - deadline_rights = [ - { id: NO, name: 'No', description: 'Action is not allowed' }, - { id: LATE, name: 'Late', description: 'Action is allowed with late penalty' }, - { id: OK, name: 'OK', description: 'Action is allowed without penalty' } - ] - - deadline_rights.each do |right_attrs| - find_or_create_by(id: right_attrs[:id]) do |dr| - dr.name = right_attrs[:name] - dr.description = right_attrs[:description] - end - end - end - - # Validation methods - def self.valid_right_names - %w[No Late OK] - end - - def self.validate_right_name(name) - valid_right_names.include?(name.to_s) - end - - # Statistics methods - def usage_count - # Count how many due_dates reference this deadline right - # This is a general count across all permission fields - count = 0 - - # Check submission permissions - count += DueDate.where(submission_allowed_id: id).count - - # Check review permissions - count += DueDate.where(review_allowed_id: id).count - - # Check quiz permissions - count += DueDate.where(quiz_allowed_id: id).count - - # Check teammate review permissions - count += DueDate.where(teammate_review_allowed_id: id).count - - # Check other permission fields if they exist - if DueDate.column_names.include?('resubmission_allowed_id') - count += DueDate.where(resubmission_allowed_id: id).count - end - - if DueDate.column_names.include?('rereview_allowed_id') - count += DueDate.where(rereview_allowed_id: id).count - end - - if DueDate.column_names.include?('review_of_review_allowed_id') - count += DueDate.where(review_of_review_allowed_id: id).count - end - - count - end - - # Check if this deadline right is being used - def in_use? - usage_count > 0 - end - - private - - # Prevent deletion if deadline right is in use - def cannot_delete_if_in_use - return unless in_use? - - errors.add(:base, 'Cannot delete deadline right that is being used by due dates') - throw :abort - end - - before_destroy :cannot_delete_if_in_use end diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb index bf490f4a4..a691e9911 100644 --- a/app/models/deadline_type.rb +++ b/app/models/deadline_type.rb @@ -1,106 +1,33 @@ # frozen_string_literal: true -# DeadlineType serves as the canonical source of truth for all deadline categories. -# It replaces hard-coded deadline_type_id comparisons with semantic helper methods. class DeadlineType < ApplicationRecord - # Constants for deadline type IDs (for backward compatibility) - SUBMISSION = 1 - REVIEW = 2 - TEAMMATE_REVIEW = 3 - METAREVIEW = 5 - DROP_TOPIC = 6 - SIGNUP = 7 - TEAM_FORMATION = 8 - QUIZ = 11 - validates :name, presence: true, uniqueness: true validates :description, presence: true has_many :due_dates, foreign_key: :deadline_type_id, dependent: :restrict_with_exception - # Scopes for categorizing deadline types - scope :submission_types, -> { where(name: ['submission']) } - scope :review_types, -> { where(name: ['review', 'metareview', 'teammate_review']) } - scope :quiz_types, -> { where(name: ['quiz']) } - scope :administrative_types, -> { where(name: ['drop_topic', 'signup', 'team_formation']) } - # Class methods for finding deadline types def self.find_by_name(name) find_by(name: name.to_s) end - def self.submission - find_by_name('submission') - end - - def self.review - find_by_name('review') - end - - def self.teammate_review - find_by_name('teammate_review') - end - - def self.metareview - find_by_name('metareview') - end - - def self.drop_topic - find_by_name('drop_topic') - end - - def self.signup - find_by_name('signup') - end - - def self.team_formation - find_by_name('team_formation') - end - - def self.quiz - find_by_name('quiz') - end - - # Dynamic method to find deadline type for action - def self.for_action(action_name) - case action_name.to_s.downcase - when 'submit', 'submission' then submission - when 'review' then review - when 'teammate_review' then teammate_review - when 'metareview' then metareview - when 'quiz' then quiz - when 'team_formation' then team_formation - when 'signup' then signup - when 'drop_topic' then drop_topic - else nil - end - end - # Semantic helper methods for deadline type identification def submission? name == 'submission' end def review? - %w[review metareview teammate_review].include?(name) + name == 'review' end def teammate_review? name == 'teammate_review' end - def metareview? - name == 'metareview' - end - def quiz? name == 'quiz' end - def administrative? - %w[drop_topic signup team_formation].include?(name) - end - def team_formation? name == 'team_formation' end @@ -113,44 +40,6 @@ def drop_topic? name == 'drop_topic' end - # Permission checking helper methods - def allows_submission? - submission? - end - - def allows_review? - review? - end - - def allows_quiz? - quiz? - end - - def allows_team_formation? - team_formation? - end - - def allows_signup? - signup? - end - - def allows_topic_drop? - drop_topic? - end - - # Category checking methods - def workflow_deadline? - %w[submission review teammate_review metareview].include?(name) - end - - def assessment_deadline? - %w[review metareview teammate_review quiz].include?(name) - end - - def student_action_deadline? - %w[submission quiz signup team_formation drop_topic].include?(name) - end - # Display methods def display_name name.humanize @@ -160,134 +49,6 @@ def to_s display_name end - # Method to seed the deadline types (for use in migrations/seeds) - def self.seed_deadline_types! - deadline_types = [ - { id: SUBMISSION, name: 'submission', description: 'Student work submission deadlines' }, - { id: REVIEW, name: 'review', description: 'Peer review deadlines' }, - { id: TEAMMATE_REVIEW, name: 'teammate_review', description: 'Team member evaluation deadlines' }, - { id: METAREVIEW, name: 'metareview', description: 'Meta-review deadlines' }, - { id: DROP_TOPIC, name: 'drop_topic', description: 'Topic drop deadlines' }, - { id: SIGNUP, name: 'signup', description: 'Course/assignment signup deadlines' }, - { id: TEAM_FORMATION, name: 'team_formation', description: 'Team formation deadlines' }, - { id: QUIZ, name: 'quiz', description: 'Quiz completion deadlines' } - ] - - deadline_types.each do |type_attrs| - find_or_create_by(id: type_attrs[:id]) do |dt| - dt.name = type_attrs[:name] - dt.description = type_attrs[:description] - end - end - end - - # Method to clean up duplicate entries - def self.cleanup_duplicates! - # Remove any duplicate team_formation entries (keep canonical ID 8) - where(name: 'team_formation').where.not(id: TEAM_FORMATION).destroy_all - - # Update any due_dates that reference deleted duplicates - ActiveRecord::Base.connection.execute(<<~SQL) - UPDATE due_dates - SET deadline_type_id = #{TEAM_FORMATION} - WHERE deadline_type_id NOT IN (#{all.pluck(:id).join(',')}) - AND deadline_type_id IS NOT NULL - SQL - end - - # Validation methods - def self.valid_deadline_names - %w[submission review teammate_review metareview drop_topic signup team_formation quiz] - end - - def self.validate_deadline_name(name) - valid_deadline_names.include?(name.to_s) - end - - # Query helpers for associations - def self.used_in_assignments - joins(:due_dates) - .where(due_dates: { parent_type: 'Assignment' }) - .distinct - end - - def self.used_in_topics - joins(:due_dates) - .where(due_dates: { parent_type: 'SignUpTopic' }) - .distinct - end - - # Statistics methods - def due_dates_count - due_dates.count - end - - def active_due_dates_count - due_dates.where('due_at > ?', Time.current).count - end - - def overdue_count - due_dates.where('due_at < ?', Time.current).count - end - - # Comparison and ordering - def <=>(other) - return nil unless other.is_a?(DeadlineType) - - id <=> other.id - end - - # Class method to get deadline type hierarchy for workflow - def self.workflow_order - %w[signup team_formation submission review teammate_review metareview quiz drop_topic] - end - - def workflow_position - self.class.workflow_order.index(name) || Float::INFINITY - end - - # Method to check if this deadline type typically comes before another - def comes_before?(other_type) - return false unless other_type.is_a?(DeadlineType) - - workflow_position < other_type.workflow_position - end - - # Method to get the next logical deadline type in workflow - def next_in_workflow - current_pos = workflow_position - return nil if current_pos == Float::INFINITY - - next_name = self.class.workflow_order[current_pos + 1] - return nil unless next_name - - self.class.find_by_name(next_name) - end - - # Method to get the previous logical deadline type in workflow - def previous_in_workflow - current_pos = workflow_position - return nil if current_pos <= 0 - - prev_name = self.class.workflow_order[current_pos - 1] - return nil unless prev_name - - self.class.find_by_name(prev_name) - end - - # Method for dynamic permission checking based on action - def allows_action?(action) - case action.to_s.downcase - when 'submit', 'submission' then allows_submission? - when 'review' then allows_review? - when 'quiz' then allows_quiz? - when 'team_formation' then allows_team_formation? - when 'signup' then allows_signup? - when 'drop_topic' then allows_topic_drop? - else false - end - end - private # Ensure we maintain referential integrity diff --git a/app/models/due_date.rb b/app/models/due_date.rb index 4637dac53..acbf39d42 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class DueDate < ApplicationRecord - include Comparable include DueDatePermissions belongs_to :parent, polymorphic: true @@ -9,7 +8,8 @@ class DueDate < ApplicationRecord validates :due_at, presence: true validates :deadline_type_id, presence: true - validates :round, presence: true, numericality: { greater_than: 0 } + validates :parent, presence: true + validates :round, numericality: { greater_than: 0 }, allow_nil: true validate :due_at_is_valid_datetime # Scopes for common queries @@ -17,25 +17,6 @@ class DueDate < ApplicationRecord 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 }) } - scope :active, -> { where('due_at > ?', Time.current) } - - # Instance methods for individual due date operations - - # Create a copy of this due date for a new parent - def copy_to(new_parent) - new_due_date = dup - new_due_date.parent = new_parent - new_due_date.save! - new_due_date - end - - # Duplicate this due date with different attributes - def duplicate_with_changes(changes = {}) - new_due_date = dup - changes.each { |attr, value| new_due_date.public_send("#{attr}=", value) } - new_due_date.save! - new_due_date - end # Check if this deadline has passed def overdue? @@ -47,82 +28,19 @@ def upcoming? due_at > Time.current end - # Check if this deadline is today - def due_today? - due_at.to_date == Time.current.to_date + 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 - # Check if this deadline is this week - def due_this_week? - due_at >= Time.current.beginning_of_week && due_at <= Time.current.end_of_week - end - - # Time remaining until deadline (returns nil if overdue) - def time_remaining - return nil if overdue? - - due_at - Time.current - end - - # Time since deadline passed (returns nil if not overdue) - def time_overdue - return nil unless overdue? - - Time.current - due_at - end - - # Get human-readable time description - def time_description - if due_today? - "Due today at #{due_at.strftime('%I:%M %p')}" - elsif overdue? - days_overdue = (Time.current.to_date - due_at.to_date).to_i - "#{days_overdue} day#{'s' if days_overdue != 1} overdue" - elsif upcoming? - days_until = (due_at.to_date - Time.current.to_date).to_i - if days_until == 0 - "Due today" - elsif days_until == 1 - "Due tomorrow" - else - "Due in #{days_until} days" - end - else - "Due #{due_at.strftime('%B %d, %Y')}" - end - end - - # Check if this deadline is for a specific type of activity - def for_submission? - deadline_type&.submission? - end - - def for_review? - deadline_type&.review? - end - - def for_quiz? - deadline_type&.quiz? - end - - def for_teammate_review? - deadline_type&.teammate_review? - end - - def for_metareview? - deadline_type&.metareview? - end - - def for_team_formation? - deadline_type&.team_formation? - end - - def for_signup? - deadline_type&.signup? - end - - def for_topic_drop? - deadline_type&.drop_topic? + 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 # Get the deadline type name @@ -130,186 +48,64 @@ def deadline_type_name deadline_type&.name end - # Get human-readable deadline type - def deadline_type_display - deadline_type&.display_name || 'Unknown' - end - - # Check if this deadline allows late submissions - def allows_late_work? - allows_late_submission? || allows_late_review? || allows_late_quiz? - end - - # Get status description - def status_description - if overdue? - allows_late_work? ? 'Overdue (Late work accepted)' : 'Closed' - elsif due_today? - 'Due today' - elsif upcoming? - time_description - else - 'Unknown status' - end - end - - # Check if this deadline is currently in effect - def currently_active? - active? && (upcoming? || (overdue? && allows_late_work?)) - end - - # Get the next deadline after this one (for the same parent) - def next_deadline - parent.due_dates - .where('due_at > ? OR (due_at = ? AND id > ?)', due_at, due_at, id) - .order(:due_at, :id) - .first - end - - # Get the previous deadline before this one (for the same parent) - def previous_deadline - parent.due_dates - .where('due_at < ? OR (due_at = ? AND id < ?)', due_at, due_at, id) - .order(due_at: :desc, id: :desc) - .first - end - # Check if this is the last deadline for the parent def last_deadline? - next_deadline.nil? - end - - # Check if this is the first deadline for the parent - def first_deadline? - previous_deadline.nil? + parent.due_dates.where('due_at > ?', due_at).empty? end # Comparison method for sorting def <=>(other) return nil unless other.is_a?(DueDate) - - # Primary sort: due_at - comparison = due_at <=> other.due_at - return comparison unless comparison.zero? - - # Secondary sort: deadline type workflow order - if deadline_type && other.deadline_type - workflow_comparison = deadline_type.workflow_position <=> other.deadline_type.workflow_position - return workflow_comparison unless workflow_comparison.zero? - end - - # Tertiary sort: id for consistency - id <=> other.id - end - - # Get all due dates for the same round and parent - def round_siblings - parent.due_dates.where(round: round).where.not(id: id).order(:due_at) - end - - # Check if this deadline conflicts with others in the same round - def has_round_conflicts? - round_siblings.where(deadline_type_id: deadline_type_id).exists? - end - - # Get summary information about this deadline - def summary - { - id: id, - deadline_type: deadline_type_name, - due_at: due_at, - round: round, - overdue: overdue?, - upcoming: upcoming?, - currently_active: currently_active?, - time_description: time_description, - status: status_description, - permissions: permissions_summary - } + due_at <=> other.due_at end # String representation def to_s - "#{deadline_type_display} - #{time_description}" - end - - # Detailed string representation - def inspect_details - "DueDate(id: #{id}, type: #{deadline_type_name}, due: #{due_at}, " \ - "round: #{round}, parent: #{parent_type}##{parent_id})" + "#{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 - def sort_by_due_date(due_dates) - due_dates.sort - end - - # Find the next upcoming due date from a collection - def next_from_collection(due_dates) - due_dates.select(&:upcoming?).min - end - - # Check if any due dates in collection allow late work - def any_allow_late_work?(due_dates) - due_dates.any?(&:allows_late_work?) + # Sort a collection of due dates chronologically + def sort_due_dates(due_dates) + due_dates.sort_by(&:due_at) end - # Get due dates grouped by deadline type - def group_by_type(due_dates) - due_dates.group_by(&:deadline_type_name) + # Check if any due dates in the future exist for a collection + def any_future_due_dates?(due_dates) + due_dates.any?(&:upcoming?) end - # Get due dates grouped by round - def group_by_round(due_dates) - due_dates.group_by(&:round) - end + def copy(from_assignment_id, to_assignment_id) + from_assignment = Assignment.find(from_assignment_id) + to_assignment = Assignment.find(to_assignment_id) - # Filter due dates that are currently actionable - def currently_actionable(due_dates) - due_dates.select(&:currently_active?) + 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 - # Get statistics for a collection of due dates - def collection_stats(due_dates) - { - total: due_dates.count, - upcoming: due_dates.count(&:upcoming?), - overdue: due_dates.count(&:overdue?), - due_today: due_dates.count(&:due_today?), - active: due_dates.count(&:currently_active?), - types: due_dates.map(&:deadline_type_name).uniq.compact.sort - } - end - # Find deadline conflicts in a collection - def find_conflicts(due_dates) - conflicts = [] - due_dates.group_by(&:round).each do |round, round_deadlines| - round_deadlines.group_by(&:deadline_type_name).each do |type, type_deadlines| - if type_deadlines.count > 1 - conflicts << { - round: round, - deadline_type: type, - conflicting_deadlines: type_deadlines.map(&:id) - } - end - end + # Fetch all due dates for a parent object + def fetch_due_dates(parent_id) + if parent_id.is_a?(Assignment) + where(parent: parent_id).includes(:deadline_type).order(:due_at) + else + includes(:deadline_type).where(parent_id: parent_id).order(:due_at) end - - conflicts end - # Get upcoming deadlines across all due dates - def upcoming_across_all(limit: 10) - upcoming.limit(limit).includes(:deadline_type, :parent) - end + def next_due_date(parent_id, topic_id = nil) + if topic_id + topic_deadline = where(parent_id: topic_id, parent_type: 'SignUpTopic') + .upcoming.first + return topic_deadline if topic_deadline + end - # Get overdue deadlines across all due dates - def overdue_across_all(limit: 10) - overdue.limit(limit).includes(:deadline_type, :parent) + where(parent_id: parent_id).upcoming.first end end @@ -318,12 +114,15 @@ def overdue_across_all(limit: 10) def due_at_is_valid_datetime return unless due_at.present? - unless due_at.is_a?(Time) || due_at.is_a?(DateTime) || due_at.is_a?(Date) + unless due_at.is_a?(Time) || due_at.is_a?(DateTime) errors.add(:due_at, 'must be a valid datetime') end + end - if due_at.is_a?(Date) - errors.add(:due_at, 'should include time information, not just date') - 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/topic_due_date.rb b/app/models/topic_due_date.rb index 739e0ad0d..333206d82 100644 --- a/app/models/topic_due_date.rb +++ b/app/models/topic_due_date.rb @@ -1,12 +1,15 @@ # 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) + topic_deadline = where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where('due_at >= ?', Time.current) + .order(:due_at) + .first - next_due_date ||= DueDate.next_due_date(assignment_id) - - next_due_date + topic_deadline || DueDate.where(parent_id: assignment_id, parent_type: 'Assignment') + .where('due_at >= ?', Time.current) + .order(:due_at) + .first end end diff --git a/app/views/.DS_Store b/app/views/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..42de66ae182cd466bcc631fb5845e0d3400104e6 GIT binary patch literal 6148 zcmeHKK~BRk5FD3E6mjVZar77ZgHVMR^Z_NM2r4B_MN=ehc?<`B!Vh=|FJRVdRZY?! zDg>&~uCyL|vuk_DQ8otP#+&&F7y=kj1$!54_J}+e?MN?L|5RLD?affCD(w;4{6 z0Xe%Xp4bvEYv|6pV`OF z@pH_7Jjd#3HxGILan1}gyz=ecFza0lGC0K`7cLD&!fQbaEXViK`dq=n)%l&GOD zUNM}8)9#~R60ug)aJaBC=ZP!3ctdevb=rM!hf5T#H3p1^wioo|uT-_--*G8WK?-&KMmbd}92by=4cXCk8s1V@oBish}mInJHij z{B{L+@5ZuU@9BoB`*%mT`SoZrFN=*X;^c8(Zc%30EH7us!C~Q_UZ&4y`|}sy%HOQ2 zTuzx%4908t4nCzE@c}E@HhB+T_vO}k(+BU4xr?&O`|JEV?+fOA2(Bf~h4Zq>ws+ai z=-}9xr-NA|tImhdeY1b60BSZ{f1qg56fgx$fp-P?d`RGoX+WKtaiP71+;s%xA#)UC*0Z|Z1z@mWb9zmC0!fUv78*D&fc;&>)`Zo>CfO z>tRoeI-m}y1L}Y}pbq>-2QX)Isg=z8{;am@fI9Fm9pLwaMP*C^W**(E1C2`ofK?oJ z1^dVYT#OBv1k60TQ1t1u2NOa~NHL5L$9c z?D5T_3X5)Oldh=)%~PnprvX+QBCXp8#Vo0lPCoihXF-3XbW!6nrqt)m+Mp5i4Z=D| zq|KZ{Tf~sM=Kkeai<-(cXdCF^aw_&o`_X){|J`UV_6SRxkVP`1q64PdYPXsQaVv j1daG1fY0+k46$!P<(MR3<`G9wbRodmpp824qYiulEj;%L literal 0 HcmV?d00001 diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..adaa7fd6086047c192a60d8b1d4946f3229d52f6 GIT binary patch literal 6148 zcmeHLK~BRk5L`nA5k;amIOYlT2cZft=m%(NXe%X61ytgg7x4{#z!&%hVs>q{O0t$X zAcW95vd3MI?a8FE>k^Th>=t995fLTGjDs^2O5%3Ty>t{JS3oCYY^b1;p33ET+GAV5 z954s|ngjgqCUj3Ln$ZT={{6kLtF0I7E;UY7=lQ&>=h8;a>-+5OM&~WQ#oMmN)5Dy+ z9?=lGYG`_Uumy*AS}M#e$a5HA8L9zVa%4@R4r-j;qXw#wc4=8ezBfii%UJpmME0Z;cVr z*BK}Bd^;>f&k6L@w9wv-^AG50wMVpcII2S}PFsh3zvR|`u)vv~&UXd+t}YxgobQf!8hBa2#-r~} zPG-h9VP+RjC{AWaJQX^*%%hd&fH}~1;Mjj0@&11sKmWHo*(Y(^b literal 0 HcmV?d00001 From e2e1540cf991f6f408780ad0642a8addffc9c152 Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:27:09 -0500 Subject: [PATCH 03/17] Update .DS_Store --- .DS_Store | Bin 8196 -> 8196 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.DS_Store b/.DS_Store index b15468b1189813e95eeff407f027f15f498b81a2..e70d867ea87c82e13dae69b5f09ace5fd8aa71d0 100644 GIT binary patch delta 40 wcmZp1XmOa}&uFqSU^hRb$z~pb%gmb%MXoVVYzW%SF7b_Jv%lyLril&y03pT>a{vGU delta 114 zcmZp1XmOa}&uF?aU^hRb>1G~*%gp|g3`Gp744DkM3 Date: Thu, 27 Nov 2025 14:35:10 -0500 Subject: [PATCH 04/17] fix: remove methods, revise shift deadlines type to use days --- app/models/concerns/due_date_actions.rb | 23 +++++---------------- app/models/concerns/due_date_permissions.rb | 10 ++------- lib/tasks/deadline_demo.rake | 4 ---- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index f0fb4ce7f..f34a7f0df 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -74,22 +74,9 @@ def copy_due_dates_to(new_parent) end # Shift deadlines of a specific type by a time interval - def shift_deadlines_of_type(deadline_type_name, time_interval) - due_dates.joins(:deadline_type) - .where(deadline_types: { name: deadline_type_name }) - .update_all("due_at = due_at + INTERVAL #{time_interval.to_i} SECOND") + 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? - sorted_deadlines = due_dates.order(:due_at) - previous_date = nil - - sorted_deadlines.each do |deadline| - return false if previous_date && deadline.due_at < previous_date - previous_date = 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 index 9229dfbbe..dde432b40 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -24,7 +24,7 @@ def can_take_quiz? deadline_right&.name&.in?(%w[OK Late]) end - def can_teammate_review? + def can_review_teammate? return false unless teammate_review_allowed_id deadline_right = DeadlineRight.find_by(id: teammate_review_allowed_id) @@ -65,11 +65,6 @@ def allows_late_quiz? deadline_right&.name == 'Late' end - # Check if this deadline is currently active (allows some action) - def active? - can_submit? || can_review? || can_take_quiz? || can_teammate_review? - end - # Get permission status for an action (OK, Late, No) def permission_status_for(action) permission_field = "#{action}_allowed_id" @@ -88,8 +83,7 @@ def permissions_summary submission: permission_status_for(:submission), review: permission_status_for(:review), quiz: permission_status_for(:quiz), - teammate_review: permission_status_for(:teammate_review), - active: active? + teammate_review: permission_status_for(:teammate_review) } end end diff --git a/lib/tasks/deadline_demo.rake b/lib/tasks/deadline_demo.rake index 5b9063bce..44ace38bc 100644 --- a/lib/tasks/deadline_demo.rake +++ b/lib/tasks/deadline_demo.rake @@ -116,10 +116,6 @@ namespace :deadline do puts "Assignment submission deadline: #{submission_deadline.due_at.strftime('%B %d, %Y')}" puts - puts "8. Deadline ordering demo:" - puts "Deadlines properly ordered: #{assignment.deadlines_properly_ordered?}" - puts - puts "9. Class method demos:" all_due_dates = assignment.due_dates.to_a sorted_dates = DueDate.sort_due_dates(all_due_dates) From 9937b3522c97bc3dd99d06649a1c4437d4d60aa0 Mon Sep 17 00:00:00 2001 From: Seojin Kim <001106ksj@gmail.com> Date: Fri, 28 Nov 2025 08:26:42 -0500 Subject: [PATCH 05/17] delete .ds_store, rename methods --- app/models/concerns/due_date_permissions.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb index dde432b40..de8f3bc9a 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -44,21 +44,21 @@ def activity_permissible?(activity) end # Check if deadline allows late submissions - def allows_late_submission? + def late_submission_allowed? return false unless submission_allowed_id deadline_right = DeadlineRight.find_by(id: submission_allowed_id) deadline_right&.name == 'Late' end - def allows_late_review? + def late_review_allowed? return false unless review_allowed_id deadline_right = DeadlineRight.find_by(id: review_allowed_id) deadline_right&.name == 'Late' end - def allows_late_quiz? + def late_quiz_allowed? return false unless quiz_allowed_id deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) From 8f0fc6b34dadf8c7fbbdb4fa83434b31b77ae155 Mon Sep 17 00:00:00 2001 From: Seojin Kim <001106ksj@gmail.com> Date: Fri, 28 Nov 2025 08:27:33 -0500 Subject: [PATCH 06/17] remove .ds_store --- .DS_Store | Bin 8196 -> 0 bytes app/.DS_Store | Bin 8196 -> 0 bytes app/models/.DS_Store | Bin 6148 -> 0 bytes app/views/.DS_Store | Bin 6148 -> 0 bytes config/.DS_Store | Bin 6148 -> 0 bytes spec/.DS_Store | Bin 8196 -> 0 bytes test/.DS_Store | Bin 6148 -> 0 bytes 7 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store delete mode 100644 app/.DS_Store delete mode 100644 app/models/.DS_Store delete mode 100644 app/views/.DS_Store delete mode 100644 config/.DS_Store delete mode 100644 spec/.DS_Store delete mode 100644 test/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e70d867ea87c82e13dae69b5f09ace5fd8aa71d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM!H&}~5FKwqDMCmqE-bfv00&k=;u@;*1JxF90&6WsU&_=df} zXCOH74;*-7Pg0LlufzpWVN1zGelvdKXUDEnA~K`X=`qm}5e;#%J-mwBj>0%!y>cKm zcVG?T>G)()FW0s<>v^Y&q(D+2DUcLM3M2*o1qHBYa~VeVeTSxdQXnbtUn;=sLxGEJ zDYkLeE*-epBLHlUYFqGp$PciE*ivlcED#jqLSbE~kt2q2;ixafuN2!j>%z&%;ll{C zkrRrs>}bER?qo`5(>*DW6zD6!XZMVr(3;kZxqp6t_i&3W>+!f+)D>#@HQP*He7WbN z(+kk`Gq2Svh+@8qp3_S4QT}U-MEPvovX1asRPoVm<{Qw2YFg1#u+BB>^m~X$xK$gG z43v+`!>vL?o5vZ=XamLPG*h``*FPbbo==d^`$tau9ezrcsGrI!D?o{rcuJMZg}%OR z>jVCF>}^a$2a745OC|GUw7>`iOgG)QTO`6IaS=8_6`vb-M(S|HuYHtEfCPNAs-4$1 zF5nAL&1ZL=0p6lh%x&(=MC*x>=XvPi0e7ow;G@QLr_YW&a(W4-S&&11<0o?Hun510 z@8s@q#XI_JNmbhg)&&ytZ!$-VxepCEq;gdqn!eOkO(|YOtN3UkwH=RkrGpWNOIe2(PPib58erDbbyi2_5|Ng&MeM-qD1(E`Prhv)E zC*u)foG1a?AJJJLvUjkuD8c=*zONQJQsqMVjE|Hp#1d@0ToF7Q%vwH YLGNh|r`YY`Y5&J>*tGxioY?#F8<)VSRR910 diff --git a/app/.DS_Store b/app/.DS_Store deleted file mode 100644 index 4ec8001e5270a797f25053ed04e30aed838d8770..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMu}&L75S=9kEZn3d-4!X*_zy0~lt__Wq&2n?I63DmFd$J~l_E-;CaLK18Fc(X z>L^vpd_vyrZq7G*yMYQ}F^g{Jbn|BS`R#D)Srd`jJD>K6c8REqi|z9sx=3R`U#+&` zpS*-Mh$s3$M^w;Raoiua(E30XPz6*0RX`O`1s;O}*t5BH6Z^jNYOe~Y0#8x_em`Wm z*yi3=&e~T8I$Hw3cJSCX+@lV#F`2iyx0SOt6l1#T!T3<)OAO<};m;Tjn|oV1>%z&n za58>o<0}+nXGdIS;bd}WwO0jHfwlr%yLV|qCv=GFy-~kU%K5^>f5kBKaxfSd<=FbD zy8dx^`#O%B-V3)l=?_m%4kagr;2_j#4THdprlSjp4o+a`Fh0mfJBvvR_=nX=Z?vb)!32j;hL_(@k7VL5rPQwKdXW$q-+oP8J zDpm*~Tk^b#W6!+2j4~M_a+Bx9m}o>q0i1Dggr>vzK6}dsK5`0lGR6yS=$_I%8z-%7 z3;aa|_}v9Gqepm7-`-zZ7AqwjoOR_CWgO4*axV9%nto<)m)&`bALXs?#>s9@UXS3F zQVC`$EolvW;&rvlxwz@dY4EyEA3U$dSVdXm)tgu6b;Z1nDZx5lL$M8*WmxgcCbz@) zzPtv%*N>U->m0Meac^#&XT&_sOaW8C6zEd`dp28eqG+utU<#N5TLt+05W*QFkCmc) zI?%`w060Wg41M``1BM&`Mjk6gL|{%!fm-VP5yNRY;-SxrJXVTYPEKaVIDTg5Pbf}i zM?92ta*?97rhq9>SKvS|N4)<}`_KRNBD*pLOo4x;fE&h1Ji#ORy|wjlyw`^CTR0o% mRf^jZbYv?=E^o!>a52O~?f@f?l_DZA`w+R%W_Hp diff --git a/app/views/.DS_Store b/app/views/.DS_Store deleted file mode 100644 index 42de66ae182cd466bcc631fb5845e0d3400104e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKK~BRk5FD3E6mjVZar77ZgHVMR^Z_NM2r4B_MN=ehc?<`B!Vh=|FJRVdRZY?! zDg>&~uCyL|vuk_DQ8otP#+&&F7y=kj1$!54_J}+e?MN?L|5RLD?affCD(w;4{6 z0Xe%Xp4bvEYv|6pV`OF z@pH_7Jjd#3HxGILan1}gyz=ecFza0lGC0K`7cLD&!fQbaEXViK`dq=n)%l&GOD zUNM}8)9#~R60ug)aJaBC=ZP!3ctdevb=rM!hf5T#H3p1^wioo|uT-_--*G8WK?-&KMmbd}92by=4cXCk8s1V@oBish}mInJHij z{B{L+@5ZuU@9BoB`*%mT`SoZrFN=*X;^c8(Zc%30EH7us!C~Q_UZ&4y`|}sy%HOQ2 zTuzx%4908t4nCzE@c}E@HhB+T_vO}k(+BU4xr?&O`|JEV?+fOA2(Bf~h4Zq>ws+ai z=-}9xr-NA|tImhdeY1b60BSZ{f1qg56fgx$fp-P?d`RGoX+WKtaiP71+;s%xA#)UC*0Z|Z1z@mWb9zmC0!fUv78*D&fc;&>)`Zo>CfO z>tRoeI-m}y1L}Y}pbq>-2QX)Isg=z8{;am@fI9Fm9pLwaMP*C^W**(E1C2`ofK?oJ z1^dVYT#OBv1k60TQ1t1u2NOa~NHL5L$9c z?D5T_3X5)Oldh=)%~PnprvX+QBCXp8#Vo0lPCoihXF-3XbW!6nrqt)m+Mp5i4Z=D| zq|KZ{Tf~sM=Kkeai<-(cXdCF^aw_&o`_X){|J`UV_6SRxkVP`1q64PdYPXsQaVv j1daG1fY0+k46$!P<(MR3<`G9wbRodmpp824qYiulEj;%L diff --git a/test/.DS_Store b/test/.DS_Store deleted file mode 100644 index adaa7fd6086047c192a60d8b1d4946f3229d52f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHLK~BRk5L`nA5k;amIOYlT2cZft=m%(NXe%X61ytgg7x4{#z!&%hVs>q{O0t$X zAcW95vd3MI?a8FE>k^Th>=t995fLTGjDs^2O5%3Ty>t{JS3oCYY^b1;p33ET+GAV5 z954s|ngjgqCUj3Ln$ZT={{6kLtF0I7E;UY7=lQ&>=h8;a>-+5OM&~WQ#oMmN)5Dy+ z9?=lGYG`_Uumy*AS}M#e$a5HA8L9zVa%4@R4r-j;qXw#wc4=8ezBfii%UJpmME0Z;cVr z*BK}Bd^;>f&k6L@w9wv-^AG50wMVpcII2S}PFsh3zvR|`u)vv~&UXd+t}YxgobQf!8hBa2#-r~} zPG-h9VP+RjC{AWaJQX^*%%hd&fH}~1;Mjj0@&11sKmWHo*(Y(^b From 29f4ba3575fa62b99f411bd55d03c74bfeff310c Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:45:23 -0500 Subject: [PATCH 07/17] Updated based on comments. Notes: talk about the participant parameter and anything else that is required before submission. --- app/models/concerns/due_date_actions.rb | 44 ++++++++++++++--- app/models/concerns/due_date_permissions.rb | 23 ++++----- app/models/concerns/due_date_queries.rb | 45 ----------------- app/models/deadline_right.rb | 5 -- app/models/deadline_type.rb | 5 -- app/models/due_date.rb | 10 +--- app/models/topic_due_date.rb | 2 +- lib/tasks/deadline_demo.rake | 55 ++++----------------- 8 files changed, 60 insertions(+), 129 deletions(-) delete mode 100644 app/models/concerns/due_date_queries.rb diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index f34a7f0df..2d6abf071 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true module DueDateActions + extend ActiveSupport::Concern + included do has_many :due_dates, as: :parent, dependent: :destroy + end # 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 + current_deadline = next_due_date() return false unless current_deadline current_deadline.activity_permissible?(activity) @@ -23,6 +26,7 @@ def activity_permissible_for?(activity, deadline_date) end # Syntactic sugar methods for common activities + # These provide clean, readable method names while avoiding DRY violations def submission_permissible? activity_permissible?(:submission) end @@ -52,18 +56,31 @@ def drop_topic_permissible? end # Get the next due date for this parent - def next_due_date + def next_due_date(topic_id = nil) + if topic_id && has_topic_specific_deadlines? + topic_deadline = due_dates.where(parent_id: topic_id, parent_type: 'ProjectTopic') + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + return topic_deadline if topic_deadline + end + due_dates.where('due_at >= ?', Time.current).order(:due_at).first end # Get the current stage name for display purposes def current_stage - deadline = next_due_date + 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| @@ -75,8 +92,21 @@ def copy_due_dates_to(new_parent) # 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") + 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? + sorted_deadlines = due_dates.order(:due_at) + previous_date = nil + + sorted_deadlines.each do |deadline| + return false if previous_date && deadline.due_at < previous_date + previous_date = 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 index de8f3bc9a..1411cfb48 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -2,30 +2,35 @@ module DueDatePermissions # Permission checking methods that combine deadline-based and role-based logic + # These methods also need to check the can_submit, can_review, etc. fields of the Participant object - def can_submit? + 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 - def can_review? + 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 - def can_take_quiz? + 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 - def can_review_teammate? + 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]) @@ -77,13 +82,5 @@ def permission_status_for(action) deadline_right&.name || 'No' end - # Get a summary of all permissions for this deadline - def permissions_summary - { - submission: permission_status_for(:submission), - review: permission_status_for(:review), - quiz: permission_status_for(:quiz), - teammate_review: permission_status_for(:teammate_review) - } - end + end diff --git a/app/models/concerns/due_date_queries.rb b/app/models/concerns/due_date_queries.rb deleted file mode 100644 index 777163379..000000000 --- a/app/models/concerns/due_date_queries.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module DueDateQueries - # Get next due date for this parent - def next_due_date(topic_id = nil) - if topic_id && has_topic_specific_deadlines? - topic_deadline = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') - .where('due_at >= ?', Time.current) - .order(:due_at) - .first - return topic_deadline if topic_deadline - end - - due_dates.where('due_at >= ?', Time.current).order(:due_at).first - end - - # Get all deadlines for a topic (topic-specific + assignment fallback) - def deadlines_for_topic(topic_id) - assignment_deadlines = due_dates.where(parent_type: 'Assignment') - topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') - - (assignment_deadlines + topic_deadlines).sort_by(&:due_at) - end - - # Check if assignment has topic-specific deadlines - def has_topic_specific_deadlines? - due_dates.where(parent_type: 'SignUpTopic').exists? - end - - private - - # Map action names to deadline type names for lookup - def action_to_deadline_type(action) - { - 'submit' => 'submission', - 'submission' => 'submission', - 'review' => 'review', - 'teammate_review' => 'teammate_review', - 'quiz' => 'quiz', - 'team_formation' => 'team_formation', - 'signup' => 'signup', - 'drop_topic' => 'drop_topic' - }[action.to_s.downcase] - end -end diff --git a/app/models/deadline_right.rb b/app/models/deadline_right.rb index 9e9ef8f03..8e668eaa7 100644 --- a/app/models/deadline_right.rb +++ b/app/models/deadline_right.rb @@ -3,11 +3,6 @@ class DeadlineRight < ApplicationRecord validates :name, presence: true, uniqueness: true - # Class methods for finding deadline rights - def self.find_by_name(name) - find_by(name: name.to_s) - end - # Permission checking methods def allows_action? %w[OK Late].include?(name) diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb index a691e9911..c93811f36 100644 --- a/app/models/deadline_type.rb +++ b/app/models/deadline_type.rb @@ -6,11 +6,6 @@ class DeadlineType < ApplicationRecord has_many :due_dates, foreign_key: :deadline_type_id, dependent: :restrict_with_exception - # Class methods for finding deadline types - def self.find_by_name(name) - find_by(name: name.to_s) - end - # Semantic helper methods for deadline type identification def submission? name == 'submission' diff --git a/app/models/due_date.rb b/app/models/due_date.rb index acbf39d42..0b616f7c2 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -90,17 +90,11 @@ def copy(from_assignment_id, to_assignment_id) # Fetch all due dates for a parent object - def fetch_due_dates(parent_id) - if parent_id.is_a?(Assignment) - where(parent: parent_id).includes(:deadline_type).order(:due_at) - else - includes(:deadline_type).where(parent_id: parent_id).order(:due_at) - end - end + def next_due_date(parent_id, topic_id = nil) if topic_id - topic_deadline = where(parent_id: topic_id, parent_type: 'SignUpTopic') + topic_deadline = where(parent_id: topic_id, parent_type: 'ProjectTopic') .upcoming.first return topic_deadline if topic_deadline end diff --git a/app/models/topic_due_date.rb b/app/models/topic_due_date.rb index 333206d82..9bce31aa2 100644 --- a/app/models/topic_due_date.rb +++ b/app/models/topic_due_date.rb @@ -2,7 +2,7 @@ class TopicDueDate < DueDate def self.next_due_date(assignment_id, topic_id) - topic_deadline = where(parent_id: topic_id, parent_type: 'SignUpTopic') + topic_deadline = where(parent_id: topic_id, parent_type: 'ProjectTopic') .where('due_at >= ?', Time.current) .order(:due_at) .first diff --git a/lib/tasks/deadline_demo.rake b/lib/tasks/deadline_demo.rake index 44ace38bc..12662c314 100644 --- a/lib/tasks/deadline_demo.rake +++ b/lib/tasks/deadline_demo.rake @@ -32,7 +32,7 @@ namespace :deadline do round: 1 ) - review_deadline = DueDate.create!( + DueDate.create!( parent: assignment, deadline_type_id: 2, due_at: 3.weeks.from_now, @@ -43,7 +43,7 @@ namespace :deadline do round: 1 ) - teammate_review_deadline = DueDate.create!( + DueDate.create!( parent: assignment, deadline_type_id: 3, due_at: 4.weeks.from_now, @@ -73,7 +73,7 @@ namespace :deadline do 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.to_s}" + puts "#{due_date.deadline_type_name.ljust(15)} | #{due_date.overdue? ? 'Overdue' : 'Upcoming'} | #{due_date}" end puts @@ -91,39 +91,18 @@ namespace :deadline do puts "Copy successful: #{assignment.due_dates.count == new_assignment.due_dates.count}" puts - puts "7. Topic-specific deadline demo..." - - topic = SignUpTopic.create!( - topic_name: "Demo Topic", - topic_identifier: "DEMO-001", - max_choosers: 5, - assignment: assignment - ) - - topic_submission = DueDate.create!( - parent: topic, - deadline_type_id: 1, - due_at: 10.days.from_now, - submission_allowed_id: 3, - review_allowed_id: 1, - teammate_review_allowed_id: 1, - quiz_allowed_id: 1, - round: 1 - ) - - puts "Created topic: #{topic.topic_name}" - puts "Topic submission deadline: #{topic_submission.due_at.strftime('%B %d, %Y')}" - puts "Assignment submission deadline: #{submission_deadline.due_at.strftime('%B %d, %Y')}" - puts - - puts "9. Class method demos:" + 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 "10. Cleaning up demo data..." + 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!" @@ -133,8 +112,7 @@ namespace :deadline do puts "- Basic permission checking methods" puts "- Next due date functionality" puts "- Due date copying between assignments" - puts "- Topic-specific deadline support" - puts "- Chronological ordering validation" + puts "- Chronological date sorting" puts "- Class methods for date management" puts "=" * 80 end @@ -158,17 +136,4 @@ namespace :deadline do puts " Overdue: #{DueDate.overdue.count}" puts end - - desc "Test basic due date functionality" - task test: :environment do - puts "Testing basic due date functionality..." - - submission_type = DeadlineType.find_by_name('submission') - puts "Submission type found: #{submission_type.present?}" - - ok_right = DeadlineRight.find_by_name('OK') - puts "OK right found: #{ok_right.present?}" - - puts "Basic functionality test completed." - end end From 096561b2d3ff11b72e14940ac66be514ad1b75bd Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Fri, 28 Nov 2025 20:06:21 -0500 Subject: [PATCH 08/17] removal of ActiveSupport::Concern (not needed) --- app/models/concerns/due_date_actions.rb | 7 ------- app/models/concerns/due_date_permissions.rb | 2 -- app/models/due_date.rb | 3 ++- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index 2d6abf071..ea19e5312 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true module DueDateActions - extend ActiveSupport::Concern - - included do - has_many :due_dates, as: :parent, dependent: :destroy - - end - # 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) diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb index 1411cfb48..d8f07b799 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -81,6 +81,4 @@ def permission_status_for(action) deadline_right = DeadlineRight.find_by(id: allowed_id) deadline_right&.name || 'No' end - - end diff --git a/app/models/due_date.rb b/app/models/due_date.rb index 0b616f7c2..1317fece5 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -43,6 +43,7 @@ def copy(to_assignment_id) new_due_date end + # Get the deadline type name def deadline_type_name deadline_type&.name @@ -89,7 +90,6 @@ def copy(from_assignment_id, to_assignment_id) - # Fetch all due dates for a parent object def next_due_date(parent_id, topic_id = nil) @@ -113,6 +113,7 @@ def due_at_is_valid_datetime end end + # Set default round if not specified before_save :set_default_round From ab83d49afcfbbd6eefa34cfbc845ae679e77fe17 Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Sun, 30 Nov 2025 10:02:50 -0500 Subject: [PATCH 09/17] Final edits from the meeting we had on Friday. Refactored due date implementation. - Remove redundant next_due_date method from TopicDueDate class - Clean up DueDate class by removing duplicate class method - Improve next_due_date method in DueDateActions with better documentation - Add missing due_dates association to Assignment model (was incorrectly documented as provided by mixin) - Enhance DueDatePermissions documentation explaining participant permission requirements - Simplify TopicDueDate to proper single table inheritance subclass --- app/models/assignment.rb | 2 +- app/models/concerns/due_date_actions.rb | 22 +++++++++++++--- app/models/concerns/due_date_permissions.rb | 28 +++++++++++++++++++-- app/models/due_date.rb | 12 +++------ app/models/topic_due_date.rb | 18 ++++++------- 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index ad2659277..abe7ef787 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -12,7 +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 - # Note: due_dates association is provided by DueDateActions mixin + has_many :due_dates, as: :parent, dependent: :destroy belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index ea19e5312..76c15bb0b 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -48,17 +48,31 @@ def drop_topic_permissible? activity_permissible?(:drop_topic) end - # Get the next due date for this parent + # Get the next due date for this assignment + # + # This method abstracts away whether the assignment has topic-specific deadlines + # or assignment-level deadlines. The caller doesn't need to know the implementation + # details, they just ask for the next due date and get the appropriate one. + # + # @param topic_id [Integer, nil] Optional topic ID. If provided and the assignment + # has topic-specific deadlines, returns the next deadline for that topic. + # If the topic has no upcoming deadlines, falls back to assignment-level deadlines. + # @return [DueDate, nil] The 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') - .where('due_at >= ?', Time.current) - .order(:due_at) + .upcoming .first return topic_deadline if topic_deadline end - due_dates.where('due_at >= ?', Time.current).order(:due_at).first + # 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 diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb index d8f07b799..1ecb1a6ee 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -2,8 +2,19 @@ module DueDatePermissions # Permission checking methods that combine deadline-based and role-based logic - # These methods also need to check the can_submit, can_review, etc. fields of the Participant object - + # + # 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 @@ -12,6 +23,10 @@ def can_submit?(participant = nil) 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 @@ -20,6 +35,10 @@ def can_review?(participant = nil) 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 @@ -28,6 +47,11 @@ def can_take_quiz?(participant = nil) 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 diff --git a/app/models/due_date.rb b/app/models/due_date.rb index 1317fece5..ff6ca80e7 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -92,15 +92,9 @@ def copy(from_assignment_id, to_assignment_id) - def next_due_date(parent_id, topic_id = nil) - if topic_id - topic_deadline = where(parent_id: topic_id, parent_type: 'ProjectTopic') - .upcoming.first - return topic_deadline if topic_deadline - end - - where(parent_id: parent_id).upcoming.first - end + # This method is no longer needed as the functionality has been moved + # to the DueDateActions concern which provides a cleaner interface + # that doesn't require the caller to know about topic vs assignment due dates end private diff --git a/app/models/topic_due_date.rb b/app/models/topic_due_date.rb index 9bce31aa2..a26fff0a5 100644 --- a/app/models/topic_due_date.rb +++ b/app/models/topic_due_date.rb @@ -1,15 +1,11 @@ # frozen_string_literal: true class TopicDueDate < DueDate - def self.next_due_date(assignment_id, topic_id) - topic_deadline = where(parent_id: topic_id, parent_type: 'ProjectTopic') - .where('due_at >= ?', Time.current) - .order(:due_at) - .first - - topic_deadline || DueDate.where(parent_id: assignment_id, parent_type: 'Assignment') - .where('due_at >= ?', Time.current) - .order(:due_at) - .first - 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 From 40e9aefe9df3c5c68a939dafe3943a2fad1d9952 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Sun, 30 Nov 2025 22:14:20 -0500 Subject: [PATCH 10/17] fix: fix migration files for proper migration --- .../20241201000001_create_deadline_types.rb | 4 +- ...nge_to_polymorphic_association_in_teams.rb | 2 + .../20250626161114_add_name_to_teams.rb | 2 +- db/schema.rb | 53 ++++++++++++------- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/db/migrate/20241201000001_create_deadline_types.rb b/db/migrate/20241201000001_create_deadline_types.rb index 81db538b6..e25e98104 100644 --- a/db/migrate/20241201000001_create_deadline_types.rb +++ b/db/migrate/20241201000001_create_deadline_types.rb @@ -11,8 +11,10 @@ def change 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 + # add_foreign_key :due_dates, :deadline_types, column: :deadline_type_id # Seed canonical deadline type data reversible do |dir| 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" From 0c6907e230da2ea738508f7ba4da4a878a049749 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Sun, 30 Nov 2025 22:14:48 -0500 Subject: [PATCH 11/17] test: update test codes for new due date modules --- spec/models/due_date_spec.rb | 592 ++++++++++++++++++++++++++--------- 1 file changed, 437 insertions(+), 155 deletions(-) diff --git a/spec/models/due_date_spec.rb b/spec/models/due_date_spec.rb index 510a583a5..a0dc1e19d 100644 --- a/spec/models/due_date_spec.rb +++ b/spec/models/due_date_spec.rb @@ -1,208 +1,490 @@ # 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 '.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]) + describe 'validations' do + it 'is invalid without a parent' do + due_date = 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 + ) + expect(due_date).to be_invalid + expect(due_date.errors[:parent]).to include('must exist') 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) + it 'is invalid without a due_at' do + due_date = DueDate.new( + parent: assignment, + due_at: nil, + deadline_type: submission_type, + submission_allowed_id: ok_right.id, + review_allowed_id: ok_right.id + ) + expect(due_date).to be_invalid + expect(due_date.errors[:due_at]).to include("can't be blank") + end + + it 'is invalid without a deadline_type_id' do + due_date = 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 + ) + expect(due_date).to be_invalid + expect(due_date.errors[:deadline_type_id]).to include("can't be blank") 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) + it 'is valid with required fields' do + due_date = 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 + ) + expect(due_date).to be_valid + end - due_date.set(1, assignment2.id, 1) + it 'validates round is greater than 0 when present' do + due_date = 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 + ) + expect(due_date).to be_invalid + expect(due_date.errors[:round]).to be_present + 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 'allows nil round' do + due_date = 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 + ) + expect(due_date).to be_valid 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 - 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!(: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 - excluded_attributes = %w[id created_at updated_at parent parent_id] + 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 - 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) + describe '.upcoming' do + it 'returns only future due dates ordered by due_at' do + upcoming = DueDate.upcoming + expect(upcoming).to eq([upcoming_due_date1, upcoming_due_date2]) + end + end - expect(copied_attributes).to eq(original_attributes) + describe '.overdue' do + it 'returns only past due dates ordered by due_at' do + overdue = DueDate.overdue + expect(overdue).to eq([past_due_date]) 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 '.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 - 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) + + 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 - 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) + + it 'returns due dates for a specific round' do + expect(DueDate.for_round(1)).to include(round1_due_date) + expect(DueDate.for_round(1)).not_to include(round2_due_date) end + 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) + describe '.for_deadline_type' do + it 'returns due dates for a specific deadline type' do + submission_dates = DueDate.for_deadline_type('submission') + expect(submission_dates).to include(past_due_date) + expect(submission_dates).not_to include(upcoming_due_date1) 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 'instance methods' do + describe '#overdue?' do + it 'returns true for past due dates' do + due_date = 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 + ) + expect(due_date.overdue?).to be true 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') + + it 'returns false for future due dates' do + due_date = 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 + ) + expect(due_date.overdue?).to be false 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') + end + + describe '#upcoming?' do + it 'returns true for future due dates' do + due_date = 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 + ) + expect(due_date.upcoming?).to be true 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') + + it 'returns false for past due dates' do + due_date = 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 + ) + expect(due_date.upcoming?).to be false 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') + end + + describe '#set' do + it 'updates deadline_type_id, parent_id, and round' do + due_date = 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 + ) + + expect(due_date.deadline_type_id).to eq(submission_type.id) + expect(due_date.parent_id).to eq(assignment.id) + expect(due_date.round).to eq(1) # default value + + due_date.set(review_type.id, assignment2.id, 2) + due_date.reload + + expect(due_date.deadline_type_id).to eq(review_type.id) + expect(due_date.parent_id).to eq(assignment2.id) + expect(due_date.round).to eq(2) end + end + + describe '#copy' do + it 'creates a duplicate due date for a new assignment' do + original = 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 + ) + + copied = original.copy(assignment2.id) - 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) + expect(copied).to be_persisted + expect(copied.id).not_to eq(original.id) + expect(copied.parent_id).to eq(assignment2.id) + expect(copied.due_at).to eq(original.due_at) + expect(copied.deadline_type_id).to eq(original.deadline_type_id) + expect(copied.submission_allowed_id).to eq(original.submission_allowed_id) + expect(copied.review_allowed_id).to eq(original.review_allowed_id) + expect(copied.round).to eq(original.round) end + end + + describe '#deadline_type_name' do + it 'returns the name of the associated deadline type' do + due_date = 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 + ) + expect(due_date.deadline_type_name).to eq('submission') + end + end + + describe '#last_deadline?' do + it 'returns true if this is the last deadline for the parent' do + last_deadline = 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 + ) - 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) + expect(last_deadline.last_deadline?).to be true 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) + it 'returns false if there are later deadlines' do + earlier_deadline = 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 + ) + + expect(earlier_deadline.last_deadline?).to be false + end + end + + describe '#<=>' do + it 'compares due dates by their due_at time' do + earlier = 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 + ) - expect(result).to eq(assignment_due_date) + later = 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 + ) + + expect(earlier <=> later).to eq(-1) + expect(later <=> earlier).to eq(1) + expect(earlier <=> earlier).to eq(0) 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 + it 'sorts due dates from earliest to latest' do + due_date1 = 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 + ) - 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 + due_date2 = 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 + ) + + due_date3 = 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 + ) + + sorted = DueDate.sort_due_dates([due_date1, due_date2, due_date3]) + expect(sorted).to eq([due_date3, due_date2, due_date1]) + 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 + it 'returns true when future due dates exist' do + due_dates = [ + 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 + ) + ] + + expect(DueDate.any_future_due_dates?(due_dates)).to be true + end + + it 'returns false when no future due dates exist' do + due_dates = [ + 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 + ) + ] + + expect(DueDate.any_future_due_dates?(due_dates)).to be false + 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 + describe '.copy' do + it 'copies all due dates from one assignment to another' 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 + ) + + original_count = assignment.due_dates.count + expect(assignment2.due_dates.count).to eq(0) + + DueDate.copy(assignment.id, assignment2.id) + + expect(assignment2.due_dates.count).to eq(original_count) + + 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 + end + end + + describe 'callbacks' do + describe 'before_save :set_default_round' do + it 'sets round to 1 when not specified' do + due_date = 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 + ) + + expect(due_date.round).to eq(1) + end + + it 'does not override explicitly set round' do + due_date = 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 + ) + + expect(due_date.round).to eq(3) + end end end end From c02ae6d78f960b5a395af8273575bdf2a833e429 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Mon, 1 Dec 2025 12:19:49 -0500 Subject: [PATCH 12/17] fix: code review - simplify comments, truncate chained method for next_due_date --- app/models/concerns/due_date_actions.rb | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index 76c15bb0b..45696bbe7 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -4,7 +4,7 @@ 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() + current_deadline = next_due_date return false unless current_deadline current_deadline.activity_permissible?(activity) @@ -48,23 +48,17 @@ def drop_topic_permissible? activity_permissible?(:drop_topic) end - # Get the next due date for this assignment + # 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. # - # This method abstracts away whether the assignment has topic-specific deadlines - # or assignment-level deadlines. The caller doesn't need to know the implementation - # details, they just ask for the next due date and get the appropriate one. - # - # @param topic_id [Integer, nil] Optional topic ID. If provided and the assignment - # has topic-specific deadlines, returns the next deadline for that topic. - # If the topic has no upcoming deadlines, falls back to assignment-level deadlines. - # @return [DueDate, nil] The next upcoming due date, or nil if none exist + # @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 + topic_deadline = due_dates.where(parent_id: topic_id, parent_type: 'ProjectTopic').upcoming.first return topic_deadline if topic_deadline end @@ -77,7 +71,7 @@ def next_due_date(topic_id = nil) # Get the current stage name for display purposes def current_stage - deadline = next_due_date() + deadline = next_due_date return 'finished' unless deadline deadline.deadline_type&.name || 'unknown' @@ -111,6 +105,7 @@ def deadlines_properly_ordered? sorted_deadlines.each do |deadline| return false if previous_date && deadline.due_at < previous_date + previous_date = deadline.due_at end From da5302545f5584483c83a54fae57efb681d3b5b9 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Mon, 1 Dec 2025 12:21:10 -0500 Subject: [PATCH 13/17] fix: code review - remove comments for removed method --- app/models/due_date.rb | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/app/models/due_date.rb b/app/models/due_date.rb index ff6ca80e7..ece872cf4 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -43,7 +43,6 @@ def copy(to_assignment_id) new_due_date end - # Get the deadline type name def deadline_type_name deadline_type&.name @@ -57,6 +56,7 @@ def last_deadline? # Comparison method for sorting def <=>(other) return nil unless other.is_a?(DueDate) + due_at <=> other.due_at end @@ -87,14 +87,6 @@ def copy(from_assignment_id, to_assignment_id) new_due_date.save! end end - - - - - - # This method is no longer needed as the functionality has been moved - # to the DueDateActions concern which provides a cleaner interface - # that doesn't require the caller to know about topic vs assignment due dates end private @@ -102,11 +94,10 @@ def copy(from_assignment_id, to_assignment_id) def due_at_is_valid_datetime return unless due_at.present? - unless due_at.is_a?(Time) || due_at.is_a?(DateTime) - errors.add(:due_at, 'must be a valid datetime') - end - end + 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 From 58fbb12617484174bf2ed587940742c1ccc4d8b8 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Mon, 1 Dec 2025 18:57:40 -0500 Subject: [PATCH 14/17] test: add test codes --- spec/models/due_date_spec.rb | 847 ++++++++++++++++++++++++++--------- 1 file changed, 638 insertions(+), 209 deletions(-) diff --git a/spec/models/due_date_spec.rb b/spec/models/due_date_spec.rb index a0dc1e19d..2b59b05b2 100644 --- a/spec/models/due_date_spec.rb +++ b/spec/models/due_date_spec.rb @@ -22,76 +22,170 @@ let!(:no_right) { DeadlineRight.create(name: 'No', description: '') } describe 'validations' do - it 'is invalid without a parent' do - due_date = 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 - ) - expect(due_date).to be_invalid - expect(due_date.errors[:parent]).to include('must exist') - end + 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 without a due_at' do - due_date = DueDate.new( - parent: assignment, - due_at: nil, - deadline_type: submission_type, - submission_allowed_id: ok_right.id, - review_allowed_id: ok_right.id - ) - expect(due_date).to be_invalid - expect(due_date.errors[:due_at]).to include("can't be blank") + it 'is invalid' do + expect(due_date).to be_invalid + end + + it 'has error on parent field' do + due_date.valid? + expect(due_date.errors[:parent]).to include('must exist') + end end - it 'is invalid without a deadline_type_id' do - due_date = 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 - ) - expect(due_date).to be_invalid - expect(due_date.errors[:deadline_type_id]).to include("can't be blank") + 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 - it 'is valid with required fields' do - due_date = 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 - ) - expect(due_date).to be_valid + 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 + + it 'is invalid' do + expect(due_date).to be_invalid + end + + 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 - it 'validates round is greater than 0 when present' do - due_date = 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 - ) - expect(due_date).to be_invalid - expect(due_date.errors[:round]).to be_present + 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 - it 'allows nil round' do - due_date = 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 - ) - expect(due_date).to be_valid + 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 @@ -127,16 +221,77 @@ end describe '.upcoming' do - it 'returns only future due dates ordered by due_at' 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 ordered by due_at' 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).to eq([past_due_date]) + 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 @@ -163,96 +318,171 @@ ) end - it 'returns due dates for a specific round' do + it 'returns due dates for specified round' do expect(DueDate.for_round(1)).to include(round1_due_date) + end + + it 'excludes due dates from other rounds' do expect(DueDate.for_round(1)).not_to include(round2_due_date) end + + 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 a specific 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 'instance methods' do describe '#overdue?' do - it 'returns true for past due dates' do - due_date = 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 - ) - expect(due_date.overdue?).to be true + 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 - it 'returns false for future due dates' do - due_date = 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 - ) - expect(due_date.overdue?).to be false + 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 + + 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 - it 'returns true for future due dates' do - due_date = 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 - ) - expect(due_date.upcoming?).to be true + 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 - it 'returns false for past due dates' do - due_date = 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 - ) - expect(due_date.upcoming?).to be false + 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 false' do + expect(due_date.upcoming?).to be false + end end end describe '#set' do - it 'updates deadline_type_id, parent_id, and round' do - due_date = DueDate.create( + 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 + + 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 - expect(due_date.deadline_type_id).to eq(submission_type.id) - expect(due_date.parent_id).to eq(assignment.id) - expect(due_date.round).to eq(1) # default value + it 'updates parent_id' do + expect(due_date.parent_id).to eq(assignment2.id) + end - due_date.set(review_type.id, assignment2.id, 2) - due_date.reload + it 'updates round' do + expect(due_date.round).to eq(2) + end - expect(due_date.deadline_type_id).to eq(review_type.id) - expect(due_date.parent_id).to eq(assignment2.id) - expect(due_date.round).to eq(2) + it 'persists changes to database' do + expect(due_date.reload.round).to eq(2) + end + end + + 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 end describe '#copy' do - it 'creates a duplicate due date for a new assignment' do - original = DueDate.create( + let(:original) do + DueDate.create( parent: assignment, due_at: 2.days.from_now, deadline_type: submission_type, @@ -260,170 +490,311 @@ review_allowed_id: late_right.id, round: 1 ) + end - copied = original.copy(assignment2.id) + 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 + + 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 describe '#deadline_type_name' do - it 'returns the name of the associated deadline type' do - due_date = DueDate.create( + 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 name of associated deadline type' do expect(due_date.deadline_type_name).to eq('submission') end + + context 'when deadline_type is nil' do + before { allow(due_date).to receive(:deadline_type).and_return(nil) } + + it 'returns nil' do + expect(due_date.deadline_type_name).to be_nil + end + end end describe '#last_deadline?' do - it 'returns true if this is the last deadline for the parent' do - last_deadline = 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 - ) + 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 - expect(last_deadline.last_deadline?).to be true + it 'returns true' do + expect(last_deadline.last_deadline?).to be true + end end - it 'returns false if there are later deadlines' do - earlier_deadline = 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 - ) + 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 - 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 - ) + 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 - expect(earlier_deadline.last_deadline?).to be false + it 'returns true' do + expect(only_deadline.last_deadline?).to be true + end end end describe '#<=>' do - it 'compares due dates by their due_at time' do - earlier = DueDate.create( + 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 - later = DueDate.create( + 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 'class methods' do describe '.sort_due_dates' do - it 'sorts due dates from earliest to latest' do - due_date1 = DueDate.create( + 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 - due_date2 = DueDate.create( + 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 - due_date3 = DueDate.create( + 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 describe '.any_future_due_dates?' do - it 'returns true when future due dates exist' do - due_dates = [ - 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 - ) - ] + 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 - expect(DueDate.any_future_due_dates?(due_dates)).to be true + it 'returns true' do + expect(DueDate.any_future_due_dates?(due_dates)).to be true + end end - it 'returns false when no future due dates exist' do - due_dates = [ - 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 - ) - ] + 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 - expect(DueDate.any_future_due_dates?(due_dates)).to be false + it 'returns true' do + expect(DueDate.any_future_due_dates?(due_dates)).to be true + end end end describe '.copy' do - it 'copies all due dates from one assignment to another' do + before do DueDate.create( parent: assignment, due_at: 2.days.from_now, @@ -441,13 +812,22 @@ submission_allowed_id: ok_right.id, round: 1 ) + end - original_count = assignment.due_dates.count - expect(assignment2.due_dates.count).to eq(0) + let(:original_count) { assignment.due_dates.count } - DueDate.copy(assignment.id, assignment2.id) + 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 - expect(assignment2.due_dates.count).to eq(original_count) + 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] @@ -456,34 +836,83 @@ 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 - it 'sets round to 1 when not specified' do - due_date = 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 - ) + 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 - expect(due_date.round).to eq(1) + it 'sets round to 1' do + expect(due_date.round).to eq(1) + end end - it 'does not override explicitly set round' do - due_date = 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 - ) + 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 - expect(due_date.round).to eq(3) + it 'sets default value before save' do + due_date.save + expect(due_date.round).to eq(1) + end end end end From a5ca1170170b163d97bd663c420b332d4a4ac4b2 Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Tue, 2 Dec 2025 14:11:01 -0500 Subject: [PATCH 15/17] test: add test codes for assignment model (with DueDateAction) --- spec/models/assignment_spec.rb | 172 ++++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) 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 From d2a5841bef0877fd832ed770648539a72857867d Mon Sep 17 00:00:00 2001 From: seojinseojin <001106ksj@gmail.com> Date: Tue, 2 Dec 2025 14:13:19 -0500 Subject: [PATCH 16/17] fix: fix deadlines_properly_ordered? method in due date actions mixin --- app/models/concerns/due_date_actions.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index 45696bbe7..78197d006 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -100,15 +100,12 @@ def shift_deadlines_of_type(deadline_type_name, days) # Check if deadlines are in proper chronological order def deadlines_properly_ordered? - sorted_deadlines = due_dates.order(:due_at) - previous_date = nil + previous_due = nil + due_dates.order(:id).each do |deadline| + return false if previous_due && deadline.due_at < previous_due - sorted_deadlines.each do |deadline| - return false if previous_date && deadline.due_at < previous_date - - previous_date = deadline.due_at + previous_due = deadline.due_at end - true end end From 9eb9a4975376ea5cd088e66047f267650c6cd092 Mon Sep 17 00:00:00 2001 From: Ash Zahabiuon <83662366+MrScruffles@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:04:20 -0500 Subject: [PATCH 17/17] Removal of Late methods as didn't' have use, added comment to due_date_actions for more readability on line 15 and removed cannot_delete_if_has_due_dates as it would only be triggered via admin view which wouldn't exist anymore. --- app/models/concerns/due_date_actions.rb | 1 + app/models/concerns/due_date_permissions.rb | 22 --------------------- app/models/deadline_type.rb | 10 ---------- 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb index 78197d006..ae0111e8a 100644 --- a/app/models/concerns/due_date_actions.rb +++ b/app/models/concerns/due_date_actions.rb @@ -12,6 +12,7 @@ def activity_permissible?(activity) # 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 diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb index 1ecb1a6ee..74ee48a2c 100644 --- a/app/models/concerns/due_date_permissions.rb +++ b/app/models/concerns/due_date_permissions.rb @@ -72,28 +72,6 @@ def activity_permissible?(activity) deadline_right&.name&.in?(%w[OK Late]) end - # Check if deadline allows late submissions - def late_submission_allowed? - return false unless submission_allowed_id - - deadline_right = DeadlineRight.find_by(id: submission_allowed_id) - deadline_right&.name == 'Late' - end - - def late_review_allowed? - return false unless review_allowed_id - - deadline_right = DeadlineRight.find_by(id: review_allowed_id) - deadline_right&.name == 'Late' - end - - def late_quiz_allowed? - return false unless quiz_allowed_id - - deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) - deadline_right&.name == 'Late' - end - # Get permission status for an action (OK, Late, No) def permission_status_for(action) permission_field = "#{action}_allowed_id" diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb index c93811f36..63c11ae11 100644 --- a/app/models/deadline_type.rb +++ b/app/models/deadline_type.rb @@ -44,15 +44,5 @@ def to_s display_name end - private - # Ensure we maintain referential integrity - def cannot_delete_if_has_due_dates - return unless due_dates.exists? - - errors.add(:base, 'Cannot delete deadline type that has associated due dates') - throw :abort - end - - before_destroy :cannot_delete_if_has_due_dates end