Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ class ApiRoot < Grape::API
mount GroupSetsApi
mount LearningOutcomesApi
mount LearningAlignmentApi
mount NotificationsApi
mount ProjectsApi
mount SettingsApi
mount StaffGrantExtensionApi
mount StudentsApi
mount Submission::PortfolioApi
mount Submission::PortfolioEvidenceApi
Expand Down Expand Up @@ -100,6 +102,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to LearningOutcomesApi
AuthenticationHelpers.add_auth_to LearningAlignmentApi
AuthenticationHelpers.add_auth_to ProjectsApi
AuthenticationHelpers.add_auth_to StaffGrantExtensionApi
AuthenticationHelpers.add_auth_to StudentsApi
AuthenticationHelpers.add_auth_to Submission::PortfolioApi
AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi
Expand Down
32 changes: 14 additions & 18 deletions app/api/extension_comments_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,21 @@ class ExtensionCommentsApi < Grape::API
requires :weeks_requested, type: Integer, desc: 'The details of the request'
end
post '/projects/:project_id/task_def_id/:task_definition_id/request_extension' do
project = Project.find(params[:project_id])
task_definition = project.unit.task_definitions.find(params[:task_definition_id])
task = project.task_for_task_definition(task_definition)

# check permissions using specific permission has with addition of request extension if allowed in unit
unless authorise? current_user, task, :request_extension, ->(role, perm_hash, other) { task.specific_permission_hash(role, perm_hash, other) }
error!({ error: 'Not authorised to request an extension for this task' }, 403)
# Use the ExtensionService to handle the extension request
result = ExtensionService.grant_extension(
params[:project_id],
params[:task_definition_id],
current_user,
params[:weeks_requested],
params[:comment]
)

# Handle the service response
if result[:success]
present result[:result].serialize(current_user), Grape::Presenters::Presenter
else
error!({ error: result[:error] }, result[:status])
end

error!({ error: 'Extension weeks can not be 0.' }, 403) if params[:weeks_requested] == 0

max_duration = task.weeks_can_extend
duration = params[:weeks_requested]
duration = max_duration unless params[:weeks_requested] <= max_duration

error!({ error: 'Extensions cannot be granted beyond task deadline.' }, 403) if duration <= 0

result = task.apply_for_extension(current_user, params[:comment], duration)
present result.serialize(current_user), Grape::Presenters::Presenter
end

desc 'Assess an extension for a task'
Expand Down
29 changes: 29 additions & 0 deletions app/api/notifications_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class NotificationsApi < Grape::API
helpers AuthenticationHelpers
helpers AuthorisationHelpers

before do
authenticated?
end

desc 'Get current user notifications'
get '/notifications' do
notifications = current_user.notifications.order(created_at: :desc)
# Return array of notifications as JSON (id and message only)
notifications.as_json(only: [:id, :message])
end

desc 'Delete user notification by id'
delete '/notifications/:id' do
notification = current_user.notifications.find_by(id: params[:id])
error!({ error: 'Notification not found' }, 404) unless notification
notification.destroy
status 204
end

desc 'Delete all user notifications'
delete '/notifications' do
current_user.notifications.delete_all
status 204
end
end
176 changes: 176 additions & 0 deletions app/api/staff_grant_extension_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
require 'grape'

#
# API endpoint for staff to grant extensions to multiple students at once
#
class StaffGrantExtensionApi < Grape::API
helpers AuthenticationHelpers
helpers AuthorisationHelpers
helpers DbHelpers

before do
authenticated?
error!({
error: 'Not authorized to grant extensions',
code: 'UNAUTHORIZED',
details: {}
}, 403) unless current_user.has_tutor_capability?
end

desc 'Grant extensions to multiple students',
detail: 'This endpoint allows staff to grant extensions to multiple students at once for a specific task. The operation is atomic - either all extensions are granted or none are. Students not found in the unit are automatically skipped without affecting the transaction.',
success: [
{ code: 201, message: 'Extensions granted successfully' }
],
failure: [
{ code: 400, message: 'Some extensions failed to be granted' },
{ code: 403, message: 'Not authorized to grant extensions for this unit' },
{ code: 404, message: 'Unit or task definition not found' },
{ code: 500, message: 'Internal server error' }
],
response: {
successful: [
{
student_id: 'Integer - ID of the student',
project_id: 'Integer - ID of the project',
weeks_requested: 'Integer - Number of weeks extension granted',
extension_response: 'String - Human readable message with new due date',
task_status: 'String - Updated status of the task'
}
],
failed: [
{
student_id: 'Integer - ID of the student',
project_id: 'Integer - ID of the project',
error: 'String - Error message explaining why extension failed'
}
],
skipped: [
{
student_id: 'Integer - ID of the student',
reason: 'String - Reason why the student was skipped'
}
]
}
params do
requires :student_ids, type: Array[Integer], desc: 'List of student IDs to grant extensions to'
requires :task_definition_id, type: Integer, desc: 'Task definition ID'
requires :weeks_requested, type: Integer, desc: 'Number of weeks to extend by (1-4)'
requires :comment, type: String, desc: 'Reason for extension (max 300 characters)'
end
post '/units/:unit_id/staff-grant-extension' do
unit = Unit.find(params[:unit_id])
task_definition = unit.task_definitions.find(params[:task_definition_id])

# Use transaction to ensure atomic operation
ActiveRecord::Base.transaction do
results = {
successful: [],
failed: [],
skipped: []
}

params[:student_ids].each do |student_id|
# Find project for this student in the unit
project = unit.projects.find_by(user_id: student_id)
if project.nil?
results[:skipped] << {
student_id: student_id,
reason: 'Student not found in unit'
}
next
end

result = ExtensionService.grant_extension(
project.id,
task_definition.id,
current_user,
params[:weeks_requested],
params[:comment],
true # is_staff_grant = true
)

if result[:success]
extension_comment = result[:result]
results[:successful] << {
student_id: student_id,
project_id: project.id,
weeks_requested: extension_comment.extension_weeks,
extension_response: extension_comment.extension_response,
task_status: extension_comment.task.status,
extension_comment: extension_comment # Store internally for notifications
}
else
results[:failed] << {
student_id: student_id,
project_id: project.id,
error: result[:error]
}
# If it's a validation error (403), raise it immediately
error!({ error: result[:error] }, result[:status]) if result[:status] == 403
end
end

# If any extensions failed (but not due to validation), rollback the entire transaction
if results[:failed].any?
error!({ error: 'Some extensions failed to be granted', results: results }, 400)
end

# Send notifications only if successful and after processing all students
if results[:successful].any?
# Use the extension comments directly from the service results (thread-safe)
successful_extensions = results[:successful].map do |result|
extension_comment = result[:extension_comment]
if extension_comment.nil?
Rails.logger.warn "No extension comment found for project #{result[:project_id]}"
nil
else
Rails.logger.debug "Using extension comment: #{extension_comment.id} for project #{result[:project_id]}"
extension_comment
end
end

# Filter out any nil results in case a comment wasn't found
successful_extensions.compact!
Rails.logger.info "Processing #{successful_extensions.count} successful extensions for notifications"

if successful_extensions.any?
begin
Rails.logger.info "Sending extension notifications for #{successful_extensions.count} extensions"
NotificationsMailer.extension_granted(
successful_extensions,
current_user,
params[:student_ids].count,
results[:failed],
true # is_staff_grant = true
).deliver_now
Rails.logger.info "Extension notifications sent successfully"
rescue => e
Rails.logger.error "Failed to send extension notifications: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Don't fail the entire request if email fails, but log the error
end

# Create in-system notifications for successful extensions
results[:successful].each do |result|
student = User.find_by(id: result[:student_id])
next unless student

Notification.create!(
user_id: student.id,
message: "#{unit.code}: You were granted an extension for task '#{task_definition.name}'."
)
end
end
end

status 201
present results, with: Grape::Presenters::Presenter
end

rescue ActiveRecord::RecordNotFound
error!({ error: 'Unit or task definition not found' }, 404)
rescue StandardError
error!({ error: 'An unexpected error occurred' }, 500)
end
end
80 changes: 78 additions & 2 deletions app/mailers/notifications_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
class NotificationsMailer < ActionMailer::Base

# Load configuration values at class level
def self.doubtfire_host
Doubtfire::Application.config.institution[:host] || 'doubtfire.deakin.edu.au'
end

def self.doubtfire_product_name
Doubtfire::Application.config.institution[:product_name] || 'Doubtfire'
end

def add_general
@doubtfire_host = Doubtfire::Application.config.institution[:host]
@doubtfire_product_name = Doubtfire::Application.config.institution[:product_name]
@doubtfire_host = self.class.doubtfire_host
@doubtfire_product_name = self.class.doubtfire_product_name
@unsubscribe_url = "#{@doubtfire_host}/#/home?notifications"
end

Expand Down Expand Up @@ -88,6 +98,72 @@ def this_these(num)
end
end

# Sends a summary email to the staff member who granted the extensions
def extension_granted_summary(extensions, granted_by, total_selected, failed_extensions = [])
@granted_by = granted_by
@extensions = extensions
@total_selected = total_selected
@failed_extensions = failed_extensions
@unit = extensions.any? ? extensions.first.task.unit : nil
@is_tutor = true

add_general

email_with_name = %("#{@granted_by.name}" <#{@granted_by.email}>)
# Set explicit from address using product name and a default sender
from_address = %("#{self.class.doubtfire_product_name}" <no-reply@#{self.class.doubtfire_host}>)

mail(
to: email_with_name,
from: from_address,
subject: @unit ? "#{@unit.name}: Staff Grant Extensions" : "Staff Grant Extensions",
template_name: 'extension_granted'
)
end

# Sends a notification to a student about their granted extension
def extension_granted_notification(extension, granted_by)
@granted_by = granted_by
@extension = extension
@task = extension.task
@student = extension.project.student
@is_tutor = false

add_general

email_with_name = %("#{@student.name}" <#{@student.email}>)
tutor_email = %("#{@granted_by.name}" <#{@granted_by.email}>)

mail(
to: email_with_name,
from: tutor_email,
subject: "#{@task.unit.name}: Extension granted for #{@task.task_definition.name}",
template_name: 'extension_granted'
)
end

# Main method to handle extension notifications from staff
def extension_granted(extensions, granted_by, total_selected, failed_extensions = [], is_staff_grant = false)
# Only send notifications for staff-granted bulk extensions
return unless is_staff_grant && (extensions.any? || failed_extensions.any?)

begin
# Send summary to staff member who granted the extensions
NotificationsMailer.extension_granted_summary(extensions, granted_by, total_selected, failed_extensions).deliver_now

# Send individual notifications only to students who have enabled email notifications
extensions.each do |extension|
student = extension.project.student
if student.receive_task_notifications
NotificationsMailer.extension_granted_notification(extension, granted_by).deliver_now
end
end
rescue => e
Rails.logger.error "Failed to send extension notifications: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
end
end

helper_method :top_task_desc
helper_method :were_was
helper_method :are_is
Expand Down
3 changes: 3 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Notification < ApplicationRecord
belongs_to :user
end
6 changes: 4 additions & 2 deletions app/models/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def self.permissions
:download_stats,
:download_unit_csv,
:download_grades,
:exceed_capacity
:exceed_capacity,
:grant_extensions
]

# What can convenors do with units?
Expand All @@ -48,7 +49,8 @@ def self.permissions
:download_grades,
:rollover_unit,
:exceed_capacity,
:perform_overseer_assessment_test
:perform_overseer_assessment_test,
:grant_extensions
]

# What can admin do with units?
Expand Down
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def token_for_text?(a_token)
has_many :projects, dependent: :destroy
has_many :auth_tokens, dependent: :destroy
has_one :webcal, dependent: :destroy
has_many :notifications, dependent: :destroy

# Model validations/constraints
validates :first_name, presence: true
Expand Down
Loading