Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7981de8
docs: fix typo in API root, add doc_version
SteveDala Nov 30, 2025
0e89378
Merge branch '10.0.x' of https://github.com/doubtfire-lms/doubtfire-a…
SteveDala Nov 30, 2025
069a023
Merge branch '10.0.x' of https://github.com/SteveDala/doubtfire-api i…
SteveDala Dec 13, 2025
835bf79
Merge branch '10.0.x' of https://github.com/SteveDala/doubtfire-api i…
SteveDala Jan 4, 2026
1312537
chore: update gems
SteveDala Jan 17, 2026
a90072d
feat(api): add staff grant extension endpoint
Apr 6, 2025
d27fd32
refactor(tests): replace double quotes with single quotes in non-inte…
SahiruWithanage Apr 9, 2025
fb80917
refactor(api): unify extension handling via shared service
SahiruWithanage Apr 11, 2025
ed7ae3d
feat(notifications): send extension notifications
SahiruWithanage Apr 27, 2025
c4162a1
Make a comment line change
SahiruWithanage May 11, 2025
b885a34
fix(mailer): fix email sender and add error handling
SahiruWithanage May 19, 2025
82ea701
feat: add notification table and model
samindiii May 18, 2025
6a310ba
feat: define notification api to GET and DELETE
samindiii May 18, 2025
3c99671
Create in-system notifications for students with successfull extensions
samindiii May 18, 2025
dca5e00
feat: fix email notifications for SGE feature
SahiruWithanage Sep 14, 2025
6bee233
refactor: use unit code instead of unit name in extension notifications
SahiruWithanage Sep 14, 2025
fe6cd01
Merge branch 'feature/staff-grant-extension-backend-t1-complete' of h…
SteveDala Jan 17, 2026
755f467
chore: update gems, regen lockfile, schema version
SteveDala Jan 17, 2026
1b8c724
fix: remove duplicate api endpoints
SteveDala Jan 17, 2026
4b2978b
chore: update latest from doubtfire
SteveDala Jan 17, 2026
c7f9b66
chore: address rubocop nits
SteveDala Jan 17, 2026
7a98ff2
chore: Remove shebang from Rakefile (rubocop)
SteveDala Jan 17, 2026
bf6347c
fix: address testing issues with SGE rebase
SteveDala Jan 18, 2026
9dad019
chore: remove block comment (rubocop)
SteveDala Jan 18, 2026
4408f58
fix: broken regex for last names with '
SteveDala Jan 19, 2026
90c8789
fix: failing test due to name ambiguity
SteveDala Jan 19, 2026
1df9bb2
fix: address changes requested to #565
SteveDala Feb 4, 2026
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
931 changes: 141 additions & 790 deletions .rubocop_todo.yml

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ GEM
bunny-pub-sub (0.5.2)
bunny (~> 2.14)
byebug (12.0.0)
cgi (0.4.2)
chronic_duration (0.10.6)
numerizer (~> 0.1.1)
ci_reporter (2.1.0)
Expand Down Expand Up @@ -152,6 +153,8 @@ GEM
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
erb (4.0.4)
cgi (>= 0.3.3)
erubi (1.13.1)
erubis (2.7.0)
et-orbi (1.2.11)
Expand All @@ -173,7 +176,6 @@ GEM
faraday (>= 1, < 3)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-x86_64-linux-gnu)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
Expand Down Expand Up @@ -208,7 +210,7 @@ GEM
ice_cube (~> 0.16)
ostruct
ice_cube (0.17.0)
io-console (0.8.0)
io-console (0.8.1)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
Expand Down Expand Up @@ -279,8 +281,6 @@ GEM
net-protocol
netrc (0.11.0)
nio4r (2.7.4)
nokogiri (1.18.7-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
racc (~> 1.4)
numerizer (0.1.1)
Expand Down Expand Up @@ -308,7 +308,7 @@ GEM
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
prism (1.4.0)
prism (1.5.1)
psych (5.2.3)
date
stringio
Expand Down Expand Up @@ -374,7 +374,8 @@ GEM
rbs (3.9.2)
logger
rbtree (0.4.6)
rdoc (6.13.1)
rdoc (6.14.0)
erb
psych (>= 4.0.0)
redis (5.4.0)
redis-client (>= 0.22.0)
Expand Down Expand Up @@ -537,7 +538,7 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
uri (1.0.4)
useragent (0.16.11)
version_gem (1.1.6)
warden (1.2.9)
Expand All @@ -556,8 +557,7 @@ GEM
zeitwerk (2.7.2)

PLATFORMS
aarch64-linux
x86_64-linux
x86_64-linux-gnu

DEPENDENCIES
better_errors
Expand Down Expand Up @@ -623,7 +623,7 @@ DEPENDENCIES
webmock

RUBY VERSION
ruby 3.4.2p28
ruby 3.4.7p58

BUNDLED WITH
2.6.6
2.6.9
1 change: 0 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env rake
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

Expand Down
5 changes: 5 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ class ApiRoot < Grape::API
mount ScormExtensionCommentsApi
mount GroupSetsApi
mount LearningOutcomesApi
# mount LearningAlignmentApi
# the mount above is available in 9.x but has not been ported to `10.0.x`
mount NotificationsApi
mount ProjectsApi
mount SettingsApi
mount StaffGrantExtensionApi
mount StudentsApi
mount Submission::PortfolioApi
mount Submission::PortfolioEvidenceApi
Expand Down Expand Up @@ -120,6 +124,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to GroupSetsApi
AuthenticationHelpers.add_auth_to LearningOutcomesApi
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
36 changes: 14 additions & 22 deletions app/api/extension_comments_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +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)
end

if project.unit.allow_flexible_dates
error!({ error: 'Extensions are disabled for this unit.' }, 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
178 changes: 178 additions & 0 deletions app/api/staff_grant_extension_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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?
unless current_user.has_tutor_capability?
error!(
{
error: 'Not authorized to grant extensions',
code: 'UNAUTHORIZED',
details: {}
},
403
)
end
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],
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 StandardError => 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
Loading