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
8 changes: 6 additions & 2 deletions .ci-setup/crontab
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ PATH=/tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:/usr/local/bun

10,15,20,25,30,35,40,45,50,55 * * * * /doubtfire/lib/shell/generate_pdfs.sh
0,10,20,30,40,50 * * * * /doubtfire/lib/shell/send_overseer_notifications.sh
0 5 * * * /doubtfire/lib/shell/check_plagiarism.sh
0 8 * * * /doubtfire/lib/shell/portfolio_autogen_check.sh
0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh
# The weekly summary email ran here, Monday at 7am. The recurring summary is the
# student digest now, three entries in config/schedule.yml, one per cadence a
# student can pick, so it is scheduled once and it runs anywhere Sidekiq runs
# rather than only in this container. The per-unit mail this line used to send
# is still there and is still `rake mailer:send_status_emails`, via
# lib/shell/send_weekly_emails.sh, but it is on no schedule now.
0 1 * * * /doubtfire/lib/shell/sync_enrolments.sh
24 changes: 24 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ class ApiRoot < Grape::API
mount Admin::OverseerAdminApi
mount ActivityTypesAuthenticatedApi
mount ActivityTypesPublicApi
mount CourseflowApi
mount TaskStatusesApi
mount AuthenticationApi
mount AdditionalNotificationEmailVerificationApi
mount BreaksApi
mount DiscussionCommentApi
mount EngagementsApi
Expand All @@ -66,6 +69,8 @@ class ApiRoot < Grape::API
mount GroupSetsApi
mount LearningOutcomesApi
mount ProjectsApi
mount SettingsPublicApi
mount PeerProgressApi
mount SettingsApi
mount StudentsApi
mount Submission::PortfolioApi
Expand Down Expand Up @@ -103,20 +108,29 @@ class ApiRoot < Grape::API
mount D2lIntegrationApi::OauthPublicApi

mount UsersApi
mount AdditionalNotificationEmailsApi
mount WebcalApi
mount WebcalPublicApi
mount MarkingSessionsApi
mount DiscussionPromptsApi
mount OverseerStepsApi
mount TaskPrioritizationApi
mount DemoScenarioApi

mount Feedback::FeedbackChipApi

# Notifications feature
mount UnitHubApi
mount NotificationsApi
mount PushSubscriptionsApi

#
# Add auth details to all end points
#
AuthenticationHelpers.add_auth_to Admin::OverseerAdminApi

AuthenticationHelpers.add_auth_to ActivityTypesAuthenticatedApi
AuthenticationHelpers.add_auth_to CourseflowApi
AuthenticationHelpers.add_auth_to BreaksApi
AuthenticationHelpers.add_auth_to DiscussionCommentApi
AuthenticationHelpers.add_auth_to EngagementsApi
Expand All @@ -125,6 +139,8 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to GroupSetsApi
AuthenticationHelpers.add_auth_to LearningOutcomesApi
AuthenticationHelpers.add_auth_to ProjectsApi
AuthenticationHelpers.add_auth_to SettingsApi
AuthenticationHelpers.add_auth_to PeerProgressApi
AuthenticationHelpers.add_auth_to StudentsApi
AuthenticationHelpers.add_auth_to Submission::PortfolioApi
AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi
Expand All @@ -149,6 +165,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to TutorialStreamsApi
AuthenticationHelpers.add_auth_to TutorialEnrolmentsApi
AuthenticationHelpers.add_auth_to UsersApi
AuthenticationHelpers.add_auth_to AdditionalNotificationEmailsApi
AuthenticationHelpers.add_auth_to UnitRolesApi
AuthenticationHelpers.add_auth_to UnitsApi
AuthenticationHelpers.add_auth_to WebcalApi
Expand All @@ -160,8 +177,15 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to MarkingSessionsApi
AuthenticationHelpers.add_auth_to DiscussionPromptsApi
AuthenticationHelpers.add_auth_to OverseerStepsApi
AuthenticationHelpers.add_auth_to TaskPrioritizationApi
AuthenticationHelpers.add_auth_to DemoScenarioApi
AuthenticationHelpers.add_auth_to TutorNotesApi

# Notifications feature
AuthenticationHelpers.add_auth_to UnitHubApi
AuthenticationHelpers.add_auth_to NotificationsApi
AuthenticationHelpers.add_auth_to PushSubscriptionsApi

add_swagger_documentation \
base_path: nil,
doc_version: 'v11.0.0',
Expand Down
117 changes: 117 additions & 0 deletions app/api/courseflow_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

require 'grape'

# Catalog reads and private student plans. No teaching-unit permissions or IDs.
class CourseflowApi < Grape::API
helpers AuthenticationHelpers

rescue_from Grape::Exceptions::InvalidMessageBody do
Rack::Response.new({ error: 'Plan must contain valid JSON' }.to_json, 422,
{ 'content-type' => 'application/json', 'cache-control' => 'private, no-store' })
end

before do
authenticated?
header 'Cache-Control', 'private, no-store'
end

helpers do
def owned_courseflow_map
Courseflow::CourseMap.where(user_id: current_user.id).find(courseflow_id(params[:id]))
end

def courseflow_id(value)
error!({ error: 'Not found' }, 404) unless /\A[1-9]\d{0,18}\z/.match?(value.to_s)
value.to_i
end

def courseflow_document(update: false)
request.body.rewind
raw = request.body.read(Courseflow::CatalogImporter::MAX_BYTES + 1).to_s
error!({ error: 'Plan exceeds 1 MiB' }, 422) if raw.bytesize > Courseflow::CatalogImporter::MAX_BYTES
document = JSON.parse(raw)
keys = %w[course_id name periods slots]
keys << 'lock_version' if update
unless document.is_a?(Hash) && document.keys.sort == keys.sort && !params.key?(:user_id)
error!({ error: "Plan must contain exactly #{keys.join(', ')}; ownership is server assigned" }, 422)
end
unless document['course_id'].is_a?(Integer) && document['course_id'].positive? &&
document['name'].is_a?(String) && document['name'].strip.present? && document['name'].length <= 200
error!({ error: 'course_id must be a positive integer and name a nonblank string up to 200 characters' }, 422)
end
if update && !(document['lock_version'].is_a?(Integer) && document['lock_version'].between?(0, 2_147_483_647))
error!({ error: 'lock_version must be a nonnegative integer' }, 422)
end
document
rescue JSON::ParserError
error!({ error: 'Plan must be a JSON object' }, 422)
end

def save_courseflow_map!(map)
map.save!
rescue ActiveRecord::RecordInvalid => e
error!({ error: 'Invalid plan', details: e.record.errors.full_messages }, 422)
rescue ActiveRecord::StaleObjectError
courseflow_conflict!
end

def courseflow_conflict!
error!({ error: 'This plan changed in another session. Reload it before saving or deleting.' }, 409)
end
end

namespace :courseflow do
get :courses do
Courseflow::Course.order(:code, :version, :id).map(&:as_catalog)
end

get 'courses/:id' do
Courseflow::Course.find(courseflow_id(params[:id])).as_catalog
end

get :maps do
Courseflow::CourseMap.where(user_id: current_user.id).includes(:course).order(updated_at: :desc, id: :desc).map(&:as_plan)
end

get 'maps/:id' do
owned_courseflow_map.as_plan
end

post :maps do
document = courseflow_document
course = Courseflow::Course.find_by(id: document['course_id'])
error!({ error: 'Selected course does not exist' }, 422) unless course
map = Courseflow::CourseMap.new(document.merge('user_id' => current_user.id))
map.course = course
save_courseflow_map!(map)
status 201
map.as_plan
end

put 'maps/:id' do
map = owned_courseflow_map
document = courseflow_document(update: true)
map.with_lock do
courseflow_conflict! unless document['lock_version'] == map.lock_version
map.assign_attributes(document.except('lock_version'))
save_courseflow_map!(map)
end
map.as_plan
end

delete 'maps/:id' do
map = owned_courseflow_map
unless params[:lock_version].is_a?(String) && /\A(?:0|[1-9]\d{0,9})\z/.match?(params[:lock_version]) &&
params[:lock_version].to_i <= 2_147_483_647 && !params.key?(:user_id)
error!({ error: 'lock_version query parameter must be a nonnegative integer; ownership is server assigned' }, 422)
end
map.with_lock do
courseflow_conflict! unless params[:lock_version].to_i == map.lock_version
map.destroy!
end
status 204
body false
end
end
end
8 changes: 4 additions & 4 deletions app/api/discussion_comment_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class DiscussionCommentApi < Grape::API

for attached_file in attached_files do
if attached_file.present?
error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank?
error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000
error!({ error: 'Attachment is empty.' }, 400) if File.size?(attached_file["tempfile"].path).blank?
error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 413) unless File.size?(attached_file["tempfile"].path) < 30_000_000
end
end

Expand Down Expand Up @@ -136,8 +136,8 @@ class DiscussionCommentApi < Grape::API
attached_file = params[:attachment]

if attached_file.present?
error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank?
error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000
error!({ error: 'Attachment is empty.' }, 400) if File.size?(attached_file["tempfile"].path).blank?
error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 413) unless File.size?(attached_file["tempfile"].path) < 30_000_000
end

logger.info("#{current_user.username} - added a reply to the discussion comment #{params[:task_comment_id]} for task #{task.id} (#{task_definition.abbreviation})")
Expand Down
13 changes: 12 additions & 1 deletion app/api/entities/comment_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class CommentEntity < Grape::Entity
expose :id
expose :comment
expose :has_attachment do |data, options|
["audio", "image", "pdf"].include?(data.content_type)
data.attachment?
end
expose :type do |data, options|
data.content_type || "text"
Expand All @@ -15,7 +15,18 @@ class CommentEntity < Grape::Entity
data.new_for?(options[:current_user])
end
end
# Written by OnTrack rather than by the author named below. Clients show
# these differently so a person is not credited with something they did not
# write.
expose :automated do |data, _options|
data.respond_to?(:automated?) && data.automated?
end
expose :reply_to_id
expose :attachment_file_name, if: ->(data, _) { data.attachment? }
expose :attachment_mime_type, if: ->(data, _) { data.attachment? }
expose :attachment_byte_size, if: ->(data, _) { data.attachment? } do |data, _|
data.attachment_size
end
expose :created_at
expose :recipient_read_time, safe: true
expose :author do |data, options|
Expand Down
1 change: 1 addition & 0 deletions app/api/entities/minimal/minimal_user_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class MinimalUserEntity < Grape::Entity
expose :last_name
expose :username
expose :nickname
expose :display_name
end
end
end
2 changes: 1 addition & 1 deletion app/api/entities/project_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ProjectEntity < Grape::Entity

expose :task_stats, as: :stats, unless: :for_student

expose :tasks, using: TaskEntity, unless: :summary_only do |project, options|
expose :tasks, using: TaskEntity, if: ->(project, options) { !options[:summary_only] || options[:include_task_definitions] } do |project, options|
project.task_details_for_shallow_serializer(options[:user])
end

Expand Down
28 changes: 28 additions & 0 deletions app/api/entities/user_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,39 @@ class UserEntity < Grape::Entity
expose :last_name
expose :username
expose :nickname
expose :display_name
expose :receive_task_notifications, unless: :minimal
expose :receive_portfolio_notifications, unless: :minimal
expose :receive_feedback_notifications, unless: :minimal
expose :receive_unit_hub_notifications, unless: :minimal
expose :receive_unit_hub_email_notifications, unless: :minimal
expose :receive_unit_hub_push_notifications, unless: :minimal
expose :receive_unit_hub_session_reminders, unless: :minimal
expose :digest_frequency, unless: :minimal
expose :display_peer_progress, unless: :minimal
expose :opt_in_to_research, unless: :minimal
expose :has_run_first_time_setup, unless: :minimal
# Theme preference is account-private presentation state. Only endpoints
# serialising the authenticated account opt in to these fields; shared user
# lookups must not disclose either the choice or when it was made.
expose :theme_preference,
unless: :minimal,
if: lambda { |user, options|
options.key?(:theme_owner_id) && user.id.present? && options[:theme_owner_id] == user.id
}
expose :theme_preference_updated_at,
unless: :minimal,
if: lambda { |user, options|
options.key?(:theme_owner_id) && user.id.present? && options[:theme_owner_id] == user.id
}

expose :institutional_identity_managed, unless: :minimal do |_user, _options|
!AuthenticationHelpers.db_auth?
end

expose :email_editable, unless: :minimal do |_user, _options|
AuthenticationHelpers.db_auth?
end

expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { TurnItIn.enabled? } do |user, options|
if TiiActionFetchFeaturesEnabled.eula_required?
Expand Down
7 changes: 6 additions & 1 deletion app/api/overseer_steps_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,12 @@ class OverseerStepsApi < Grape::API

unit = project.unit

overseer_assessment = OverseerAssessment.find(params[:id])
# Look the assessment up through the project and task definition in the url, so that
# an id from outside the authorised project raises RecordNotFound and returns a 404.
overseer_assessment = OverseerAssessment.joins(:task)
.where(tasks: { project_id: project.id, task_definition_id: params[:task_def_id] })
.find(params[:id])

present overseer_assessment.overseer_step_results, with: Entities::OverseerStepResultEntity, my_role: unit.role_for(current_user)
end
end
36 changes: 18 additions & 18 deletions app/api/settings_api.rb
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
require 'grape'

class SettingsApi < Grape::API
#
# Returns the current auth method
#
desc 'Return configurable details for the Doubtfire front end'
helpers AuthenticationHelpers

before do
authenticated?
end

desc 'Return authenticated feature configuration for the Doubtfire front end'
get '/settings' do
response = {
externalName: Doubtfire::Application.config.institution[:product_name],
hasLogo: Doubtfire::Application.config.institution[:has_logo],
logoUrl: Doubtfire::Application.config.institution[:logo_url],
logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url],
overseerEnabled: Doubtfire::Application.config.overseer_enabled,
tiiEnabled: TurnItIn.enabled?,
d2lEnabled: D2lIntegration.enabled?
}
d2lEnabled: D2lIntegration.enabled?,
tutorialEnabled: Doubtfire::Application.config.tutorial_enabled,

present response, with: Grape::Presenters::Presenter
end

desc 'Return privacy policy details'
get '/settings/privacy' do
response = {
privacy: Doubtfire::Application.config.institution[:privacy],
plagiarism: Doubtfire::Application.config.institution[:plagiarism]
# Web push. The VAPID *public* key is not a secret — the browser has to
# send it to the push service to subscribe at all. Serving it here means it
# is configured in one place instead of being copied into the front end and
# going stale the first time the keys are rotated.
#
# Blank when push is not configured, which is how the client knows not to
# offer the opt-in.
pushEnabled: PushNotificationService.configured?,
vapidPublicKey: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY', nil).presence
}

present response, with: Grape::Presenters::Presenter
Expand Down
Loading