573 lines
22 KiB
TypeScript
573 lines
22 KiB
TypeScript
// (C) Copyright 2015 Moodle Pty Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import { Injectable } from '@angular/core';
|
|
import { CoreEvents, CoreEventSiteData } from '@singletons/events';
|
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
|
import { CoreSyncBlockedError } from '@classes/base-sync';
|
|
import {
|
|
AddonModAssignProvider,
|
|
AddonModAssignAssign,
|
|
AddonModAssignSubmission,
|
|
AddonModAssign,
|
|
AddonModAssignGetSubmissionStatusWSResponse,
|
|
AddonModAssignSubmissionStatusOptions,
|
|
} from './assign';
|
|
import { makeSingleton, Translate } from '@singletons';
|
|
import { CoreCourse } from '@features/course/services/course';
|
|
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
|
|
import {
|
|
AddonModAssignOffline,
|
|
AddonModAssignSubmissionsDBRecordFormatted,
|
|
AddonModAssignSubmissionsGradingDBRecordFormatted,
|
|
} from './assign-offline';
|
|
import { CoreSync } from '@services/sync';
|
|
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
import { CoreApp } from '@services/app';
|
|
import { CoreTextUtils } from '@services/utils/text';
|
|
import { CoreNetworkError } from '@classes/errors/network-error';
|
|
import { CoreGradesFormattedItem, CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper';
|
|
import { AddonModAssignSubmissionDelegate } from './submission-delegate';
|
|
import { AddonModAssignFeedbackDelegate } from './feedback-delegate';
|
|
|
|
/**
|
|
* Service to sync assigns.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class AddonModAssignSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModAssignSyncResult> {
|
|
|
|
static readonly AUTO_SYNCED = 'addon_mod_assign_autom_synced';
|
|
static readonly MANUAL_SYNCED = 'addon_mod_assign_manual_synced';
|
|
|
|
protected componentTranslate: string;
|
|
|
|
constructor() {
|
|
super('AddonModLessonSyncProvider');
|
|
this.componentTranslate = CoreCourse.instance.translateModuleName('assign');
|
|
}
|
|
|
|
/**
|
|
* Get the sync ID for a certain user grade.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param userId User the grade belongs to.
|
|
* @return Sync ID.
|
|
*/
|
|
getGradeSyncId(assignId: number, userId: number): string {
|
|
return 'assignGrade#' + assignId + '#' + userId;
|
|
}
|
|
|
|
/**
|
|
* Convenience function to get scale selected option.
|
|
*
|
|
* @param options Possible options.
|
|
* @param selected Selected option to search.
|
|
* @return Index of the selected option.
|
|
*/
|
|
protected getSelectedScaleId(options: string, selected: string): number {
|
|
let optionsList = options.split(',');
|
|
|
|
optionsList = optionsList.map((value) => value.trim());
|
|
|
|
optionsList.unshift('');
|
|
|
|
const index = options.indexOf(selected) || 0;
|
|
if (index < 0) {
|
|
return 0;
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Check if an assignment has data to synchronize.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved with boolean: whether it has data to sync.
|
|
*/
|
|
hasDataToSync(assignId: number, siteId?: string): Promise<boolean> {
|
|
return AddonModAssignOffline.instance.hasAssignOfflineData(assignId, siteId);
|
|
}
|
|
|
|
/**
|
|
* Try to synchronize all the assignments in a certain site or in all sites.
|
|
*
|
|
* @param siteId Site ID to sync. If not defined, sync all sites.
|
|
* @param force Wether to force sync not depending on last execution.
|
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
|
*/
|
|
syncAllAssignments(siteId?: string, force?: boolean): Promise<void> {
|
|
return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this, !!force), siteId);
|
|
}
|
|
|
|
/**
|
|
* Sync all assignments on a site.
|
|
*
|
|
* @param force Wether to force sync not depending on last execution.
|
|
* @param siteId Site ID to sync. If not defined, sync all sites.
|
|
* @param Promise resolved if sync is successful, rejected if sync fails.
|
|
*/
|
|
protected async syncAllAssignmentsFunc(force: boolean, siteId: string): Promise<void> {
|
|
// Get all assignments that have offline data.
|
|
const assignIds = await AddonModAssignOffline.instance.getAllAssigns(siteId);
|
|
|
|
// Try to sync all assignments.
|
|
await Promise.all(assignIds.map(async (assignId) => {
|
|
const result = force
|
|
? await this.syncAssign(assignId, siteId)
|
|
: await this.syncAssignIfNeeded(assignId, siteId);
|
|
|
|
if (result?.updated) {
|
|
CoreEvents.trigger<AddonModAssignAutoSyncData>(AddonModAssignSyncProvider.AUTO_SYNCED, {
|
|
assignId: assignId,
|
|
warnings: result.warnings,
|
|
gradesBlocked: result.gradesBlocked,
|
|
}, siteId);
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Sync an assignment only if a certain time has passed since the last time.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved when the assign is synced or it doesn't need to be synced.
|
|
*/
|
|
async syncAssignIfNeeded(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult | undefined> {
|
|
const needed = await this.isSyncNeeded(assignId, siteId);
|
|
|
|
if (needed) {
|
|
return this.syncAssign(assignId, siteId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to synchronize an assign.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved in success.
|
|
*/
|
|
async syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
|
|
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
|
this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('assign');
|
|
|
|
if (this.isSyncing(assignId, siteId)) {
|
|
// There's already a sync ongoing for this assign, return the promise.
|
|
return this.getOngoingSync(assignId, siteId)!;
|
|
}
|
|
|
|
// Verify that assign isn't blocked.
|
|
if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) {
|
|
this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.');
|
|
|
|
throw new CoreSyncBlockedError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
|
}
|
|
|
|
|
|
this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId);
|
|
|
|
const syncPromise = this.performSyncAssign(assignId, siteId);
|
|
|
|
return this.addOngoingSync(assignId, syncPromise, siteId);
|
|
}
|
|
|
|
/**
|
|
* Perform the assign submission.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved in success.
|
|
*/
|
|
protected async performSyncAssign(assignId: number, siteId: string): Promise<AddonModAssignSyncResult> {
|
|
// Sync offline logs.
|
|
await CoreUtils.instance.ignoreErrors(
|
|
CoreCourseLogHelper.instance.syncActivity(AddonModAssignProvider.COMPONENT, assignId, siteId),
|
|
);
|
|
|
|
const result: AddonModAssignSyncResult = {
|
|
warnings: [],
|
|
updated: false,
|
|
gradesBlocked: [],
|
|
};
|
|
|
|
// Load offline data and sync offline logs.
|
|
const [submissions, grades] = await Promise.all([
|
|
this.getOfflineSubmissions(assignId, siteId),
|
|
this.getOfflineGrades(assignId, siteId),
|
|
]);
|
|
|
|
if (!submissions.length && !grades.length) {
|
|
// Nothing to sync.
|
|
await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId));
|
|
|
|
return result;
|
|
}
|
|
|
|
if (!CoreApp.instance.isOnline()) {
|
|
// Cannot sync in offline.
|
|
throw new CoreNetworkError();
|
|
}
|
|
|
|
const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid;
|
|
|
|
const assign = await AddonModAssign.instance.getAssignmentById(courseId, assignId, { siteId });
|
|
|
|
let promises: Promise<void>[] = [];
|
|
|
|
promises = promises.concat(submissions.map(async (submission) => {
|
|
await this.syncSubmission(assign, submission, result.warnings, siteId);
|
|
|
|
result.updated = true;
|
|
|
|
return;
|
|
}));
|
|
|
|
promises = promises.concat(grades.map(async (grade) => {
|
|
try {
|
|
await this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId);
|
|
|
|
result.updated = true;
|
|
} catch (error) {
|
|
if (error instanceof CoreSyncBlockedError) {
|
|
// Grade blocked, but allow finish the sync.
|
|
result.gradesBlocked.push(grade.userid);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}));
|
|
|
|
await CoreUtils.instance.allPromises(promises);
|
|
|
|
if (result.updated) {
|
|
// Data has been sent to server. Now invalidate the WS calls.
|
|
await CoreUtils.instance.ignoreErrors(AddonModAssign.instance.invalidateContent(assign.cmid, courseId, siteId));
|
|
}
|
|
|
|
// Sync finished, set sync time.
|
|
await CoreUtils.instance.ignoreErrors(this.setSyncTime(assignId, siteId));
|
|
|
|
// All done, return the result.
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get offline grades to be sent.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise with grades.
|
|
*/
|
|
protected async getOfflineGrades(
|
|
assignId: number,
|
|
siteId: string,
|
|
): Promise<AddonModAssignSubmissionsGradingDBRecordFormatted[]> {
|
|
// If no offline data found, return empty array.
|
|
return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissionsGrade(assignId, siteId), []);
|
|
}
|
|
|
|
/**
|
|
* Get offline submissions to be sent.
|
|
*
|
|
* @param assignId Assign ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise with submissions.
|
|
*/
|
|
protected async getOfflineSubmissions(
|
|
assignId: number,
|
|
siteId: string,
|
|
): Promise<AddonModAssignSubmissionsDBRecordFormatted[]> {
|
|
// If no offline data found, return empty array.
|
|
return CoreUtils.instance.ignoreErrors(AddonModAssignOffline.instance.getAssignSubmissions(assignId, siteId), []);
|
|
}
|
|
|
|
/**
|
|
* Synchronize a submission.
|
|
*
|
|
* @param assign Assignment.
|
|
* @param offlineData Submission offline data.
|
|
* @param warnings List of warnings.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved if success, rejected otherwise.
|
|
*/
|
|
protected async syncSubmission(
|
|
assign: AddonModAssignAssign,
|
|
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
|
|
warnings: string[],
|
|
siteId: string,
|
|
): Promise<void> {
|
|
|
|
const userId = offlineData.userid;
|
|
const pluginData = {};
|
|
const options: AddonModAssignSubmissionStatusOptions = {
|
|
userId,
|
|
cmId: assign.cmid,
|
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
|
siteId,
|
|
};
|
|
|
|
const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options);
|
|
|
|
const submission = AddonModAssign.instance.getSubmissionObjectFromAttempt(assign, status.lastattempt);
|
|
|
|
if (submission && submission.timemodified != offlineData.onlinetimemodified) {
|
|
// The submission was modified in Moodle, discard the submission.
|
|
this.addOfflineDataDeletedWarning(
|
|
warnings,
|
|
this.componentTranslate,
|
|
assign.name,
|
|
Translate.instance.instant('addon.mod_assign.warningsubmissionmodified'),
|
|
);
|
|
|
|
return this.deleteSubmissionData(assign, offlineData, submission, siteId);
|
|
}
|
|
|
|
try {
|
|
if (submission?.plugins) {
|
|
// Prepare plugins data.
|
|
await Promise.all(submission.plugins.map((plugin) =>
|
|
AddonModAssignSubmissionDelegate.instance.preparePluginSyncData(
|
|
assign,
|
|
submission,
|
|
plugin,
|
|
offlineData,
|
|
pluginData,
|
|
siteId,
|
|
)));
|
|
}
|
|
|
|
// Now save the submission.
|
|
if (Object.keys(pluginData).length > 0) {
|
|
await AddonModAssign.instance.saveSubmissionOnline(assign.id, pluginData, siteId);
|
|
}
|
|
|
|
if (assign.submissiondrafts && offlineData.submitted) {
|
|
// The user submitted the assign manually. Submit it for grading.
|
|
await AddonModAssign.instance.submitForGradingOnline(assign.id, !!offlineData.submissionstatement, siteId);
|
|
}
|
|
|
|
// Submission data sent, update cached data. No need to block the user for this.
|
|
AddonModAssign.instance.getSubmissionStatus(assign.id, options);
|
|
} catch (error) {
|
|
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
|
|
// Local error, reject.
|
|
throw error;
|
|
}
|
|
|
|
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
|
|
this.addOfflineDataDeletedWarning(
|
|
warnings,
|
|
this.componentTranslate,
|
|
assign.name,
|
|
CoreTextUtils.instance.getErrorMessageFromError(error) || '',
|
|
);
|
|
}
|
|
|
|
// Delete the offline data.
|
|
await this.deleteSubmissionData(assign, offlineData, submission, siteId);
|
|
}
|
|
|
|
/**
|
|
* Delete the submission offline data (not grades).
|
|
*
|
|
* @param assign Assign.
|
|
* @param submission Submission.
|
|
* @param offlineData Offline data.
|
|
* @param siteId Site ID.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async deleteSubmissionData(
|
|
assign: AddonModAssignAssign,
|
|
offlineData: AddonModAssignSubmissionsDBRecordFormatted,
|
|
submission?: AddonModAssignSubmission,
|
|
siteId?: string,
|
|
): Promise<void> {
|
|
|
|
// Delete the offline data.
|
|
await AddonModAssignOffline.instance.deleteSubmission(assign.id, offlineData.userid, siteId);
|
|
|
|
if (submission?.plugins){
|
|
// Delete plugins data.
|
|
await Promise.all(submission.plugins.map((plugin) =>
|
|
AddonModAssignSubmissionDelegate.instance.deletePluginOfflineData(
|
|
assign,
|
|
submission,
|
|
plugin,
|
|
offlineData,
|
|
siteId,
|
|
)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronize a submission grade.
|
|
*
|
|
* @param assign Assignment.
|
|
* @param offlineData Submission grade offline data.
|
|
* @param warnings List of warnings.
|
|
* @param courseId Course Id.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @return Promise resolved if success, rejected otherwise.
|
|
*/
|
|
protected async syncSubmissionGrade(
|
|
assign: AddonModAssignAssign,
|
|
offlineData: AddonModAssignSubmissionsGradingDBRecordFormatted,
|
|
warnings: string[],
|
|
courseId: number,
|
|
siteId: string,
|
|
): Promise<void> {
|
|
|
|
const userId = offlineData.userid;
|
|
const syncId = this.getGradeSyncId(assign.id, userId);
|
|
const options: AddonModAssignSubmissionStatusOptions = {
|
|
userId,
|
|
cmId: assign.cmid,
|
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
|
siteId,
|
|
};
|
|
|
|
// Check if this grade sync is blocked.
|
|
if (CoreSync.instance.isBlocked(AddonModAssignProvider.COMPONENT, syncId, siteId)) {
|
|
this.logger.error(`Cannot sync grade for assign ${assign.id} and user ${userId} because it is blocked.!!!!`);
|
|
|
|
throw new CoreSyncBlockedError(Translate.instance.instant(
|
|
'core.errorsyncblocked',
|
|
{ $a: Translate.instance.instant('addon.mod_assign.syncblockedusercomponent') },
|
|
));
|
|
}
|
|
|
|
const status = await AddonModAssign.instance.getSubmissionStatus(assign.id, options);
|
|
|
|
const timemodified = (status.feedback && (status.feedback.gradeddate || status.feedback.grade?.timemodified)) || 0;
|
|
|
|
if (timemodified > offlineData.timemodified) {
|
|
// The submission grade was modified in Moodle, discard it.
|
|
this.addOfflineDataDeletedWarning(
|
|
warnings,
|
|
this.componentTranslate,
|
|
assign.name,
|
|
Translate.instance.instant('addon.mod_assign.warningsubmissiongrademodified'),
|
|
);
|
|
|
|
return AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId);
|
|
}
|
|
|
|
// If grade has been modified from gradebook, do not use offline.
|
|
const grades: CoreGradesFormattedItem[] | CoreGradesFormattedRow[] =
|
|
await CoreGradesHelper.instance.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true);
|
|
|
|
const gradeInfo = await CoreCourse.instance.getModuleBasicGradeInfo(assign.cmid, siteId);
|
|
|
|
// Override offline grade and outcomes based on the gradebook data.
|
|
grades.forEach((grade: CoreGradesFormattedItem | CoreGradesFormattedRow) => {
|
|
if ('gradedategraded' in grade && (grade.gradedategraded || 0) >= offlineData.timemodified) {
|
|
if (!grade.outcomeid && !grade.scaleid) {
|
|
if (gradeInfo && gradeInfo.scale) {
|
|
offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.grade || '');
|
|
} else {
|
|
offlineData.grade = parseFloat(grade.grade || '');
|
|
}
|
|
} else if (gradeInfo && grade.outcomeid && AddonModAssign.instance.isOutcomesEditEnabled() && gradeInfo.outcomes) {
|
|
gradeInfo.outcomes.forEach((outcome, index) => {
|
|
if (outcome.scale && grade.itemnumber == index) {
|
|
offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId(
|
|
outcome.scale,
|
|
grade.grade || '',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
// Now submit the grade.
|
|
await AddonModAssign.instance.submitGradingFormOnline(
|
|
assign.id,
|
|
userId,
|
|
offlineData.grade,
|
|
offlineData.attemptnumber,
|
|
!!offlineData.addattempt,
|
|
offlineData.workflowstate,
|
|
!!offlineData.applytoall,
|
|
offlineData.outcomes,
|
|
offlineData.plugindata,
|
|
siteId,
|
|
);
|
|
|
|
// Grades sent. Discard grades drafts.
|
|
let promises: Promise<void | AddonModAssignGetSubmissionStatusWSResponse>[] = [];
|
|
if (status.feedback && status.feedback.plugins) {
|
|
promises = status.feedback.plugins.map((plugin) =>
|
|
AddonModAssignFeedbackDelegate.instance.discardPluginFeedbackData(assign.id, userId, plugin, siteId));
|
|
}
|
|
|
|
// Update cached data.
|
|
promises.push(AddonModAssign.instance.getSubmissionStatus(assign.id, options));
|
|
|
|
await CoreUtils.instance.allPromises(promises);
|
|
} catch (error) {
|
|
if (!error || !CoreUtils.instance.isWebServiceError(error)) {
|
|
// Local error, reject.
|
|
throw error;
|
|
}
|
|
|
|
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
|
|
this.addOfflineDataDeletedWarning(
|
|
warnings,
|
|
this.componentTranslate,
|
|
assign.name,
|
|
CoreTextUtils.instance.getErrorMessageFromError(error) || '',
|
|
);
|
|
}
|
|
|
|
// Delete the offline data.
|
|
await AddonModAssignOffline.instance.deleteSubmissionGrade(assign.id, userId, siteId);
|
|
}
|
|
|
|
}
|
|
export const AddonModAssignSync = makeSingleton(AddonModAssignSyncProvider);
|
|
|
|
/**
|
|
* Data returned by a assign sync.
|
|
*/
|
|
export type AddonModAssignSyncResult = {
|
|
warnings: string[]; // List of warnings.
|
|
updated: boolean; // Whether some data was sent to the server or offline data was updated.
|
|
courseId?: number; // Course the assign belongs to (if known).
|
|
gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade.
|
|
};
|
|
|
|
|
|
/**
|
|
* Data passed to AUTO_SYNCED event.
|
|
*/
|
|
export type AddonModAssignAutoSyncData = CoreEventSiteData & {
|
|
assignId: number;
|
|
warnings: string[];
|
|
gradesBlocked: number[]; // Whether some grade couldn't be synced because it was blocked. UserId fields of the blocked grade.
|
|
};
|
|
|
|
/**
|
|
* Data passed to MANUAL_SYNCED event.
|
|
*/
|
|
export type AddonModAssignManualSyncData = AddonModAssignAutoSyncData & {
|
|
context: string;
|
|
submitId?: number;
|
|
};
|