Website : rimsha.abasa.com
backdoor
Home
Console
Upload
information
Create File
Create Folder
About
Tools
:
/
var
/
canvas
/
ui
/
shared
/
calendar
/
jquery
/
Filename :
EventDataSource.js
back
Copy
/* * Copyright (C) 2012 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see <http://www.gnu.org/licenses/>. */ import $ from 'jquery' import {reject, maxBy, isEmpty, partition, minBy} from 'lodash' import fcUtil from './fcUtil' import commonEventFactory from './CommonEvent/index' import '@canvas/jquery/jquery.ajaxJSON' import 'jquery-tinypubsub' import moment from 'moment' export default class EventDataSource { constructor(contexts) { this.eventSaved = this.eventSaved.bind(this) this.eventsSavedFromSeries = this.eventsSavedFromSeries.bind(this) this.eventDeleted = this.eventDeleted.bind(this) this.eventWithId = this.eventWithId.bind(this) this.clearCache = this.clearCache.bind(this) this.needUndatedEventsForContexts = this.needUndatedEventsForContexts.bind(this) this.getEventsFromCacheForContext = this.getEventsFromCacheForContext.bind(this) this.processNextRequest = this.processNextRequest.bind(this) this.getEventsFromCache = this.getEventsFromCache.bind(this) this.getAppointmentGroupsFromCache = this.getAppointmentGroupsFromCache.bind(this) this.getAppointmentGroups = this.getAppointmentGroups.bind(this) this.processAppointmentData = this.processAppointmentData.bind(this) this.getEventsForAppointmentGroup = this.getEventsForAppointmentGroup.bind(this) this.getEvents = this.getEvents.bind(this) this.getParticipants = this.getParticipants.bind(this) this.startFetch = this.startFetch.bind(this) this.fetchNextBatch = this.fetchNextBatch.bind(this) this.contexts = contexts this.clearCache() this.inFlightRequest = {} this.pendingRequests = [] // The cache will store all the events we've fetched so far, and looks like this: // { // contexts: { // "user_1": { // fetchedRanges: [ // sorted list of [start, end] tuples that represent // ranges of dates that we have already fetched for // ] // events: { // "assignment_1": <CommonEvent object> // }, // fetchedUndated: true/false // }, ... // }, // appointmentGroups: { // "1": <object> // }, // participants: { // "1_unregistered": [ users or groups ] // } // fetchedAppointmentGroups: { manageable: true/false } // } // Note that the appointmentGroups are not cached per context, as // we get them all in the same request (not scoped to contexts at // all.) This might end up being confusing. $.subscribe('CommonEvent/eventDeleted', this.eventDeleted) $.subscribe('CommonEvent/eventSaved', this.eventSaved) $.subscribe('CommonEvent/eventsSavedFromSeries', this.eventsSavedFromSeries) } eventSaved(event) { return this.addEventToCache(event) } eventsSavedFromSeries(events) { events.seriesEvents.forEach(event => { this.addEventToCache(event) }) } eventDeleted(event) { const cached = this.cache.contexts[event.contextCode()] const events = cached && cached.events if (events) delete events[event.id] } eventWithId(id) { for (const contextCode in this.cache.contexts) { const contextData = this.cache.contexts[contextCode] if (contextData.events[id]) { return contextData.events[id] } } return null } clearCache() { this.cache = { contexts: {}, appointmentGroups: {}, participants: {}, fetchedAppointmentGroups: null, } this.resetContexts() } // clear the calendar event cache w/o clearing appointment group data resetContexts() { this.cache.contexts = {} this.contexts.forEach(contextInfo => { this.cache.contexts[contextInfo.asset_string] = { events: {}, fetchedRanges: [], fetchedUndated: false, } }) } syncNewContexts(additionalContexts) { if (additionalContexts?.length > 0) { additionalContexts.forEach(additionalContext => { const context = this.contexts.find(c => c.asset_string === additionalContext.asset_string) if (!context) { this.contexts.push(additionalContext) } }) this.clearCache() } } removeCachedReservation(event) { const cached_ag = this.cache.appointmentGroups[event.appointment_group_id] if (cached_ag) { cached_ag.reserved_times = reject( cached_ag.reserved_times, reservation => reservation.id === event.id ) if (cached_ag.reserved_times.length === 0) { cached_ag.requiring_action = true } } } requiredDateRangeForContext(start, end, context) { let contextInfo, ranges if (!(contextInfo = this.cache.contexts[context])) return [start, end] if (!(ranges = contextInfo.fetchedRanges)) return [start, end] ranges.forEach(range => { if (range[0] <= start && start < range[1]) start = range[1] if (range[0] < end && end <= range[1]) end = range[0] }) return [start, end] } requiredDateRangeForContexts(start, end, contexts) { // We assume that we're not going to need anything from the cache - setting // the initial assumptions to the opposites of the requests is a fun way to // do that. let earliest = end let latest = start contexts.forEach(context => { const [s, e] = this.requiredDateRangeForContext(start, end, context) if (s < earliest) earliest = s if (e > latest) latest = e }) return [earliest, latest] } needUndatedEventsForContexts(contexts) { return contexts.some(context => !this.cache.contexts[context].fetchedUndated) } addEventToCache(event) { if (event.old_context_code) { delete this.cache.contexts[event.old_context_code]?.events[event.id] delete event.old_context_code } // Split by comma, for the odd case where #contextCode() returns a comma seprated list const possibleContexts = event.contextCode().split(',') const okayContexts = possibleContexts.filter(cCode => !!this.cache.contexts[cCode]) const contextCode = okayContexts[0] const contextInfo = this.cache.contexts[contextCode] contextInfo.events[event.id] = event } getEventsFromCacheForContext(start, end, context) { const contextInfo = this.cache.contexts[context] const events = [] for (const id in contextInfo.events) { const event = contextInfo.events[id] if (this.eventInRange(event, start, end)) { events.push(event) } } return events } eventInRange(event, start, end) { // Want dated, have dated. but when comparing to the range, remember // that we made start/end be unwrapped values (down in getEvents), so // unwrap event.originalStart/originalEndDate too before comparing. if (!event.originalStart && !start) { // want undated, have undated, include it return true } else if (!event.originalStart || !start) { // want undated, have dated (or vice versa), skip it return false } else if (event.originalStart && event.originalEndDate) { // Returns true if the event date range contains dates between start date and end date. return ( start <= fcUtil.unwrap(event.originalEndDate) && fcUtil.unwrap(event.originalStart) <= end ) } else { // Assignments, Planner Items or Planner notes don't have an end date const originalStart = fcUtil.unwrap(event.originalStart) return start <= originalStart && originalStart < end } } processNextRequest(inFlightCheckKey = 'default') { let i, id, len const ref = this.pendingRequests for (id = i = 0, len = ref.length; i < len; id = ++i) { const [method, args, key] = ref[id] if (key === inFlightCheckKey) { this.pendingRequests.splice(id, 1) method(...args) return } } } getEventsFromCache(start, end, contexts) { let events = [] for (let i = 0, len = contexts.length; i < len; i++) { const context = contexts[i] if (context.match(/^appointment_group_/)) { continue } events = events.concat(this.getEventsFromCacheForContext(start, end, context)) } return events } getAppointmentGroupsFromCache() { const results = [] for (const id in this.cache.appointmentGroups) { const group = this.cache.appointmentGroups[id] results.push(group) } return results } getAppointmentGroups(fetchManageable, cb) { if (this.inFlightRequest.appointmentGroups) { this.pendingRequests.push([this.getAppointmentGroups, arguments, 'appointmentGroups']) return } if ( this.cache.fetchedAppointmentGroups && this.cache.fetchedAppointmentGroups.manageable === fetchManageable ) { cb(this.getAppointmentGroupsFromCache()) this.processNextRequest('appointmentGroups') return } this.cache.fetchedAppointmentGroups = { manageable: fetchManageable, } this.cache.appointmentGroups = {} const dataCB = (data, url, params) => { let group, i, len, results if (data) { results = [] for (i = 0, len = data.length; i < len; i++) { group = data[i] if (params.scope === 'manageable') { group.is_manageable = true } else { group.is_scheduleable = true } results.push(this.processAppointmentData(group)) } return results } } const doneCB = () => cb(this.getAppointmentGroupsFromCache()) const fetchJobs = [ [ '/api/v1/appointment_groups', { include: ['reserved_times', 'participant_count'], }, ], ] if (fetchManageable) { fetchJobs.push([ '/api/v1/appointment_groups', { scope: 'manageable', include: ['reserved_times', 'participant_count'], include_past_appointments: true, }, ]) } return this.startFetch(fetchJobs, dataCB, doneCB, { inFlightCheckKey: 'appointmentGroups', }) } processAppointmentData(group) { const {id} = group if (this.cache.appointmentGroups[id] && this.cache.appointmentGroups[id].is_manageable) { group.is_manageable = true } else { group.is_scheduleable = true } this.cache.appointmentGroups[id] = group if (group.appointments) { group.appointmentEvents = [] group.appointments.forEach(eventData => { const event = commonEventFactory(eventData, this.contexts) if (event && event.object.workflow_state !== 'deleted') { group.appointmentEvents.push(event) this.addEventToCache(event) if (eventData.child_events) { event.childEvents = [] eventData.child_events.forEach(childEventData => { const childEvent = commonEventFactory(childEventData, this.contexts) this.addEventToCache(event) if (childEvent) event.childEvents.push(childEvent) }) } } }) } } getEventsForAppointmentGroup(group, cb) { if (this.inFlightRequest.default) { this.pendingRequests.push([this.getEventsForAppointmentGroup, arguments, 'default']) return } const cachedEvents = this.cache.appointmentGroups[group.id] && this.cache.appointmentGroups[group.id].appointmentEvents if (cachedEvents) { cb(cachedEvents) this.processNextRequest() return } const dataCB = data => { if (data) { return this.processAppointmentData(data) } } const params = { include: ['reserved_times', 'participant_count', 'appointments', 'child_events'], } return this.startFetch([[group.url, params]], dataCB, () => cb(this.cache.appointmentGroups[group.id].appointmentEvents) ) } getEvents(start, end, contexts, donecb, datacb, options = {}) { if (this.inFlightRequest.default) { this.pendingRequests.push([this.getEvents, arguments, 'default']) return } // start/end as they come from fullcalendar or AgendaView may be // ambiguously-timed and/or ambiguously-zoned. that's just way too much // confusion. instead, let's always works with unwrapped datetimes, so we // know we're interpreting times in the context of the profile timezone, // and particularly ambiguously-timed dates as midnight in the profile // timezone. if (start) start = fcUtil.unwrap(start) if (end) end = fcUtil.unwrap(end) const paramsForDatedEvents = (startDate, endDate, contextList) => { const [startDay, endDay] = this.requiredDateRangeForContexts(startDate, endDate, contextList) if (startDay >= endDay) { return null } return { // we treat end as an exclusive upper bound. the API treats it as // inclusive, so we may get back some events we didn't intend. but // addEventToCache handles the duplicate fine, so it's ok context_codes: contextList, start_date: startDay.toISOString(), end_date: endDay.toISOString(), } } const paramsForUndatedEvents = contextList => { if (!this.needUndatedEventsForContexts(contextList)) { return null } return { context_codes: contextList, undated: '1', } } const params = start ? paramsForDatedEvents(start, end, contexts) : paramsForUndatedEvents(contexts) if (!params) { // Yay, this request can be satisfied by the cache const list = this.getEventsFromCache(start, end, contexts) list.requestID = options.requestID if (datacb != null) datacb(list) donecb(list) this.processNextRequest() return } if ('per_page' in options) { params.per_page = options.per_page } const requestResults = {} const dataCB = (data, url, parameters) => { let key if (!data) return const newEvents = [] // planner_items and planner_notes are passing thru here too now // detect and add some missing fields the calendar code needs if (data.length && 'plannable' in data[0]) { data = this.transformPlannerItems(data) key = 'type_planner_item' } else if (data.length && 'todo_date' in data[0]) { data = this.fillOutPlannerNotes(data, url) key = 'type_planner_note' } else { key = `type_${parameters.type}` } const requestResult = requestResults[key] || { events: [], } requestResult.next = data.next data.forEach(e => { const event = commonEventFactory(e, this.contexts) if (event && event.object.workflow_state !== 'deleted') { if (event.blackout_date && event.end && event.start !== event.end) { // We need to add a day to the end of multiple day events on the calendar because fullcalendar // treats event end dates as exclusive while Canvas blackout date calculations treat them as // inclusive. event.end = moment(event.end.toISOString()).add(1, 'days') } newEvents.push(event) requestResult.events.push(event) } }) newEvents.requestID = options.requestID if (datacb != null) datacb(newEvents) return (requestResults[key] = requestResult) } const doneCB = () => { let nextPageDate // TODO: there's a rare problem in this implementation. if a full page // or more of events have the same start time, then the first time one // or more show up in a response, that date will be the nextPageDate. as // such, all events for that date will be excluded. but then on the // followup, the nextPageDate will _still_ be that date, and zero events // will be included. it will then loop indefinitely in this state. // If any request had a next page, the combined results are valid // only through the earliest page end date. note that it's an exclusive // upper bound, just as we treated end earlier. (this is so that it can // be an inclusive lower bound on the next request) const rendered = new Set() const upperBounds = [] for (const key in requestResults) { const requestResult = requestResults[key] const dates = [] requestResult.events.forEach(event => { this.addEventToCache(event) rendered.add(event.id) if (requestResult.next && event.originalStart) { dates.push(event.originalStart) } }) if (!isEmpty(dates)) { upperBounds.push(maxBy(dates)) } } if (!isEmpty(upperBounds)) { nextPageDate = fcUtil.clone(minBy(upperBounds)) end = fcUtil.unwrap(nextPageDate) } contexts.forEach(context => { let contextInfo = this.cache.contexts[context] if (!contextInfo) { contextInfo = this.cache.contexts[context] = { fetchedRanges: [], } } if (contextInfo) { if (start) { contextInfo.fetchedRanges.push([start, end]) } else { contextInfo.fetchedUndated = true } } }) const list = this.getEventsFromCache(start, end, contexts) if (datacb != null && list.length > 0) { const renderFromCache = list.filter(x => !rendered.has(x.id)) if (renderFromCache.length > 0) { datacb(renderFromCache) } } list.nextPageDate = nextPageDate list.requestID = options.requestID return donecb(list) } const eventDataSources = [['/api/v1/calendar_events', this.indexParams(params)]] params.context_codes = params.context_codes.filter( context => !context.match(/^appointment_group_/) ) eventDataSources.push(['/api/v1/calendar_events', this.assignmentParams(params)]) if (ENV.STUDENT_PLANNER_ENABLED) { eventDataSources.push(['/api/v1/planner_notes', params]) } const [admin_contexts, student_contexts] = partition( params.context_codes, cc => ENV.CALENDAR?.MANAGE_CONTEXTS?.indexOf(cc) >= 0 ) if (student_contexts.length) { const pparams = {filter: 'ungraded_todo_items', ...params, context_codes: student_contexts} eventDataSources.push(['/api/v1/planner/items', pparams]) } if (admin_contexts.length) { const pparams = { filter: 'all_ungraded_todo_items', ...params, context_codes: admin_contexts, } eventDataSources.push(['/api/v1/planner/items', pparams]) } return this.startFetch(eventDataSources, dataCB, doneCB, options) } // rewrite `context_codes[]=appointment_group_X&context_codes[]=appointment_group_Y` // as `appointment_group_ids=X,Y` to reduce Link header size in the HTTP response // (which Apache limits to 8K) indexParams(params) { const ag_ids = [] const context_codes = [] params.context_codes.forEach(context_code => { const match = context_code.match(/^appointment_group_(\d+)$/) if (match && match.length === 2) { ag_ids.push(match[1]) } else { context_codes.push(context_code) } }) const p = {...params, context_codes} if (ag_ids.length > 0) { p.appointment_group_ids = ag_ids.join(',') } p.include = ['web_conference', 'series_head', 'series_natural_language'] return p } assignmentParams(params) { // We only want to see assignments from courses that do not use Course Pacing, unless the // user is a student in the course. In that case, they should see their assignments on the calendar if (ENV.CALENDAR?.CONTEXTS) { params.context_codes = ENV.CALENDAR.CONTEXTS.filter( context => params.context_codes.includes(context.asset_string) && (!context.course_pacing_enabled || context.user_is_student) ).map(context => context.asset_string) } return {type: 'assignment', ...params} } getParticipants(appointmentGroup, registrationStatus, cb) { if (this.inFlightRequest.default) { this.pendingRequests.push([this.getParticipants, arguments, 'default']) return } const key = `${appointmentGroup.id}_${registrationStatus}` if (this.cache.participants[key]) { cb(this.cache.participants[key]) this.processNextRequest() return } this.cache.participants[key] = [] const dataCB = data => { if (data) { return this.cache.participants[key].push(...data) } } const doneCB = () => cb(this.cache.participants[key]) const type = appointmentGroup.participant_type === 'Group' ? 'groups' : 'users' return this.startFetch( [ [ `/api/v1/appointment_groups/${appointmentGroup.id}/${type}`, { registration_status: registrationStatus, }, ], ], dataCB, doneCB ) } // Starts a paginated fetch of the url/param combinations in the array. This makes // situations where you need to do paginated fetches of data from N different endpoints // a little simpler. dataCB(data, url, params) is called on every request with the data, // and completionCB is called when all fetches have completed. startFetch(urlAndParamsArray, dataCB, doneCB, options = {}) { let numCompleted = 0 const inFlightCheckKey = options.inFlightCheckKey || 'default' this.inFlightRequest[inFlightCheckKey] = true const wrapperCB = (data, isDone, url, params) => { dataCB(data, url, params) if (isDone) { numCompleted += 1 if (numCompleted >= urlAndParamsArray.length) { doneCB() this.inFlightRequest[inFlightCheckKey] = false return this.processNextRequest(inFlightCheckKey) } } } const results = [] for (let i = 0, len = urlAndParamsArray.length; i < len; i++) { const urlAndParams = urlAndParamsArray[i] results.push( (urlAndParameters => this.fetchNextBatch( urlAndParameters[0], urlAndParameters[1], (data, isDone) => wrapperCB(data, isDone, urlAndParameters[0], urlAndParameters[1]), options ))(urlAndParams) ) } return results } // Will fetch the URL with the given params, and if the response includes a Link // header, will fetch that link too (with the same params). At the end of every // request it will call cb(data, isDone). isDone will be true on the last request. fetchNextBatch(url, params, cb, options = {}) { const parseLinkHeader = function (header) { if (!header) { // TODO: Write a real Link header parser. This will only work with what we output, // and might be fragile. return null } const rels = {} const ref = header.split(',') for (let i = 0, len = ref.length; i < len; i++) { const component = ref[i] let [link, rel] = component.split(';') link = link.replace(/^</, '').replace(/>$/, '') rel = rel.split('"')[1] rels[rel] = link } return rels } $.publish('EventDataSource/ajaxStarted') if (!(url.match(/per_page=/) || params.per_page != null)) { params.per_page = 50 } return $.ajaxJSON(url, 'GET', params, (data, xhr) => { $.publish('EventDataSource/ajaxEnded') const linkHeader = typeof xhr.getResponseHeader === 'function' ? xhr.getResponseHeader('Link') : undefined const rels = parseLinkHeader(linkHeader) data.next = rels != null ? rels.next : undefined if (rels && rels.next && !options.singlePage) { cb(data, false) this.fetchNextBatch(rels.next, {}, cb) return } return cb(data, true) }) } // Planner notes are getting pulled from the planner_notes api // Add some necessary fields so they can be processed just like a calendar event fillOutPlannerNotes(notes) { notes.forEach(note => { note.type = 'planner_note' note.context_code = note.course_id ? `course_${note.course_id}` : `user_${note.user_id}` note.all_context_codes = note.context_code }) return notes } // make planner items readable as calendar events transformPlannerItems(items) { items.forEach(item => { item.type = 'todo_item' if (item.course_id) { item.context_code = `course_${item.course_id}` } else if (item.group_id) { item.context_code = `group_${item.group_id}` } else { item.context_code = `user_${item.user_id}` } item.all_context_codes = item.context_code }) return items } }