556 lines
20 KiB
TypeScript
Raw Normal View History

// (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 } 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 { CoreNetwork } from '@services/network';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreGradesFormattedItem, 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 componentTranslatableString = 'assign';
constructor() {
super('AddonModAssignSyncProvider');
}
/**
* 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.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.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(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.getCurrentSiteId();
const currentSyncPromise = this.getOngoingSync(assignId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this assign, return the promise.
return currentSyncPromise;
}
// Verify that assign isn't blocked.
if (CoreSync.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) {
this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.');
throw new CoreSyncBlockedError(Translate.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.
* @return Promise resolved in success.
*/
protected async performSyncAssign(assignId: number, siteId: string): Promise<AddonModAssignSyncResult> {
// Sync offline logs.
await CoreUtils.ignoreErrors(
CoreCourseLogHelper.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.ignoreErrors(this.setSyncTime(assignId, siteId));
return result;
}
if (!CoreNetwork.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid;
const assign = await AddonModAssign.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.allPromises(promises);
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
await CoreUtils.ignoreErrors(AddonModAssign.invalidateContent(assign.cmid, courseId, siteId));
}
// Sync finished, set sync time.
await CoreUtils.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.ignoreErrors(AddonModAssignOffline.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.ignoreErrors(AddonModAssignOffline.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.ONLY_NETWORK,
siteId,
};
const status = await AddonModAssign.getSubmissionStatus(assign.id, options);
const submission = AddonModAssign.getSubmissionObjectFromAttempt(assign, status.lastattempt);
if (submission && submission.timemodified != offlineData.onlinetimemodified) {
// The submission was modified in Moodle, discard the submission.
this.addOfflineDataDeletedWarning(
warnings,
assign.name,
Translate.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.preparePluginSyncData(
assign,
submission,
plugin,
offlineData,
pluginData,
siteId,
)));
}
// Now save the submission.
if (Object.keys(pluginData).length > 0) {
await AddonModAssign.saveSubmissionOnline(assign.id, pluginData, siteId);
}
if (assign.submissiondrafts && offlineData.submitted) {
// The user submitted the assign manually. Submit it for grading.
await AddonModAssign.submitForGradingOnline(assign.id, !!offlineData.submissionstatement, siteId);
}
// Submission data sent, update cached data. No need to block the user for this.
AddonModAssign.getSubmissionStatus(assign.id, options);
} catch (error) {
if (!error || !CoreUtils.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, assign.name, 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.deleteSubmission(assign.id, offlineData.userid, siteId);
if (submission?.plugins){
// Delete plugins data.
await Promise.all(submission.plugins.map((plugin) =>
AddonModAssignSubmissionDelegate.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.ONLY_NETWORK,
siteId,
};
// Check if this grade sync is blocked.
if (CoreSync.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.instant(
'core.errorsyncblocked',
{ $a: Translate.instant('addon.mod_assign.syncblockedusercomponent') },
));
}
const status = await AddonModAssign.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,
assign.name,
Translate.instant('addon.mod_assign.warningsubmissiongrademodified'),
);
return AddonModAssignOffline.deleteSubmissionGrade(assign.id, userId, siteId);
}
// If grade has been modified from gradebook, do not use offline.
const grades = await CoreGradesHelper.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true);
const gradeInfo = await CoreCourse.getModuleBasicGradeInfo(assign.cmid, siteId);
// Override offline grade and outcomes based on the gradebook data.
grades.forEach((grade: CoreGradesFormattedItem) => {
if ((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 && 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.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.discardPluginFeedbackData(assign.id, userId, plugin, siteId));
}
// Update cached data.
promises.push(AddonModAssign.getSubmissionStatus(assign.id, options));
await CoreUtils.allPromises(promises);
} catch (error) {
if (!error || !CoreUtils.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, assign.name, error);
}
// Delete the offline data.
await AddonModAssignOffline.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 = {
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;
};