Skip to content
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
30 changes: 30 additions & 0 deletions app/api/notifications_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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)
.limit(20)
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
162 changes: 162 additions & 0 deletions app/api/staff_grant_extension_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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'
requires :comment, type: String, desc: 'Reason for extension'
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
}
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?
successful_extensions = results[:successful].map do |result|
# Re-fetch project within the transaction to ensure consistency
project = Project.find(result[:project_id])
task = project.task_for_task_definition(task_definition)
# Ensure we get the latest extension comment created within this transaction
task.all_comments.where(content_type: :extension).order(created_at: :desc).first
end

# Filter out any nil results in case a comment wasn't found (shouldn't happen ideally)
successful_extensions.compact!

if successful_extensions.any?
NotificationsMailer.extension_granted(
successful_extensions,
current_user,
params[:student_ids].count,
results[:failed],
true # is_staff_grant = true
).deliver_later

# 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.name}: 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
79 changes: 77 additions & 2 deletions app/mailers/notifications_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
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

# Set default from address using class methods
default from: -> { "#{self.class.doubtfire_product_name} <#{@granted_by&.email}>" }

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 +101,68 @@ 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}>)
mail(
to: email_with_name,
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
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
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
5 changes: 5 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Notification < ApplicationRecord
belongs_to :user

validates :message, presence: true, length: { maximum: 500 }
end
3 changes: 2 additions & 1 deletion 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 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