From e9f7bd0bc5c43317b72ed0f9dcfac864d5d2d88a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 9 Apr 2018 12:07:29 +0200 Subject: [PATCH 01/16] MOBILE-2334 assign: Implement feedback and submission delegates --- src/addon/mod/assign/assign.module.ts | 31 ++ .../providers/default-feedback-handler.ts | 106 +++++ .../providers/default-submission-handler.ts | 130 ++++++ .../mod/assign/providers/feedback-delegate.ts | 314 ++++++++++++++ .../assign/providers/submission-delegate.ts | 405 ++++++++++++++++++ src/app/app.module.ts | 2 + 6 files changed, 988 insertions(+) create mode 100644 src/addon/mod/assign/assign.module.ts create mode 100644 src/addon/mod/assign/providers/default-feedback-handler.ts create mode 100644 src/addon/mod/assign/providers/default-submission-handler.ts create mode 100644 src/addon/mod/assign/providers/feedback-delegate.ts create mode 100644 src/addon/mod/assign/providers/submission-delegate.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts new file mode 100644 index 000000000..798a6bf5e --- /dev/null +++ b/src/addon/mod/assign/assign.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; +import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; +import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; +import { AddonModAssignDefaultSubmissionHandler } from './providers/default-submission-handler'; + +@NgModule({ + declarations: [ + ], + providers: [ + AddonModAssignFeedbackDelegate, + AddonModAssignSubmissionDelegate, + AddonModAssignDefaultFeedbackHandler, + AddonModAssignDefaultSubmissionHandler + ] +}) +export class AddonModAssignModule { } diff --git a/src/addon/mod/assign/providers/default-feedback-handler.ts b/src/addon/mod/assign/providers/default-feedback-handler.ts new file mode 100644 index 000000000..021bdb564 --- /dev/null +++ b/src/addon/mod/assign/providers/default-feedback-handler.ts @@ -0,0 +1,106 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { AddonModAssignFeedbackHandler } from './feedback-delegate'; + +/** + * Default handler used when a feedback plugin doesn't have a specific implementation. + */ +@Injectable() +export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedbackHandler { + name = 'AddonModAssignDefaultFeedbackHandler'; + type = 'default'; + + constructor(private translate: TranslateService) { } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return []; + } + + /** + * Get a readable name to use for the plugin. + * + * @param {any} plugin The plugin object. + * @return {string} The plugin name. + */ + getPluginName(plugin: any): string { + // Check if there's a translated string for the plugin. + const translationId = 'addon.mod_assign_feedback_' + plugin.type + '.pluginname', + translation = this.translate.instant(translationId); + + if (translationId != translation) { + // Translation found, use it. + return translation; + } + + // Fallback to WS string. + if (plugin.name) { + return plugin.name; + } + } + + /** + * Check if the feedback data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the feedback. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(assign: any, submission: any, plugin: any, inputData: any): boolean | Promise { + return false; + } + + /** + * Check whether the plugin has draft data stored. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData(assignId: number, userId: number, siteId?: string): boolean | Promise { + return false; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + return false; + } +} diff --git a/src/addon/mod/assign/providers/default-submission-handler.ts b/src/addon/mod/assign/providers/default-submission-handler.ts new file mode 100644 index 000000000..e473ad898 --- /dev/null +++ b/src/addon/mod/assign/providers/default-submission-handler.ts @@ -0,0 +1,130 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { AddonModAssignSubmissionHandler } from './submission-delegate'; + +/** + * Default handler used when a submission plugin doesn't have a specific implementation. + */ +@Injectable() +export class AddonModAssignDefaultSubmissionHandler implements AddonModAssignSubmissionHandler { + name = 'AddonModAssignDefaultSubmissionHandler'; + type = 'default'; + + constructor(private translate: TranslateService) { } + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(assign: any, submission: any, plugin: any): boolean | Promise { + return false; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return []; + } + + /** + * Get a readable name to use for the plugin. + * + * @param {any} plugin The plugin object. + * @return {string} The plugin name. + */ + getPluginName(plugin: any): string { + // Check if there's a translated string for the plugin. + const translationId = 'addon.mod_assign_submission_' + plugin.type + '.pluginname', + translation = this.translate.instant(translationId); + + if (translationId != translation) { + // Translation found, use it. + return translation; + } + + // Fallback to WS string. + if (plugin.name) { + return plugin.name; + } + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForCopy(assign: any, plugin: any): number | Promise { + return 0; + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForEdit(assign: any, plugin: any): number | Promise { + return 0; + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(assign: any, submission: any, plugin: any, inputData: any): boolean | Promise { + return false; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + return false; + } +} diff --git a/src/addon/mod/assign/providers/feedback-delegate.ts b/src/addon/mod/assign/providers/feedback-delegate.ts new file mode 100644 index 000000000..38f09e6ec --- /dev/null +++ b/src/addon/mod/assign/providers/feedback-delegate.ts @@ -0,0 +1,314 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModAssignDefaultFeedbackHandler } from './default-feedback-handler'; + +/** + * Interface that all feedback handlers must implement. + */ +export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { + + /** + * Name of the type of feedback the handler supports. E.g. 'file'. + * @type {string} + */ + type: string; + + /** + * Discard the draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + discardDraft?(assignId: number, userId: number, siteId?: string): void | Promise; + + /** + * Return the Component to use to display the plugin data. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?(injector: Injector, plugin: any): any | Promise; + + /** + * Return the draft saved data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any|Promise} Data (or promise resolved with the data). + */ + getDraft?(assignId: number, userId: number, siteId?: string): any | Promise; + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles?(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise; + + /** + * Get a readable name to use for the plugin. + * + * @param {any} plugin The plugin object. + * @return {string} The plugin name. + */ + getPluginName?(plugin: any): string; + + /** + * Check if the feedback data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the feedback. + * @param {number} userId User ID of the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged?(assign: any, submission: any, plugin: any, inputData: any, userId: number): boolean | Promise; + + /** + * Check whether the plugin has draft data stored. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData?(assignId: number, userId: number, siteId?: string): boolean | Promise; + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit?(): boolean | Promise; + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetch?(assign: any, submission: any, plugin: any, siteId?: string): Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the draft data saved. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareFeedbackData?(assignId: number, userId: number, plugin: any, pluginData: any, siteId?: string): void | Promise; + + /** + * Save draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} data The data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + saveDraft?(assignId: number, userId: number, plugin: any, data: any, siteId?: string): void | Promise; +} + +/** + * Delegate to register plugins for assign feedback. + */ +@Injectable() +export class AddonModAssignFeedbackDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected defaultHandler: AddonModAssignDefaultFeedbackHandler) { + super('AddonModAssignFeedbackDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Discard the draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + discardPluginFeedbackData(assignId: number, userId: number, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'discardDraft', [assignId, userId, siteId])); + } + + /** + * Get the component to use for a certain feedback plugin. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @return {Promise} Promise resolved with the component to use, undefined if not found. + */ + getComponentForPlugin(injector: Injector, plugin: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getComponent', [injector, plugin])); + } + + /** + * Return the draft saved data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the draft data. + */ + getPluginDraftData(assignId: number, userId: number, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getDraft', [assignId, userId, siteId])); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId])); + } + + /** + * Get a readable name to use for a certain feedback plugin. + * + * @param {any} plugin Plugin to get the name for. + * @return {string} Human readable name. + */ + getPluginName(plugin: any): string { + return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]); + } + + /** + * Check if the feedback data has changed for a certain plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the feedback. + * @param {number} userId User ID of the submission. + * @return {Promise} Promise resolved with true if data has changed, resolved with false otherwise. + */ + hasPluginDataChanged(assign: any, submission: any, plugin: any, inputData: any, userId: number): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'hasDataChanged', + [assign, submission, plugin, inputData, userId])); + } + + /** + * Check whether the plugin has draft data stored. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if it has draft data. + */ + hasPluginDraftData(assignId: number, userId: number, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'hasDraftData', [assignId, userId, siteId])); + } + + /** + * Check if a feedback plugin is supported. + * + * @param {string} pluginType Type of the plugin. + * @return {boolean} Whether it's supported. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Check if a feedback plugin is supported for edit. + * + * @param {string} pluginType Type of the plugin. + * @return {Promise} Whether it's supported for edit. + */ + isPluginSupportedForEdit(pluginType: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(pluginType, 'isEnabledForEdit')); + } + + /** + * Prefetch any required data for a feedback plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetch(assign: any, submission: any, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId])); + } + + /** + * Prepare and add to pluginData the data to submit for a certain feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data has been gathered. + */ + preparePluginFeedbackData(assignId: number, userId: number, plugin: any, pluginData: any, siteId?: string): Promise { + + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'prepareFeedbackData', + [assignId, userId, plugin, pluginData, siteId])); + } + + /** + * Save draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} inputData Data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data has been saved. + */ + saveFeedbackDraft(assignId: number, userId: number, plugin: any, inputData: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'saveDraft', + [assignId, userId, plugin, inputData, siteId])); + } +} diff --git a/src/addon/mod/assign/providers/submission-delegate.ts b/src/addon/mod/assign/providers/submission-delegate.ts new file mode 100644 index 000000000..8ad8666d5 --- /dev/null +++ b/src/addon/mod/assign/providers/submission-delegate.ts @@ -0,0 +1,405 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { AddonModAssignDefaultSubmissionHandler } from './default-submission-handler'; + +/** + * Interface that all submission handlers must implement. + */ +export interface AddonModAssignSubmissionHandler extends CoreDelegateHandler { + + /** + * Name of the type of submission the handler supports. E.g. 'file'. + * @type {string} + */ + type: string; + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline?(assign: any, submission: any, plugin: any): boolean | Promise; + + /** + * Should clear temporary data for a cancelled submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + */ + clearTmpData?(assign: any, submission: any, plugin: any, inputData: any): void; + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + copySubmissionData?(assign: any, plugin: any, pluginData: any, userId?: number, siteId?: string): void | Promise; + + /** + * Delete any stored data for the plugin and submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + deleteOfflineData?(assign: any, submission: any, plugin: any, offlineData: any, siteId?: string): void | Promise; + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @param {boolean} [edit] Whether the user is editing. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent?(injector: Injector, plugin: any, edit?: boolean): any | Promise; + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles?(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise; + + /** + * Get a readable name to use for the plugin. + * + * @param {any} plugin The plugin object. + * @return {string} The plugin name. + */ + getPluginName?(plugin: any): string; + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForCopy?(assign: any, plugin: any): number | Promise; + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForEdit?(assign: any, submission: any, plugin: any, inputData: any): number | Promise; + + /** + * Check if the submission data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged?(assign: any, submission: any, plugin: any, inputData: any): boolean | Promise; + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit?(): boolean | Promise; + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetch?(assign: any, submission: any, plugin: any, siteId?: string): Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @param {any} pluginData Object where to store the data to send. + * @param {boolean} [offline] Whether the user is editing in offline. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData?(assign: any, submission: any, plugin: any, inputData: any, pluginData: any, offline?: boolean, + userId?: number, siteId?: string): void | Promise; + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData?(assign: any, submission: any, plugin: any, offlineData: any, pluginData: any, siteId?: string) + : void | Promise; +} + +/** + * Delegate to register plugins for assign submission. + */ +@Injectable() +export class AddonModAssignSubmissionDelegate extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected defaultHandler: AddonModAssignDefaultSubmissionHandler) { + super('AddonModAssignSubmissionDelegate', logger, sitesProvider, eventsProvider); + } + + /** + * Whether the plugin can be edited in offline for existing submissions. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Promise resolved with boolean: whether it can be edited in offline. + */ + canPluginEditOffline(assign: any, submission: any, plugin: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'canEditOffline', [assign, submission, plugin])); + } + + /** + * Clear some temporary data for a certain plugin because a submission was cancelled. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + */ + clearTmpData(assign: any, submission: any, plugin: any, inputData: any): void { + return this.executeFunctionOnEnabled(plugin.type, 'return', [assign, submission, plugin, inputData]); + } + + /** + * Copy the data from last submitted attempt to the current submission for a certain plugin. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data has been copied. + */ + copyPluginSubmissionData(assign: any, plugin: any, pluginData: any, userId?: number, siteId?: string): void | Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'copySubmissionData', + [assign, plugin, pluginData, userId, siteId])); + } + + /** + * Delete offline data stored for a certain submission and plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deletePluginOfflineData(assign: any, submission: any, plugin: any, offlineData: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'deleteOfflineData', + [assign, submission, plugin, offlineData, siteId])); + } + + /** + * Get the component to use for a certain submission plugin. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @param {boolean} [edit] Whether the user is editing. + * @return {Promise} Promise resolved with the component to use, undefined if not found. + */ + getComponentForPlugin(injector: Injector, plugin: any, edit?: boolean): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getComponent', [injector, plugin, edit])); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getPluginFiles', [assign, submission, plugin, siteId])); + } + + /** + * Get a readable name to use for a certain submission plugin. + * + * @param {any} plugin Plugin to get the name for. + * @return {string} Human readable name. + */ + getPluginName(plugin: any): string { + return this.executeFunctionOnEnabled(plugin.type, 'getPluginName', [plugin]); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {Promise} Promise resolved with size. + */ + getPluginSizeForCopy(assign: any, plugin: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getSizeForCopy', [assign, plugin])); + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {Promise} Promise resolved with size. + */ + getPluginSizeForEdit(assign: any, submission: any, plugin: any, inputData: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'getSizeForEdit', + [assign, submission, plugin, inputData])); + } + + /** + * Check if the submission data has changed for a certain plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {Promise} Promise resolved with true if data has changed, resolved with false otherwise. + */ + hasPluginDataChanged(assign: any, submission: any, plugin: any, inputData: any): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'hasDataChanged', + [assign, submission, plugin, inputData])); + } + + /** + * Check if a submission plugin is supported. + * + * @param {string} pluginType Type of the plugin. + * @return {boolean} Whether it's supported. + */ + isPluginSupported(pluginType: string): boolean { + return this.hasHandler(pluginType, true); + } + + /** + * Check if a submission plugin is supported for edit. + * + * @param {string} pluginType Type of the plugin. + * @return {Promise} Whether it's supported for edit. + */ + isPluginSupportedForEdit(pluginType: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(pluginType, 'isEnabledForEdit')); + } + + /** + * Prefetch any required data for a submission plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetch(assign: any, submission: any, plugin: any, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'prefetch', [assign, submission, plugin, siteId])); + } + + /** + * Prepare and add to pluginData the data to submit for a certain submission plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @param {any} pluginData Object where to store the data to send. + * @param {boolean} [offline] Whether the user is editing in offline. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data has been gathered. + */ + preparePluginSubmissionData(assign: any, submission: any, plugin: any, inputData: any, pluginData: any, offline?: boolean, + userId?: number, siteId?: string): Promise { + + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'prepareSubmissionData', + [assign, submission, plugin, inputData, pluginData, offline, userId, siteId])); + } + + /** + * Prepare and add to pluginData the data to send to server to synchronize an offline submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data has been gathered. + */ + preparePluginSyncData(assign: any, submission: any, plugin: any, offlineData: any, pluginData: any, siteId?: string) + : Promise { + + return Promise.resolve(this.executeFunctionOnEnabled(plugin.type, 'prepareSyncData', + [assign, submission, plugin, offlineData, pluginData, siteId])); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 53a1e497f..42107168e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -76,6 +76,7 @@ import { AddonCalendarModule } from '@addon/calendar/calendar.module'; import { AddonCompetencyModule } from '@addon/competency/competency.module'; import { AddonUserProfileFieldModule } from '@addon/userprofilefield/userprofilefield.module'; import { AddonFilesModule } from '@addon/files/files.module'; +import { AddonModAssignModule } from '@addon/mod/assign/assign.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModChatModule } from '@addon/mod/chat/chat.module'; import { AddonModChoiceModule } from '@addon/mod/choice/choice.module'; @@ -174,6 +175,7 @@ export const CORE_PROVIDERS: any[] = [ AddonCompetencyModule, AddonUserProfileFieldModule, AddonFilesModule, + AddonModAssignModule, AddonModBookModule, AddonModChatModule, AddonModChoiceModule, From 941295cccd186b2f462ca27b86d4ee573f98e491 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 10 Apr 2018 09:23:32 +0200 Subject: [PATCH 02/16] MOBILE-2334 assign: Implement assign provider and offline --- src/addon/mod/assign/assign.module.ts | 4 + .../mod/assign/providers/assign-offline.ts | 503 +++++++ src/addon/mod/assign/providers/assign.ts | 1238 +++++++++++++++++ src/core/grades/providers/grades.ts | 15 +- src/providers/utils/utils.ts | 2 +- 5 files changed, 1760 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/assign/providers/assign-offline.ts create mode 100644 src/addon/mod/assign/providers/assign.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index 798a6bf5e..d14a5851b 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -13,6 +13,8 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { AddonModAssignProvider } from './providers/assign'; +import { AddonModAssignOfflineProvider } from './providers/assign-offline'; import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; @@ -22,6 +24,8 @@ import { AddonModAssignDefaultSubmissionHandler } from './providers/default-subm declarations: [ ], providers: [ + AddonModAssignProvider, + AddonModAssignOfflineProvider, AddonModAssignFeedbackDelegate, AddonModAssignSubmissionDelegate, AddonModAssignDefaultFeedbackHandler, diff --git a/src/addon/mod/assign/providers/assign-offline.ts b/src/addon/mod/assign/providers/assign-offline.ts new file mode 100644 index 000000000..f3596b594 --- /dev/null +++ b/src/addon/mod/assign/providers/assign-offline.ts @@ -0,0 +1,503 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreFileProvider } from '@providers/file'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; + +/** + * Service to handle offline assign. + */ +@Injectable() +export class AddonModAssignOfflineProvider { + + protected logger; + + // Variables for database. + protected SUBMISSIONS_TABLE = 'addon_mod_assign_submissions'; + protected SUBMISSIONS_GRADES_TABLE = 'addon_mod_assign_submissions_grading'; + protected tablesSchema = [ + { + name: this.SUBMISSIONS_TABLE, + columns: [ + { + name: 'assignId', + type: 'INTEGER' + }, + { + name: 'courseId', + type: 'INTEGER' + }, + { + name: 'userId', + type: 'INTEGER' + }, + { + name: 'pluginData', + type: 'TEXT' + }, + { + name: 'onlineTimemodified', + type: 'INTEGER' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'submitted', + type: 'INTEGER' + }, + { + name: 'submissionStatement', + type: 'INTEGER' + } + ], + primaryKeys: ['assignId', 'userId'] + }, + { + name: this.SUBMISSIONS_GRADES_TABLE, + columns: [ + { + name: 'assignId', + type: 'INTEGER' + }, + { + name: 'courseId', + type: 'INTEGER' + }, + { + name: 'userId', + type: 'INTEGER' + }, + { + name: 'grade', + type: 'REAL' + }, + { + name: 'attemptNumber', + type: 'INTEGER' + }, + { + name: 'addAttempt', + type: 'INTEGER' + }, + { + name: 'workflowState', + type: 'TEXT' + }, + { + name: 'applyToAll', + type: 'INTEGER' + }, + { + name: 'outcomes', + type: 'TEXT' + }, + { + name: 'pluginData', + type: 'TEXT' + }, + { + name: 'timemodified', + type: 'INTEGER' + } + ], + primaryKeys: ['assignId', 'userId'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider, private timeUtils: CoreTimeUtilsProvider) { + this.logger = logger.getInstance('AddonModAssignOfflineProvider'); + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete a submission. + * + * @param {number} assignId Assignment ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteSubmission(assignId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().deleteRecords(this.SUBMISSIONS_TABLE, {assignId, userId}); + }); + } + + /** + * Delete a submission grade. + * + * @param {number} assignId Assignment ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteSubmissionGrade(assignId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().deleteRecords(this.SUBMISSIONS_GRADES_TABLE, {assignId, userId}); + }); + } + + /** + * Get all the assignments ids that have something to be synced. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with assignments id that have something to be synced. + */ + getAllAssigns(siteId?: string): Promise { + const promises = []; + + promises.push(this.getAllSubmissions(siteId)); + promises.push(this.getAllSubmissionsGrade(siteId)); + + return Promise.all(promises).then((results) => { + // Flatten array. + results = [].concat.apply([], results); + + // Get assign id. + results = results.map((object) => { + return object.assignId; + }); + + // Get unique values. + results = results.filter((id, pos) => { + return results.indexOf(id) == pos; + }); + + return results; + }); + } + + /** + * Get all the stored submissions from all the assignments. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.SUBMISSIONS_TABLE); + }).then((submissions) => { + + // Parse the plugin data. + submissions.forEach((submission) => { + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + }); + + return submissions; + }); + } + + /** + * Get all the stored submissions grades from all the assignments. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with submissions grades. + */ + protected getAllSubmissionsGrade(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.SUBMISSIONS_GRADES_TABLE); + }).then((submissions) => { + + // Parse the plugin data and outcomes. + submissions.forEach((submission) => { + submission.outcomes = this.textUtils.parseJSON(submission.outcomes, {}); + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + }); + + return submissions; + }); + } + + /** + * Get all the stored submissions for a certain assignment. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with submissions. + */ + getAssignSubmissions(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecords(this.SUBMISSIONS_TABLE, {assignId}); + }).then((submissions) => { + + // Parse the plugin data. + submissions.forEach((submission) => { + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + }); + + return submissions; + }); + } + + /** + * Get all the stored submissions grades for a certain assignment. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with submissions grades. + */ + getAssignSubmissionsGrade(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecords(this.SUBMISSIONS_GRADES_TABLE, {assignId}); + }).then((submissions) => { + + // Parse the plugin data and outcomes. + submissions.forEach((submission) => { + submission.outcomes = this.textUtils.parseJSON(submission.outcomes, {}); + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + }); + + return submissions; + }); + } + + /** + * Get a stored submission. + * + * @param {number} assignId Assignment ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with submission. + */ + getSubmission(assignId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecord(this.SUBMISSIONS_TABLE, {assignId, userId}); + }).then((submission) => { + + // Parse the plugin data. + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + + return submission; + }); + } + + /** + * Get the path to the folder where to store files for an offline submission. + * + * @param {number} assignId Assignment ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getSubmissionFolder(assignId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const siteFolderPath = this.fileProvider.getSiteFolder(site.getId()), + submissionFolderPath = 'offlineassign/' + assignId + '/' + userId; + + return this.textUtils.concatenatePaths(siteFolderPath, submissionFolderPath); + }); + } + + /** + * Get a stored submission grade. + * Submission grades are not identified using attempt number so it can retrieve the feedback for a previous attempt. + * + * @param {number} assignId Assignment ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with submission grade. + */ + getSubmissionGrade(assignId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecord(this.SUBMISSIONS_GRADES_TABLE, {assignId, userId}); + }).then((submission) => { + + // Parse the plugin data and outcomes. + submission.outcomes = this.textUtils.parseJSON(submission.outcomes, {}); + submission.pluginData = this.textUtils.parseJSON(submission.pluginData, {}); + + return submission; + }); + } + + /** + * Get the path to the folder where to store files for a certain plugin in an offline submission. + * + * @param {number} assignId Assignment ID. + * @param {string} pluginName Name of the plugin. Must be unique (both in submission and feedback plugins). + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getSubmissionPluginFolder(assignId: number, pluginName: string, userId?: number, siteId?: string): Promise { + return this.getSubmissionFolder(assignId, userId, siteId).then((folderPath) => { + return this.textUtils.concatenatePaths(folderPath, pluginName); + }); + } + + /** + * Check if the assignment has something to be synced. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether the assignment has something to be synced. + */ + hasAssignOfflineData(assignId: number, siteId?: string): Promise { + const promises = []; + + promises.push(this.getAssignSubmissions(assignId, siteId)); + promises.push(this.getAssignSubmissionsGrade(assignId, siteId)); + + return Promise.all(promises).then((results) => { + for (let i = 0; i < results.length; i++) { + const result = results[i]; + + if (result && result.length) { + return true; + } + } + + return false; + }).catch(() => { + // No offline data found. + return false; + }); + } + + /** + * Mark/Unmark a submission as being submitted. + * + * @param {number} assignId Assignment ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {boolean} submitted True to mark as submitted, false to mark as not submitted. + * @param {boolean} acceptStatement True to accept the submission statement, false otherwise. + * @param {number} timemodified The time the submission was last modified in online. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if marked, rejected if failure. + */ + markSubmitted(assignId: number, courseId: number, submitted: boolean, acceptStatement: boolean, timemodified: number, + userId?: number, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + // Check if there's a submission stored. + return this.getSubmission(assignId, userId, site.getId()).catch(() => { + // No submission, create an empty one. + const now = this.timeUtils.timestamp(); + + return { + assignId: assignId, + courseId: courseId, + pluginData: '{}', + userId: userId, + onlineTimemodified: timemodified, + timecreated: now, + timemodified: now + }; + }).then((submission) => { + // Mark the submission. + submission.submitted = !!submitted; + submission.submissionstatement = !!acceptStatement; + + return site.getDb().insertRecord(this.SUBMISSIONS_TABLE, submission); + }); + }); + } + + /** + * Save a submission to be sent later. + * + * @param {number} assignId Assignment ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {any} pluginData Data to save. + * @param {number} timemodified The time the submission was last modified in online. + * @param {boolean} submitted True if submission has been submitted, false otherwise. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveSubmission(assignId: number, courseId: number, pluginData: any, timemodified: number, submitted: boolean, userId?: number, + siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const now = this.timeUtils.timestamp(), + entry = { + assignId: assignId, + courseId: courseId, + pluginData: pluginData ? JSON.stringify(pluginData) : '{}', + userId: userId, + submitted: !!submitted, + timecreated: now, + timemodified: now, + onlineTimemodified: timemodified + }; + + return site.getDb().insertRecord(this.SUBMISSIONS_TABLE, entry); + }); + } + + /** + * Save a grading to be sent later. + * + * @param {number} assignId Assign ID. + * @param {number} userId User ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {number} grade Grade to submit. + * @param {number} attemptNumber Number of the attempt being graded. + * @param {boolean} addAttempt Admit the user to attempt again. + * @param {string} workflowState Next workflow State. + * @param {boolean} applyToAll If it's a team submission, whether the grade applies to all group members. + * @param {any} outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param {any} pluginData Plugin data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + submitGradingForm(assignId: number, userId: number, courseId: number, grade: number, attemptNumber: number, addAttempt: boolean, + workflowState: string, applyToAll: boolean, outcomes: any, pluginData: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const now = this.timeUtils.timestamp(), + entry = { + assignId: assignId, + userId: userId, + courseId: courseId, + grade: grade, + attemptNumber: attemptNumber, + addAttempt: !!addAttempt, + workflowState: workflowState, + applyToAll: !!applyToAll, + outcomes: outcomes ? JSON.stringify(outcomes) : '{}', + pluginData: pluginData ? JSON.stringify(pluginData) : '{}', + timemodified: now + }; + + return site.getDb().insertRecord(this.SUBMISSIONS_GRADES_TABLE, entry); + }); + } +} diff --git a/src/addon/mod/assign/providers/assign.ts b/src/addon/mod/assign/providers/assign.ts new file mode 100644 index 000000000..651926410 --- /dev/null +++ b/src/addon/mod/assign/providers/assign.ts @@ -0,0 +1,1238 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreAppProvider } from '@providers/app'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreGradesProvider } from '@core/grades/providers/grades'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; +import { AddonModAssignOfflineProvider } from './assign-offline'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreInterceptor } from '@classes/interceptor'; + +/** + * Service that provides some functions for assign. + */ +@Injectable() +export class AddonModAssignProvider { + static COMPONENT = 'mmaModAssign'; + static SUBMISSION_COMPONENT = 'mmaModAssignSubmission'; + static UNLIMITED_ATTEMPTS = -1; + + // Submission status. + static SUBMISSION_STATUS_NEW = 'new'; + static SUBMISSION_STATUS_REOPENED = 'reopened'; + static SUBMISSION_STATUS_DRAFT = 'draft'; + static SUBMISSION_STATUS_SUBMITTED = 'submitted'; + + // "Re-open" methods (to retry the assign). + static ATTEMPT_REOPEN_METHOD_NONE = 'none'; + static ATTEMPT_REOPEN_METHOD_MANUAL = 'manual'; + + // Grading status. + static GRADING_STATUS_GRADED = 'graded'; + static GRADING_STATUS_NOT_GRADED = 'notgraded'; + static MARKING_WORKFLOW_STATE_RELEASED = 'released'; + static NEED_GRADING = 'needgrading'; + + // Events. + static SUBMISSION_SAVED_EVENT = 'addon_mod_assign_submission_saved'; + static SUBMITTED_FOR_GRADING_EVENT = 'addon_mod_assign_submitted_for_grading'; + static GRADED_EVENT = 'addon_mod_assign_graded'; + + protected ROOT_CACHE_KEY = 'mmaModAssign:'; + + protected logger; + protected gradingOfflineEnabled: {[siteId: string]: boolean} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, + private userProvider: CoreUserProvider, private submissionDelegate: AddonModAssignSubmissionDelegate, + private gradesProvider: CoreGradesProvider, private filepoolProvider: CoreFilepoolProvider, + private assignOffline: AddonModAssignOfflineProvider, private commentsProvider: CoreCommentsProvider) { + this.logger = logger.getInstance('AddonModAssignProvider'); + } + + /** + * Check if the user can submit in offline. This should only be used if submissionStatus.lastattempt.cansubmit cannot + * be used (offline usage). + * This function doesn't check if the submission is empty, it should be checked before calling this function. + * + * @param {any} assign Assignment instance. + * @param {any} submissionStatus Submission status returned by getSubmissionStatus. + * @return {boolean} Whether it can submit. + */ + canSubmitOffline(assign: any, submissionStatus: any): boolean { + if (!this.isSubmissionOpen(assign, submissionStatus)) { + return false; + } + + const userSubmission = submissionStatus.lastattempt.submission, + teamSubmission = submissionStatus.lastattempt.teamsubmission; + + if (teamSubmission) { + if (teamSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The assignment submission has been completed. + return false; + } else if (userSubmission && userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The user has already clicked the submit button on the team submission. + return false; + } else if (assign.preventsubmissionnotingroup && !submissionStatus.lastattempt.submissiongroup) { + return false; + } + } else if (userSubmission) { + if (userSubmission.status === AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // The assignment submission has been completed. + return false; + } + } else { + // No valid submission or team submission. + return false; + } + + // Last check is that this instance allows drafts. + return assign.submissiondrafts; + } + + /** + * Get an assignment by course module ID. + * + * @param {number} courseId Course ID the assignment belongs to. + * @param {number} cmId Assignment module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the assignment. + */ + getAssignment(courseId: number, cmId: number, siteId?: string): Promise { + return this.getAssignmentByField(courseId, 'cmid', cmId, siteId); + } + + /** + * Get an assigment with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the assignment is retrieved. + */ + protected getAssignmentByField(courseId: number, key: string, value: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getAssignmentCacheKey(courseId) + }; + + return site.read('mod_assign_get_assignments', params, preSets).then((response) => { + // Search the assignment to return. + if (response.courses && response.courses.length) { + const assignments = response.courses[0].assignments; + + for (let i = 0; i < assignments.length; i++) { + if (assignments[i][key] == value) { + return assignments[i]; + } + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get an assignment by instance ID. + * + * @param {number} courseId Course ID the assignment belongs to. + * @param {number} cmId Assignment instance ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the assignment. + */ + getAssignmentById(courseId: number, id: number, siteId?: string): Promise { + return this.getAssignmentByField(courseId, 'id', id, siteId); + } + + /** + * Get cache key for assignment data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getAssignmentCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'assignment:' + courseId; + } + + /** + * Get an assignment user mapping for blind marking. + * + * @param {number} assignId Assignment Id. + * @param {number} userId User Id to be blinded. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the user blind id. + */ + getAssignmentUserMappings(assignId: number, userId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignmentids: [assignId] + }, + preSets = { + cacheKey: this.getAssignmentUserMappingsCacheKey(assignId) + }; + + return site.read('mod_assign_get_user_mappings', params, preSets).then((response) => { + // Search the user. + if (userId && userId > 0 && response.assignments && response.assignments.length) { + const assignment = response.assignments[0]; + + if (assignment.assignmentid == assignId) { + const mappings = assignment.mappings; + + for (let i = 0; i < mappings.length; i++) { + if (mappings[i].userid == userId) { + return mappings[i].id; + } + } + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for assignment user mappings data WS calls. + * + * @param {number} assignId Assignment ID. + * @return {string} Cache key. + */ + protected getAssignmentUserMappingsCacheKey(assignId: number): string { + return this.ROOT_CACHE_KEY + 'usermappings:' + assignId; + } + + /** + * Find participant on a list. + * + * @param {any[]} participants List of participants. + * @param {number} id ID of the participant to get. + * @return {any} Participant, undefined if not found. + */ + protected getParticipantFromUserId(participants: any[], id: number): any { + if (participants) { + for (const i in participants) { + if (participants[i].id == id) { + // Remove the participant from the list and return it. + const participant = participants[i]; + delete participants[i]; + + return participant; + } + } + } + } + + /** + * Returns the color name for a given grading status name. + * + * @param {string} status Grading status name + * @return {string} The color name. + */ + getSubmissionGradingStatusColor(status: string): string { + if (!status) { + return ''; + } + + if (status == AddonModAssignProvider.GRADING_STATUS_GRADED || + status == AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) { + return 'success'; + } + + return 'danger'; + } + + /** + * Returns the translation id for a given grading status name. + * + * @param {string} status Grading Status name + * @return {string} The status translation identifier. + */ + getSubmissionGradingStatusTranslationId(status: string): string { + if (!status) { + return; + } + + if (status == AddonModAssignProvider.GRADING_STATUS_GRADED || status == AddonModAssignProvider.GRADING_STATUS_NOT_GRADED) { + return 'addon.mod_assign.' + status; + } + + return 'addon.mod_assign.markingworkflowstate' + status; + } + + /** + * Get the submission object from an attempt. + * + * @param {any} assign Assign. + * @param {any} attempt Attempt. + * @return {any} Submission object. + */ + getSubmissionObjectFromAttempt(assign: any, attempt: any): any { + return assign.teamsubmission ? attempt.teamsubmission : attempt.submission; + } + + /** + * Get attachments of a submission plugin. + * + * @param {any} submissionPlugin Submission plugin. + * @return {any[]} Submission plugin attachments. + */ + getSubmissionPluginAttachments(submissionPlugin: any): any[] { + const files = []; + + if (submissionPlugin.fileareas) { + submissionPlugin.fileareas.forEach((filearea) => { + if (!filearea || !filearea.files) { + // No files to get. + return; + } + + filearea.files.forEach((file) => { + let filename; + + if (file.filename) { + filename = file.filename; + } else { + // We don't have filename, extract it from the path. + filename = file.filepath[0] == '/' ? file.filepath.substr(1) : file.filepath; + } + + files.push({ + filename: filename, + fileurl: file.fileurl + }); + }); + }); + } + + return files; + } + + /** + * Get text of a submission plugin. + * + * @param {any} submissionPlugin Submission plugin. + * @param {boolean} [keepUrls] True if it should keep original URLs, false if they should be replaced. + * @return {string} Submission text. + */ + getSubmissionPluginText(submissionPlugin: any, keepUrls?: boolean): string { + let text = ''; + + if (submissionPlugin.editorfields) { + submissionPlugin.editorfields.forEach((field) => { + text += field.text; + }); + + if (!keepUrls && submissionPlugin.fileareas && submissionPlugin.fileareas[0]) { + text = this.textUtils.replacePluginfileUrls(text, submissionPlugin.fileareas[0].files); + } + } + + return text; + } + + /** + * Get an assignment submissions. + * + * @param {number} assignId Assignment id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{canviewsubmissions: boolean, submissions?: any[]}>} Promise resolved when done. + */ + getSubmissions(assignId: number, siteId?: string): Promise<{canviewsubmissions: boolean, submissions?: any[]}> { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignmentids: [assignId] + }, + preSets = { + cacheKey: this.getSubmissionsCacheKey(assignId) + }; + + return site.read('mod_assign_get_submissions', params, preSets).then((response): any => { + // Check if we can view submissions, with enough permissions. + if (response.warnings.length > 0 && response.warnings[0].warningcode == 1) { + return {canviewsubmissions: false}; + } + + if (response.assignments && response.assignments.length) { + return { + canviewsubmissions: true, + submissions: response.assignments[0].submissions + }; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for assignment submissions data WS calls. + * + * @param {number} assignId Assignment id. + * @return {string} Cache key. + */ + protected getSubmissionsCacheKey(assignId: number): string { + return this.ROOT_CACHE_KEY + 'submissions:' + assignId; + } + + /** + * Get information about an assignment submission status for a given user. + * + * @param {number} assignId Assignment instance id. + * @param {number} [userId] User id (empty for current user). + * @param {boolean} [isBlind] If blind marking is enabled or not. + * @param {number} [filter=true] True to filter WS response and rewrite URLs, false otherwise. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site id (empty for current site). + * @return {Promise} Promise always resolved with the user submission status. + */ + getSubmissionStatus(assignId: number, userId?: number, isBlind?: boolean, filter: boolean = true, ignoreCache?: boolean, + siteId?: string): Promise { + + userId = userId || 0; + + return this.sitesProvider.getSite(siteId).then((site) => { + + const params = { + assignid: assignId, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubmissionStatusCacheKey(assignId, userId, isBlind), + getCacheUsingCacheKey: true, // We use the cache key to take isBlind into account. + filter: filter, + rewriteurls: filter + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + if (!filter) { + // Don't cache when getting text without filters. + // @todo Change this to support offline editing. + preSets.saveToCache = false; + } + + return site.read('mod_assign_get_submission_status', params, preSets); + }); + } + + /** + * Get cache key for get submission status data WS calls. + * + * @param {number} assignId Assignment instance id. + * @param {number} [userId] User id (empty for current user). + * @param {number} [isBlind] If blind marking is enabled or not. + * @return {string} Cache key. + */ + protected getSubmissionStatusCacheKey(assignId: number, userId: number, isBlind?: boolean): string { + if (!userId) { + isBlind = false; + userId = this.sitesProvider.getCurrentSiteUserId(); + } + + return this.getSubmissionsCacheKey(assignId) + ':' + userId + ':' + (isBlind ? 1 : 0); + } + + /** + * Returns the color name for a given status name. + * + * @param {string} status Status name + * @return {string} The color name. + */ + getSubmissionStatusColor(status: string): string { + switch (status) { + case 'submitted': + return 'success'; + case 'draft': + return 'info'; + case 'new': + case 'noattempt': + case 'noonlinesubmissions': + case 'nosubmission': + return 'danger'; + default: + return ''; + } + } + + /** + * Get user data for submissions since they only have userid. + * + * @param {any[]} submissions Submissions to get the data for. + * @param {number} courseId ID of the course the submissions belong to. + * @param {number} assignId ID of the assignment the submissions belong to. + * @param {boolean} [blind] Whether the user data need to be blinded. + * @param {any[]} [participants] List of participants in the assignment. + * @param {string} [siteId] Site id (empty for current site). + * @return {Promise} Promise always resolved. Resolve param is the formatted submissions. + */ + getSubmissionsUserData(submissions: any[], courseId: number, assignId: number, blind?: boolean, participants?: any[], + siteId?: string): Promise { + + const promises = [], + subs = [], + hasParticipants = participants && participants.length > 0; + + submissions.forEach((submission) => { + submission.submitid = submission.userid > 0 ? submission.userid : submission.blindid; + if (submission.submitid <= 0) { + return; + } + + const participant = this.getParticipantFromUserId(participants, submission.submitid); + if (hasParticipants && !participant) { + // Avoid permission denied error. Participant not found on list. + return; + } + + if (participant) { + if (!blind) { + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } + + submission.manyGroups = !!participant.groups && participant.groups.length > 1; + if (participant.groupname) { + submission.groupid = participant.groupid; + submission.groupname = participant.groupname; + } + } + + let promise; + if (submission.userid > 0) { + if (blind) { + // Blind but not blinded! (Moodle < 3.1.1, 3.2). + delete submission.userid; + + promise = this.getAssignmentUserMappings(assignId, submission.submitid, siteId).then((blindId) => { + submission.blindid = blindId; + }); + } else if (!participant) { + // No blind, no participant. + promise = this.userProvider.getProfile(submission.userid, courseId, true).then((user) => { + submission.userfullname = user.fullname; + submission.userprofileimageurl = user.profileimageurl; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + }); + } + } + + promise = promise || Promise.resolve(); + + promises.push(promise.then(() => { + // Add to the list. + if (submission.userfullname || submission.blindid) { + subs.push(submission); + } + })); + }); + + return Promise.all(promises).then(() => { + if (hasParticipants) { + // Create a submission for each participant left in the list (the participants already treated were removed). + participants.forEach((participant) => { + const submission: any = { + submitid: participant.id + }; + + if (!blind) { + submission.userid = participant.id; + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } else { + submission.blindid = participant.id; + } + + if (participant.groupname) { + submission.groupid = participant.groupid; + submission.groupname = participant.groupname; + } + submission.status = participant.submitted ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : + AddonModAssignProvider.SUBMISSION_STATUS_NEW; + + subs.push(submission); + }); + } + + return subs; + }); + } + + /** + * Given a list of plugins, returns the plugin names that aren't supported for editing. + * + * @param {any[]} plugins Plugins to check. + * @return {Promise} Promise resolved with unsupported plugin names. + */ + getUnsupportedEditPlugins(plugins: any[]): Promise { + const notSupported = [], + promises = []; + + plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.isPluginSupportedForEdit(plugin.type).then((enabled) => { + if (!enabled) { + notSupported.push(plugin.name); + } + })); + }); + + return Promise.all(promises).then(() => { + return notSupported; + }); + } + + /** + * List the participants for a single assignment, with some summary info about their submissions. + * + * @param {number} assignId Assignment id. + * @param {number} [groupId] Group id. If not defined, 0. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of participants and summary of submissions. + */ + listParticipants(assignId: number, groupId?: number, siteId?: string): Promise { + groupId = groupId || 0; + + return this.sitesProvider.getSite(siteId).then((site) => { + if (!site.wsAvailable('mod_assign_list_participants')) { + // Silently fail if is not available. (needs Moodle version >= 3.2) + return Promise.reject(null); + } + + const params = { + assignid: assignId, + groupid: groupId, + filter: '' + }, + preSets = { + cacheKey: this.listParticipantsCacheKey(assignId, groupId) + }; + + return site.read('mod_assign_list_participants', params, preSets); + }); + } + + /** + * Get cache key for assignment list participants data WS calls. + * + * @param {number} assignId Assignment id. + * @param {number} groupId Group id. + * @return {string} Cache key. + */ + protected listParticipantsCacheKey(assignId: number, groupId: number): string { + return this.listParticipantsPrefixCacheKey(assignId) + ':' + groupId; + } + + /** + * Get prefix cache key for assignment list participants data WS calls. + * + * @param {number} assignId Assignment id. + * @return {string} Cache key. + */ + protected listParticipantsPrefixCacheKey(assignId: number): string { + return this.ROOT_CACHE_KEY + 'participants:' + assignId; + } + + /** + * Invalidates all submission status data. + * + * @param {number} assignId Assignment instance id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllSubmissionData(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getSubmissionsCacheKey(assignId)); + }); + } + + /** + * Invalidates assignment data WS calls. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAssignmentData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAssignmentCacheKey(courseId)); + }); + } + + /** + * Invalidates assignment user mappings data WS calls. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAssignmentUserMappingsData(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAssignmentUserMappingsCacheKey(assignId)); + }); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModAssignProvider.invalidateFiles. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getAssignment(courseId, moduleId, siteId).then((assign) => { + const promises = []; + + // Do not invalidate assignment data before getting assignment info, we need it! + promises.push(this.invalidateAllSubmissionData(assign.id, siteId)); + promises.push(this.invalidateAssignmentUserMappingsData(assign.id, siteId)); + promises.push(this.invalidateListParticipantsData(assign.id, siteId)); + promises.push(this.commentsProvider.invalidateCommentsByInstance('module', assign.id, siteId)); + promises.push(this.invalidateAssignmentData(courseId, siteId)); + promises.push(this.gradesProvider.invalidateAllCourseGradesData(courseId)); + + return Promise.all(promises); + }); + } + + /** + * Invalidate the prefetched files. + * + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved when the files are invalidated. + */ + invalidateFiles(moduleId: number): Promise { + return this.filepoolProvider.invalidateFilesByComponent(this.sitesProvider.getCurrentSiteId(), + AddonModAssignProvider.COMPONENT, moduleId); + } + + /** + * Invalidates assignment submissions data WS calls. + * + * @param {number} assignId Assignment instance id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSubmissionData(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getSubmissionsCacheKey(assignId)); + }); + } + + /** + * Invalidates submission status data. + * + * @param {number} assignId Assignment instance id. + * @param {number} [userId] User id (empty for current user). + * @param {boolean} [isBlind] Whether blind marking is enabled or not. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSubmissionStatusData(assignId: number, userId?: number, isBlind?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getSubmissionStatusCacheKey(assignId, userId, isBlind)); + }); + } + + /** + * Invalidates assignment participants data. + * + * @param {number} assignId Assignment instance id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateListParticipantsData(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.listParticipantsPrefixCacheKey(assignId)); + }); + } + + /** + * Convenience function to check if grading offline is enabled. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether grading offline is enabled. + */ + protected isGradingOfflineEnabled(siteId?: string): Promise { + if (typeof this.gradingOfflineEnabled[siteId] != 'undefined') { + return Promise.resolve(this.gradingOfflineEnabled[siteId]); + } + + return this.gradesProvider.isGradeItemsAvalaible(siteId).then((enabled) => { + this.gradingOfflineEnabled[siteId] = enabled; + + return enabled; + }); + } + + /** + * Outcomes only can be edited if mod_assign_submit_grading_form is avalaible. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if outcomes edit is enabled, rejected or resolved with false otherwise. + * @since 3.2 + */ + isOutcomesEditEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.wsAvailable('mod_assign_submit_grading_form'); + }); + } + + /** + * Check if assignments plugin is enabled in a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {boolean} Whether the plugin is enabled. + */ + isPluginEnabled(siteId?: string): boolean { + return true; + } + + /** + * Check if a submission is open. This function is based on Moodle's submissions_open. + * + * @param {any} assign Assignment instance. + * @param {any} submissionStatus Submission status returned by getSubmissionStatus. + * @return {boolean} Whether submission is open. + */ + isSubmissionOpen(assign: any, submissionStatus: any): boolean { + if (!assign || !submissionStatus) { + return false; + } + + const time = this.timeUtils.timestamp(), + lastAttempt = submissionStatus.lastattempt, + submission = this.getSubmissionObjectFromAttempt(assign, lastAttempt); + + let dateOpen = true, + finalDate; + + if (assign.cutoffdate) { + finalDate = assign.cutoffdate; + } + + if (lastAttempt && lastAttempt.locked) { + return false; + } + + // User extensions. + if (finalDate) { + if (lastAttempt && lastAttempt.extensionduedate) { + // Extension can be before cut off date. + if (lastAttempt.extensionduedate > finalDate) { + finalDate = lastAttempt.extensionduedate; + } + } + } + + if (finalDate) { + dateOpen = assign.allowsubmissionsfromdate <= time && time <= finalDate; + } else { + dateOpen = assign.allowsubmissionsfromdate <= time; + } + + if (!dateOpen) { + return false; + } + + if (submission) { + if (assign.submissiondrafts && submission.status == AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + // Drafts are tracked and the student has submitted the assignment. + return false; + } + } + + return true; + } + + /** + * Report an assignment submission as being viewed. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logSubmissionView(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignid: assignId + }; + + return site.write('mod_assign_view_submission_status', params); + }); + } + + /** + * Report an assignment grading table is being viewed. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logGradingView(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignid: assignId + }; + + return site.write('mod_assign_view_grading_table', params); + }); + } + + /** + * Report an assign as being viewed. + * + * @param {number} assignId Assignment ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(assignId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignid: assignId + }; + + return site.write('mod_assign_view_assign', params); + }); + } + + /** + * Returns if a submissions needs to be graded. + * + * @param {any} submission Submission. + * @param {number} assignId Assignment ID. + * @return {Promise} Promise resolved with boolean: whether it needs to be graded or not. + */ + needsSubmissionToBeGraded(submission: any, assignId: number): Promise { + if (!submission.gradingstatus) { + // This should not happen, but it's better to show rather than not showing any of the submissions. + return Promise.resolve(true); + } + + if (submission.gradingstatus != AddonModAssignProvider.GRADING_STATUS_GRADED && + submission.gradingstatus != AddonModAssignProvider.MARKING_WORKFLOW_STATE_RELEASED) { + // Not graded. + return Promise.resolve(true); + } + + // We need more data to decide that. + return this.getSubmissionStatus(assignId, submission.submitid, submission.blindid).then((response) => { + if (!response.feedback || !response.feedback.gradeddate) { + // Not graded. + return true; + } + + // Submitted after grading? + return response.feedback.gradeddate < submission.timemodified; + }); + } + + /** + * Save current user submission for a certain assignment. + * + * @param {number} assignId Assign ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {any} pluginData Data to save. + * @param {boolean} allowOffline Whether to allow offline usage. + * @param {number} timemodified The time the submission was last modified in online. + * @param {boolean} [allowsDrafts] Whether the assignment allows submission drafts. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if sent to server, resolved with false if stored in offline. + */ + saveSubmission(assignId: number, courseId: number, pluginData: any, allowOffline: boolean, timemodified: number, + allowsDrafts?: boolean, userId?: number, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = (): Promise => { + return this.assignOffline.saveSubmission(assignId, courseId, pluginData, timemodified, !allowsDrafts, userId, siteId) + .then(() => { + return false; + }); + }; + + if (allowOffline && !this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a submission to be sent to the server, discard it first. + return this.assignOffline.deleteSubmission(assignId, userId, siteId).then(() => { + return this.saveSubmissionOnline(assignId, pluginData, siteId).then(() => { + return true; + }).catch((error) => { + if (allowOffline && error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Save current user submission for a certain assignment. It will fail if offline or cannot connect. + * + * @param {number} assignId Assign ID. + * @param {any} pluginData Data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when saved, rejected otherwise. + */ + saveSubmissionOnline(assignId: number, pluginData: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignmentid: assignId, + plugindata: pluginData + }; + + return site.write('mod_assign_save_submission', params).then((warnings) => { + if (warnings && warnings.length) { + // The WebService returned warnings, reject. + return Promise.reject(warnings[0]); + } + }); + }); + } + + /** + * Submit the current user assignment for grading. + * + * @param {number} assignId Assign ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {boolean} acceptStatement True if submission statement is accepted, false otherwise. + * @param {number} timemodified The time the submission was last modified in online. + * @param {boolean} [forceOffline] True to always mark it in offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if sent to server, resolved with false if stored in offline. + */ + submitForGrading(assignId: number, courseId: number, acceptStatement: boolean, timemodified: number, forceOffline?: boolean, + siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the submission to be synchronized later. + const storeOffline = (): Promise => { + return this.assignOffline.markSubmitted(assignId, courseId, true, acceptStatement, timemodified, undefined, siteId) + .then(() => { + return false; + }); + }; + + if (forceOffline || !this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a submission to be sent to the server, discard it first. + return this.assignOffline.deleteSubmission(assignId, undefined, siteId).then(() => { + return this.submitForGradingOnline(assignId, acceptStatement, siteId).then(() => { + return true; + }).catch((error) => { + if (error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Submit the current user assignment for grading. It will fail if offline or cannot connect. + * + * @param {number} assignId Assign ID. + * @param {boolean} acceptStatement True if submission statement is accepted, false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when submitted, rejected otherwise. + */ + submitForGradingOnline(assignId: number, acceptStatement: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + assignmentid: assignId, + acceptsubmissionstatement: acceptStatement ? 1 : 0 + }; + + return site.write('mod_assign_submit_for_grading', params).then((warnings) => { + if (warnings && warnings.length) { + // The WebService returned warnings, reject. + return Promise.reject(warnings[0]); + } + }); + }); + } + + /** + * Submit the grading for the current user and assignment. It will use old or new WS depending on availability. + * + * @param {number} assignId Assign ID. + * @param {number} userId User ID. + * @param {number} courseId Course ID the assign belongs to. + * @param {number} grade Grade to submit. + * @param {number} attemptNumber Number of the attempt being graded. + * @param {boolean} addAttempt Admit the user to attempt again. + * @param {string} workflowState Next workflow State. + * @param {boolean} applyToAll If it's a team submission, whether the grade applies to all group members. + * @param {any} outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param {any} pluginData Feedback plugin data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if sent to server, resolved with false if stored offline. + */ + submitGradingForm(assignId: number, userId: number, courseId: number, grade: number, attemptNumber: number, addAttempt: boolean, + workflowState: string, applyToAll: boolean, outcomes: any, pluginData: any, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Function to store the grading to be synchronized later. + const storeOffline = (): Promise => { + return this.assignOffline.submitGradingForm(assignId, userId, courseId, grade, attemptNumber, addAttempt, workflowState, + applyToAll, outcomes, pluginData, siteId).then(() => { + return false; + }); + }; + + // Grading offline is only allowed if WS of grade items is enabled to avoid inconsistency. + return this.isGradingOfflineEnabled(siteId).then((enabled) => { + if (!enabled) { + return this.submitGradingFormOnline(assignId, userId, grade, attemptNumber, addAttempt, workflowState, + applyToAll, outcomes, pluginData, siteId); + } + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // If there's already a grade to be sent to the server, discard it first. + return this.assignOffline.deleteSubmissionGrade(assignId, userId, siteId).then(() => { + return this.submitGradingFormOnline(assignId, userId, grade, attemptNumber, addAttempt, workflowState, applyToAll, + outcomes, pluginData, siteId).then(() => { + return true; + }).catch((error) => { + if (error && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error, reject. + return Promise.reject(error); + } + }); + }); + }); + } + + /** + * Submit the grading for the current user and assignment. It will use old or new WS depending on availability. + * It will fail if offline or cannot connect. + * + * @param {number} assignId Assign ID. + * @param {number} userId User ID. + * @param {number} grade Grade to submit. + * @param {number} attemptNumber Number of the attempt being graded. + * @param {number} addAttempt Allow the user to attempt again. + * @param {string} workflowState Next workflow State. + * @param {boolean} applyToAll If it's a team submission, if the grade applies to all group members. + * @param {any} outcomes Object including all outcomes values. If empty, any of them will be sent. + * @param {any} pluginData Feedback plugin data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when submitted, rejected otherwise. + */ + submitGradingFormOnline(assignId: number, userId: number, grade: number, attemptNumber: number, addAttempt: boolean, + workflowState: string, applyToAll: boolean, outcomes: any, pluginData: any, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + if (site.wsAvailable('mod_assign_submit_grading_form')) { + // WS available @since 3.2. + + const jsonData = { + grade: grade, + attemptnumber: attemptNumber, + addattempt: addAttempt ? 1 : 0, + workflowstate: workflowState, + applytoall: applyToAll ? 1 : 0 + }; + + for (const index in outcomes) { + jsonData['outcome_' + index + '[' + userId + ']'] = outcomes[index]; + } + + for (const index in pluginData) { + jsonData[index] = pluginData[index]; + } + + const serialized = CoreInterceptor.serialize(jsonData, true), + params = { + assignmentid: assignId, + userid: userId, + jsonformdata: JSON.stringify(serialized) + }; + + return site.write('mod_assign_submit_grading_form', params).then((warnings) => { + if (warnings && warnings.length) { + // The WebService returned warnings, reject. + return Promise.reject(warnings[0]); + } + }); + } else { + // WS not available, fallback to save_grade. + + const params = { + assignmentid: assignId, + userid: userId, + grade: grade, + attemptnumber: attemptNumber, + addattempt: addAttempt ? 1 : 0, + workflowstate: workflowState, + applytoall: applyToAll ? 1 : 0, + plugindata: pluginData + }, + preSets = { + responseExpected: false + }; + + return site.write('mod_assign_save_grade', params, preSets); + } + }); + } +} diff --git a/src/core/grades/providers/grades.ts b/src/core/grades/providers/grades.ts index 922788f53..b040678c7 100644 --- a/src/core/grades/providers/grades.ts +++ b/src/core/grades/providers/grades.ts @@ -210,6 +210,19 @@ export class CoreGradesProvider { }); } + /** + * Invalidates courses grade table and items WS calls for all users. + * + * @param {number} courseId ID of the course to get the grades from. + * @param {string} [siteId] Site ID (empty for current site). + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllCourseGradesData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getCourseGradesPrefixCacheKey(courseId)); + }); + } + /** * Invalidates grade table data WS calls. * @@ -247,7 +260,7 @@ export class CoreGradesProvider { * @param {string} [siteId] Site id (empty for current site). * @return {Promise} Promise resolved when the data is invalidated. */ - invalidateCourseGradesItemsData(courseId: number, userId: number, groupId: number, siteId?: string): Promise { + invalidateCourseGradesItemsData(courseId: number, userId: number, groupId?: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.invalidateWsCacheForKey(this.getCourseGradesItemsCacheKey(courseId, userId, groupId)); }); diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 85ca2166b..5b56e841b 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -626,7 +626,7 @@ export class CoreUtilsProvider { * @return {boolean} Whether the error was returned by the WebService. */ isWebServiceError(error: any): boolean { - return typeof error.errorcode == 'undefined'; + return typeof error.errorcode == 'undefined' && typeof error.warningcode == 'undefined'; } /** From 0433fbdafc65c112976ce19cb9500ad2f4ab9f2a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 10 Apr 2018 17:06:37 +0200 Subject: [PATCH 03/16] MOBILE-2334 assign: Implement prefetch handler and helper --- src/addon/mod/assign/assign.module.ts | 13 +- src/addon/mod/assign/providers/helper.ts | 447 ++++++++++++++++++ .../mod/assign/providers/prefetch-handler.ts | 378 +++++++++++++++ src/core/user/providers/user.ts | 16 +- 4 files changed, 845 insertions(+), 9 deletions(-) create mode 100644 src/addon/mod/assign/providers/helper.ts create mode 100644 src/addon/mod/assign/providers/prefetch-handler.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index d14a5851b..32dbecdcb 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -13,12 +13,15 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModAssignProvider } from './providers/assign'; import { AddonModAssignOfflineProvider } from './providers/assign-offline'; +import { AddonModAssignHelperProvider } from './providers/helper'; import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; import { AddonModAssignDefaultSubmissionHandler } from './providers/default-submission-handler'; +import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; @NgModule({ declarations: [ @@ -26,10 +29,16 @@ import { AddonModAssignDefaultSubmissionHandler } from './providers/default-subm providers: [ AddonModAssignProvider, AddonModAssignOfflineProvider, + AddonModAssignHelperProvider, AddonModAssignFeedbackDelegate, AddonModAssignSubmissionDelegate, AddonModAssignDefaultFeedbackHandler, - AddonModAssignDefaultSubmissionHandler + AddonModAssignDefaultSubmissionHandler, + AddonModAssignPrefetchHandler ] }) -export class AddonModAssignModule { } +export class AddonModAssignModule { + constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler) { + prefetchDelegate.registerHandler(prefetchHandler); + } +} diff --git a/src/addon/mod/assign/providers/helper.ts b/src/addon/mod/assign/providers/helper.ts new file mode 100644 index 000000000..2fa522672 --- /dev/null +++ b/src/addon/mod/assign/providers/helper.ts @@ -0,0 +1,447 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreFileProvider } from '@providers/file'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; +import { AddonModAssignProvider } from './assign'; +import { AddonModAssignOfflineProvider } from './assign-offline'; + +/** + * Service that provides some helper functions for assign. + */ +@Injectable() +export class AddonModAssignHelperProvider { + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private fileProvider: CoreFileProvider, + private assignProvider: AddonModAssignProvider, private utils: CoreUtilsProvider, + private assignOffline: AddonModAssignOfflineProvider, private feedbackDelegate: AddonModAssignFeedbackDelegate, + private submissionDelegate: AddonModAssignSubmissionDelegate, private fileUploaderProvider: CoreFileUploaderProvider, + private groupsProvider: CoreGroupsProvider) { + this.logger = logger.getInstance('AddonModAssignHelperProvider'); + } + + /** + * Check if a submission can be edited in offline. + * + * @param {any} assign Assignment. + * @param {any} submission Submission. + * @return {boolean} Whether it can be edited offline. + */ + canEditSubmissionOffline(assign: any, submission: any): boolean { + if (!submission) { + return false; + } + + if (submission.status == AddonModAssignProvider.SUBMISSION_STATUS_NEW || + submission.status == AddonModAssignProvider.SUBMISSION_STATUS_REOPENED) { + // It's a new submission, allow creating it in offline. + return true; + } + + for (let i = 0; i < submission.plugins.length; i++) { + const plugin = submission.plugins[i]; + if (!this.submissionDelegate.canPluginEditOffline(assign, submission, plugin)) { + return false; + } + } + + return true; + } + + /** + * Clear plugins temporary data because a submission was cancelled. + * + * @param {any} assign Assignment. + * @param {any} submission Submission to clear the data for. + * @param {any} inputData Data entered in the submission form. + */ + clearSubmissionPluginTmpData(assign: any, submission: any, inputData: any): void { + submission.plugins.forEach((plugin) => { + this.submissionDelegate.clearTmpData(assign, submission, plugin, inputData); + }); + } + + /** + * Copy the data from last submitted attempt to the current submission. + * Since we don't have any WS for that we'll have to re-submit everything manually. + * + * @param {any} assign Assignment. + * @param {any} previousSubmission Submission to copy. + * @return {Promise} Promise resolved when done. + */ + copyPreviousAttempt(assign: any, previousSubmission: any): Promise { + const pluginData = {}, + promises = []; + + previousSubmission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.copyPluginSubmissionData(assign, plugin, pluginData)); + }); + + return Promise.all(promises).then(() => { + // We got the plugin data. Now we need to submit it. + if (Object.keys(pluginData).length) { + // There's something to save. + return this.assignProvider.saveSubmissionOnline(assign.id, pluginData); + } + }); + } + + /** + * Delete stored submission files for a plugin. See storeSubmissionFiles. + * + * @param {number} assignId Assignment ID. + * @param {string} folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteStoredSubmissionFiles(assignId: number, folderName: string, userId?: number, siteId?: string): Promise { + return this.assignOffline.getSubmissionPluginFolder(assignId, folderName, userId, siteId).then((folderPath) => { + return this.fileProvider.removeDir(folderPath); + }); + } + + /** + * Delete all drafts of the feedback plugin data. + * + * @param {number} assignId Assignment Id. + * @param {number} userId User Id. + * @param {any} feedback Feedback data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + discardFeedbackPluginData(assignId: number, userId: number, feedback: any, siteId?: string): Promise { + const promises = []; + + feedback.plugins.forEach((plugin) => { + promises.push(this.feedbackDelegate.discardPluginFeedbackData(assignId, userId, plugin, siteId)); + }); + + return Promise.all(promises); + } + + /** + * List the participants for a single assignment, with some summary info about their submissions. + * + * @param {any} assign Assignment object + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get the participants without specifying a group. + return this.assignProvider.listParticipants(assign.id, undefined, siteId).then((participants) => { + if (participants && participants.length > 0) { + return participants; + } + + // If no participants returned, get participants by groups. + return this.groupsProvider.getActivityAllowedGroupsIfEnabled(assign.cmid, undefined, siteId).then((userGroups) => { + const promises = [], + participants = {}; + + userGroups.forEach((userGroup) => { + promises.push(this.assignProvider.listParticipants(assign.id, userGroup.id, siteId).then((parts) => { + // Do not get repeated users. + parts.forEach((participant) => { + participants[participant.id] = participant; + }); + })); + }); + + return Promise.all(promises).then(() => { + return this.utils.objectToArray(participants); + }); + }); + }); + } + + /** + * Get plugin config from assignment config. + * + * @param {any} assign Assignment object including all config. + * @param {string} subtype Subtype name (assignsubmission or assignfeedback) + * @param {string} type Name of the subplugin. + * @return {any} Object containing all configurations of the subplugin selected. + */ + getPluginConfig(assign: any, subtype: string, type: string): any { + const configs = {}; + + assign.configs.forEach((config) => { + if (config.subtype == subtype && config.plugin == type) { + configs[config.name] = config.value; + } + }); + + return configs; + } + + /** + * Get enabled subplugins. + * + * @param {any} assign Assignment object including all config. + * @param {string} subtype Subtype name (assignsubmission or assignfeedback) + * @return {any} List of enabled plugins for the assign. + */ + getPluginsEnabled(assign: any, subtype: string): any[] { + const enabled = []; + + assign.configs.forEach((config) => { + if (config.subtype == subtype && config.name == 'enabled' && parseInt(config.value, 10) === 1) { + // Format the plugin objects. + enabled.push({ + type: config.plugin + }); + } + }); + + return enabled; + } + + /** + * Get a list of stored submission files. See storeSubmissionFiles. + * + * @param {number} assignId Assignment ID. + * @param {string} folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getStoredSubmissionFiles(assignId: number, folderName: string, userId?: number, siteId?: string): Promise { + return this.assignOffline.getSubmissionPluginFolder(assignId, folderName, userId, siteId).then((folderPath) => { + return this.fileProvider.getDirectoryContents(folderPath); + }); + } + + /** + * Get the size that will be uploaded to perform an attempt copy. + * + * @param {any} assign Assignment. + * @param {any} previousSubmission Submission to copy. + * @return {Promise} Promise resolved with the size. + */ + getSubmissionSizeForCopy(assign: any, previousSubmission: any): Promise { + const promises = []; + let totalSize = 0; + + previousSubmission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.getPluginSizeForCopy(assign, plugin).then((size) => { + totalSize += size; + })); + }); + + return Promise.all(promises).then(() => { + return totalSize; + }); + } + + /** + * Get the size that will be uploaded to save a submission. + * + * @param {any} assign Assignment. + * @param {any} submission Submission to check data. + * @param {any} inputData Data entered in the submission form. + * @return {Promise} Promise resolved with the size. + */ + getSubmissionSizeForEdit(assign: any, submission: any, inputData: any): Promise { + const promises = []; + let totalSize = 0; + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.getPluginSizeForEdit(assign, submission, plugin, inputData).then((size) => { + totalSize += size; + })); + }); + + return Promise.all(promises).then(() => { + return totalSize; + }); + } + + /** + * Check if the feedback data has changed for a certain submission and assign. + * + * @param {any} assign Assignment. + * @param {number} userId User Id. + * @param {any} feedback Feedback data. + * @return {Promise} Promise resolved with true if data has changed, resolved with false otherwise. + */ + hasFeedbackDataChanged(assign: any, userId: number, feedback: any): Promise { + const promises = []; + let hasChanged = false; + + feedback.plugins.forEach((plugin) => { + promises.push(this.prepareFeedbackPluginData(assign.id, userId, feedback).then((inputData) => { + return this.feedbackDelegate.hasPluginDataChanged(assign, userId, plugin, inputData, userId).then((changed) => { + if (changed) { + hasChanged = true; + } + }); + }).catch(() => { + // Ignore errors. + })); + }); + + return this.utils.allPromises(promises).then(() => { + return hasChanged; + }); + } + + /** + * Check if the submission data has changed for a certain submission and assign. + * + * @param {any} assign Assignment. + * @param {any} submission Submission to check data. + * @param {any} inputData Data entered in the submission form. + * @return {Promise} Promise resolved with true if data has changed, resolved with false otherwise. + */ + hasSubmissionDataChanged(assign: any, submission: any, inputData: any): Promise { + const promises = []; + let hasChanged = false; + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.hasPluginDataChanged(assign, submission, plugin, inputData).then((changed) => { + if (changed) { + hasChanged = true; + } + }).catch(() => { + // Ignore errors. + })); + }); + + return this.utils.allPromises(promises).then(() => { + return hasChanged; + }); + } + + /** + * Prepare and return the plugin data to send for a certain feedback and assign. + * + * @param {number} assignId Assignment Id. + * @param {number} userId User Id. + * @param {any} feedback Feedback data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with plugin data to send to server. + */ + prepareFeedbackPluginData(assignId: number, userId: number, feedback: any, siteId?: string): Promise { + const pluginData = {}, + promises = []; + + feedback.plugins.forEach((plugin) => { + promises.push(this.feedbackDelegate.preparePluginFeedbackData(assignId, userId, plugin, pluginData, siteId)); + }); + + return Promise.all(promises).then(() => { + return pluginData; + }); + } + + /** + * Prepare and return the plugin data to send for a certain submission and assign. + * + * @param {any} assign Assignment. + * @param {any} submission Submission to check data. + * @param {any} inputData Data entered in the submission form. + * @param {boolean} [offline] True to prepare the data for an offline submission, false otherwise. + * @return {Promise} Promise resolved with plugin data to send to server. + */ + prepareSubmissionPluginData(assign: any, submission: any, inputData: any, offline?: boolean): Promise { + const pluginData = {}, + promises = []; + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.preparePluginSubmissionData(assign, submission, plugin, inputData, pluginData, + offline)); + }); + + return Promise.all(promises).then(() => { + return pluginData; + }); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param {number} assignId Assignment ID. + * @param {string} folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param {any[]} files List of files. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + storeSubmissionFiles(assignId: number, folderName: string, files: any[], userId?: number, siteId?: string): Promise { + // Get the folder where to store the files. + return this.assignOffline.getSubmissionPluginFolder(assignId, folderName, userId, siteId).then((folderPath) => { + return this.fileUploaderProvider.storeFilesToUpload(folderPath, files); + }); + } + + /** + * Upload a file to a draft area. If the file is an online file it will be downloaded and then re-uploaded. + * + * @param {number} assignId Assignment ID. + * @param {any} file Online file or local FileEntry. + * @param {number} [itemId] Draft ID to use. Undefined or 0 to create a new draft ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the itemId. + */ + uploadFile(assignId: number, file: any, itemId?: number, siteId?: string): Promise { + return this.fileUploaderProvider.uploadOrReuploadFile(file, itemId, AddonModAssignProvider.COMPONENT, assignId, siteId); + } + + /** + * Given a list of files (either online files or local files), upload them to a draft area and return the draft ID. + * Online files will be downloaded and then re-uploaded. + * If there are no files to upload it will return a fake draft ID (1). + * + * @param {number} assignId Assignment ID. + * @param {any[]} files List of files. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the itemId. + */ + uploadFiles(assignId: number, files: any[], siteId?: string): Promise { + return this.fileUploaderProvider.uploadOrReuploadFiles(files, AddonModAssignProvider.COMPONENT, assignId, siteId); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param {number} assignId Assignment ID. + * @param {string} folderName Name of the plugin folder. Must be unique (both in submission and feedback plugins). + * @param {any[]} files List of files. + * @param {boolean} offline True if files sould be stored for offline, false to upload them. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + uploadOrStoreFiles(assignId: number, folderName: string, files: any[], offline?: boolean, userId?: number, siteId?: string) + : Promise { + + if (offline) { + return this.storeSubmissionFiles(assignId, folderName, files, userId, siteId); + } else { + return this.uploadFiles(assignId, files, siteId); + } + } +} diff --git a/src/addon/mod/assign/providers/prefetch-handler.ts b/src/addon/mod/assign/providers/prefetch-handler.ts new file mode 100644 index 000000000..e3d104265 --- /dev/null +++ b/src/addon/mod/assign/providers/prefetch-handler.ts @@ -0,0 +1,378 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModAssignProvider } from './assign'; +import { AddonModAssignHelperProvider } from './helper'; +import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; + +/** + * Handler to prefetch assigns. + */ +@Injectable() +export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModAssign'; + modName = 'assign'; + component = AddonModAssignProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^submissions$|^grades$|^gradeitems$|^outcomes$|^comments$/; + + constructor(protected injector: Injector, protected assignProvider: AddonModAssignProvider, + protected textUtils: CoreTextUtilsProvider, protected feedbackDelegate: AddonModAssignFeedbackDelegate, + protected submissionDelegate: AddonModAssignSubmissionDelegate, protected courseProvider: CoreCourseProvider, + protected courseHelper: CoreCourseHelperProvider, protected filepoolProvider: CoreFilepoolProvider, + protected groupsProvider: CoreGroupsProvider, protected gradesHelper: CoreGradesHelperProvider, + protected userProvider: CoreUserProvider, protected assignHelper: AddonModAssignHelperProvider) { + super(injector); + } + + /** + * Check if a certain module can use core_course_check_updates to check if it has updates. + * If not defined, it will assume all modules can be checked. + * The modules that return false will always be shown as outdated when they're downloaded. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can use check_updates. The promise should never be rejected. + */ + canUseCheckUpdates(module: any, courseId: number): boolean | Promise { + // Teachers cannot use the WS because it doesn't check student submissions. + return this.assignProvider.getAssignment(courseId, module.id).then((assign) => { + return this.assignProvider.getSubmissions(assign.id); + }).then((data) => { + return !data.canviewsubmissions; + }).catch(() => { + return false; + }); + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download or prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.assignProvider.getAssignment(courseId, module.id, siteId).then((assign) => { + // Get intro files and attachments. + let files = assign.introattachments || []; + files = files.concat(this.getIntroFilesFromInstance(module, assign)); + + // Now get the files in the submissions. + return this.assignProvider.getSubmissions(assign.id, siteId).then((data) => { + const blindMarking = assign.blindmarking && !assign.revealidentities; + + if (data.canviewsubmissions) { + // Teacher, get all submissions. + return this.assignProvider.getSubmissionsUserData(data.submissions, courseId, assign.id, blindMarking, + undefined, siteId).then((submissions) => { + + const promises = []; + + // Get all the files in the submissions. + submissions.forEach((submission) => { + promises.push(this.getSubmissionFiles(assign, submission.submitid, !!submission.blindid, siteId) + .then((submissionFiles) => { + files = files.concat(submissionFiles); + })); + }); + + return Promise.all(promises).then(() => { + return files; + }); + }); + } else { + // Student, get only his/her submissions. + const userId = this.sitesProvider.getCurrentSiteUserId(); + + return this.getSubmissionFiles(assign, userId, blindMarking, siteId).then((submissionFiles) => { + files = files.concat(submissionFiles); + + return files; + }); + } + }); + }).catch(() => { + // Error getting data, return empty list. + return []; + }); + } + + /** + * Get submission files. + * + * @param {any} assign Assign. + * @param {number} submitId User ID of the submission to get. + * @param {boolean} blindMarking True if blind marking, false otherwise. + * @param {string} siteId Site ID. If not defined, current site. + * @return {Promise} Promise resolved with array of files. + */ + protected getSubmissionFiles(assign: any, submitId: number, blindMarking: boolean, siteId?: string) + : Promise { + + return this.assignProvider.getSubmissionStatus(assign.id, submitId, blindMarking, true, false, siteId).then((response) => { + const promises = []; + + if (response.lastattempt) { + const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(assign, response.lastattempt); + if (userSubmission) { + // Add submission plugin files. + userSubmission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.getPluginFiles(assign, userSubmission, plugin, siteId)); + }); + } + } + + if (response.feedback) { + // Add feedback plugin files. + response.feedback.plugins.forEach((plugin) => { + promises.push(this.feedbackDelegate.getPluginFiles(assign, response, plugin, siteId)); + }); + } + + return Promise.all(promises); + + }).then((filesLists) => { + let files = []; + + filesLists.forEach((filesList) => { + files = files.concat(filesList); + }); + + return files; + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.assignProvider.invalidateContent(moduleId, courseId); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.assignProvider.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, single, this.prefetchAssign.bind(this)); + } + + /** + * Prefetch an assignment. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {String} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected prefetchAssign(module: any, courseId: number, single: boolean, siteId: string): Promise { + const userId = this.sitesProvider.getCurrentSiteUserId(), + promises = []; + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId)); + + // Get assignment to retrieve all its submissions. + promises.push(this.assignProvider.getAssignment(courseId, module.id, siteId).then((assign) => { + const subPromises = [], + blindMarking = assign.blindmarking && !assign.revealidentities; + + if (blindMarking) { + subPromises.push(this.assignProvider.getAssignmentUserMappings(assign.id, undefined, siteId)); + } + + subPromises.push(this.prefetchSubmissions(assign, courseId, module.id, userId, siteId)); + + subPromises.push(this.courseHelper.getModuleCourseIdByInstance(assign.id, 'assign', siteId)); + + // Get all files and fetch them. + subPromises.push(this.getFiles(module, courseId, single, siteId).then((files) => { + return this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id); + })); + + return Promise.all(subPromises); + })); + + return Promise.all(promises); + } + + /** + * Prefetch assign submissions. + * + * @param {any} assign Assign. + * @param {number} courseId Course ID. + * @param {number} moduleId Module ID. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when prefetched, rejected otherwise. + */ + protected prefetchSubmissions(assign: any, courseId: number, moduleId: number, userId?: number, siteId?: string): Promise { + + // Get submissions. + return this.assignProvider.getSubmissions(assign.id, siteId).then((data) => { + const promises = [], + blindMarking = assign.blindmarking && !assign.revealidentities; + + if (data.canviewsubmissions) { + // Teacher. Do not send participants to getSubmissionsUserData to retrieve user profiles. + promises.push(this.assignProvider.getSubmissionsUserData(data.submissions, courseId, assign.id, blindMarking, + undefined, siteId).then((submissions) => { + + const subPromises = []; + + submissions.forEach((submission) => { + subPromises.push(this.assignProvider.getSubmissionStatus(assign.id, submission.submitid, + !!submission.blindid, true, false, siteId).then((subm) => { + return this.prefetchSubmission(assign, courseId, moduleId, subm, submission.submitid, siteId); + })); + }); + + return Promise.all(subPromises); + })); + + // Get list participants. + promises.push(this.assignHelper.getParticipants(assign, siteId).then((participants) => { + participants.forEach((participant) => { + if (participant.profileimageurl) { + this.filepoolProvider.addToQueueByUrl(siteId, participant.profileimageurl); + } + }); + }).catch(() => { + // Fail silently (Moodle < 3.2). + })); + } else { + // Student. + promises.push(this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, false, siteId) + .then((subm) => { + return this.prefetchSubmission(assign, courseId, moduleId, subm, userId, siteId); + })); + } + + promises.push(this.groupsProvider.activityHasGroups(assign.cmid)); + promises.push(this.groupsProvider.getActivityAllowedGroups(assign.cmid, undefined, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Prefetch a submission. + * + * @param {any} assign Assign. + * @param {number} courseId Course ID. + * @param {number} moduleId Module ID. + * @param {any} submission Data returned by AddonModAssignProvider.getSubmissionStatus. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when prefetched, rejected otherwise. + */ + protected prefetchSubmission(assign: any, courseId: number, moduleId: number, submission: any, userId?: number, + siteId?: string): Promise { + + const promises = [], + blindMarking = assign.blindmarking && !assign.revealidentities; + let userIds = []; + + if (submission.lastattempt) { + const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(assign, submission.lastattempt); + + // Get IDs of the members who need to submit. + if (!blindMarking && submission.lastattempt.submissiongroupmemberswhoneedtosubmit) { + userIds = userIds.concat(submission.lastattempt.submissiongroupmemberswhoneedtosubmit); + } + + if (userSubmission && userSubmission.id) { + // Prefetch submission plugins data. + userSubmission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.prefetch(assign, userSubmission, plugin, siteId)); + }); + + // Get ID of the user who did the submission. + if (userSubmission.userid) { + userIds.push(userSubmission.userid); + } + } + } + + // Prefetch feedback. + if (submission.feedback) { + // Get profile and image of the grader. + if (submission.feedback.grade && submission.feedback.grade.grader) { + userIds.push(submission.feedback.grade.grader); + } + + if (userId) { + promises.push(this.gradesHelper.getGradeModuleItems(courseId, moduleId, userId, undefined, siteId)); + } + + // Prefetch feedback plugins data. + submission.feedback.plugins.forEach((plugin) => { + promises.push(this.feedbackDelegate.prefetch(assign, submission, plugin, siteId)); + }); + } + + // Prefetch user profiles. + promises.push(this.userProvider.prefetchProfiles(userIds, courseId, siteId)); + + return Promise.all(promises); + } +} diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index 3464a8d0f..6cb79a6f4 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -13,11 +13,11 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSite } from '@classes/site'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { CoreFilepoolProvider } from '@providers/filepool'; /** * Service to provide user functionalities. @@ -371,10 +371,10 @@ export class CoreUserProvider { /** * Prefetch user profiles and their images from a certain course. It prevents duplicates. * - * @param {number[]} userIds List of user IDs. - * @param {number} [courseId] Course the users belong to. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when prefetched. + * @param {number[]} userIds List of user IDs. + * @param {number} [courseId] Course the users belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when prefetched. */ prefetchProfiles(userIds: number[], courseId?: number, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); @@ -383,11 +383,13 @@ export class CoreUserProvider { promises = []; userIds.forEach((userId) => { + userId = Number(userId); // Make sure it's a number. + // Prevent repeats and errors. - if (!treated[userId]) { + if (!isNaN(userId) && !treated[userId]) { treated[userId] = true; - promises.push(this.getProfile(userId, courseId).then((profile) => { + promises.push(this.getProfile(userId, courseId, false, siteId).then((profile) => { if (profile.profileimageurl) { this.filepoolProvider.addToQueueByUrl(siteId, profile.profileimageurl); } From 7ab247cf7c090ad2daf353b085873046249a6a69 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 11 Apr 2018 10:06:20 +0200 Subject: [PATCH 04/16] MOBILE-2334 assign: Implement sync provider --- src/addon/mod/assign/assign.module.ts | 11 +- src/addon/mod/assign/providers/assign-sync.ts | 432 ++++++++++++++++++ .../mod/assign/providers/sync-cron-handler.ts | 47 ++ 3 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/assign/providers/assign-sync.ts create mode 100644 src/addon/mod/assign/providers/sync-cron-handler.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index 32dbecdcb..d3a41cfbf 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -13,15 +13,18 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModAssignProvider } from './providers/assign'; import { AddonModAssignOfflineProvider } from './providers/assign-offline'; +import { AddonModAssignSyncProvider } from './providers/assign-sync'; import { AddonModAssignHelperProvider } from './providers/helper'; import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; import { AddonModAssignDefaultSubmissionHandler } from './providers/default-submission-handler'; import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; @NgModule({ declarations: [ @@ -29,16 +32,20 @@ import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; providers: [ AddonModAssignProvider, AddonModAssignOfflineProvider, + AddonModAssignSyncProvider, AddonModAssignHelperProvider, AddonModAssignFeedbackDelegate, AddonModAssignSubmissionDelegate, AddonModAssignDefaultFeedbackHandler, AddonModAssignDefaultSubmissionHandler, - AddonModAssignPrefetchHandler + AddonModAssignPrefetchHandler, + AddonModAssignSyncCronHandler ] }) export class AddonModAssignModule { - constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler) { + constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModAssignSyncCronHandler) { prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts new file mode 100644 index 000000000..1f43c7dde --- /dev/null +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -0,0 +1,432 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModAssignProvider } from './assign'; +import { AddonModAssignOfflineProvider } from './assign-offline'; +import { AddonModAssignSubmissionDelegate } from './submission-delegate'; + +/** + * Data returned by an assign sync. + */ +export interface AddonModAssignSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether data was updated in the site. + * @type {boolean} + */ + updated: boolean; +} + +/** + * Service to sync assigns. + */ +@Injectable() +export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_assign_autom_synced'; + static MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + private courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private assignProvider: AddonModAssignProvider, private assignOfflineProvider: AddonModAssignOfflineProvider, + private utils: CoreUtilsProvider, private submissionDelegate: AddonModAssignSubmissionDelegate, + private gradesHelper: CoreGradesHelperProvider) { + + super('AddonModAssignSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('assign'); + } + + /** + * Convenience function to get scale selected option. + * + * @param {string} options Possible options. + * @param {number} selected Selected option to search. + * @return {number} Index of the selected option. + */ + protected getSelectedScaleId(options: string, selected: string): number { + let optionsList = options.split(','); + + optionsList = optionsList.map((value) => { + return 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 {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether it has data to sync. + */ + hasDataToSync(assignId: number, siteId?: string): Promise { + return this.assignOfflineProvider.hasAssignOfflineData(assignId, siteId); + } + + /** + * Try to synchronize all the assignments in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllAssignments(siteId?: string): Promise { + return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [], siteId); + } + + /** + * Sync all assignments on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllAssignmentsFunc(siteId?: string): Promise { + // Get all assignments that have offline data. + return this.assignOfflineProvider.getAllAssigns(siteId).then((assignIds) => { + const promises = []; + + // Sync all assignments that haven't been synced for a while. + assignIds.forEach((assignId) => { + promises.push(this.syncAssignIfNeeded(assignId, siteId).then((data) => { + if (data && data.updated) { + // Sync done. Send event. + this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { + assignId: assignId, + warnings: data.warnings + }, siteId); + } + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync an assignment only if a certain time has passed since the last time. + * + * @param {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the assign is synced or it doesn't need to be synced. + */ + syncAssignIfNeeded(assignId: number, siteId?: string): Promise { + return this.isSyncNeeded(assignId, siteId).then((needed) => { + if (needed) { + return this.syncAssign(assignId, siteId); + } + }); + } + + /** + * Try to synchronize an assign. + * + * @param {number} assignId Assign ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncAssign(assignId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = [], + result: AddonModAssignSyncResult = { + warnings: [], + updated: false + }; + let assign, + courseId, + syncPromise; + + 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 (this.syncProvider.isBlocked(AddonModAssignProvider.COMPONENT, assignId, siteId)) { + this.logger.debug('Cannot sync assign ' + assignId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync assign ' + assignId + ' in site ' + siteId); + + // Get offline submissions to be sent. + promises.push(this.assignOfflineProvider.getAssignSubmissions(assignId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + })); + + // Get offline submission grades to be sent. + promises.push(this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + })); + + syncPromise = Promise.all(promises).then((results) => { + const submissions = results[0], + grades = results[1]; + + if (!submissions.length && !grades.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; + + return this.assignProvider.getAssignmentById(courseId, assignId, siteId).then((assignData) => { + assign = assignData; + + const promises = []; + + submissions.forEach((submission) => { + promises.push(this.syncSubmission(assign, submission, result.warnings, siteId).then(() => { + result.updated = true; + })); + }); + + grades.forEach((grade) => { + promises.push(this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId).then(() => { + result.updated = true; + })); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated) { + // Data has been sent to server. Now invalidate the WS calls. + return this.assignProvider.invalidateContent(assign.cmid, courseId, siteId).catch(() => { + // Ignore errors. + }); + } + }); + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(assignId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(assignId, syncPromise, siteId); + } + + /** + * Synchronize a submission. + * + * @param {any} assign Assignment. + * @param {any} offlineData Submission offline data. + * @param {string[]} warnings List of warnings. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + protected syncSubmission(assign: any, offlineData: any, warnings: string[], siteId?: string): Promise { + const userId = offlineData.userId, + pluginData = {}; + let discardError, + submission; + + return this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId).then((status) => { + const promises = []; + + submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt); + + if (submission.timemodified != offlineData.onlineTimemodified) { + // The submission was modified in Moodle, discard the submission. + discardError = this.translate.instant('addon.mod_assign.warningsubmissionmodified'); + + return; + } + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.preparePluginSyncData(assign, submission, plugin, offlineData, pluginData, + siteId)); + }); + + return Promise.all(promises).then(() => { + // Now save the submission. + let promise; + + if (!Object.keys(pluginData).length) { + // Nothing to save. + promise = Promise.resolve(); + } else { + promise = this.assignProvider.saveSubmissionOnline(assign.id, pluginData, siteId); + } + + return promise.then(() => { + if (assign.submissiondrafts && offlineData.submitted) { + // The user submitted the assign manually. Submit it for grading. + return this.assignProvider.submitForGradingOnline(assign.id, offlineData.submissionStatement, siteId); + } + }).then(() => { + // Submission data sent, update cached data. No need to block the user for this. + this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId); + }); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // A WebService has thrown an error, this means it cannot be submitted. Discard the submission. + discardError = error.message || error.error || error.content || error.body; + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }).then(() => { + // Delete the offline data. + return this.assignOfflineProvider.deleteSubmission(assign.id, userId, siteId).then(() => { + const promises = []; + + submission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.deletePluginOfflineData(assign, submission, plugin, offlineData, siteId)); + }); + + return Promise.all(promises); + }); + }).then(() => { + if (discardError) { + // Submission was discarded, add a warning. + const message = this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: assign.name, + error: discardError + }); + + if (warnings.indexOf(message) == -1) { + warnings.push(message); + } + } + }); + } + + /** + * Synchronize a submission grade. + * + * @param {any} assign Assignment. + * @param {any} offlineData Submission grade offline data. + * @param {string[]} warnings List of warnings. + * @param {number} courseId Course Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + protected syncSubmissionGrade(assign: any, offlineData: any, warnings: string[], courseId: number, siteId?: string) + : Promise { + + const userId = offlineData.userId; + let discardError; + + return this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId).then((status) => { + const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified); + + if (timemodified > offlineData.timemodified) { + // The submission grade was modified in Moodle, discard it. + discardError = this.translate.instant('addon.mod_assign.warningsubmissiongrademodified'); + + return; + } + + // If grade has been modified from gradebook, do not use offline. + return this.gradesHelper.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true).then((grades) => { + return this.courseProvider.getModuleBasicGradeInfo(assign.cmid, siteId).then((gradeInfo) => { + + // Override offline grade and outcomes based on the gradebook data. + grades.forEach((grade) => { + if (grade.gradedategraded >= offlineData.timemodified) { + if (!grade.outcomeid && !grade.scaleid) { + if (gradeInfo && gradeInfo.scale) { + offlineData.grade = this.getSelectedScaleId(gradeInfo.scale, grade.gradeformatted); + } else { + offlineData.grade = parseFloat(grade.gradeformatted) || null; + } + } else if (grade.outcomeid && this.assignProvider.isOutcomesEditEnabled() && gradeInfo.outcomes) { + gradeInfo.outcomes.forEach((outcome, index) => { + if (outcome.scale && grade.itemnumber == index) { + offlineData.outcomes[grade.itemnumber] = this.getSelectedScaleId(outcome.scale, + outcome.selected); + } + }); + } + } + }); + }); + }).then(() => { + // Now submit the grade. + return this.assignProvider.submitGradingFormOnline(assign.id, userId, offlineData.grade, offlineData.attemptNumber, + offlineData.addAttempt, offlineData.workflowState, offlineData.applyToAll, offlineData.outcomes, + offlineData.pluginData, siteId).then(() => { + + // Grades sent, update cached data. No need to block the user for this. + this.assignProvider.getSubmissionStatus(assign.id, userId, false, true, true, siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be submitted. Discard the offline data. + discardError = error.message || error.error || error.content || error.body; + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + }).then(() => { + // Delete the offline data. + return this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId); + }).then(() => { + if (discardError) { + // Submission grade was discarded, add a warning. + const message = this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: assign.name, + error: discardError + }); + + if (warnings.indexOf(message) == -1) { + warnings.push(message); + } + } + }); + } +} diff --git a/src/addon/mod/assign/providers/sync-cron-handler.ts b/src/addon/mod/assign/providers/sync-cron-handler.ts new file mode 100644 index 000000000..9cc969259 --- /dev/null +++ b/src/addon/mod/assign/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreCronHandler } from '@providers/cron'; +import { AddonModAssignSyncProvider } from './assign-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModAssignSyncCronHandler implements CoreCronHandler { + name = 'AddonModAssignSyncCronHandler'; + + constructor(private assignSync: AddonModAssignSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.assignSync.syncAllAssignments(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; // 10 minutes. + } +} From 0bb96f0e80f5d36ffa04443dbccd6a9ce1fda1b9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 11 Apr 2018 13:49:59 +0200 Subject: [PATCH 05/16] MOBILE-2334 assign: Implement assign index page --- src/addon/mod/assign/assign.module.ts | 7 +- .../assign/components/components.module.ts | 45 +++ .../mod/assign/components/index/index.html | 87 +++++ .../mod/assign/components/index/index.ts | 312 ++++++++++++++++++ src/addon/mod/assign/lang/en.json | 99 ++++++ src/addon/mod/assign/pages/index/index.html | 16 + .../mod/assign/pages/index/index.module.ts | 33 ++ src/addon/mod/assign/pages/index/index.ts | 48 +++ .../assign/providers/index-link-handler.ts | 29 ++ .../mod/assign/providers/module-handler.ts | 72 ++++ .../mod/choice/components/index/index.ts | 4 +- .../mod/feedback/components/index/index.ts | 4 +- src/addon/mod/quiz/components/index/index.ts | 4 +- .../mod/survey/components/index/index.ts | 10 +- .../mod/survey/providers/module-handler.ts | 1 + src/app/app.scss | 5 + .../course/classes/main-activity-component.ts | 23 +- src/core/viewer/pages/text/text.html | 4 + src/core/viewer/pages/text/text.module.ts | 2 + src/core/viewer/pages/text/text.ts | 2 + src/directives/format-text.ts | 5 + src/providers/utils/text.ts | 6 +- 22 files changed, 798 insertions(+), 20 deletions(-) create mode 100644 src/addon/mod/assign/components/components.module.ts create mode 100644 src/addon/mod/assign/components/index/index.html create mode 100644 src/addon/mod/assign/components/index/index.ts create mode 100644 src/addon/mod/assign/lang/en.json create mode 100644 src/addon/mod/assign/pages/index/index.html create mode 100644 src/addon/mod/assign/pages/index/index.module.ts create mode 100644 src/addon/mod/assign/pages/index/index.ts create mode 100644 src/addon/mod/assign/providers/index-link-handler.ts create mode 100644 src/addon/mod/assign/providers/module-handler.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index d3a41cfbf..cb3d922fb 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModAssignProvider } from './providers/assign'; import { AddonModAssignOfflineProvider } from './providers/assign-offline'; @@ -23,6 +24,7 @@ import { AddonModAssignFeedbackDelegate } from './providers/feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './providers/submission-delegate'; import { AddonModAssignDefaultFeedbackHandler } from './providers/default-feedback-handler'; import { AddonModAssignDefaultSubmissionHandler } from './providers/default-submission-handler'; +import { AddonModAssignModuleHandler } from './providers/module-handler'; import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; @@ -38,13 +40,16 @@ import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; AddonModAssignSubmissionDelegate, AddonModAssignDefaultFeedbackHandler, AddonModAssignDefaultSubmissionHandler, + AddonModAssignModuleHandler, AddonModAssignPrefetchHandler, AddonModAssignSyncCronHandler ] }) export class AddonModAssignModule { - constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler, + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModAssignModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler, cronDelegate: CoreCronDelegate, syncHandler: AddonModAssignSyncCronHandler) { + moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); cronDelegate.register(syncHandler); } diff --git a/src/addon/mod/assign/components/components.module.ts b/src/addon/mod/assign/components/components.module.ts new file mode 100644 index 000000000..b1911094b --- /dev/null +++ b/src/addon/mod/assign/components/components.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModAssignIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModAssignIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModAssignIndexComponent + ], + entryComponents: [ + AddonModAssignIndexComponent + ] +}) +export class AddonModAssignComponentsModule {} diff --git a/src/addon/mod/assign/components/index/index.html b/src/addon/mod/assign/components/index/index.html new file mode 100644 index 000000000..c92079a16 --- /dev/null +++ b/src/addon/mod/assign/components/index/index.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + {{ note }} + + + + + + + + +
+ + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} +
+ + + + +

{{ 'addon.mod_assign.timeremaining' | translate }}

+

{{ timeRemaining }}

+
+ +

{{ 'addon.mod_assign.latesubmissions' | translate }}

+

{{ lateSubmissions }}

+
+ + + +

{{ 'addon.mod_assign.numberofteams' | translate }}

+

{{ 'addon.mod_assign.numberofparticipants' | translate }}

+ + {{ summary.participantcount }} + +
+ + + +

{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}

+ + {{ summary.submissiondraftscount }} + +
+ + + +

{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}

+ + {{ summary.submissionssubmittedcount }} + +
+ + + +

{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}

+ + {{ summary.submissionsneedgradingcount }} + +
+ + +
+ + {{ 'addon.mod_assign.ungroupedusers' | translate }} +
+
+ + + +
diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts new file mode 100644 index 000000000..ea7497ff5 --- /dev/null +++ b/src/addon/mod/assign/components/index/index.ts @@ -0,0 +1,312 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Optional, Injector } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import { AddonModAssignSyncProvider } from '../../providers/assign-sync'; +import * as moment from 'moment'; + +/** + * Component that displays an assignment. + */ +@Component({ + selector: 'addon-mod-assign-index', + templateUrl: 'index.html', +}) +export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent { + component = AddonModAssignProvider.COMPONENT; + moduleName = 'assign'; + + assign: any; // The assign object. + canViewSubmissions: boolean; // Whether the user can view all submissions. + timeRemaining: string; // Message about time remaining to submit. + lateSubmissions: string; // Message about late submissions. + showNumbers = true; // Whether to show number of submissions with each status. + summary: any; // The summary. + needsGradingAvalaible: boolean; // Whether we can see the submissions that need grading. + + // Status. + submissionStatusSubmitted = AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED; + submissionStatusDraft = AddonModAssignProvider.SUBMISSION_STATUS_DRAFT; + needGrading = AddonModAssignProvider.NEED_GRADING; + + protected userId: number; // Current user ID. + protected syncEventName = AddonModAssignSyncProvider.AUTO_SYNCED; + + // Observers. + protected savedObserver; + protected submittedObserver; + protected gradedObserver; + + constructor(injector: Injector, protected assignProvider: AddonModAssignProvider, @Optional() content: Content, + protected assignHelper: AddonModAssignHelperProvider, protected assignOffline: AddonModAssignOfflineProvider, + protected syncProvider: AddonModAssignSyncProvider, protected timeUtils: CoreTimeUtilsProvider, + protected groupsProvider: CoreGroupsProvider, protected navCtrl: NavController) { + super(injector, content); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.userId = this.sitesProvider.getCurrentSiteUserId(); + + this.loadContent(false, true).then(() => { + this.assignProvider.logView(this.assign.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch(() => { + // Ignore errors. + }); + + if (!this.canViewSubmissions) { + // User can only see his submission, log view the user submission. + this.assignProvider.logSubmissionView(this.assign.id).catch(() => { + // Ignore errors. + }); + } else { + // User can see all submissions, log grading view. + this.assignProvider.logGradingView(this.assign.id).catch(() => { + // Ignore errors. + }); + } + }); + + // Listen to events. + this.savedObserver = this.eventsProvider.on(AddonModAssignProvider.SUBMISSION_SAVED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.userId) { + // Assignment submission saved, refresh data. + this.showLoadingAndRefresh(true, false); + } + }, this.siteId); + + this.submittedObserver = this.eventsProvider.on(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.userId) { + // Assignment submitted, check completion. + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + }, this.siteId); + + this.gradedObserver = this.eventsProvider.on(AddonModAssignProvider.GRADED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == this.userId) { + // Assignment graded, refresh data. + this.showLoadingAndRefresh(true, false); + } + }, this.siteId); + } + + /** + * Expand the description. + */ + expandDescription(ev?: Event): void { + ev && ev.preventDefault(); + ev && ev.stopPropagation(); + + if (this.assign && (this.description || this.assign.introattachments)) { + this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component, + this.module.id, this.assign.introattachments); + } + } + + /** + * Get assignment data. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + + // Get assignment data. + return this.assignProvider.getAssignment(this.courseId, this.module.id).then((assignData) => { + this.assign = assignData; + + this.dataRetrieved.emit(this.assign); + this.description = this.assign.intro || this.description; + + if (sync) { + // Try to synchronize the assign. + return this.syncActivity(showErrors).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Check if there's any offline data for this assign. + return this.assignOffline.hasAssignOfflineData(this.assign.id); + }).then((hasOffline) => { + this.hasOffline = hasOffline; + + // Get assignment submissions. + return this.assignProvider.getSubmissions(this.assign.id).then((data) => { + const time = this.timeUtils.timestamp(); + + this.canViewSubmissions = data.canviewsubmissions; + + if (data.canviewsubmissions) { + + // Calculate the messages to display about time remaining and late submissions. + if (this.assign.duedate > 0) { + if (this.assign.duedate - time <= 0) { + this.timeRemaining = this.translate.instant('addon.mod_assign.assignmentisdue'); + } else { + this.timeRemaining = this.timeUtils.formatDuration(this.assign.duedate - time, 3); + + if (this.assign.cutoffdate) { + if (this.assign.cutoffdate > time) { + const dateFormat = this.translate.instant('core.dfmediumdate'); + + this.lateSubmissions = this.translate.instant('addon.mod_assign.latesubmissionsaccepted', + {$a: moment(this.assign.cutoffdate * 1000).format(dateFormat)}); + } else { + this.lateSubmissions = this.translate.instant('addon.mod_assign.nomoresubmissionsaccepted'); + } + } else { + this.lateSubmissions = ''; + } + } + } else { + this.timeRemaining = ''; + this.lateSubmissions = ''; + } + + // Check if groupmode is enabled to avoid showing wrong numbers. + return this.groupsProvider.activityHasGroups(this.assign.cmid).then((hasGroups) => { + this.showNumbers = !hasGroups; + + return this.assignProvider.getSubmissionStatus(this.assign.id).then((response) => { + this.summary = response.gradingsummary; + + this.needsGradingAvalaible = response.gradingsummary.submissionsneedgradingcount > 0 && + this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.2'); + }); + }); + } + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }); + } + + /** + * Go to view a list of submissions. + * + * @param {string} status Status to see. + * @param {number} count Number of submissions with the status. + */ + goToSubmissionList(status: string, count: number): void { + if (typeof status == 'undefined') { + this.navCtrl.push('AddonModAssignSubmissionListPage', { + courseId: this.courseId, + moduleId: this.module.id, + moduleName: this.moduleName + }); + } else if (count || !this.showNumbers) { + this.navCtrl.push('AddonModAssignSubmissionListPage', { + status: status, + courseId: this.courseId, + moduleId: this.module.id, + moduleName: this.moduleName + }); + } + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned by the sync function. + * @return {boolean} If succeed or not. + */ + protected hasSyncSucceed(result: any): boolean { + if (result.updated) { + // Sync done, trigger event. + this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { + assignId: this.assign.id, + warnings: result.warnings + }, this.siteId); + } + + return result.updated; + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); + + if (this.assign) { + promises.push(this.assignProvider.invalidateAllSubmissionData(this.assign.id)); + + if (this.canViewSubmissions) { + promises.push(this.assignProvider.invalidateSubmissionStatusData(this.assign.id)); + } + } + + return Promise.all(promises).finally(() => { + // @todo $scope.$broadcast(mmaModAssignSubmissionInvalidatedEvent); + }); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (this.assign && syncEventData.assignId == this.assign.id) { + if (syncEventData.warnings && syncEventData.warnings.length) { + // Show warnings. + this.domUtils.showErrorModal(syncEventData.warnings[0]); + } + + return true; + } + + return false; + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.syncProvider.syncAssign(this.assign.id); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.savedObserver && this.savedObserver.off(); + this.submittedObserver && this.submittedObserver.off(); + this.gradedObserver && this.gradedObserver.off(); + } +} diff --git a/src/addon/mod/assign/lang/en.json b/src/addon/mod/assign/lang/en.json new file mode 100644 index 000000000..e04437755 --- /dev/null +++ b/src/addon/mod/assign/lang/en.json @@ -0,0 +1,99 @@ +{ + "acceptsubmissionstatement": "Please accept the submission statement.", + "addattempt": "Allow another attempt", + "addnewattempt": "Add a new attempt", + "addnewattemptfromprevious": "Add a new attempt based on previous submission", + "addsubmission": "Add submission", + "allowsubmissionsfromdate": "Allow submissions from", + "allowsubmissionsfromdatesummary": "This assignment will accept submissions from {{$a}}", + "allowsubmissionsanddescriptionfromdatesummary": "The assignment details and submission form will be available from {{$a}}", + "applytoteam": "Apply grades and feedback to entire group", + "assignmentisdue": "Assignment is due", + "attemptnumber": "Attempt number", + "attemptreopenmethod": "Attempts reopened", + "attemptreopenmethod_manual": "Manually", + "attemptreopenmethod_untilpass": "Automatically until pass", + "attemptsettings": "Attempt settings", + "cannotgradefromapp": "Certain grading methods are not yet supported by the app and cannot be modified.", + "cannoteditduetostatementsubmission": "You can't add or edit a submission in the app because the submission statement could not be retrieved from the site.", + "cannotsubmitduetostatementsubmission": "You can't make a submission in the app because the submission statement could not be retrieved from the site.", + "confirmsubmission": "Are you sure you want to submit your work for grading? You will not be able to make any more changes.", + "currentgrade": "Current grade in gradebook", + "cutoffdate": "Cut-off date", + "currentattempt": "This is attempt {{$a}}.", + "currentattemptof": "This is attempt {{$a.attemptnumber}} ( {{$a.maxattempts}} attempts allowed ).", + "defaultteam": "Default group", + "duedate": "Due date", + "duedateno": "No due date", + "duedatereached": "The due date for this assignment has now passed", + "editingstatus": "Editing status", + "editsubmission": "Edit submission", + "erroreditpluginsnotsupported": "You can't add or edit a submission in the app because certain plugins are not yet supported for editing.", + "errorshowinginformation": "Submission information cannot be displayed.", + "extensionduedate": "Extension due date", + "feedbacknotsupported": "This feedback is not supported by the app and may not contain all the information.", + "grade": "Grade", + "graded": "Graded", + "gradedby": "Graded by", + "gradenotsynced": "Grade not synced", + "gradedon": "Graded on", + "gradeoutof": "Grade out of {{$a}}", + "gradingstatus": "Grading status", + "groupsubmissionsettings": "Group submission settings", + "hiddenuser": "Participant", + "latesubmissions": "Late submissions", + "latesubmissionsaccepted": "Allowed until {{$a}}", + "markingworkflowstate": "Marking workflow state", + "markingworkflowstateinmarking": "In marking", + "markingworkflowstateinreview": "In review", + "markingworkflowstatenotmarked": "Not marked", + "markingworkflowstatereadyforreview": "Marking completed", + "markingworkflowstatereadyforrelease": "Ready for release", + "markingworkflowstatereleased": "Released", + "multipleteams": "Member of more than one group", + "noattempt": "No attempt", + "nomoresubmissionsaccepted": "Only allowed for participants who have been granted an extension", + "noonlinesubmissions": "This assignment does not require you to submit anything online", + "nosubmission": "Nothing has been submitted for this assignment", + "notallparticipantsareshown": "Participants who have not made a submission are not shown.", + "noteam": "Not a member of any group", + "notgraded": "Not graded", + "numberofdraftsubmissions": "Drafts", + "numberofparticipants": "Participants", + "numberofsubmittedassignments": "Submitted", + "numberofsubmissionsneedgrading": "Needs grading", + "numberofteams": "Groups", + "numwords": "({{$a}} words)", + "outof": "{{$a.current}} out of {{$a.total}}", + "overdue": "Assignment is overdue by: {{$a}}", + "savechanges": "Save changes", + "submissioneditable": "Student can edit this submission", + "submissionnoteditable": "Student cannot edit this submission", + "submissionnotsupported": "This submission is not supported by the app and may not contain all the information.", + "submission": "Submission", + "submissionslocked": "This assignment is not accepting submissions", + "submissionstatus_draft": "Draft (not submitted)", + "submissionstatusheading": "Submission status", + "submissionstatus_marked": "Graded", + "submissionstatus_new": "No submission", + "submissionstatus_reopened": "Reopened", + "submissionstatus_submitted": "Submitted for grading", + "submissionstatus_": "No submission", + "submissionstatus": "Submission status", + "submissionstatusheading": "Submission status", + "submissionteam": "Group", + "submitassignment_help": "Once this assignment is submitted you will not be able to make any more changes.", + "submitassignment": "Submit assignment", + "submittedearly": "Assignment was submitted {{$a}} early", + "submittedlate": "Assignment was submitted {{$a}} late", + "timemodified": "Last modified", + "timeremaining": "Time remaining", + "ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.", + "unlimitedattempts": "Unlimited", + "userwithid": "User with ID {{id}}", + "userswhoneedtosubmit": "Users who need to submit: {{$a}}", + "viewsubmission": "View submission", + "warningsubmissionmodified": "The user submission was modified on the site.", + "warningsubmissiongrademodified": "The submission grade was modified on the site.", + "wordlimit": "Word limit" +} \ No newline at end of file diff --git a/src/addon/mod/assign/pages/index/index.html b/src/addon/mod/assign/pages/index/index.html new file mode 100644 index 000000000..e66a37dd0 --- /dev/null +++ b/src/addon/mod/assign/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/assign/pages/index/index.module.ts b/src/addon/mod/assign/pages/index/index.module.ts new file mode 100644 index 000000000..ebaca9739 --- /dev/null +++ b/src/addon/mod/assign/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModAssignComponentsModule } from '../../components/components.module'; +import { AddonModAssignIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModAssignIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModAssignComponentsModule, + IonicPageModule.forChild(AddonModAssignIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModAssignIndexPageModule {} diff --git a/src/addon/mod/assign/pages/index/index.ts b/src/addon/mod/assign/pages/index/index.ts new file mode 100644 index 000000000..f9b960f67 --- /dev/null +++ b/src/addon/mod/assign/pages/index/index.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModAssignIndexComponent } from '../../components/index/index'; + +/** + * Page that displays an assign. + */ +@IonicPage({ segment: 'addon-mod-assign-index' }) +@Component({ + selector: 'page-addon-mod-assign-index', + templateUrl: 'index.html', +}) +export class AddonModAssignIndexPage { + @ViewChild(AddonModAssignIndexComponent) assignComponent: AddonModAssignIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the assign instance. + * + * @param {any} assign Assign instance. + */ + updateData(assign: any): void { + this.title = assign.name || this.title; + } +} diff --git a/src/addon/mod/assign/providers/index-link-handler.ts b/src/addon/mod/assign/providers/index-link-handler.ts new file mode 100644 index 000000000..f129eb1b9 --- /dev/null +++ b/src/addon/mod/assign/providers/index-link-handler.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to assign index page. + */ +@Injectable() +export class AddonModAssignIndexLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModAssignIndexLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'AddonModAssign', 'assign'); + } +} diff --git a/src/addon/mod/assign/providers/module-handler.ts b/src/addon/mod/assign/providers/module-handler.ts new file mode 100644 index 000000000..4de424c27 --- /dev/null +++ b/src/addon/mod/assign/providers/module-handler.ts @@ -0,0 +1,72 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController, NavOptions } from 'ionic-angular'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModAssignProvider } from './assign'; +import { AddonModAssignIndexComponent } from '../components/index/index'; + +/** + * Handler to support assign modules. + */ +@Injectable() +export class AddonModAssignModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModAssign'; + modName = 'assign'; + + constructor(private courseProvider: CoreCourseProvider, private assignProvider: AddonModAssignProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return this.assignProvider.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('assign'), + title: module.name, + class: 'addon-mod_assign-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModAssignIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModAssignIndexComponent; + } +} diff --git a/src/addon/mod/choice/components/index/index.ts b/src/addon/mod/choice/components/index/index.ts index 3c52e925f..b1dd2c9bf 100644 --- a/src/addon/mod/choice/components/index/index.ts +++ b/src/addon/mod/choice/components/index/index.ts @@ -48,9 +48,9 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo protected hasAnsweredOnline = false; protected now: number; - constructor(injector: Injector, private choiceProvider: AddonModChoiceProvider, @Optional() private content: Content, + constructor(injector: Injector, private choiceProvider: AddonModChoiceProvider, @Optional() content: Content, private choiceOffline: AddonModChoiceOfflineProvider, private choiceSync: AddonModChoiceSyncProvider) { - super(injector); + super(injector, content); } /** diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts index 6e31b5a9f..cd90d9d65 100644 --- a/src/addon/mod/feedback/components/index/index.ts +++ b/src/addon/mod/feedback/components/index/index.ts @@ -65,11 +65,11 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity protected submitObserver: any; - constructor(injector: Injector, private feedbackProvider: AddonModFeedbackProvider, @Optional() private content: Content, + constructor(injector: Injector, private feedbackProvider: AddonModFeedbackProvider, @Optional() content: Content, private feedbackOffline: AddonModFeedbackOfflineProvider, private groupsProvider: CoreGroupsProvider, private feedbackSync: AddonModFeedbackSyncProvider, private navCtrl: NavController, private feedbackHelper: AddonModFeedbackHelperProvider) { - super(injector); + super(injector, content); // Listen for form submit events. this.submitObserver = this.eventsProvider.on(AddonModFeedbackProvider.FORM_SUBMITTED, (data) => { diff --git a/src/addon/mod/quiz/components/index/index.ts b/src/addon/mod/quiz/components/index/index.ts index 59561a7c9..2e60facfa 100644 --- a/src/addon/mod/quiz/components/index/index.ts +++ b/src/addon/mod/quiz/components/index/index.ts @@ -68,12 +68,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected finishedObserver: any; // It will observe attempt finished events. protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted). - constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() protected content: Content, + constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() content: Content, protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate) { - super(injector); + super(injector, content); } /** diff --git a/src/addon/mod/survey/components/index/index.ts b/src/addon/mod/survey/components/index/index.ts index 731ce8b66..9197331e3 100644 --- a/src/addon/mod/survey/components/index/index.ts +++ b/src/addon/mod/survey/components/index/index.ts @@ -38,10 +38,10 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo protected userId: number; protected syncEventName = AddonModSurveySyncProvider.AUTO_SYNCED; - constructor(injector: Injector, private surveyProvider: AddonModSurveyProvider, @Optional() private content: Content, + constructor(injector: Injector, private surveyProvider: AddonModSurveyProvider, @Optional() content: Content, private surveyHelper: AddonModSurveyHelperProvider, private surveyOffline: AddonModSurveyOfflineProvider, private surveySync: AddonModSurveySyncProvider) { - super(injector); + super(injector, content); } /** @@ -83,8 +83,6 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo */ protected isRefreshSyncNeeded(syncEventData: any): boolean { if (this.survey && syncEventData.surveyId == this.survey.id && syncEventData.userId == this.userId) { - this.content.scrollToTop(); - return true; } @@ -189,9 +187,7 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo } return this.surveyProvider.submitAnswers(this.survey.id, this.survey.name, this.courseId, answers).then(() => { - this.content.scrollToTop(); - - return this.refreshContent(false); + return this.showLoadingAndRefresh(false); }).finally(() => { modal.dismiss(); }); diff --git a/src/addon/mod/survey/providers/module-handler.ts b/src/addon/mod/survey/providers/module-handler.ts index 4cefa0b32..01a526702 100644 --- a/src/addon/mod/survey/providers/module-handler.ts +++ b/src/addon/mod/survey/providers/module-handler.ts @@ -50,6 +50,7 @@ export class AddonModSurveyModuleHandler implements CoreCourseModuleHandler { icon: this.courseProvider.getModuleIconSrc('survey'), title: module.name, class: 'addon-mod_survey-handler', + showDownloadButton: true, action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { navCtrl.push('AddonModSurveyIndexPage', {module: module, courseId: courseId}, options); } diff --git a/src/app/app.scss b/src/app/app.scss index fc70cf588..642918b05 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -636,6 +636,11 @@ canvas[core-chart] { background-image: url("data:image/svg+xml;charset=utf-8,") !important; } +// For list where some items have detail icon and some others don't. +.core-list-align-detail-right .item .item-inner { + @include padding-horizontal(null, 32px); +} + [ion-fixed] { width: 100%; } diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 3db66af55..875fec056 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injector } from '@angular/core'; +import { Content } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -47,7 +48,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR protected eventsProvider: CoreEventsProvider; protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate; - constructor(injector: Injector) { + constructor(injector: Injector, protected content?: Content) { super(injector); this.sitesProvider = injector.get(CoreSitesProvider); @@ -118,10 +119,8 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR */ protected autoSyncEventReceived(syncEventData: any): void { if (this.isRefreshSyncNeeded(syncEventData)) { - this.loaded = false; - // Refresh the data. - this.refreshContent(false); + this.showLoadingAndRefresh(false); } } @@ -146,6 +145,22 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR }); } + /** + * Show loading and perform the refresh content function. + * + * @param {boolean} [sync=false] If the refresh needs syncing. + * @param {boolean} [showErrors=false] Wether to show errors to the user or hide them. + * @return {Promise} Resolved when done. + */ + protected showLoadingAndRefresh(sync: boolean = false, showErrors: boolean = false): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.loaded = false; + this.content && this.content.scrollToTop(); + + return this.refreshContent(true, showErrors); + } + /** * Download the component contents. * diff --git a/src/core/viewer/pages/text/text.html b/src/core/viewer/pages/text/text.html index b00ee1f27..f2419198c 100644 --- a/src/core/viewer/pages/text/text.html +++ b/src/core/viewer/pages/text/text.html @@ -11,4 +11,8 @@ + + + + diff --git a/src/core/viewer/pages/text/text.module.ts b/src/core/viewer/pages/text/text.module.ts index 2cfce877b..08a594bc3 100644 --- a/src/core/viewer/pages/text/text.module.ts +++ b/src/core/viewer/pages/text/text.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { IonicPageModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreViewerTextPage } from './text'; +import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; /** @@ -26,6 +27,7 @@ import { CoreDirectivesModule } from '@directives/directives.module'; CoreViewerTextPage ], imports: [ + CoreComponentsModule, CoreDirectivesModule, IonicPageModule.forChild(CoreViewerTextPage), TranslateModule.forChild() diff --git a/src/core/viewer/pages/text/text.ts b/src/core/viewer/pages/text/text.ts index 98d1bf6ad..9139d5473 100644 --- a/src/core/viewer/pages/text/text.ts +++ b/src/core/viewer/pages/text/text.ts @@ -29,12 +29,14 @@ export class CoreViewerTextPage { content: string; // Page content. component: string; // Component to use in format-text. componentId: string | number; // Component ID to use in format-text. + files: any[]; // List of files. constructor(private viewCtrl: ViewController, params: NavParams, textUtils: CoreTextUtilsProvider) { this.title = params.get('title'); this.content = params.get('content'); this.component = params.get('component'); this.componentId = params.get('componentId'); + this.files = params.get('files'); } /** diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index aa6b2e678..a79cc2f1c 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -211,6 +211,11 @@ export class CoreFormatTextDirective implements OnChanges { this.element.style.maxHeight = this.maxHeight + 'px'; this.element.addEventListener('click', (e) => { + if (e.defaultPrevented) { + // Ignore it if the event was prevented by some other listener. + return; + } + e.preventDefault(); e.stopPropagation(); diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 3020d4ce6..9e47ed8ea 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -303,14 +303,16 @@ export class CoreTextUtilsProvider { * @param {string} text Content of the text to be expanded. * @param {string} [component] Component to link the embedded files to. * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {any[]} [files] List of files to display along with the text. */ - expandText(title: string, text: string, component?: string, componentId?: string | number): void { + expandText(title: string, text: string, component?: string, componentId?: string | number, files?: any[]): void { if (text.length > 0) { const params: any = { title: title, content: text, component: component, - componentId: componentId + componentId: componentId, + files: files }; // Open a modal with the contents. From f3ae04600fed921da50c57de01aefbaa4910f8d5 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 13 Apr 2018 08:16:14 +0200 Subject: [PATCH 06/16] MOBILE-2334 assign: Implement submission component --- .../assign/components/components.module.ts | 9 +- .../mod/assign/components/index/index.html | 3 +- .../mod/assign/components/index/index.ts | 13 +- .../components/submission/submission.html | 247 +++++ .../components/submission/submission.scss | 18 + .../components/submission/submission.ts | 924 ++++++++++++++++++ src/addon/mod/assign/providers/assign-sync.ts | 1 - src/components/tabs/tabs.html | 2 +- src/core/grades/lang/en.json | 2 + src/core/grades/providers/helper.ts | 44 + src/providers/utils/utils.ts | 29 + 11 files changed, 1280 insertions(+), 12 deletions(-) create mode 100644 src/addon/mod/assign/components/submission/submission.html create mode 100644 src/addon/mod/assign/components/submission/submission.scss create mode 100644 src/addon/mod/assign/components/submission/submission.ts diff --git a/src/addon/mod/assign/components/components.module.ts b/src/addon/mod/assign/components/components.module.ts index b1911094b..e45d5d817 100644 --- a/src/addon/mod/assign/components/components.module.ts +++ b/src/addon/mod/assign/components/components.module.ts @@ -18,12 +18,15 @@ import { IonicModule } from 'ionic-angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModAssignIndexComponent } from './index/index'; +import { AddonModAssignSubmissionComponent } from './submission/submission'; @NgModule({ declarations: [ - AddonModAssignIndexComponent + AddonModAssignIndexComponent, + AddonModAssignSubmissionComponent ], imports: [ CommonModule, @@ -31,12 +34,14 @@ import { AddonModAssignIndexComponent } from './index/index'; TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, + CorePipesModule, CoreCourseComponentsModule ], providers: [ ], exports: [ - AddonModAssignIndexComponent + AddonModAssignIndexComponent, + AddonModAssignSubmissionComponent ], entryComponents: [ AddonModAssignIndexComponent diff --git a/src/addon/mod/assign/components/index/index.html b/src/addon/mod/assign/components/index/index.html index c92079a16..8f0e96a3a 100644 --- a/src/addon/mod/assign/components/index/index.html +++ b/src/addon/mod/assign/components/index/index.html @@ -82,6 +82,7 @@ - + + diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index ea7497ff5..2a132806f 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Optional, Injector } from '@angular/core'; +import { Component, Optional, Injector, ViewChild } from '@angular/core'; import { Content, NavController } from 'ionic-angular'; import { CoreGroupsProvider } from '@providers/groups'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; @@ -22,6 +22,7 @@ import { AddonModAssignHelperProvider } from '../../providers/helper'; import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; import { AddonModAssignSyncProvider } from '../../providers/assign-sync'; import * as moment from 'moment'; +import { AddonModAssignSubmissionComponent } from '../submission/submission'; /** * Component that displays an assignment. @@ -31,6 +32,8 @@ import * as moment from 'moment'; templateUrl: 'index.html', }) export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityComponent { + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent: AddonModAssignSubmissionComponent; + component = AddonModAssignProvider.COMPONENT; moduleName = 'assign'; @@ -238,11 +241,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo */ protected hasSyncSucceed(result: any): boolean { if (result.updated) { - // Sync done, trigger event. - this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, { - assignId: this.assign.id, - warnings: result.warnings - }, this.siteId); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); } return result.updated; @@ -267,7 +266,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo } return Promise.all(promises).finally(() => { - // @todo $scope.$broadcast(mmaModAssignSubmissionInvalidatedEvent); + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); }); } diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html new file mode 100644 index 000000000..81dbcf04a --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.html @@ -0,0 +1,247 @@ + + + + + + + +

{{ user.fullname }}

+ +
+ + + +

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

+ +
+ + + +

{{ 'addon.mod_assign.submissionstatus' | translate }}

+ +
+ + + + + + + + + + + +

{{ 'addon.mod_assign.timemodified' | translate }}

+

{{ userSubmission.timemodified * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

{{ 'addon.mod_assign.timeremaining' | translate }}

+

+
+ + +

+

+
+ + +

{{ 'addon.mod_assign.duedate' | translate }}

+

{{ assign.duedate * 1000 | coreFormatDate:"dfmediumdate" }}

+

{{ 'addon.mod_assign.duedateno' | translate }}

+
+ + +

{{ 'addon.mod_assign.cutoffdate' | translate }}

+

{{ assign.cutoffdate * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

{{ 'addon.mod_assign.extensionduedate' | translate }}

+

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ + +

{{ 'addon.mod_assign.attemptnumber' | translate }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

+
+ + + + +
+

{{ 'addon.mod_assign.erroreditpluginsnotsupported' | translate }}

+

{{ name }}

+
+
+

{{ 'addon.mod_assign.cannoteditduetostatementsubmission' | translate }}

+
+
+ + +
+ + + + + + + + {{ 'addon.mod_assign.submitassignment' | translate }} +

{{ 'addon.mod_assign.submitassignment_help' | translate }}

+
+ + +

{{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }}

+
+
+ + + +

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate }}

+
+ + + + +

{{ user.fullname }}

+
+

+ {{ 'addon.mod_assign.hiddenuser' | translate }} +

+
+
+ + + +

{{ 'addon.mod_assign.submissionslocked' | translate }}

+
+ + + +

{{ 'addon.mod_assign.editingstatus' | translate }}

+

{{ 'addon.mod_assign.submissioneditable' | translate }}

+

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

+
+
+
+
+ + + + + + + +

{{ 'addon.mod_assign.currentgrade' | translate }}

+

+ + + +
+ + + + {{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo.grade} }} + + + + + + {{ 'addon.mod_assign.grade' | translate }} + + {{grade.label}} + + + + + + {{ outcome.name }} + + {{grade.label}} + +

{{ outcome.selected }}

+
+ + + + + +

{{ 'addon.mod_assign.markingworkflowstate' | translate }}

+

{{ workflowStatusTranslationId | translate }}

+
+ + + +

{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}

+ {{ 'addon.mod_assign.applytoteam' | translate }} + +
+ + + +

{{ 'addon.mod_assign.attemptsettings' | translate }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

+

{{ 'addon.mod_assign.attemptreopenmethod' | translate }}: {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }}

+ + {{ 'addon.mod_assign.addattempt' | translate }} + + +
+ + + +

{{ 'addon.mod_assign.gradedby' | translate }}

+ + + + +

{{ grader.fullname }}

+

{{ feedback.gradeddate * 1000 | coreFormatDate:"dfmediumdate" }}

+
+
+ + +
+ +

{{ 'addon.mod_assign.cannotgradefromapp' | translate:{$a: moduleName} }}

+ + {{ 'core.openinbrowser' | translate }} + + +
+
+
+
+
+
+ + + +

+ {{lastAttempt.submissiongroupname}} + {{ 'addon.mod_assign.noteam' | translate }} + {{ 'addon.mod_assign.multipleteams' | translate }} + {{ 'addon.mod_assign.defaultteam' | translate }} +

+ + {{ statusTranslated }} + + + {{ gradingStatusTranslationId | translate }} + +
diff --git a/src/addon/mod/assign/components/submission/submission.scss b/src/addon/mod/assign/components/submission/submission.scss new file mode 100644 index 000000000..90c6890e8 --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.scss @@ -0,0 +1,18 @@ +addon-mod-assign-submission { + div.latesubmission, + div.overdue { + // @extend .core-danger-item; + } + + div.earlysubmission { + // @extend .core-success-item; + } + + div.submissioneditable p { + color: $red; + } + + .core-grading-summary .advancedgrade { + display: none; + } +} diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts new file mode 100644 index 000000000..9383ea511 --- /dev/null +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -0,0 +1,924 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Input, OnInit, OnDestroy, ViewChild, Optional } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreLangProvider } from '@providers/lang'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/helper'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import * as moment from 'moment'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Component that displays an assignment submission. + */ +@Component({ + selector: 'addon-mod-assign-submission', + templateUrl: 'submission.html', +}) +export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { + @ViewChild(CoreTabsComponent) tabs: CoreTabsComponent; + + @Input() courseId: number; // Course ID the submission belongs to. + @Input() moduleId: number; // Module ID the submission belongs to. + @Input() submitId: number; // User that did the submission. + @Input() blindId: number; // Blinded user ID (if it's blinded). + @Input() showGrade: boolean | string; // Whether to display the grade tab at start. + + loaded: boolean; // Whether data has been loaded. + selectedTab: number; // Tab selected on start. + assign: any; // The assignment the submission belongs to. + userSubmission: any; // The submission object. + isSubmittedForGrading: boolean; // Whether the submission has been submitted for grading. + submitModel: any = {}; // Model where to store the data to submit (for grading). + feedback: any; // The feedback. + hasOffline: boolean; // Whether there is offline data. + submittedOffline: boolean; // Whether it was submitted in offline. + fromDate: string; // Readable date when the assign started accepting submissions. + currentAttempt: number; // The current attempt number. + maxAttemptsText: string; // The text for maximum attempts. + blindMarking: boolean; // Whether blind marking is enabled. + user: any; // The user. + lastAttempt: any; // The last attempt. + membersToSubmit: any[]; // Team members that need to submit the assignment. + canSubmit: boolean; // Whether the user can submit for grading. + canEdit: boolean; // Whether the user can edit the submission. + submissionStatement: string; // The submission statement. + showErrorStatementEdit: boolean; // Whether to show an error in edit due to submission statement. + showErrorStatementSubmit: boolean; // Whether to show an error in submit due to submission statement. + gradingStatusTranslationId: string; // Key of the text to display for the grading status. + gradingColor: string; // Color to apply to the grading status. + workflowStatusTranslationId: string; // Key of the text to display for the workflow status. + submissionPlugins: string[]; // List of submission plugins names. + timeRemaining: string; // Message about time remaining. + timeRemainingClass: string; // Class to apply to time remaining message. + statusTranslated: string; // Status. + statusColor: string; // Color to apply to the status. + unsupportedEditPlugins: string[]; // List of submission plugins that don't support edit. + grade: any; // Data about the grade. + grader: any; // Profile of the teacher that graded the submission. + gradeInfo: any; // Grade data for the assignment, retrieved from the server. + isGrading: boolean; // Whether the user is grading. + canSaveGrades: boolean; // Whether the user can save the grades. + allowAddAttempt: boolean; // Allow adding a new attempt when grading. + gradeUrl: string; // URL to grade in browser. + + // Some constants. + statusNew = AddonModAssignProvider.SUBMISSION_STATUS_NEW; + statusReopened = AddonModAssignProvider.SUBMISSION_STATUS_REOPENED; + attemptReopenMethodNone = AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_NONE; + unlimitedAttempts = AddonModAssignProvider.UNLIMITED_ATTEMPTS; + + protected siteId: string; // Current site ID. + protected currentUserId: number; // Current user ID. + protected previousAttempt: any; // The previous attempt. + protected submissionStatusAvailable: boolean; // Whether we were able to retrieve the submission status. + protected originalGrades: any = {}; // Object with the original grade data, to check for changes. + protected isDestroyed: boolean; // Whether the component has been destroyed. + + constructor(protected navCtrl: NavController, protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, + sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, protected timeUtils: CoreTimeUtilsProvider, + protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService, protected utils: CoreUtilsProvider, + protected eventsProvider: CoreEventsProvider, protected courseProvider: CoreCourseProvider, + protected fileUploaderHelper: CoreFileUploaderHelperProvider, protected gradesHelper: CoreGradesHelperProvider, + protected userProvider: CoreUserProvider, protected groupsProvider: CoreGroupsProvider, + protected langProvider: CoreLangProvider, protected assignProvider: AddonModAssignProvider, + protected assignHelper: AddonModAssignHelperProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, + @Optional() protected splitviewCtrl: CoreSplitViewComponent) { + + this.siteId = sitesProvider.getCurrentSiteId(); + this.currentUserId = sitesProvider.getCurrentSiteUserId(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.selectedTab = this.showGrade && this.showGrade !== 'false' ? 1 : 0; + this.isSubmittedForGrading = !!this.submitId; + + this.loadData(); + } + + /** + * Calculate the time remaining message and class. + * + * @param {any} response Response of get submission status. + */ + protected calculateTimeRemaining(response: any): void { + if (this.assign.duedate > 0) { + const time = this.timeUtils.timestamp(), + dueDate = response.lastattempt && response.lastattempt.extensionduedate ? + response.lastattempt.extensionduedate : this.assign.duedate, + timeRemaining = dueDate - time; + + if (timeRemaining <= 0) { + if (!this.userSubmission || this.userSubmission.status != AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED) { + + if ((response.lastattempt && response.lastattempt.submissionsenabled) || + (response.gradingsummary && response.gradingsummary.submissionsenabled)) { + this.timeRemaining = this.translate.instant('addon.mod_assign.overdue', + {$a: this.timeUtils.formatDuration(-timeRemaining, 3) }); + this.timeRemainingClass = 'overdue'; + } else { + this.timeRemaining = this.translate.instant('addon.mod_assign.duedatereached'); + this.timeRemainingClass = ''; + } + } else { + + const timeSubmittedDiff = this.userSubmission.timemodified - dueDate; + if (timeSubmittedDiff > 0) { + this.timeRemaining = this.translate.instant('addon.mod_assign.submittedlate', + {$a: this.timeUtils.formatDuration(timeSubmittedDiff, 2) }); + this.timeRemainingClass = 'latesubmission'; + } else { + this.timeRemaining = this.translate.instant('addon.mod_assign.submittedearly', + {$a: this.timeUtils.formatDuration(-timeSubmittedDiff, 2) }); + this.timeRemainingClass = 'earlysubmission'; + } + } + } else { + this.timeRemaining = this.timeUtils.formatDuration(timeRemaining, 3); + this.timeRemainingClass = ''; + } + } else { + this.timeRemaining = ''; + this.timeRemainingClass = ''; + } + } + + /** + * Check if the user can leave the view. If there are changes to be saved, it will ask for confirm. + * + * @return {Promise} Promise resolved if can leave the view, rejected otherwise. + */ + canLeave(): Promise { + // Check if there is data to save. + return this.hasDataToSave().then((modified) => { + if (modified) { + // Modified, confirm user wants to go back. + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')).then(() => { + return this.discardDrafts().catch(() => { + // Ignore errors. + }); + }); + } + }); + } + + /** + * Copy a previous attempt and then go to edit. + */ + copyPrevious(): void { + if (!this.appProvider.isOnline()) { + this.domUtils.showErrorModal('mm.core.networkerrormsg', true); + + return; + } + + if (!this.previousAttempt) { + // Cannot access previous attempts, just go to edit. + return this.goToEdit(); + } + + const previousSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, this.previousAttempt); + let modal = this.domUtils.showModalLoading(); + + this.assignHelper.getSubmissionSizeForCopy(this.assign, previousSubmission).catch(() => { + // Error calculating size, return -1. + return -1; + }).then((size) => { + modal.dismiss(); + + // Confirm action. + return this.fileUploaderHelper.confirmUploadFile(size, true); + }).then(() => { + // User confirmed, copy the attempt. + modal = this.domUtils.showModalLoading('core.sending', true); + + this.assignHelper.copyPreviousAttempt(this.assign, previousSubmission).then(() => { + // Now go to edit. + this.goToEdit(); + + // Invalidate and refresh data to update this view. + this.invalidateAndRefresh(); + + if (!this.assign.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign.id, + submissionId: this.userSubmission.id, + userId: this.currentUserId + }, this.siteId); + } + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).finally(() => { + modal.dismiss(); + }); + }); + } + + /** + * Discard feedback drafts. + * + * @return {Promise} Promise resolved when done. + */ + protected discardDrafts(): Promise { + if (this.feedback && this.feedback.plugins) { + return this.assignHelper.discardFeedbackPluginData(this.assign.id, this.submitId, this.feedback); + } + + return Promise.resolve(); + } + + /** + * Go to the page to add or edit submission. + */ + goToEdit(): void { + this.navCtrl.push('AddonModAssignEditPage', { + moduleId: this.moduleId, + courseId: this.courseId, + userId: this.submitId, + blindId: this.blindId + }); + } + + /** + * Check if there's data to save (grade). + * + * @return {Promise} Promise resolved with boolean: whether there's data to save. + */ + protected hasDataToSave(): Promise { + if (!this.canSaveGrades || !this.loaded) { + return Promise.resolve(false); + } + + // Check if numeric grade and toggles changed. + if (this.originalGrades.grade != this.grade.grade || this.originalGrades.addAttempt != this.grade.addAttempt || + this.originalGrades.applyToAll != this.grade.applyToAll) { + return Promise.resolve(true); + } + + // Check if outcomes changed. + if (this.gradeInfo && this.gradeInfo.outcomes) { + for (const x in this.gradeInfo.outcomes) { + const outcome = this.gradeInfo.outcomes[x]; + + if (this.originalGrades.outcomes[outcome.id] == 'undefined' || + this.originalGrades.outcomes[outcome.id] != outcome.selectedId) { + return Promise.resolve(true); + } + } + } + + if (this.feedback && this.feedback.plugins) { + return this.assignHelper.hasFeedbackDataChanged(this.assign, this.submitId, this.feedback).catch(() => { + // Error ocurred, consider there are no changes. + return false; + }); + } + + return Promise.resolve(false); + } + + /** + * Invalidate and refresh data. + * + * @return {Promise} Promise resolved when done. + */ + invalidateAndRefresh(): Promise { + this.loaded = false; + + const promises = []; + + promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(this.assignProvider.invalidateSubmissionStatusData(this.assign.id, this.submitId, !!this.blindId)); + promises.push(this.assignProvider.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(this.assignProvider.invalidateListParticipantsData(this.assign.id)); + } + promises.push(this.gradesHelper.invalidateGradeModuleItems(this.courseId, this.submitId)); + promises.push(this.courseProvider.invalidateModule(this.moduleId)); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return this.loadData(); + }); + } + + /** + * Load the data to render the submission. + * + * @return {Promise} Promise resolved when done. + */ + protected loadData(): Promise { + let isBlind = !!this.blindId; + + this.previousAttempt = undefined; + + if (!this.submitId) { + this.submitId = this.currentUserId; + isBlind = false; + } + + // Get the assignment. + return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { + const time = this.timeUtils.timestamp(), + promises = []; + + this.assign = assign; + + if (assign.allowsubmissionsfromdate && assign.allowsubmissionsfromdate >= time) { + this.fromDate = moment(assign.allowsubmissionsfromdate * 1000).format(this.translate.instant('core.dfmediumdate')); + } + + this.currentAttempt = 0; + this.maxAttemptsText = this.translate.instant('addon.mod_assign.unlimitedattempts'); + this.blindMarking = this.isSubmittedForGrading && assign.blindmarking && !assign.revealidentities; + + if (!this.blindMarking && this.submitId != this.currentUserId) { + promises.push(this.userProvider.getProfile(this.submitId, this.courseId).then((profile) => { + this.user = profile; + })); + } + + // Check if there's any offline data for this submission. + promises.push(this.assignOfflineProvider.getSubmission(assign.id, this.submitId).then((data) => { + this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + this.submittedOffline = data && data.submitted; + }).catch(() => { + // No offline data found. + this.hasOffline = false; + this.submittedOffline = false; + })); + + return Promise.all(promises); + }).then(() => { + // Get submission status. + return this.assignProvider.getSubmissionStatus(this.assign.id, this.submitId, isBlind); + }).then((response) => { + + const promises = []; + + this.submissionStatusAvailable = true; + this.lastAttempt = response.lastattempt; + this.membersToSubmit = []; + + // Search the previous attempt. + if (response.previousattempts && response.previousattempts.length > 0) { + const previousAttempts = response.previousattempts.sort((a, b) => { + return a.attemptnumber - b.attemptnumber; + }); + this.previousAttempt = previousAttempts[previousAttempts.length - 1]; + } + + // Treat last attempt. + this.treatLastAttempt(response, promises); + + // Calculate the time remaining. + this.calculateTimeRemaining(response); + + // Load the feedback. + promises.push(this.loadFeedback(response.feedback)); + + // Check if there's any unsupported plugin for editing. + if (!this.userSubmission || !this.userSubmission.plugins) { + // Submission not created yet, we have to use assign configs to detect the plugins used. + this.userSubmission = {}; + this.userSubmission.plugins = this.assignHelper.getPluginsEnabled(this.assign, 'assignsubmission'); + } + + // Get the submission plugins that don't support editing. + promises.push(this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins).then((list) => { + this.unsupportedEditPlugins = list; + })); + + return Promise.all(promises); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Load the data to render the feedback and grade. + * + * @param {any} feedback The feedback data from the submission status. + * @return {Promise} Promise resolved when done. + */ + protected loadFeedback(feedback: any): Promise { + this.grade = { + method: false, + grade: false, + modified: 0, + gradingStatus: false, + addAttempt : false, + applyToAll: false, + scale: false, + lang: false + }; + + this.originalGrades = { + grade: false, + addAttempt: false, + applyToAll: false, + outcomes: {} + }; + + if (feedback) { + this.feedback = feedback; + + // If we have data about the grader, get its profile. + if (feedback.grade && feedback.grade.grader) { + this.userProvider.getProfile(feedback.grade.grader, this.courseId).then((profile) => { + this.grader = profile; + }).catch(() => { + // Ignore errors. + }); + } + + // Check if the grade uses advanced grading. + if (feedback.gradefordisplay) { + const position = feedback.gradefordisplay.indexOf('class="advancedgrade"'); + if (position > -1) { + this.feedback.advancedgrade = true; + } + } + + // Do not override already loaded grade. + if (feedback.grade && feedback.grade.grade && !this.grade.grade) { + const parsedGrade = parseFloat(feedback.grade.grade); + this.grade.grade = parsedGrade || parsedGrade == 0 ? parsedGrade : null; + } + } else { + // If no feedback, always show Submission. + this.selectedTab = 0; + this.tabs.selectTab(0); + } + + this.grade.gradingStatus = this.lastAttempt && this.lastAttempt.gradingstatus; + + // Get the grade for the assign. + return this.courseProvider.getModuleBasicGradeInfo(this.moduleId).then((gradeInfo) => { + this.gradeInfo = gradeInfo; + + if (!gradeInfo) { + return; + } + + if (!this.isDestroyed) { + // Block the assignment. + this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + + // Treat the grade info. + return this.treatGradeInfo(); + }).then(() => { + if (!this.isGrading) { + return; + } + + const isManual = this.assign.attemptreopenmethod == AddonModAssignProvider.ATTEMPT_REOPEN_METHOD_MANUAL, + isUnlimited = this.assign.maxattempts == AddonModAssignProvider.UNLIMITED_ATTEMPTS, + isLessThanMaxAttempts = this.userSubmission && (this.userSubmission.attemptnumber < (this.assign.maxattempts - 1)); + + this.allowAddAttempt = isManual && (!this.userSubmission || isUnlimited || isLessThanMaxAttempts); + + if (this.assign.teamsubmission) { + this.grade.applyToAll = true; + this.originalGrades.applyToAll = true; + } + if (this.assign.markingworkflow && this.grade.gradingStatus) { + this.workflowStatusTranslationId = + this.assignProvider.getSubmissionGradingStatusTranslationId(this.grade.gradingStatus); + } + + if (!this.feedback || !this.feedback.plugins) { + // Feedback plugins not present, we have to use assign configs to detect the plugins used. + this.feedback = {}; + this.feedback.plugins = this.assignHelper.getPluginsEnabled(this.assign, 'assignfeedback'); + } + + // Check if there's any offline data for this submission. + if (this.canSaveGrades) { + // Submission grades aren't identified by attempt number so it can retrieve the feedback for a previous attempt. + // The app will not treat that as an special case. + return this.assignOfflineProvider.getSubmissionGrade(this.assign.id, this.submitId).catch(() => { + // Grade not found. + }).then((data) => { + + // Load offline grades. + if (data && (!feedback || !feedback.gradeddate || feedback.gradeddate < data.timemodified)) { + // If grade has been modified from gradebook, do not use offline. + if (this.grade.modified < data.timemodified) { + this.grade.grade = data.grade; + this.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + this.gradingColor = ''; + this.originalGrades.grade = this.grade.grade; + } + + this.grade.applyToAll = data.applytoall; + this.grade.addAttempt = data.addattempt; + this.originalGrades.applyToAll = this.grade.applyToAll; + this.originalGrades.addAttempt = this.grade.addAttempt; + + if (data.outcomes && Object.keys(data.outcomes).length) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (typeof data.outcomes[outcome.itemNumber] != 'undefined') { + // If outcome has been modified from gradebook, do not use offline. + if (outcome.modified < data.timemodified) { + outcome.selectedId = data.outcomes[outcome.itemNumber]; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + } + } + }); + } + } + }); + } else { + // User cannot save grades in the app. Load the URL to grade it in browser. + return this.courseProvider.getModule(this.moduleId, this.courseId, undefined, true).then((mod) => { + this.gradeUrl = mod.url + '&action=grader&userid=' + this.submitId; + }); + } + }); + } + + /** + * Open a user profile. + * + * @param {number} userId User to open. + */ + openUserProfile(userId: number): void { + // Open a user profile. If this component is inside a split view, use the master nav to open it. + const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl; + navCtrl.push('CoreUserProfilePage', { userId: userId, courseId: this.courseId }); + } + + /** + * Set the submission status name and class. + * + * @param {any} status Submission status. + */ + protected setStatusNameAndClass(status: any): void { + if (this.hasOffline) { + // Offline data. + this.statusTranslated = this.translate.instant('core.notsent'); + this.statusColor = 'warning'; + } else if (!this.assign.teamsubmission) { + + // Single submission. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = this.translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = this.assignProvider.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt.submissionsenabled) { + this.statusTranslated = this.translate.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = this.translate.instant('addon.mod_assign.noattempt'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noattempt'); + } + } + } else { + + // Team submission. + if (!status.lastattempt.submissiongroup && this.assign.preventsubmissionnotingroup) { + this.statusTranslated = this.translate.instant('addon.mod_assign.nosubmission'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('nosubmission'); + } else if (this.userSubmission && this.userSubmission.status != this.statusNew) { + this.statusTranslated = this.translate.instant('addon.mod_assign.submissionstatus_' + this.userSubmission.status); + this.statusColor = this.assignProvider.getSubmissionStatusColor(this.userSubmission.status); + } else { + if (!status.lastattempt.submissionsenabled) { + this.statusTranslated = this.translate.instant('addon.mod_assign.noonlinesubmissions'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('noonlinesubmissions'); + } else { + this.statusTranslated = this.translate.instant('addon.mod_assign.nosubmission'); + this.statusColor = this.assignProvider.getSubmissionStatusColor('nosubmission'); + } + } + } + } + + /** + * Show advanced grade. + */ + showAdvancedGrade(): void { + if (this.feedback && this.feedback.advancedgrade) { + this.textUtils.expandText(this.translate.instant('core.grades.grade'), this.feedback.gradefordisplay, + AddonModAssignProvider.COMPONENT, this.moduleId); + } + } + + /** + * Submit for grading. + * + * @param {boolean} acceptStatement Whether the statement has been accepted. + */ + submitForGrading(acceptStatement: boolean): void { + if (this.assign.requiresubmissionstatement && !acceptStatement) { + this.domUtils.showErrorModal('addon.mod_assign.acceptsubmissionstatement', true); + + return; + } + + // Ask for confirmation. @todo plugin precheck_submission + this.domUtils.showConfirm(this.translate.instant('addon.mod_assign.confirmsubmission')).then(() => { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.assignProvider.submitForGrading(this.assign.id, this.courseId, acceptStatement, this.userSubmission.timemodified, + this.hasOffline).then(() => { + + // Invalidate and refresh data. + this.invalidateAndRefresh(); + + // Submitted, trigger event. + this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { + assignmentId: this.assign.id, + submissionId: this.userSubmission.id, + userId: this.currentUserId + }, this.siteId); + }).finally(() => { + modal.dismiss(); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }); + } + + /** + * Submit a grade and feedback. + * + * @return {Promise} Promise resolved when done. + */ + submitGrade(): Promise { + // Check if there's something to be saved. + return this.hasDataToSave().then((modified) => { + if (!modified) { + return; + } + + const attemptNumber = this.userSubmission ? this.userSubmission.attemptnumber : -1, + outcomes = {}, + // Scale "no grade" uses -1 instead of 0. + grade = this.grade.scale && this.grade.grade == 0 ? -1 : this.utils.unformatFloat(this.grade.grade); + + if (grade === false) { + // Grade is invalid. + return Promise.reject(this.translate.instant('core.grades.badgrade')); + } + + const modal = this.domUtils.showModalLoading('core.sending', true); + let pluginPromise; + + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.itemNumber) { + outcomes[outcome.itemNumber] = outcome.selectedId; + } + }); + + if (this.feedback && this.feedback.plugins) { + pluginPromise = this.assignHelper.prepareFeedbackPluginData(this.assign.id, this.submitId, this.feedback); + } else { + pluginPromise = Promise.resolve({}); + } + + return pluginPromise.then((pluginData) => { + // We have all the data, now send it. + return this.assignProvider.submitGradingForm(this.assign.id, this.submitId, this.courseId, grade, attemptNumber, + this.grade.addAttempt, this.grade.gradingStatus, this.grade.applyToAll, outcomes, pluginData).then(() => { + + // Data sent, discard draft. + return this.discardDrafts(); + }).finally(() => { + // Invalidate and refresh data. + this.invalidateAndRefresh(); + + this.eventsProvider.trigger(AddonModAssignProvider.GRADED_EVENT, { + assignmentId: this.assign.id, + submissionId: this.submitId, + userId: this.currentUserId + }, this.siteId); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).finally(() => { + // Select submission view. + this.tabs.selectTab(0); + modal.dismiss(); + }); + }); + } + + /** + * Treat the grade info. + * + * @return {Promise} Promise resolved when done. + */ + protected treatGradeInfo(): Promise { + // Check if grading method is simple or not. + if (this.gradeInfo.advancedgrading && this.gradeInfo.advancedgrading[0] && + typeof this.gradeInfo.advancedgrading[0].method != 'undefined') { + this.grade.method = this.gradeInfo.advancedgrading[0].method || 'simple'; + } else { + this.grade.method = 'simple'; + } + + this.isGrading = true; + this.canSaveGrades = this.grade.method == 'simple'; // Grades can be saved if simple grading. + + if (this.gradeInfo.scale) { + this.grade.scale = this.utils.makeMenuFromList(this.gradeInfo.scale, this.translate.instant('core.nograde')); + } else { + // Get current language to format grade input field. + this.langProvider.getCurrentLanguage().then((lang) => { + this.grade.lang = lang; + }); + } + + // Treat outcomes. + if (this.assignProvider.isOutcomesEditEnabled()) { + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.scale) { + outcome.options = + this.utils.makeMenuFromList(outcome.scale, this.translate.instant('core.grades.nooutcome')); + } + outcome.selectedId = 0; + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + }); + } + + // Get grade items. + return this.gradesHelper.getGradeModuleItems(this.courseId, this.moduleId, this.submitId).then((grades) => { + const outcomes = []; + + grades.forEach((grade) => { + if (!grade.outcomeid && !grade.scaleid) { + + // Not using outcomes or scale, get the numeric grade. + if (this.grade.scale) { + this.grade.grade = this.gradesHelper.getGradeValueFromLabel(this.grade.scale, grade.gradeformatted); + } else { + const parsedGrade = parseFloat(grade.gradeformatted); + this.grade.grade = parsedGrade || parsedGrade == 0 ? parsedGrade : null; + } + + this.grade.modified = grade.gradedategraded; + this.originalGrades.grade = this.grade.grade; + } else if (grade.outcomeid) { + + // Only show outcomes with info on it, outcomeid could be null if outcomes are disabled on site. + this.gradeInfo.outcomes.forEach((outcome) => { + if (outcome.id == grade.outcomeid) { + outcome.selected = grade.gradeformatted; + outcome.modified = grade.gradedategraded; + if (outcome.options) { + outcome.selectedId = this.gradesHelper.getGradeValueFromLabel(outcome.options, outcome.selected); + this.originalGrades.outcomes[outcome.id] = outcome.selectedId; + outcome.itemNumber = grade.itemnumber; + } + outcomes.push(outcome); + } + }); + } + }); + + this.gradeInfo.outcomes = outcomes; + }); + } + + /** + * Treat the last attempt. + * + * @param {any} response Response of get submission status. + * @param {any[]} promises List where to add the promises. + */ + protected treatLastAttempt(response: any, promises: any[]): void { + if (!response.lastattempt) { + return; + } + + const submissionStatementMissing = this.assign.requiresubmissionstatement && + typeof this.assign.submissionstatement == 'undefined'; + + this.canSubmit = !this.isSubmittedForGrading && !this.submittedOffline && (response.lastattempt.cansubmit || + (this.hasOffline && this.assignProvider.canSubmitOffline(this.assign, response))); + this.canEdit = !this.isSubmittedForGrading && response.lastattempt.canedit && + (!this.submittedOffline || !this.assign.submissiondrafts); + + // Get submission statement if needed. + if (this.assign.requiresubmissionstatement && this.assign.submissiondrafts && this.submitId == this.currentUserId) { + this.submissionStatement = this.assign.submissionstatement; + this.submitModel.submissionStatement = false; + } else { + this.submissionStatement = undefined; + this.submitModel.submissionStatement = true; // No submission statement, so it's accepted. + } + + // Show error if submission statement should be shown but it couldn't be retrieved. + this.showErrorStatementEdit = submissionStatementMissing && !this.assign.submissiondrafts && + this.submitId == this.currentUserId; + this.showErrorStatementSubmit = submissionStatementMissing && this.assign.submissiondrafts; + + this.userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt); + + if (this.assign.attemptreopenmethod != this.attemptReopenMethodNone && this.userSubmission) { + this.currentAttempt = this.userSubmission.attemptnumber + 1; + } + + this.setStatusNameAndClass(response); + + if (this.assign.teamsubmission) { + if (response.lastattempt.submissiongroup) { + // Get the name of the group. + promises.push(this.groupsProvider.getActivityAllowedGroups(this.assign.cmid).then((groups) => { + groups.forEach((group) => { + if (group.id == response.lastattempt.submissiongroup) { + this.lastAttempt.submissiongroupname = group.name; + } + }); + })); + } + + // Get the members that need to submit. + if (this.userSubmission && this.userSubmission.status != this.statusNew) { + response.lastattempt.submissiongroupmemberswhoneedtosubmit.forEach((member) => { + if (this.blindMarking) { + // Users not blinded! (Moodle < 3.1.1, 3.2). + promises.push(this.assignProvider.getAssignmentUserMappings(this.assign.id, member).then((blindId) => { + this.membersToSubmit.push(blindId); + })); + } else { + promises.push(this.userProvider.getProfile(member, this.courseId).then((profile) => { + this.membersToSubmit.push(profile); + })); + } + }); + + response.lastattempt.submissiongroupmemberswhoneedtosubmitblind.forEach((member) => { + this.membersToSubmit.push(member); + }); + } + } + + // Get grading text and color. + this.gradingStatusTranslationId = this.assignProvider.getSubmissionGradingStatusTranslationId( + response.lastattempt.gradingstatus); + this.gradingColor = this.assignProvider.getSubmissionGradingStatusColor(response.lastattempt.gradingstatus); + + // Get the submission plugins. + if (this.userSubmission) { + if (!this.assign.teamsubmission || !response.lastattempt.submissiongroup || !this.assign.preventsubmissionnotingroup) { + if (this.previousAttempt && this.previousAttempt.submission.plugins && + this.userSubmission.status == this.statusReopened) { + // Get latest attempt if avalaible. + this.submissionPlugins = this.previousAttempt.submission.plugins; + } else { + this.submissionPlugins = this.userSubmission.plugins; + } + } + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + + if (this.assign && this.isGrading) { + this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + } +} diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts index 1f43c7dde..af891b29c 100644 --- a/src/addon/mod/assign/providers/assign-sync.ts +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -52,7 +52,6 @@ export interface AddonModAssignSyncResult { export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_assign_autom_synced'; - static MANUAL_SYNCED = 'addon_mod_assign_manual_synced'; static SYNC_TIME = 300000; protected componentTranslate: string; diff --git a/src/components/tabs/tabs.html b/src/components/tabs/tabs.html index 51e2fe3ba..7c9aea9a6 100644 --- a/src/components/tabs/tabs.html +++ b/src/components/tabs/tabs.html @@ -1,5 +1,5 @@ -
+
diff --git a/src/core/grades/lang/en.json b/src/core/grades/lang/en.json index 720a51127..e6eb0bba2 100644 --- a/src/core/grades/lang/en.json +++ b/src/core/grades/lang/en.json @@ -1,5 +1,6 @@ { "average": "Average", + "badgrade": "Supplied grade is invalid", "contributiontocoursetotal": "Contribution to course total", "feedback": "Feedback", "grade": "Grade", @@ -7,6 +8,7 @@ "grades": "Grades", "lettergrade": "Letter grade", "nogradesreturned": "No grades returned", + "nooutcome": "No outcome", "percentage": "Percentage", "range": "Range", "rank": "Rank", diff --git a/src/core/grades/providers/helper.ts b/src/core/grades/providers/helper.ts index a91a9d8bc..4f40b6caf 100644 --- a/src/core/grades/providers/helper.ts +++ b/src/core/grades/providers/helper.ts @@ -234,6 +234,29 @@ export class CoreGradesHelperProvider { }); } + /** + * Returns the label of the selected grade. + * + * @param {any[]} grades Array with objects with value and label. + * @param {number} selectedGrade Selected grade value. + * @return {string} Selected grade label. + */ + getGradeLabelFromValue(grades: any[], selectedGrade: number): string { + selectedGrade = Number(selectedGrade); + + if (!grades || !selectedGrade || selectedGrade <= 0) { + return ''; + } + + for (const x in grades) { + if (grades[x].value == selectedGrade) { + return grades[x].label; + } + } + + return ''; + } + /** * Get the grade items for a certain module. Keep in mind that may have more than one item to include outcomes and scales. * @@ -266,6 +289,27 @@ export class CoreGradesHelperProvider { }); } + /** + * Returns the value of the selected grade. + * + * @param {any[]} grades Array with objects with value and label. + * @param {string} selectedGrade Selected grade label. + * @return {number} Selected grade value. + */ + getGradeValueFromLabel(grades: any[], selectedGrade: string): number { + if (!grades || !selectedGrade) { + return 0; + } + + for (const x in grades) { + if (grades[x].label == selectedGrade) { + return grades[x].value < 0 ? 0 : grades[x].value; + } + } + + return 0; + } + /** * Gets the link to the module for the selected grade. * diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 5b56e841b..598415aa9 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -629,6 +629,35 @@ export class CoreUtilsProvider { return typeof error.errorcode == 'undefined' && typeof error.warningcode == 'undefined'; } + /** + * Given a list (e.g. a,b,c,d,e) this function returns an array of 1->a, 2->b, 3->c etc. + * Taken from make_menu_from_list on moodlelib.php (not the same but similar). + * + * @param {string} list The string to explode into array bits + * @param {string} [defaultLabel] Element that will become default option, if not defined, it won't be added. + * @param {string} [separator] The separator used within the list string. Default ','. + * @param {any} [defaultValue] Element that will become default option value. Default 0. + * @return {any[]} The now assembled array + */ + makeMenuFromList(list: string, defaultLabel?: string, separator: string = ',', defaultValue?: any): any[] { + // Split and format the list. + const split = list.split(separator).map((label, index) => { + return { + label: label.trim(), + value: index + 1 + }; + }); + + if (defaultLabel) { + split.unshift({ + label: defaultLabel, + value: defaultValue || 0 + }); + } + + return split; + } + /** * Merge two arrays, removing duplicate values. * From cf927d4344c9768d77faab6f8091228a30d9f46c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 13 Apr 2018 15:32:06 +0200 Subject: [PATCH 07/16] MOBILE-2334 assign: Implement submission plugins --- src/addon/mod/assign/assign.module.ts | 4 + .../classes/submission-plugin-component.ts | 40 ++ .../assign/components/components.module.ts | 7 +- .../submission-plugin/submission-plugin.html | 16 + .../submission-plugin/submission-plugin.ts | 98 +++++ .../components/submission/submission.html | 2 +- .../components/submission/submission.ts | 11 +- .../submission/comments/comments.module.ts | 48 +++ .../comments/component/comments.html | 4 + .../submission/comments/component/comments.ts | 50 +++ .../assign/submission/comments/lang/en.json | 3 + .../submission/comments/providers/handler.ts | 93 +++++ .../submission/file/component/file.html | 17 + .../assign/submission/file/component/file.ts | 74 ++++ .../mod/assign/submission/file/file.module.ts | 50 +++ .../mod/assign/submission/file/lang/en.json | 3 + .../submission/file/providers/handler.ts | 361 ++++++++++++++++++ .../onlinetext/component/onlinetext.html | 21 + .../onlinetext/component/onlinetext.ts | 129 +++++++ .../assign/submission/onlinetext/lang/en.json | 3 + .../onlinetext/onlinetext.module.ts | 50 +++ .../onlinetext/providers/handler.ts | 277 ++++++++++++++ .../assign/submission/submission.module.ts | 31 ++ src/app/app.scss | 6 + src/components/attachments/attachments.html | 25 ++ src/components/attachments/attachments.ts | 135 +++++++ src/components/components.module.ts | 7 +- src/components/file/file.ts | 4 +- src/components/local-file/local-file.ts | 4 +- .../fileuploader/providers/fileuploader.ts | 2 +- 30 files changed, 1564 insertions(+), 11 deletions(-) create mode 100644 src/addon/mod/assign/classes/submission-plugin-component.ts create mode 100644 src/addon/mod/assign/components/submission-plugin/submission-plugin.html create mode 100644 src/addon/mod/assign/components/submission-plugin/submission-plugin.ts create mode 100644 src/addon/mod/assign/submission/comments/comments.module.ts create mode 100644 src/addon/mod/assign/submission/comments/component/comments.html create mode 100644 src/addon/mod/assign/submission/comments/component/comments.ts create mode 100644 src/addon/mod/assign/submission/comments/lang/en.json create mode 100644 src/addon/mod/assign/submission/comments/providers/handler.ts create mode 100644 src/addon/mod/assign/submission/file/component/file.html create mode 100644 src/addon/mod/assign/submission/file/component/file.ts create mode 100644 src/addon/mod/assign/submission/file/file.module.ts create mode 100644 src/addon/mod/assign/submission/file/lang/en.json create mode 100644 src/addon/mod/assign/submission/file/providers/handler.ts create mode 100644 src/addon/mod/assign/submission/onlinetext/component/onlinetext.html create mode 100644 src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts create mode 100644 src/addon/mod/assign/submission/onlinetext/lang/en.json create mode 100644 src/addon/mod/assign/submission/onlinetext/onlinetext.module.ts create mode 100644 src/addon/mod/assign/submission/onlinetext/providers/handler.ts create mode 100644 src/addon/mod/assign/submission/submission.module.ts create mode 100644 src/components/attachments/attachments.html create mode 100644 src/components/attachments/attachments.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index cb3d922fb..d623ec3fb 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -27,10 +27,14 @@ import { AddonModAssignDefaultSubmissionHandler } from './providers/default-subm import { AddonModAssignModuleHandler } from './providers/module-handler'; import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModAssignSubmissionModule } from './submission/submission.module'; @NgModule({ declarations: [ ], + imports: [ + AddonModAssignSubmissionModule + ], providers: [ AddonModAssignProvider, AddonModAssignOfflineProvider, diff --git a/src/addon/mod/assign/classes/submission-plugin-component.ts b/src/addon/mod/assign/classes/submission-plugin-component.ts new file mode 100644 index 000000000..35e750585 --- /dev/null +++ b/src/addon/mod/assign/classes/submission-plugin-component.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Input } from '@angular/core'; + +/** + * Base class for component to render a submission plugin. + */ +export class AddonModAssignSubmissionPluginComponent { + @Input() assign: any; // The assignment. + @Input() submission: any; // The submission. + @Input() plugin: any; // The plugin object. + @Input() configs: any; // The configs for the plugin. + @Input() edit: boolean; // Whether the user is editing. + @Input() allowOffline: boolean; // Whether to allow offline. + + constructor() { + // Nothing to do. + } + + /** + * Invalidate the data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return Promise.resolve(); + } +} diff --git a/src/addon/mod/assign/components/components.module.ts b/src/addon/mod/assign/components/components.module.ts index e45d5d817..94e81c86e 100644 --- a/src/addon/mod/assign/components/components.module.ts +++ b/src/addon/mod/assign/components/components.module.ts @@ -22,11 +22,13 @@ import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModAssignIndexComponent } from './index/index'; import { AddonModAssignSubmissionComponent } from './submission/submission'; +import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin'; @NgModule({ declarations: [ AddonModAssignIndexComponent, - AddonModAssignSubmissionComponent + AddonModAssignSubmissionComponent, + AddonModAssignSubmissionPluginComponent ], imports: [ CommonModule, @@ -41,7 +43,8 @@ import { AddonModAssignSubmissionComponent } from './submission/submission'; ], exports: [ AddonModAssignIndexComponent, - AddonModAssignSubmissionComponent + AddonModAssignSubmissionComponent, + AddonModAssignSubmissionPluginComponent ], entryComponents: [ AddonModAssignIndexComponent diff --git a/src/addon/mod/assign/components/submission-plugin/submission-plugin.html b/src/addon/mod/assign/components/submission-plugin/submission-plugin.html new file mode 100644 index 000000000..0a82623c2 --- /dev/null +++ b/src/addon/mod/assign/components/submission-plugin/submission-plugin.html @@ -0,0 +1,16 @@ + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.submissionnotsupported' | translate }} + +

+ +

+ +
+
+
diff --git a/src/addon/mod/assign/components/submission-plugin/submission-plugin.ts b/src/addon/mod/assign/components/submission-plugin/submission-plugin.ts new file mode 100644 index 000000000..b2f186a00 --- /dev/null +++ b/src/addon/mod/assign/components/submission-plugin/submission-plugin.ts @@ -0,0 +1,98 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Input, OnInit, Injector, ViewChild } from '@angular/core'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { AddonModAssignSubmissionDelegate } from '../../providers/submission-delegate'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; + +/** + * Component that displays an assignment submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-plugin', + templateUrl: 'submission-plugin.html', +}) +export class AddonModAssignSubmissionPluginComponent implements OnInit { + @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; + + @Input() assign: any; // The assignment. + @Input() submission: any; // The submission. + @Input() plugin: any; // The plugin object. + @Input() edit: boolean | string; // Whether the user is editing. + @Input() allowOffline: boolean | string; // Whether to allow offline. + + pluginComponent: any; // Component to render the plugin. + data: any; // Data to pass to the component. + + // Data to render the plugin if it isn't supported. + component = AddonModAssignProvider.COMPONENT; + text = ''; + files = []; + notSupported: boolean; + pluginLoaded: boolean; + + constructor(protected injector: Injector, protected submissionDelegate: AddonModAssignSubmissionDelegate, + protected assignProvider: AddonModAssignProvider, protected assignHelper: AddonModAssignHelperProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.plugin) { + return; + } + + this.plugin.name = this.submissionDelegate.getPluginName(this.plugin); + if (!this.plugin.name) { + return; + } + + this.edit = this.edit && this.edit !== 'false'; + this.allowOffline = this.allowOffline && this.allowOffline !== 'false'; + + // Check if the plugin has defined its own component to render itself. + this.submissionDelegate.getComponentForPlugin(this.injector, this.plugin, this.edit).then((component) => { + this.pluginComponent = component; + + if (component) { + // Prepare the data to pass to the component. + this.data = { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + configs: this.assignHelper.getPluginConfig(this.assign, 'assignsubmission', this.plugin.type), + edit: this.edit, + allowOffline: this.allowOffline + }; + } else { + // Data to render the plugin. + this.text = this.assignProvider.getSubmissionPluginText(this.plugin); + this.files = this.assignProvider.getSubmissionPluginAttachments(this.plugin); + this.notSupported = this.submissionDelegate.isPluginSupported(this.plugin.type); + this.pluginLoaded = true; + } + }); + } + + /** + * Invalidate the plugin data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return Promise.resolve(this.dynamicComponent && this.dynamicComponent.callComponentFunction('invalidate', [])); + } +} diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html index 81dbcf04a..ab02ee679 100644 --- a/src/addon/mod/assign/components/submission/submission.html +++ b/src/addon/mod/assign/components/submission/submission.html @@ -27,7 +27,7 @@ - + diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts index 9383ea511..978c2b391 100644 --- a/src/addon/mod/assign/components/submission/submission.ts +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnDestroy, ViewChild, Optional } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy, ViewChild, Optional, ViewChildren, QueryList } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; @@ -35,6 +35,7 @@ import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; import * as moment from 'moment'; import { CoreTabsComponent } from '@components/tabs/tabs'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin'; /** * Component that displays an assignment submission. @@ -45,6 +46,7 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; }) export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { @ViewChild(CoreTabsComponent) tabs: CoreTabsComponent; + @ViewChildren(AddonModAssignSubmissionPluginComponent) submissionComponents: QueryList; @Input() courseId: number; // Course ID the submission belongs to. @Input() moduleId: number; // Module ID the submission belongs to. @@ -328,6 +330,13 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { promises.push(this.gradesHelper.invalidateGradeModuleItems(this.courseId, this.submitId)); promises.push(this.courseProvider.invalidateModule(this.moduleId)); + // Invalidate plugins. + if (this.submissionComponents && this.submissionComponents.length) { + this.submissionComponents.forEach((component) => { + promises.push(component.invalidate()); + }); + } + return Promise.all(promises).catch(() => { // Ignore errors. }).then(() => { diff --git a/src/addon/mod/assign/submission/comments/comments.module.ts b/src/addon/mod/assign/submission/comments/comments.module.ts new file mode 100644 index 000000000..06ea8bafd --- /dev/null +++ b/src/addon/mod/assign/submission/comments/comments.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignSubmissionCommentsHandler } from './providers/handler'; +import { AddonModAssignSubmissionCommentsComponent } from './component/comments'; +import { AddonModAssignSubmissionDelegate } from '../../providers/submission-delegate'; +import { CoreCommentsComponentsModule } from '@core/comments/components/components.module'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionCommentsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreCommentsComponentsModule + ], + providers: [ + AddonModAssignSubmissionCommentsHandler + ], + exports: [ + AddonModAssignSubmissionCommentsComponent + ], + entryComponents: [ + AddonModAssignSubmissionCommentsComponent + ] +}) +export class AddonModAssignSubmissionCommentsModule { + constructor(submissionDelegate: AddonModAssignSubmissionDelegate, handler: AddonModAssignSubmissionCommentsHandler) { + submissionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/submission/comments/component/comments.html b/src/addon/mod/assign/submission/comments/component/comments.html new file mode 100644 index 000000000..e259a78fc --- /dev/null +++ b/src/addon/mod/assign/submission/comments/component/comments.html @@ -0,0 +1,4 @@ + +

{{plugin.name}}

+ +
diff --git a/src/addon/mod/assign/submission/comments/component/comments.ts b/src/addon/mod/assign/submission/comments/component/comments.ts new file mode 100644 index 000000000..c27751bcc --- /dev/null +++ b/src/addon/mod/assign/submission/comments/component/comments.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, ViewChild } from '@angular/core'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { CoreCommentsCommentsComponent } from '@core/comments/components/comments/comments'; +import { AddonModAssignSubmissionPluginComponent } from '../../../classes/submission-plugin-component'; + +/** + * Component to render a comments submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-comments', + templateUrl: 'comments.html' +}) +export class AddonModAssignSubmissionCommentsComponent extends AddonModAssignSubmissionPluginComponent { + @ViewChild(CoreCommentsCommentsComponent) commentsComponent: CoreCommentsCommentsComponent; + + constructor(protected commentsProvider: CoreCommentsProvider) { + super(); + } + + /** + * Invalidate the data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return this.commentsProvider.invalidateCommentsData('module', this.assign.cmid, 'assignsubmission_comments', + this.submission.id, 'submission_comments'); + } + + /** + * Show the comments. + */ + showComments(): void { + this.commentsComponent && this.commentsComponent.openComments(); + } +} diff --git a/src/addon/mod/assign/submission/comments/lang/en.json b/src/addon/mod/assign/submission/comments/lang/en.json new file mode 100644 index 000000000..c69c732aa --- /dev/null +++ b/src/addon/mod/assign/submission/comments/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Submission comments" +} \ No newline at end of file diff --git a/src/addon/mod/assign/submission/comments/providers/handler.ts b/src/addon/mod/assign/submission/comments/providers/handler.ts new file mode 100644 index 000000000..fa4f7af2b --- /dev/null +++ b/src/addon/mod/assign/submission/comments/providers/handler.ts @@ -0,0 +1,93 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; +import { AddonModAssignSubmissionHandler } from '../../../providers/submission-delegate'; +import { AddonModAssignSubmissionCommentsComponent } from '../component/comments'; + +/** + * Handler for comments submission plugin. + */ +@Injectable() +export class AddonModAssignSubmissionCommentsHandler implements AddonModAssignSubmissionHandler { + name = 'AddonModAssignSubmissionCommentsHandler'; + type = 'comments'; + + constructor(private commentsProvider: CoreCommentsProvider) { } + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(assign: any, submission: any, plugin: any): boolean | Promise { + // This plugin is read only, but return true to prevent blocking the edition. + return true; + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @param {boolean} [edit] Whether the user is editing. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any, edit?: boolean): any | Promise { + return edit ? undefined : AddonModAssignSubmissionCommentsComponent; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + return true; + } + + /** + * Prefetch any required data for the plugin. + * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetch(assign: any, submission: any, plugin: any, siteId?: string): Promise { + return this.commentsProvider.getComments('module', assign.cmid, 'assignsubmission_comments', submission.id, + 'submission_comments', 0, siteId).catch(() => { + // Fail silently (Moodle < 3.1.1, 3.2) + }); + } +} diff --git a/src/addon/mod/assign/submission/file/component/file.html b/src/addon/mod/assign/submission/file/component/file.html new file mode 100644 index 000000000..5264ff0bc --- /dev/null +++ b/src/addon/mod/assign/submission/file/component/file.html @@ -0,0 +1,17 @@ + + +

{{plugin.name}}

+
+ + + + + +
+
+ + +
+ {{plugin.name}} + +
diff --git a/src/addon/mod/assign/submission/file/component/file.ts b/src/addon/mod/assign/submission/file/component/file.ts new file mode 100644 index 000000000..a25ac1bbb --- /dev/null +++ b/src/addon/mod/assign/submission/file/component/file.ts @@ -0,0 +1,74 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit } from '@angular/core'; +import { CoreFileSessionProvider } from '@providers/file-session'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../../providers/helper'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignSubmissionFileHandler } from '../providers/handler'; +import { AddonModAssignSubmissionPluginComponent } from '../../../classes/submission-plugin-component'; + +/** + * Component to render a file submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-file', + templateUrl: 'file.html' +}) +export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmissionPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + files: any[]; + + constructor(protected fileSessionprovider: CoreFileSessionProvider, protected assignProvider: AddonModAssignProvider, + protected assignOfflineProvider: AddonModAssignOfflineProvider, protected assignHelper: AddonModAssignHelperProvider, + protected fileUploaderProvider: CoreFileUploaderProvider) { + super(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Get the offline data. + this.assignOfflineProvider.getSubmission(this.assign.id).catch(() => { + // Error getting data, assume there's no offline submission. + }).then((offlineData) => { + if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) { + // It has offline data. + let promise; + if (offlineData.plugindata.files_filemanager.offline) { + promise = this.assignHelper.getStoredSubmissionFiles(this.assign.id, + AddonModAssignSubmissionFileHandler.FOLDER_NAME); + } else { + promise = Promise.resolve([]); + } + + return promise.then((offlineFiles) => { + const onlineFiles = offlineData.plugindata.files_filemanager.online || []; + offlineFiles = this.fileUploaderProvider.markOfflineFiles(offlineFiles); + + this.files = onlineFiles.concat(offlineFiles); + }); + } else { + // No offline data, get the online files. + this.files = this.assignProvider.getSubmissionPluginAttachments(this.plugin); + } + }).finally(() => { + this.fileSessionprovider.setFiles(this.component, this.assign.id, this.files); + }); + } +} diff --git a/src/addon/mod/assign/submission/file/file.module.ts b/src/addon/mod/assign/submission/file/file.module.ts new file mode 100644 index 000000000..64c898ba8 --- /dev/null +++ b/src/addon/mod/assign/submission/file/file.module.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignSubmissionFileHandler } from './providers/handler'; +import { AddonModAssignSubmissionFileComponent } from './component/file'; +import { AddonModAssignSubmissionDelegate } from '../../providers/submission-delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionFileComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModAssignSubmissionFileHandler + ], + exports: [ + AddonModAssignSubmissionFileComponent + ], + entryComponents: [ + AddonModAssignSubmissionFileComponent + ] +}) +export class AddonModAssignSubmissionFileModule { + constructor(submissionDelegate: AddonModAssignSubmissionDelegate, handler: AddonModAssignSubmissionFileHandler) { + submissionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/submission/file/lang/en.json b/src/addon/mod/assign/submission/file/lang/en.json new file mode 100644 index 000000000..7262ba217 --- /dev/null +++ b/src/addon/mod/assign/submission/file/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "File submissions" +} \ No newline at end of file diff --git a/src/addon/mod/assign/submission/file/providers/handler.ts b/src/addon/mod/assign/submission/file/providers/handler.ts new file mode 100644 index 000000000..1e013db7a --- /dev/null +++ b/src/addon/mod/assign/submission/file/providers/handler.ts @@ -0,0 +1,361 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreFileProvider } from '@providers/file'; +import { CoreFileSessionProvider } from '@providers/file-session'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreWSProvider } from '@providers/ws'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignHelperProvider } from '../../../providers/helper'; +import { AddonModAssignSubmissionHandler } from '../../../providers/submission-delegate'; +import { AddonModAssignSubmissionFileComponent } from '../component/file'; + +/** + * Handler for file submission plugin. + */ +@Injectable() +export class AddonModAssignSubmissionFileHandler implements AddonModAssignSubmissionHandler { + static FOLDER_NAME = 'submission_file'; + + name = 'AddonModAssignSubmissionFileHandler'; + type = 'file'; + + constructor(private sitesProvider: CoreSitesProvider, private wsProvider: CoreWSProvider, + private assignProvider: AddonModAssignProvider, private assignOfflineProvider: AddonModAssignOfflineProvider, + private assignHelper: AddonModAssignHelperProvider, private fileSessionProvider: CoreFileSessionProvider, + private fileUploaderProvider: CoreFileUploaderProvider, private filepoolProvider: CoreFilepoolProvider, + private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider) { } + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(assign: any, submission: any, plugin: any): boolean | Promise { + // This plugin doesn't use Moodle filters, it can be edited in offline. + return true; + } + + /** + * Should clear temporary data for a cancelled submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + */ + clearTmpData(assign: any, submission: any, plugin: any, inputData: any): void { + const files = this.fileSessionProvider.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + + // Clear the files in session for this assign. + this.fileSessionProvider.clearFiles(AddonModAssignProvider.COMPONENT, assign.id); + + // Now delete the local files from the tmp folder. + this.fileUploaderProvider.clearTmpFiles(files); + } + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + copySubmissionData(assign: any, plugin: any, pluginData: any, userId?: number, siteId?: string): void | Promise { + // We need to re-upload all the existing files. + const files = this.assignProvider.getSubmissionPluginAttachments(plugin); + + return this.assignHelper.uploadFiles(assign.id, files).then((itemId) => { + pluginData.files_filemanager = itemId; + }); + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @param {boolean} [edit] Whether the user is editing. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any, edit?: boolean): any | Promise { + return AddonModAssignSubmissionFileComponent; + } + + /** + * Delete any stored data for the plugin and submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + deleteOfflineData(assign: any, submission: any, plugin: any, offlineData: any, siteId?: string): void | Promise { + return this.assignHelper.deleteStoredSubmissionFiles(assign.id, AddonModAssignSubmissionFileHandler.FOLDER_NAME, + submission.userid, siteId).catch(() => { + // Ignore errors, maybe the folder doesn't exist. + }); + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return this.assignProvider.getSubmissionPluginAttachments(plugin); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForCopy(assign: any, plugin: any): number | Promise { + const files = this.assignProvider.getSubmissionPluginAttachments(plugin), + promises = []; + let totalSize = 0; + + files.forEach((file) => { + promises.push(this.wsProvider.getRemoteFileSize(file.fileurl).then((size) => { + if (size == -1) { + // Couldn't determine the size, reject. + return Promise.reject(null); + } + + totalSize += size; + })); + }); + + return Promise.all(promises).then(() => { + return totalSize; + }); + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForEdit(assign: any, submission: any, plugin: any, inputData: any): number | Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Check if there's any change. + if (this.hasDataChanged(assign, submission, plugin, inputData)) { + const files = this.fileSessionProvider.getFiles(AddonModAssignProvider.COMPONENT, assign.id), + promises = []; + let totalSize = 0; + + files.forEach((file) => { + if (file.filename && !file.name) { + // It's a remote file. First check if we have the file downloaded since it's more reliable. + promises.push(this.filepoolProvider.getFilePathByUrl(siteId, file.fileurl).then((path) => { + return this.fileProvider.getFile(path).then((fileEntry) => { + return this.fileProvider.getFileObjectFromFileEntry(fileEntry); + }).then((file) => { + totalSize += file.size; + }); + }).catch(() => { + // Error getting the file, maybe it's not downloaded. Get remote size. + return this.wsProvider.getRemoteFileSize(file.fileurl).then((size) => { + if (size == -1) { + // Couldn't determine the size, reject. + return Promise.reject(null); + } + + totalSize += size; + }); + })); + } else if (file.name) { + // It's a local file, get its size. + promises.push(this.fileProvider.getFileObjectFromFileEntry(file).then((file) => { + totalSize += file.size; + })); + } + }); + + return Promise.all(promises).then(() => { + return totalSize; + }); + } else { + // Nothing has changed, we won't upload any file. + return 0; + } + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(assign: any, submission: any, plugin: any, inputData: any): boolean | Promise { + // Check if there's any offline data. + return this.assignOfflineProvider.getSubmission(assign.id, submission.userid).catch(() => { + // No offline data found. + }).then((offlineData) => { + if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) { + // Has offline data, return the number of files. + return offlineData.plugindata.files_filemanager.offline + offlineData.plugindata.files_filemanager.online.length; + } + + // No offline data, return the number of online files. + const pluginFiles = this.assignProvider.getSubmissionPluginAttachments(plugin); + + return pluginFiles && pluginFiles.length; + }).then((numFiles) => { + const currentFiles = this.fileSessionProvider.getFiles(AddonModAssignProvider.COMPONENT, assign.id); + + if (currentFiles.length != numFiles) { + // Number of files has changed. + return true; + } + + // Search if there is any local file added. + for (let i = 0; i < currentFiles.length; i++) { + const file = currentFiles[i]; + if (!file.filename && typeof file.name != 'undefined' && !file.offline) { + // There's a local file added, list has changed. + return true; + } + } + + // No local files and list length is the same, this means the list hasn't changed. + return false; + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + return true; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @param {any} pluginData Object where to store the data to send. + * @param {boolean} [offline] Whether the user is editing in offline. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData(assign: any, submission: any, plugin: any, inputData: any, pluginData: any, offline?: boolean, + userId?: number, siteId?: string): void | Promise { + + if (this.hasDataChanged(assign, submission, plugin, inputData)) { + // Data has changed, we need to upload new files and re-upload all the existing files. + const currentFiles = this.fileSessionProvider.getFiles(AddonModAssignProvider.COMPONENT, assign.id), + error = this.utils.hasRepeatedFilenames(currentFiles); + + if (error) { + return Promise.reject(error); + } + + return this.assignHelper.uploadOrStoreFiles(assign.id, AddonModAssignSubmissionFileHandler.FOLDER_NAME, + currentFiles, offline, userId, siteId).then((result) => { + pluginData.files_filemanager = result; + }); + } + } + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData(assign: any, submission: any, plugin: any, offlineData: any, pluginData: any, siteId?: string) + : void | Promise { + + const filesData = offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager; + if (filesData) { + // Has some data to sync. + let files = filesData.online || [], + promise; + + if (filesData.offline) { + // Has offline files, get them and add them to the list. + promise = this.assignHelper.getStoredSubmissionFiles(assign.id, AddonModAssignSubmissionFileHandler.FOLDER_NAME, + submission.userid, siteId).then((result) => { + files = files.concat(result); + }).catch(() => { + // Folder not found, no files to add. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return this.assignHelper.uploadFiles(assign.id, files, siteId).then((itemId) => { + pluginData.files_filemanager = itemId; + }); + }); + } + } +} diff --git a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html new file mode 100644 index 000000000..e74173388 --- /dev/null +++ b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html @@ -0,0 +1,21 @@ + + +

{{ plugin.name }}

+

{{ 'addon.mod_assign.numwords' | translate: {'$a': words} }}

+

+ +

+
+ + +
+ {{ plugin.name }} + +

{{ 'addon.mod_assign.wordlimit' | translate }}

+

{{ 'core.numwords' | translate: {'$a': words + ' / ' + configs.wordlimit} }}

+
+ + + + +
diff --git a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts new file mode 100644 index 000000000..b2c96f266 --- /dev/null +++ b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts @@ -0,0 +1,129 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit, ElementRef } from '@angular/core'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignSubmissionPluginComponent } from '../../../classes/submission-plugin-component'; + +/** + * Component to render an onlinetext submission plugin. + */ +@Component({ + selector: 'addon-mod-assign-submission-online-text', + templateUrl: 'onlinetext.html' +}) +export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignSubmissionPluginComponent implements OnInit { + + control: FormControl; + words: number; + component = AddonModAssignProvider.COMPONENT; + text: string; + loaded: boolean; + + protected wordCountTimeout: any; + protected element: HTMLElement; + + constructor(protected fb: FormBuilder, protected domUtils: CoreDomUtilsProvider, protected textUtils: CoreTextUtilsProvider, + protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, + element: ElementRef) { + + super(); + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + let promise, + rteEnabled; + + // Check if rich text editor is enabled. + if (this.edit) { + promise = this.domUtils.isRichTextEditorEnabled(); + } else { + // We aren't editing, so no rich text editor. + promise = Promise.resolve(false); + } + + promise.then((enabled) => { + rteEnabled = enabled; + + // Get the text. Check if we have anything offline. + return this.assignOfflineProvider.getSubmission(this.assign.id).catch(() => { + // No offline data found. + }).then((offlineData) => { + if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) { + return offlineData.plugindata.onlinetext_editor.text; + } + + // No offline data found, return online text. + return this.assignProvider.getSubmissionPluginText(this.plugin, this.edit && !rteEnabled); + }); + }).then((text) => { + // We receive them as strings, convert to int. + this.configs.wordlimit = parseInt(this.configs.wordlimit, 10); + this.configs.wordlimitenabled = parseInt(this.configs.wordlimitenabled, 10); + + // Set the text. + this.text = text; + + if (!this.edit) { + // Not editing, see full text when clicked. + this.element.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (text) { + // Open a new state with the interpolated contents. + this.textUtils.expandText(this.plugin.name, text, this.component, this.assign.cmid); + } + }); + } else { + // Create and add the control. + this.control = this.fb.control(text); + } + + // Calculate initial words. + if (this.configs.wordlimitenabled) { + this.words = this.textUtils.countWords(text); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Text changed. + * + * @param {string} text The new text. + */ + onChange(text: string): void { + // Count words if needed. + if (this.configs.wordlimitenabled) { + // Cancel previous wait. + clearTimeout(this.wordCountTimeout); + + // Wait before calculating, if the user keeps inputing we won't calculate. + // This is to prevent slowing down devices, this calculation can be slow if the text is long. + this.wordCountTimeout = setTimeout(() => { + this.words = this.textUtils.countWords(text); + }, 1500); + } + } +} diff --git a/src/addon/mod/assign/submission/onlinetext/lang/en.json b/src/addon/mod/assign/submission/onlinetext/lang/en.json new file mode 100644 index 000000000..9b8a3d9f9 --- /dev/null +++ b/src/addon/mod/assign/submission/onlinetext/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Online text submissions" +} \ No newline at end of file diff --git a/src/addon/mod/assign/submission/onlinetext/onlinetext.module.ts b/src/addon/mod/assign/submission/onlinetext/onlinetext.module.ts new file mode 100644 index 000000000..bed8c8545 --- /dev/null +++ b/src/addon/mod/assign/submission/onlinetext/onlinetext.module.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignSubmissionOnlineTextHandler } from './providers/handler'; +import { AddonModAssignSubmissionOnlineTextComponent } from './component/onlinetext'; +import { AddonModAssignSubmissionDelegate } from '../../providers/submission-delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionOnlineTextComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModAssignSubmissionOnlineTextHandler + ], + exports: [ + AddonModAssignSubmissionOnlineTextComponent + ], + entryComponents: [ + AddonModAssignSubmissionOnlineTextComponent + ] +}) +export class AddonModAssignSubmissionOnlineTextModule { + constructor(submissionDelegate: AddonModAssignSubmissionDelegate, handler: AddonModAssignSubmissionOnlineTextHandler) { + submissionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/submission/onlinetext/providers/handler.ts b/src/addon/mod/assign/submission/onlinetext/providers/handler.ts new file mode 100644 index 000000000..4f06dc018 --- /dev/null +++ b/src/addon/mod/assign/submission/onlinetext/providers/handler.ts @@ -0,0 +1,277 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreWSProvider } from '@providers/ws'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignHelperProvider } from '../../../providers/helper'; +import { AddonModAssignSubmissionHandler } from '../../../providers/submission-delegate'; +import { AddonModAssignSubmissionOnlineTextComponent } from '../component/onlinetext'; + +/** + * Handler for online text submission plugin. + */ +@Injectable() +export class AddonModAssignSubmissionOnlineTextHandler implements AddonModAssignSubmissionHandler { + name = 'AddonModAssignSubmissionOnlineTextHandler'; + type = 'onlinetext'; + + constructor(private sitesProvider: CoreSitesProvider, private wsProvider: CoreWSProvider, + private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, + private assignProvider: AddonModAssignProvider, private assignOfflineProvider: AddonModAssignOfflineProvider, + private assignHelper: AddonModAssignHelperProvider) { } + + /** + * Whether the plugin can be edited in offline for existing submissions. In general, this should return false if the + * plugin uses Moodle filters. The reason is that the app only prefetches filtered data, and the user should edit + * unfiltered data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether it can be edited in offline. + */ + canEditOffline(assign: any, submission: any, plugin: any): boolean | Promise { + // This plugin uses Moodle filters, it cannot be edited in offline. + return false; + } + + /** + * This function will be called when the user wants to create a new submission based on the previous one. + * It should add to pluginData the data to send to server based in the data in plugin (previous attempt). + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + copySubmissionData(assign: any, plugin: any, pluginData: any, userId?: number, siteId?: string): void | Promise { + const text = this.assignProvider.getSubmissionPluginText(plugin, true), + files = this.assignProvider.getSubmissionPluginAttachments(plugin); + let promise; + + if (!files.length) { + // No files to copy, no item ID. + promise = Promise.resolve(0); + } else { + // Re-upload the files. + promise = this.assignHelper.uploadFiles(assign.id, files, siteId); + } + + return promise.then((itemId) => { + pluginData.onlinetext_editor = { + text: text, + format: 1, + itemid: itemId + }; + }); + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @param {boolean} [edit] Whether the user is editing. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any, edit?: boolean): any | Promise { + return AddonModAssignSubmissionOnlineTextComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return this.assignProvider.getSubmissionPluginAttachments(plugin); + } + + /** + * Get the size of data (in bytes) this plugin will send to copy a previous submission. + * + * @param {any} assign The assignment. + * @param {any} plugin The plugin object. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForCopy(assign: any, plugin: any): number | Promise { + const text = this.assignProvider.getSubmissionPluginText(plugin, true), + files = this.assignProvider.getSubmissionPluginAttachments(plugin), + promises = []; + let totalSize = text.length; + + if (!files.length) { + return totalSize; + } + + files.forEach((file) => { + promises.push(this.wsProvider.getRemoteFileSize(file.fileurl).then((size) => { + if (size == -1) { + // Couldn't determine the size, reject. + return Promise.reject(null); + } + + totalSize += size; + })); + }); + + return Promise.all(promises).then(() => { + return totalSize; + }); + } + + /** + * Get the size of data (in bytes) this plugin will send to add or edit a submission. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {number|Promise} The size (or promise resolved with size). + */ + getSizeForEdit(assign: any, submission: any, plugin: any, inputData: any): number | Promise { + const text = this.assignProvider.getSubmissionPluginText(plugin, true); + + return text.length; + } + + /** + * Get the text to submit. + * + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {string} Text to submit. + */ + protected getTextToSubmit(plugin: any, inputData: any): string { + const text = inputData.onlinetext_editor_text, + files = plugin.fileareas && plugin.fileareas[0] ? plugin.fileareas[0].files : []; + + return this.textUtils.restorePluginfileUrls(text, files); + } + + /** + * Check if the submission data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(assign: any, submission: any, plugin: any, inputData: any): boolean | Promise { + // Get the original text from plugin or offline. + return this.assignOfflineProvider.getSubmission(assign.id, submission.userid).catch(() => { + // No offline data found. + }).then((data) => { + if (data && data.plugindata && data.plugindata.onlinetext_editor) { + return data.plugindata.onlinetext_editor.text; + } + + // No offline data found, get text from plugin. + return plugin.editorfields && plugin.editorfields[0] ? plugin.editorfields[0].text : ''; + }).then((initialText) => { + // Check if text has changed. + return initialText != this.getTextToSubmit(plugin, inputData); + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + // There's a bug in Moodle 3.1.0 that doesn't allow submitting HTML, so we'll disable this plugin in that case. + // Bug was fixed in 3.1.1 minor release and in 3.2. + const currentSite = this.sitesProvider.getCurrentSite(); + + return currentSite.isVersionGreaterEqualThan('3.1.1') || currentSite.checkIfAppUsesLocalMobile(); + } + + /** + * Prepare and add to pluginData the data to send to the server based on the input data. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the submission. + * @param {any} pluginData Object where to store the data to send. + * @param {boolean} [offline] Whether the user is editing in offline. + * @param {number} [userId] User ID. If not defined, site's current user. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSubmissionData(assign: any, submission: any, plugin: any, inputData: any, pluginData: any, offline?: boolean, + userId?: number, siteId?: string): void | Promise { + + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + let text = this.getTextToSubmit(plugin, inputData); + if (!enabled) { + // Rich text editor not enabled, add some HTML to the text if needed. + text = this.textUtils.formatHtmlLines(text); + } + + pluginData.onlinetext_editor = { + text: text, + format: 1, + itemid: 0 // Can't add new files yet, so we use a fake itemid. + }; + }); + } + + /** + * Prepare and add to pluginData the data to send to the server based on the offline data stored. + * This will be used when performing a synchronization. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} offlineData Offline data stored. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareSyncData(assign: any, submission: any, plugin: any, offlineData: any, pluginData: any, siteId?: string) + : void | Promise { + + const textData = offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor; + if (textData) { + // Has some data to sync. + pluginData.onlinetext_editor = textData; + } + } +} diff --git a/src/addon/mod/assign/submission/submission.module.ts b/src/addon/mod/assign/submission/submission.module.ts new file mode 100644 index 000000000..a95495457 --- /dev/null +++ b/src/addon/mod/assign/submission/submission.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { AddonModAssignSubmissionCommentsModule } from './comments/comments.module'; +import { AddonModAssignSubmissionFileModule } from './file/file.module'; +import { AddonModAssignSubmissionOnlineTextModule } from './onlinetext/onlinetext.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonModAssignSubmissionCommentsModule, + AddonModAssignSubmissionFileModule, + AddonModAssignSubmissionOnlineTextModule + ], + providers: [ + ], + exports: [] +}) +export class AddonModAssignSubmissionModule { } diff --git a/src/app/app.scss b/src/app/app.scss index 642918b05..624b27ebd 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -81,6 +81,12 @@ background-color: $gray-lighter; } +// Make no-lines work in any element, not just ion-item and ion-list. +.item *[no-lines] .item-inner, +*[no-lines] .item .item-inner { + border: 0; +} + .core-oauth-icon, .item.core-oauth-icon, .list .item.core-oauth-icon { min-height: 32px; img, .label { diff --git a/src/components/attachments/attachments.html b/src/components/attachments/attachments.html new file mode 100644 index 000000000..7343fc4f5 --- /dev/null +++ b/src/components/attachments/attachments.html @@ -0,0 +1,25 @@ + + {{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} + + +

{{ 'core.fileuploader.filesofthesetypes' | translate }}

+
    +
  • + {{typeInfo.name}} {{typeInfo.extlist}} +
  • +
+
+
+ + + + + +
+ + +
+ + {{ 'core.fileuploader.addfiletext' | translate }} + + \ No newline at end of file diff --git a/src/components/attachments/attachments.ts b/src/components/attachments/attachments.ts new file mode 100644 index 000000000..71f120dc9 --- /dev/null +++ b/src/components/attachments/attachments.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Input, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/helper'; + +/** + * Component to render attachments, allow adding more and delete the current ones. + * + * All the changes done will be applied to the "files" input array, no file will be uploaded. The component using this + * component should be the one uploading and moving the files. + * + * All the files added will be copied to the app temporary folder, so they should be deleted after uploading them + * or if the user cancels the action. + * + * + * + */ +@Component({ + selector: 'core-attachments', + templateUrl: 'attachments.html' +}) +export class CoreAttachmentsComponent implements OnInit { + @Input() files: any[]; // List of attachments. New attachments will be added to this array. + @Input() maxSize: number; // Max size for attachments. If not defined, 0 or -1, unknown size. + @Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, unknown limit. + @Input() component: string; // Component the downloaded files will be linked to. + @Input() componentId: string | number; // Component ID. + @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline. + @Input() acceptedTypes: string; // List of supported filetypes. If undefined, all types supported. + + maxSizeReadable: string; + maxSubmissionsReadable: string; + unlimitedFiles: boolean; + + protected fileTypes: { info: any[], mimetypes: string[] }; + + constructor(protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, + protected textUtils: CoreTextUtilsProvider, protected fileUploaderProvider: CoreFileUploaderProvider, + protected translate: TranslateService, protected fileUploaderHelper: CoreFileUploaderHelperProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number. + this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1; + + if (this.maxSize == -1) { + this.maxSizeReadable = this.translate.instant('core.unknown'); + } else { + this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); + } + + if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) { + this.maxSubmissionsReadable = this.translate.instant('core.unknown'); + this.unlimitedFiles = true; + } else { + this.maxSubmissionsReadable = String(this.maxSubmissions); + } + + if (this.acceptedTypes && this.acceptedTypes.trim()) { + this.fileTypes = this.fileUploaderProvider.prepareFiletypeList(this.acceptedTypes); + } + } + + /** + * Add a new attachment. + */ + add(): void { + const allowOffline = this.allowOffline && this.allowOffline !== 'false'; + + if (!allowOffline && !this.appProvider.isOnline()) { + this.domUtils.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true); + } else { + const mimetypes = this.fileTypes && this.fileTypes.mimetypes; + + this.fileUploaderHelper.selectFile(this.maxSize, allowOffline, undefined, mimetypes).then((result) => { + this.files.push(result); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error selecting file.'); + }); + } + } + + /** + * Delete a file from the list. + * + * @param {number} index The index of the file. + * @param {boolean} [askConfirm] Whether to ask confirm. + */ + delete(index: number, askConfirm?: boolean): void { + let promise; + + if (askConfirm) { + promise = this.domUtils.showConfirm(this.translate.instant('core.confirmdeletefile')); + } else { + promise = Promise.resolve(); + } + + promise.then(() => { + // Remove the file from the list. + this.files.splice(index, 1); + }).catch(() => { + // User cancelled. + }); + } + + /** + * A file was renamed. + * + * @param {number} index Index of the file. + * @param {any} file The new file entry. + */ + renamed(index: number, file: any): void { + this.files[index] = file; + } +} diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 11f1b3efa..4c50dea20 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -43,6 +43,7 @@ import { CoreSendMessageFormComponent } from './send-message-form/send-message-f import { CoreTimerComponent } from './timer/timer'; import { CoreRecaptchaComponent, CoreRecaptchaModalComponent } from './recaptcha/recaptcha'; import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; +import { CoreAttachmentsComponent } from './attachments/attachments'; @NgModule({ declarations: [ @@ -72,7 +73,8 @@ import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; CoreTimerComponent, CoreRecaptchaComponent, CoreRecaptchaModalComponent, - CoreNavigationBarComponent + CoreNavigationBarComponent, + CoreAttachmentsComponent ], entryComponents: [ CoreContextMenuPopoverComponent, @@ -109,7 +111,8 @@ import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; CoreSendMessageFormComponent, CoreTimerComponent, CoreRecaptchaComponent, - CoreNavigationBarComponent + CoreNavigationBarComponent, + CoreAttachmentsComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/file/file.ts b/src/components/file/file.ts index ab0585190..a073e2d59 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -39,7 +39,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { @Input() alwaysDownload?: boolean | string; // Whether it should always display the refresh button when the file is downloaded. // Use it for files that you cannot determine if they're outdated or not. @Input() canDownload?: boolean | string = true; // Whether file can be downloaded. - @Output() onDelete?: EventEmitter; // Will notify when the delete button is clicked. + @Output() onDelete?: EventEmitter; // Will notify when the delete button is clicked. isDownloaded: boolean; isDownloading: boolean; @@ -178,7 +178,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { * * @param {Event} e Click event. */ - deleteFile(e: Event): void { + delete(e: Event): void { e.preventDefault(); e.stopPropagation(); diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts index 1acdcbd5d..ac7284306 100644 --- a/src/components/local-file/local-file.ts +++ b/src/components/local-file/local-file.ts @@ -177,8 +177,8 @@ export class CoreLocalFileComponent implements OnInit { }).finally(() => { modal.dismiss(); }); - }).catch(() => { - this.domUtils.showErrorModal('core.errordeletefile', true); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordeletefile', true); }); } } diff --git a/src/core/fileuploader/providers/fileuploader.ts b/src/core/fileuploader/providers/fileuploader.ts index 4934ead60..4effe967c 100644 --- a/src/core/fileuploader/providers/fileuploader.ts +++ b/src/core/fileuploader/providers/fileuploader.ts @@ -487,7 +487,7 @@ export class CoreFileUploaderProvider { * @return {Promise} Promise resolved with the itemId. */ uploadOrReuploadFiles(files: any[], component?: string, componentId?: string | number, siteId?: string): Promise { - siteId = siteId || this.sitesProvider.getCurrentSiteId(); + siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (!files || !files.length) { // Return fake draft ID. From 9d69c0f623a6d5ef642da255baee8a892703a4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 13 Apr 2018 12:33:17 +0200 Subject: [PATCH 08/16] MOBILE-2334 assign: Fix tab scrolling --- .../assign/components/submission/submission.html | 2 +- src/components/tabs/tabs.scss | 11 +++++++++++ src/components/tabs/tabs.ts | 13 +++++++++---- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html index ab02ee679..33d75d8b6 100644 --- a/src/addon/mod/assign/components/submission/submission.html +++ b/src/addon/mod/assign/components/submission/submission.html @@ -22,7 +22,7 @@ - + diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss index 460e33760..09e3ec64a 100644 --- a/src/components/tabs/tabs.scss +++ b/src/components/tabs/tabs.scss @@ -25,6 +25,17 @@ core-tabs { .core-tabs-content-container { height: 100%; + + &.no-scroll { + height: auto; + padding-bottom: 0 !important; + + .scroll-content { + overflow: hidden !important; + contain: initial; + position: relative; + } + } } &.tabs-hidden { diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index 9ce98db9a..5d57b056a 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -44,6 +44,7 @@ import { Content } from 'ionic-angular'; export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges { @Input() selectedIndex = 0; // Index of the tab to select. @Input() hideUntil = true; // Determine when should the contents be shown. + @Input() parentScrollable = false; // Determine if the scroll should be in the parent content or the tab itself. @Output() ionChange: EventEmitter = new EventEmitter(); // Emitted when the tab changes. @ViewChild('originalTabs') originalTabsRef: ElementRef; @ViewChild('topTabs') topTabs: ElementRef; @@ -58,7 +59,6 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges { protected tabBarHeight; protected tabBarElement: HTMLElement; // Host element. protected tabsShown = true; - protected scroll: HTMLElement; // Parent scroll element (if core-tabs is inside a ion-content). constructor(element: ElementRef, protected content: Content) { this.tabBarElement = element.nativeElement; @@ -164,9 +164,14 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges { this.tabBarHeight = this.topTabsElement.offsetHeight; this.originalTabsContainer.style.paddingBottom = this.tabBarHeight + 'px'; if (this.content) { - this.scroll = this.content.getScrollElement(); - if (this.scroll) { - this.scroll.classList.add('no-scroll'); + if (!this.parentScrollable) { + // Parent scroll element (if core-tabs is inside a ion-content). + const scroll = this.content.getScrollElement(); + if (scroll) { + scroll.classList.add('no-scroll'); + } + } else { + this.originalTabsContainer.classList.add('no-scroll'); } } From 847a9da41b166b265a53d6dcc7884d781e4bb89b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 16 Apr 2018 13:04:41 +0200 Subject: [PATCH 09/16] MOBILE-2334 assign: Implement edit page --- src/addon/mod/assign/pages/edit/edit.html | 27 ++ .../mod/assign/pages/edit/edit.module.ts | 35 ++ src/addon/mod/assign/pages/edit/edit.ts | 335 ++++++++++++++++++ src/core/fileuploader/providers/helper.ts | 6 +- src/providers/filepool.ts | 28 +- src/providers/utils/dom.ts | 2 +- 6 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 src/addon/mod/assign/pages/edit/edit.html create mode 100644 src/addon/mod/assign/pages/edit/edit.module.ts create mode 100644 src/addon/mod/assign/pages/edit/edit.ts diff --git a/src/addon/mod/assign/pages/edit/edit.html b/src/addon/mod/assign/pages/edit/edit.html new file mode 100644 index 000000000..58dbf25aa --- /dev/null +++ b/src/addon/mod/assign/pages/edit/edit.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
diff --git a/src/addon/mod/assign/pages/edit/edit.module.ts b/src/addon/mod/assign/pages/edit/edit.module.ts new file mode 100644 index 000000000..8b02b1c96 --- /dev/null +++ b/src/addon/mod/assign/pages/edit/edit.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModAssignComponentsModule } from '../../components/components.module'; +import { AddonModAssignEditPage } from './edit'; + +@NgModule({ + declarations: [ + AddonModAssignEditPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + AddonModAssignComponentsModule, + IonicPageModule.forChild(AddonModAssignEditPage), + TranslateModule.forChild() + ], +}) +export class AddonModAssignEditPageModule {} diff --git a/src/addon/mod/assign/pages/edit/edit.ts b/src/addon/mod/assign/pages/edit/edit.ts new file mode 100644 index 000000000..9fcfc67cc --- /dev/null +++ b/src/addon/mod/assign/pages/edit/edit.ts @@ -0,0 +1,335 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit, OnDestroy } from '@angular/core'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/helper'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import { AddonModAssignSyncProvider } from '../../providers/assign-sync'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; + +/** + * Page that allows adding or editing an assigment submission. + */ +@IonicPage({ segment: 'addon-mod-assign-edit' }) +@Component({ + selector: 'page-addon-mod-assign-edit', + templateUrl: 'edit.html', +}) +export class AddonModAssignEditPage implements OnInit, OnDestroy { + title: string; // Title to display. + assign: any; // Assignment. + courseId: number; // Course ID the assignment belongs to. + userSubmission: any; // The user submission. + allowOffline: boolean; // Whether offline is allowed. + submissionStatement: string; // The submission statement. + loaded: boolean; // Whether data has been loaded. + + protected moduleId: number; // Module ID the submission belongs to. + protected userId: number; // User doing the submission. + protected isBlind: boolean; // Whether blind is used. + protected editText: string; // "Edit submission" translated text. + protected saveOffline = false; // Whether to save data in offline. + protected hasOffline = false; // Whether the assignment has offline data. + protected isDestroyed = false; // Whether the component has been destroyed. + protected forceLeave = false; // To allow leaving the page without checking for changes. + + constructor(navParams: NavParams, protected navCtrl: NavController, protected sitesProvider: CoreSitesProvider, + protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, + protected translate: TranslateService, protected fileUploaderHelper: CoreFileUploaderHelperProvider, + protected eventsProvider: CoreEventsProvider, protected assignProvider: AddonModAssignProvider, + protected assignOfflineProvider: AddonModAssignOfflineProvider, protected assignHelper: AddonModAssignHelperProvider, + protected assignSyncProvider: AddonModAssignSyncProvider) { + + this.moduleId = navParams.get('moduleId'); + this.courseId = navParams.get('courseId'); + this.userId = sitesProvider.getCurrentSiteUserId(); // Right now we can only edit current user's submissions. + this.isBlind = !!navParams.get('blindId'); + + this.editText = translate.instant('addon.mod_assign.editsubmission'); + this.title = this.editText; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchAssignment().finally(() => { + this.loaded = true; + }); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + // Check if data has changed. + return this.hasDataChanged().then((changed) => { + if (changed) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + }).then(() => { + // Nothing has changed or user confirmed to leave. Clear temporary data from plugins. + this.assignHelper.clearSubmissionPluginTmpData(this.assign, this.userSubmission, this.getInputData()); + }); + } + + /** + * Fetch assignment data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchAssignment(): Promise { + const currentUserId = this.sitesProvider.getCurrentSiteUserId(); + + // Get assignment data. + return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { + this.assign = assign; + this.title = assign.name || this.title; + + if (!this.isDestroyed) { + // Block the assignment. + this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, assign.id); + } + + // Wait for sync to be over (if any). + return this.assignSyncProvider.waitForSync(assign.id); + }).then(() => { + + // Get submission status. Ignore cache to get the latest data. + return this.assignProvider.getSubmissionStatus(this.assign.id, this.userId, this.isBlind, false, true).catch((err) => { + // Cannot connect. Get cached data. + return this.assignProvider.getSubmissionStatus(this.assign.id, this.userId, this.isBlind).then((response) => { + const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt); + + if (this.assignHelper.canEditSubmissionOffline(this.assign, userSubmission)) { + return response; + } + + // Submission cannot be edited in offline, reject. + this.allowOffline = false; + + return Promise.reject(err); + }); + }).then((response) => { + if (!response.lastattempt.canedit) { + // Can't edit. Reject. + return Promise.reject(this.translate.instant('core.nopermissions', {$a: this.editText})); + } + + this.userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt); + this.allowOffline = true; // If offline isn't allowed we shouldn't have reached this point. + + // Only show submission statement if we are editing our own submission. + if (this.assign.requiresubmissionstatement && !this.assign.submissiondrafts && this.userId == currentUserId) { + this.submissionStatement = this.assign.submissionstatement; + } else { + this.submissionStatement = undefined; + } + + // Check if there's any offline data for this submission. + return this.assignOfflineProvider.getSubmission(this.assign.id, this.userId).then((data) => { + this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + }).catch(() => { + // No offline data found. + this.hasOffline = false; + }); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); + + // Leave the player. + this.leaveWithoutCheck(); + }); + } + + /** + * Get the input data. + * + * @return {any} Input data. + */ + protected getInputData(): any { + return this.domUtils.getDataFromForm(document.forms['addon-mod_assign-edit-form']); + } + + /** + * Check if data has changed. + * + * @return {Promise} Promise resolved with boolean: whether data has changed. + */ + protected hasDataChanged(): Promise { + // Usually the hasSubmissionDataChanged call will be resolved inmediately, causing the modal to be shown just an instant. + // We'll wait a bit before showing it to prevent this "blink". + let modal, + showModal = true; + + setTimeout(() => { + if (showModal) { + modal = this.domUtils.showModalLoading(); + } + }, 100); + + const data = this.getInputData(); + + return this.assignHelper.hasSubmissionDataChanged(this.assign, this.userSubmission, data).finally(() => { + if (modal) { + modal.dismiss(); + } else { + showModal = false; + } + }); + } + + /** + * Leave the view without checking for changes. + */ + protected leaveWithoutCheck(): void { + this.forceLeave = true; + this.navCtrl.pop(); + } + + /** + * Get data to submit based on the input data. + * + * @param {any} inputData The input data. + * @return {Promise} Promise resolved with the data to submit. + */ + protected prepareSubmissionData(inputData: any): Promise { + // If there's offline data, always save it in offline. + this.saveOffline = this.hasOffline; + + return this.assignHelper.prepareSubmissionPluginData(this.assign, this.userSubmission, inputData, this.hasOffline) + .catch((error) => { + + if (this.allowOffline && !this.saveOffline) { + // Cannot submit in online, prepare for offline usage. + this.saveOffline = true; + + return this.assignHelper.prepareSubmissionPluginData(this.assign, this.userSubmission, inputData, true); + } + + return Promise.reject(error); + }); + } + + /** + * Save the submission. + */ + save(): void { + // Check if data has changed. + this.hasDataChanged().then((changed) => { + if (changed) { + this.saveSubmission().then(() => { + this.leaveWithoutCheck(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error saving submission.'); + }); + } else { + // Nothing to save, just go back. + this.leaveWithoutCheck(); + } + }); + } + + /** + * Save the submission. + * + * @return {Promise} Promise resolved when done. + */ + protected saveSubmission(): Promise { + const inputData = this.getInputData(); + + if (this.submissionStatement && !inputData.submissionstatement) { + return Promise.reject(this.translate.instant('addon.mod_assign.acceptsubmissionstatement')); + } + + let modal = this.domUtils.showModalLoading(); + + // Get size to ask for confirmation. + return this.assignHelper.getSubmissionSizeForEdit(this.assign, this.userSubmission, inputData).catch(() => { + // Error calculating size, return -1. + return -1; + }).then((size) => { + modal.dismiss(); + + // Confirm action. + return this.fileUploaderHelper.confirmUploadFile(size, true, this.allowOffline); + }).then(() => { + modal = this.domUtils.showModalLoading('core.sending', true); + + return this.prepareSubmissionData(inputData).then((pluginData) => { + if (!Object.keys(pluginData).length) { + // Nothing to save. + return; + } + + let promise; + + if (this.saveOffline) { + // Save submission in offline. + promise = this.assignOfflineProvider.saveSubmission(this.assign.id, this.courseId, pluginData, + this.userSubmission.timemodified, !this.assign.submissiondrafts, this.userId); + } else { + // Try to send it to server. + promise = this.assignProvider.saveSubmission(this.assign.id, this.courseId, pluginData, this.allowOffline, + this.userSubmission.timemodified, this.assign.submissiondrafts, this.userId); + } + + return promise.then(() => { + // Submission saved, trigger event. + const params = { + assignmentId: this.assign.id, + submissionId: this.userSubmission.id, + userId: this.userId, + }; + + this.eventsProvider.trigger(AddonModAssignProvider.SUBMISSION_SAVED_EVENT, params, + this.sitesProvider.getCurrentSiteId()); + + if (!this.assign.submissiondrafts) { + // No drafts allowed, so it was submitted. Trigger event. + this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, params, + this.sitesProvider.getCurrentSiteId()); + } + }); + }); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = false; + + // Unblock the assignment. + if (this.assign) { + this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); + } + } +} diff --git a/src/core/fileuploader/providers/helper.ts b/src/core/fileuploader/providers/helper.ts index 3518c3b54..168fee217 100644 --- a/src/core/fileuploader/providers/helper.ts +++ b/src/core/fileuploader/providers/helper.ts @@ -198,13 +198,9 @@ export class CoreFileUploaderHelperProvider { */ filePickerClosed(): void { if (this.filePickerDeferred) { - this.filePickerDeferred.reject(); + this.filePickerDeferred.reject(this.domUtils.createCanceledError()); this.filePickerDeferred = undefined; } - // Close the action sheet if it's opened. - if (this.actionSheet) { - this.actionSheet.dismiss(); - } } /** diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index eb398387c..673606dad 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -548,6 +548,32 @@ export class CoreFilepoolProvider { }); } + /** + * Adds a hash to a filename if needed. + * + * @param {string} url The URL of the file, already treated (decoded, without revision, etc.). + * @param {string} filename The filename. + * @return {string} The filename with the hash. + */ + protected addHashToFilename(url: string, filename: string): string { + // Check if the file already has a hash. If a file is downloaded and re-uploaded with the app it will have a hash already. + const matches = filename.match(/_[a-f0-9]{32}/g); + + if (matches && matches.length) { + // There is at least 1 match. Get the last one. + const hash = matches[matches.length - 1], + treatedUrl = url.replace(hash, ''); // Remove the hash from the URL. + + // Check that the hash is valid. + if ('_' + Md5.hashAsciiStr('url:' + treatedUrl) == hash) { + // The data found is a hash of the URL, don't need to add it again. + return filename; + } + } + + return filename + '_' + Md5.hashAsciiStr('url:' + url); + } + /** * Add a file to the queue. * @@ -1414,7 +1440,7 @@ export class CoreFilepoolProvider { // We want to keep the original file name so people can easily identify the files after the download. filename = this.guessFilenameFromUrl(url); - return filename + '_' + Md5.hashAsciiStr('url:' + url); + return this.addHashToFilename(url, filename); } /** diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 494e2bd6f..e52e75121 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -296,7 +296,7 @@ export class CoreDomUtilsProvider { // Ignore submit inputs. if (!name || element.type == 'submit' || element.tagName == 'BUTTON') { - return; + continue; } // Get the value. From bb6f4b21e78df4d4c2afe80ba12a9e00b86c3e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 16 Apr 2018 12:27:05 +0200 Subject: [PATCH 10/16] MOBILE-2334 core: Fix loading content height after loaded --- src/components/loading/loading.html | 6 ++-- src/components/loading/loading.scss | 53 ++++++++++++++++++----------- src/components/loading/loading.ts | 23 +++++++++++-- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/components/loading/loading.html b/src/components/loading/loading.html index c7fcb6833..26ba42d78 100644 --- a/src/components/loading/loading.html +++ b/src/components/loading/loading.html @@ -4,5 +4,7 @@

{{message}}

- - \ No newline at end of file +
+ + +
\ No newline at end of file diff --git a/src/components/loading/loading.scss b/src/components/loading/loading.scss index 8dce40229..503eca75b 100644 --- a/src/components/loading/loading.scss +++ b/src/components/loading/loading.scss @@ -1,4 +1,6 @@ core-loading { + @include core-transition(height, 200ms); + .core-loading-container { width: 100%; text-align: center; @@ -7,34 +9,45 @@ core-loading { } .core-loading-content { + display: unset; padding-bottom: 1px; /* This makes height be real */ } &.core-loading-noheight .core-loading-content { height: auto; } - @include core-transition(core-show-animation); } -.scroll-content > core-loading > .core-loading-container, -ion-content > .scroll-content > core-loading > .core-loading-container, -.core-loading-center .core-loading-container { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: table; - height: 100%; - width: 100%; - z-index: 1; - margin: 0; - padding: 0; - clear: both; +.scroll-content > core-loading, +ion-content > .scroll-content > core-loading, +.core-loading-center { + position: unset !important; +} - .core-loading-spinner { - display: table-cell; - text-align: center; - vertical-align: middle; +.scroll-content > core-loading, +ion-content > .scroll-content > core-loading, +.core-loading-center, +core-loading.core-loading-loaded { + position: relative; + + > .core-loading-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; + + .core-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } } } diff --git a/src/components/loading/loading.ts b/src/components/loading/loading.ts index e04326540..09695df97 100644 --- a/src/components/loading/loading.ts +++ b/src/components/loading/loading.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnChanges, SimpleChange, ViewChild, ElementRef } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { coreShowHideAnimation } from '@classes/animations'; @@ -41,11 +41,15 @@ import { coreShowHideAnimation } from '@classes/animations'; templateUrl: 'loading.html', animations: [coreShowHideAnimation] }) -export class CoreLoadingComponent implements OnInit { +export class CoreLoadingComponent implements OnInit, OnChanges { @Input() hideUntil: boolean; // Determine when should the contents be shown. @Input() message?: string; // Message to show while loading. + @ViewChild('content') content: ElementRef; + protected element: HTMLElement; // Current element. - constructor(private translate: TranslateService) { } + constructor(private translate: TranslateService, element: ElementRef) { + this.element = element.nativeElement; + } /** * Component being initialized. @@ -57,4 +61,17 @@ export class CoreLoadingComponent implements OnInit { } } + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.hideUntil.currentValue === true) { + setTimeout(() => { + // Content is loaded so, center the spinner on the content itself. + this.element.classList.add('core-loading-loaded'); + setTimeout(() => { + // Change CSS to force calculate height. + this.content.nativeElement.classList.add('core-loading-content'); + }, 500); + }); + } + } + } From 2aa4a55d172f95a50a38dd6db29ec489a1e9db96 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 17 Apr 2018 13:16:22 +0200 Subject: [PATCH 11/16] MOBILE-2334 assign: Implement submission list and review pages --- .../submission-list/submission-list.html | 48 +++ .../submission-list/submission-list.module.ts | 33 +++ .../pages/submission-list/submission-list.ts | 273 ++++++++++++++++++ .../submission-review/submission-review.html | 22 ++ .../submission-review.module.ts | 35 +++ .../submission-review/submission-review.ts | 154 ++++++++++ 6 files changed, 565 insertions(+) create mode 100644 src/addon/mod/assign/pages/submission-list/submission-list.html create mode 100644 src/addon/mod/assign/pages/submission-list/submission-list.module.ts create mode 100644 src/addon/mod/assign/pages/submission-list/submission-list.ts create mode 100644 src/addon/mod/assign/pages/submission-review/submission-review.html create mode 100644 src/addon/mod/assign/pages/submission-review/submission-review.module.ts create mode 100644 src/addon/mod/assign/pages/submission-review/submission-review.ts diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.html b/src/addon/mod/assign/pages/submission-list/submission-list.html new file mode 100644 index 000000000..5e33c96a7 --- /dev/null +++ b/src/addon/mod/assign/pages/submission-list/submission-list.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + +

{{submission.userfullname}}

+

{{ 'addon.mod_assign.hiddenuser' | translate }}{{submission.blindid}}

+

+ {{submission.groupname}} + {{ 'addon.mod_assign.noteam' | translate }} + {{ 'addon.mod_assign.multipleteams' | translate }} + {{ 'addon.mod_assign.defaultteam' | translate }} +

+ + {{ submission.statusTranslated }} + + + {{ submission.gradingStatusTranslationId | translate }} + +
+
+ + + + {{ 'addon.mod_assign.notallparticipantsareshown' | translate }} + +
+
+
+
diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.module.ts b/src/addon/mod/assign/pages/submission-list/submission-list.module.ts new file mode 100644 index 000000000..19a54ad06 --- /dev/null +++ b/src/addon/mod/assign/pages/submission-list/submission-list.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModAssignSubmissionListPage } from './submission-list'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionListPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModAssignSubmissionListPage), + TranslateModule.forChild() + ], +}) +export class AddonModAssignSubmissionListPageModule {} diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.ts b/src/addon/mod/assign/pages/submission-list/submission-list.ts new file mode 100644 index 000000000..5f947416d --- /dev/null +++ b/src/addon/mod/assign/pages/submission-list/submission-list.ts @@ -0,0 +1,273 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../providers/assign-offline'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Page that displays a list of submissions of an assignment. + */ +@IonicPage({ segment: 'addon-mod-assign-submission-list' }) +@Component({ + selector: 'page-addon-mod-assign-submission-list', + templateUrl: 'submission-list.html', +}) +export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + title: string; // Title to display. + assign: any; // Assignment. + submissions: any[]; // List of submissions + loaded: boolean; // Whether data has been loaded. + haveAllParticipants: boolean; // Whether all participants have been loaded. + selectedSubmissionId: number; // Selected submission ID. + + protected moduleId: number; // Module ID the submission belongs to. + protected courseId: number; // Course ID the assignment belongs to. + protected selectedStatus: string; // The status to see. + protected gradedObserver; // Observer to refresh data when a grade changes. + + constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + protected domUtils: CoreDomUtilsProvider, protected translate: TranslateService, + protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, + protected assignHelper: AddonModAssignHelperProvider) { + + this.moduleId = navParams.get('moduleId'); + this.courseId = navParams.get('courseId'); + this.selectedStatus = navParams.get('status'); + + if (this.selectedStatus) { + if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { + this.title = this.translate.instant('addon.mod_assign.numberofsubmissionsneedgrading'); + } else { + this.title = this.translate.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); + } + } else { + this.title = this.translate.instant('addon.mod_assign.numberofparticipants'); + } + + // Update data if some grade changes. + this.gradedObserver = eventsProvider.on(AddonModAssignProvider.GRADED_EVENT, (data) => { + if (this.assign && data.assignmentId == this.assign.id && data.userId == sitesProvider.getCurrentSiteUserId()) { + // Grade changed, refresh the data. + this.loaded = false; + + this.refreshAllData().finally(() => { + this.loaded = true; + }); + } + }, sitesProvider.getCurrentSiteId()); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchAssignment().finally(() => { + if (!this.selectedSubmissionId && this.splitviewCtrl.isOn() && this.submissions.length > 0) { + // Take first and load it. + this.loadSubmission(this.submissions[0]); + } + + this.loaded = true; + }); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + // If split view is enabled, check if we can leave the details page. + if (this.splitviewCtrl.isOn()) { + const detailsPage = this.splitviewCtrl.getDetailsNav().getActive().instance; + if (detailsPage && detailsPage.ionViewCanLeave) { + return detailsPage.ionViewCanLeave(); + } + } + + return true; + } + + /** + * Fetch assignment data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchAssignment(): Promise { + let participants, + submissionsData; + + // Get assignment data. + return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => { + this.title = assign.name || this.title; + this.assign = assign; + this.haveAllParticipants = true; + + // Get assignment submissions. + return this.assignProvider.getSubmissions(assign.id); + }).then((data) => { + if (!data.canviewsubmissions) { + // User shouldn't be able to reach here. + return Promise.reject(null); + } + + submissionsData = data; + + // Get the participants. + return this.assignHelper.getParticipants(this.assign).then((parts) => { + this.haveAllParticipants = true; + participants = parts; + }).catch(() => { + this.haveAllParticipants = false; + }); + }).then(() => { + // We want to show the user data on each submission. + return this.assignProvider.getSubmissionsUserData(submissionsData.submissions, this.courseId, this.assign.id, + this.assign.blindmarking && !this.assign.revealidentities, participants); + }).then((submissions) => { + + // Filter the submissions to get only the ones with the right status and add some extra data. + const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING, + searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus, + promises = []; + + this.submissions = []; + submissions.forEach((submission) => { + if (!searchStatus || searchStatus == submission.status) { + promises.push(this.assignOfflineProvider.getSubmissionGrade(this.assign.id, submission.userid).catch(() => { + // Ignore errors. + }).then((data) => { + let promise, + notSynced = false; + + // Load offline grades. + if (data && submission.timemodified < data.timemodified) { + notSynced = true; + } + + if (getNeedGrading) { + // Only show the submissions that need to be graded. + promise = this.assignProvider.needsSubmissionToBeGraded(submission, this.assign.id); + } else { + promise = Promise.resolve(true); + } + + return promise.then((add) => { + if (!add) { + return; + } + + submission.statusColor = this.assignProvider.getSubmissionStatusColor(submission.status); + submission.gradingColor = this.assignProvider.getSubmissionGradingStatusColor(submission.gradingstatus); + + // Show submission status if not submitted for grading. + if (submission.statusColor != 'success' || !submission.gradingstatus) { + submission.statusTranslated = this.translate.instant('addon.mod_assign.submissionstatus_' + + submission.status); + } else { + submission.statusTranslated = false; + } + + if (notSynced) { + submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; + submission.gradingColor = ''; + } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { + // Show grading status if one of the statuses is not done. + submission.gradingStatusTranslationId = + this.assignProvider.getSubmissionGradingStatusTranslationId(submission.gradingstatus); + } else { + submission.gradingStatusTranslationId = false; + } + + this.submissions.push(submission); + }); + })); + } + }); + + return Promise.all(promises); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.'); + }); + } + + /** + * Load a certain submission. + * + * @param {any} submission The submission to load. + */ + loadSubmission(submission: any): void { + if (this.selectedSubmissionId === submission.id) { + // Already selected. + return; + } + + this.selectedSubmissionId = submission.id; + + this.splitviewCtrl.push('AddonModAssignSubmissionReviewPage', { + courseId: this.courseId, + moduleId: this.moduleId, + submitId: submission.submitid, + blindId: submission.blindid + }); + } + + /** + * Refresh all the data. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshAllData(): Promise { + const promises = []; + + promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(this.assignProvider.invalidateAllSubmissionData(this.assign.id)); + promises.push(this.assignProvider.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(this.assignProvider.invalidateListParticipantsData(this.assign.id)); + } + + return Promise.all(promises).finally(() => { + return this.fetchAssignment(); + }); + } + + /** + * Refresh the list. + * + * @param {any} refresher Refresher. + */ + refreshList(refresher: any): void { + this.refreshAllData().finally(() => { + refresher.complete(); + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.gradedObserver && this.gradedObserver.off(); + } +} diff --git a/src/addon/mod/assign/pages/submission-review/submission-review.html b/src/addon/mod/assign/pages/submission-review/submission-review.html new file mode 100644 index 000000000..0deb67f29 --- /dev/null +++ b/src/addon/mod/assign/pages/submission-review/submission-review.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/assign/pages/submission-review/submission-review.module.ts b/src/addon/mod/assign/pages/submission-review/submission-review.module.ts new file mode 100644 index 000000000..788b4f527 --- /dev/null +++ b/src/addon/mod/assign/pages/submission-review/submission-review.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModAssignComponentsModule } from '../../components/components.module'; +import { AddonModAssignSubmissionReviewPage } from './submission-review'; + +@NgModule({ + declarations: [ + AddonModAssignSubmissionReviewPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + AddonModAssignComponentsModule, + IonicPageModule.forChild(AddonModAssignSubmissionReviewPage), + TranslateModule.forChild() + ], +}) +export class AddonModAssignSubmissionReviewPageModule {} diff --git a/src/addon/mod/assign/pages/submission-review/submission-review.ts b/src/addon/mod/assign/pages/submission-review/submission-review.ts new file mode 100644 index 000000000..8fe64533d --- /dev/null +++ b/src/addon/mod/assign/pages/submission-review/submission-review.ts @@ -0,0 +1,154 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit, ViewChild } from '@angular/core'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { CoreAppProvider } from '@providers/app'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignSubmissionComponent } from '../../components/submission/submission'; + +/** + * Page that displays a submission. + */ +@IonicPage({ segment: 'addon-mod-assign-submission-review' }) +@Component({ + selector: 'page-addon-mod-assign-submission-review', + templateUrl: 'submission-review.html', +}) +export class AddonModAssignSubmissionReviewPage implements OnInit { + @ViewChild(AddonModAssignSubmissionComponent) submissionComponent: AddonModAssignSubmissionComponent; + + title: string; // Title to display. + moduleId: number; // Module ID the submission belongs to. + courseId: number; // Course ID the assignment belongs to. + submitId: number; // User that did the submission. + blindId: number; // Blinded user ID (if it's blinded). + showGrade: boolean; // Whether to display the grade at start. + loaded: boolean; // Whether data has been loaded. + canSaveGrades: boolean; // Whether the user can save grades. + + protected assign: any; // The assignment the submission belongs to. + protected blindMarking: boolean; // Whether it uses blind marking. + protected forceLeave = false; // To allow leaving the page without checking for changes. + + constructor(navParams: NavParams, protected navCtrl: NavController, protected courseProvider: CoreCourseProvider, + protected appProvider: CoreAppProvider, protected assignProvider: AddonModAssignProvider) { + + this.moduleId = navParams.get('moduleId'); + this.courseId = navParams.get('courseId'); + this.submitId = navParams.get('submitId'); + this.blindId = navParams.get('blindId'); + this.showGrade = !!navParams.get('showGrade'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchSubmission().finally(() => { + this.loaded = true; + }); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (!this.submissionComponent || this.forceLeave) { + return true; + } + + // Check if data has changed. + return this.submissionComponent.canLeave(); + } + + /** + * Get the submission. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchSubmission(): Promise { + return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assignment) => { + this.assign = assignment; + this.title = this.assign.name; + + this.blindMarking = this.assign.blindmarking && !this.assign.revealidentities; + + return this.courseProvider.getModuleBasicGradeInfo(this.moduleId).then((gradeInfo) => { + if (gradeInfo) { + // Grades can be saved if simple grading. + if (gradeInfo.advancedgrading && gradeInfo.advancedgrading[0] && + typeof gradeInfo.advancedgrading[0].method != 'undefined') { + + const method = gradeInfo.advancedgrading[0].method || 'simple'; + this.canSaveGrades = method == 'simple'; + } else { + this.canSaveGrades = true; + } + } + }); + }); + } + + /** + * Refresh all the data. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshAllData(): Promise { + const promises = []; + + promises.push(this.assignProvider.invalidateAssignmentData(this.courseId)); + if (this.assign) { + promises.push(this.assignProvider.invalidateSubmissionData(this.assign.id)); + promises.push(this.assignProvider.invalidateAssignmentUserMappingsData(this.assign.id)); + promises.push(this.assignProvider.invalidateSubmissionStatusData(this.assign.id, this.submitId, this.blindMarking)); + } + + return Promise.all(promises).finally(() => { + this.submissionComponent && this.submissionComponent.invalidateAndRefresh(); + + return this.fetchSubmission(); + }); + } + + /** + * Refresh the data. + * + * @param {any} refresher Refresher. + */ + refreshSubmission(refresher: any): void { + this.refreshAllData().finally(() => { + refresher.complete(); + }); + } + + /** + * Submit a grade and feedback. + */ + submitGrade(): void { + if (this.submissionComponent) { + this.submissionComponent.submitGrade().then(() => { + // Grade submitted, leave the view if not in tablet. + if (!this.appProvider.isWide()) { + this.forceLeave = true; + this.navCtrl.pop(); + } + }); + } + } +} From dc74546f3d965fc126bc52ac16a2ad0373b3cc54 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 18 Apr 2018 13:00:29 +0200 Subject: [PATCH 12/16] MOBILE-2334 assign: Implement feedback plugins --- src/addon/mod/assign/assign.module.ts | 4 +- .../classes/feedback-plugin-component.ts | 70 ++++++ .../assign/components/components.module.ts | 7 +- .../feedback-plugin/feedback-plugin.html | 16 ++ .../feedback-plugin/feedback-plugin.ts | 104 ++++++++ .../components/submission/submission.html | 2 +- .../feedback/comments/comments.module.ts | 50 ++++ .../feedback/comments/component/comments.html | 24 ++ .../feedback/comments/component/comments.ts | 148 ++++++++++++ .../mod/assign/feedback/comments/lang/en.json | 3 + .../feedback/comments/providers/handler.ts | 224 ++++++++++++++++++ .../feedback/editpdf/component/editpdf.html | 7 + .../feedback/editpdf/component/editpdf.ts | 44 ++++ .../assign/feedback/editpdf/editpdf.module.ts | 50 ++++ .../mod/assign/feedback/editpdf/lang/en.json | 3 + .../feedback/editpdf/providers/handler.ts | 65 +++++ .../mod/assign/feedback/feedback.module.ts | 31 +++ .../assign/feedback/file/component/file.html | 7 + .../assign/feedback/file/component/file.ts | 44 ++++ .../mod/assign/feedback/file/file.module.ts | 50 ++++ .../mod/assign/feedback/file/lang/en.json | 3 + .../assign/feedback/file/providers/handler.ts | 65 +++++ .../edit-feedback-modal.html | 16 ++ .../edit-feedback-modal.module.ts | 35 +++ .../edit-feedback-modal.ts | 103 ++++++++ 25 files changed, 1171 insertions(+), 4 deletions(-) create mode 100644 src/addon/mod/assign/classes/feedback-plugin-component.ts create mode 100644 src/addon/mod/assign/components/feedback-plugin/feedback-plugin.html create mode 100644 src/addon/mod/assign/components/feedback-plugin/feedback-plugin.ts create mode 100644 src/addon/mod/assign/feedback/comments/comments.module.ts create mode 100644 src/addon/mod/assign/feedback/comments/component/comments.html create mode 100644 src/addon/mod/assign/feedback/comments/component/comments.ts create mode 100644 src/addon/mod/assign/feedback/comments/lang/en.json create mode 100644 src/addon/mod/assign/feedback/comments/providers/handler.ts create mode 100644 src/addon/mod/assign/feedback/editpdf/component/editpdf.html create mode 100644 src/addon/mod/assign/feedback/editpdf/component/editpdf.ts create mode 100644 src/addon/mod/assign/feedback/editpdf/editpdf.module.ts create mode 100644 src/addon/mod/assign/feedback/editpdf/lang/en.json create mode 100644 src/addon/mod/assign/feedback/editpdf/providers/handler.ts create mode 100644 src/addon/mod/assign/feedback/feedback.module.ts create mode 100644 src/addon/mod/assign/feedback/file/component/file.html create mode 100644 src/addon/mod/assign/feedback/file/component/file.ts create mode 100644 src/addon/mod/assign/feedback/file/file.module.ts create mode 100644 src/addon/mod/assign/feedback/file/lang/en.json create mode 100644 src/addon/mod/assign/feedback/file/providers/handler.ts create mode 100644 src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.html create mode 100644 src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.module.ts create mode 100644 src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.ts diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index d623ec3fb..145356eae 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -28,12 +28,14 @@ import { AddonModAssignModuleHandler } from './providers/module-handler'; import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModAssignSubmissionModule } from './submission/submission.module'; +import { AddonModAssignFeedbackModule } from './feedback/feedback.module'; @NgModule({ declarations: [ ], imports: [ - AddonModAssignSubmissionModule + AddonModAssignSubmissionModule, + AddonModAssignFeedbackModule ], providers: [ AddonModAssignProvider, diff --git a/src/addon/mod/assign/classes/feedback-plugin-component.ts b/src/addon/mod/assign/classes/feedback-plugin-component.ts new file mode 100644 index 000000000..847e7fe5a --- /dev/null +++ b/src/addon/mod/assign/classes/feedback-plugin-component.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Input } from '@angular/core'; +import { ModalController } from 'ionic-angular'; + +/** + * Base class for component to render a feedback plugin. + */ +export class AddonModAssignFeedbackPluginComponent { + @Input() assign: any; // The assignment. + @Input() submission: any; // The submission. + @Input() plugin: any; // The plugin object. + @Input() userId: number; // The user ID of the submission. + @Input() configs: any; // The configs for the plugin. + @Input() canEdit: boolean; // Whether the user can edit. + @Input() edit: boolean; // Whether the user is editing. + + constructor(protected modalCtrl: ModalController) { } + + /** + * Open a modal to edit the feedback plugin. + * + * @return {Promise} Promise resolved with the input data, rejected if cancelled. + */ + editFeedback(): Promise { + if (this.canEdit) { + return new Promise((resolve, reject): void => { + // Create the navigation modal. + const modal = this.modalCtrl.create('AddonModAssignEditFeedbackModalPage', { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + userId: this.userId + }); + + modal.present(); + modal.onDidDismiss((data) => { + if (typeof data == 'undefined') { + reject(); + } else { + resolve(data); + } + }); + }); + } else { + return Promise.reject(null); + } + } + + /** + * Invalidate the data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return Promise.resolve(); + } +} diff --git a/src/addon/mod/assign/components/components.module.ts b/src/addon/mod/assign/components/components.module.ts index 94e81c86e..b794ebe49 100644 --- a/src/addon/mod/assign/components/components.module.ts +++ b/src/addon/mod/assign/components/components.module.ts @@ -23,12 +23,14 @@ import { CoreCourseComponentsModule } from '@core/course/components/components.m import { AddonModAssignIndexComponent } from './index/index'; import { AddonModAssignSubmissionComponent } from './submission/submission'; import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/submission-plugin'; +import { AddonModAssignFeedbackPluginComponent } from './feedback-plugin/feedback-plugin'; @NgModule({ declarations: [ AddonModAssignIndexComponent, AddonModAssignSubmissionComponent, - AddonModAssignSubmissionPluginComponent + AddonModAssignSubmissionPluginComponent, + AddonModAssignFeedbackPluginComponent ], imports: [ CommonModule, @@ -44,7 +46,8 @@ import { AddonModAssignSubmissionPluginComponent } from './submission-plugin/sub exports: [ AddonModAssignIndexComponent, AddonModAssignSubmissionComponent, - AddonModAssignSubmissionPluginComponent + AddonModAssignSubmissionPluginComponent, + AddonModAssignFeedbackPluginComponent ], entryComponents: [ AddonModAssignIndexComponent diff --git a/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.html b/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.html new file mode 100644 index 000000000..fd814742c --- /dev/null +++ b/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.html @@ -0,0 +1,16 @@ + + + + + +

{{ plugin.name }}

+ + {{ 'addon.mod_assign.feedbacknotsupported' | translate }} + +

+ +

+ +
+
+
diff --git a/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.ts b/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.ts new file mode 100644 index 000000000..efc561c4b --- /dev/null +++ b/src/addon/mod/assign/components/feedback-plugin/feedback-plugin.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Input, OnInit, Injector, ViewChild } from '@angular/core'; +import { AddonModAssignProvider } from '../../providers/assign'; +import { AddonModAssignHelperProvider } from '../../providers/helper'; +import { AddonModAssignFeedbackDelegate } from '../../providers/feedback-delegate'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; + +/** + * Component that displays an assignment feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-plugin', + templateUrl: 'feedback-plugin.html', +}) +export class AddonModAssignFeedbackPluginComponent implements OnInit { + @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; + + @Input() assign: any; // The assignment. + @Input() submission: any; // The submission. + @Input() plugin: any; // The plugin object. + @Input() userId: number; // The user ID of the submission. + @Input() canEdit: boolean | string; // Whether the user can edit. + @Input() edit: boolean | string; // Whether the user is editing. + + pluginComponent: any; // Component to render the plugin. + data: any; // Data to pass to the component. + + // Data to render the plugin if it isn't supported. + component = AddonModAssignProvider.COMPONENT; + text = ''; + files = []; + notSupported: boolean; + pluginLoaded: boolean; + + constructor(protected injector: Injector, protected feedbackDelegate: AddonModAssignFeedbackDelegate, + protected assignProvider: AddonModAssignProvider, protected assignHelper: AddonModAssignHelperProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.plugin) { + this.pluginLoaded = true; + + return; + } + + this.plugin.name = this.feedbackDelegate.getPluginName(this.plugin); + if (!this.plugin.name) { + this.pluginLoaded = true; + + return; + } + + this.edit = this.edit && this.edit !== 'false'; + this.canEdit = this.canEdit && this.canEdit !== 'false'; + + // Check if the plugin has defined its own component to render itself. + this.feedbackDelegate.getComponentForPlugin(this.injector, this.plugin).then((component) => { + this.pluginComponent = component; + + if (component) { + // Prepare the data to pass to the component. + this.data = { + assign: this.assign, + submission: this.submission, + plugin: this.plugin, + userId: this.userId, + configs: this.assignHelper.getPluginConfig(this.assign, 'assignfeedback', this.plugin.type), + edit: this.edit, + canEdit: this.canEdit + }; + } else { + // Data to render the plugin. + this.text = this.assignProvider.getSubmissionPluginText(this.plugin); + this.files = this.assignProvider.getSubmissionPluginAttachments(this.plugin); + this.notSupported = this.feedbackDelegate.isPluginSupported(this.plugin.type); + this.pluginLoaded = true; + } + }); + } + + /** + * Invalidate the plugin data. + * + * @return {Promise} Promise resolved when done. + */ + invalidate(): Promise { + return Promise.resolve(this.dynamicComponent && this.dynamicComponent.callComponentFunction('invalidate', [])); + } +} diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html index 33d75d8b6..f9cf275bc 100644 --- a/src/addon/mod/assign/components/submission/submission.html +++ b/src/addon/mod/assign/components/submission/submission.html @@ -176,7 +176,7 @@

{{ outcome.selected }}

- + diff --git a/src/addon/mod/assign/feedback/comments/comments.module.ts b/src/addon/mod/assign/feedback/comments/comments.module.ts new file mode 100644 index 000000000..c5905c452 --- /dev/null +++ b/src/addon/mod/assign/feedback/comments/comments.module.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignFeedbackCommentsHandler } from './providers/handler'; +import { AddonModAssignFeedbackCommentsComponent } from './component/comments'; +import { AddonModAssignFeedbackDelegate } from '../../providers/feedback-delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackCommentsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModAssignFeedbackCommentsHandler + ], + exports: [ + AddonModAssignFeedbackCommentsComponent + ], + entryComponents: [ + AddonModAssignFeedbackCommentsComponent + ] +}) +export class AddonModAssignFeedbackCommentsModule { + constructor(feedbackDelegate: AddonModAssignFeedbackDelegate, handler: AddonModAssignFeedbackCommentsHandler) { + feedbackDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/feedback/comments/component/comments.html b/src/addon/mod/assign/feedback/comments/component/comments.html new file mode 100644 index 000000000..8c68abdb8 --- /dev/null +++ b/src/addon/mod/assign/feedback/comments/component/comments.html @@ -0,0 +1,24 @@ + + +

{{ plugin.name }}

+

+ +

+
+
+ + + +
+ + + {{ 'core.notsent' | translate }} + +
+
+ + + + + + diff --git a/src/addon/mod/assign/feedback/comments/component/comments.ts b/src/addon/mod/assign/feedback/comments/component/comments.ts new file mode 100644 index 000000000..23ddedaf8 --- /dev/null +++ b/src/addon/mod/assign/feedback/comments/component/comments.ts @@ -0,0 +1,148 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit, ElementRef } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignFeedbackDelegate } from '../../../providers/feedback-delegate'; +import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; +import { AddonModAssignFeedbackCommentsHandler } from '../providers/handler'; + +/** + * Component to render a comments feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-comments', + templateUrl: 'comments.html' +}) +export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + control: FormControl; + component = AddonModAssignProvider.COMPONENT; + text: string; + isSent: boolean; + loaded: boolean; + + protected element: HTMLElement; + + constructor(modalCtrl: ModalController, element: ElementRef, protected domUtils: CoreDomUtilsProvider, + protected textUtils: CoreTextUtilsProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, + protected assignProvider: AddonModAssignProvider, protected fb: FormBuilder, + protected feedbackDelegate: AddonModAssignFeedbackDelegate) { + super(modalCtrl); + + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + let promise, + rteEnabled; + + // Check if rich text editor is enabled. + if (this.edit) { + promise = this.domUtils.isRichTextEditorEnabled(); + } else { + // We aren't editing, so no rich text editor. + promise = Promise.resolve(false); + } + + promise.then((enabled) => { + rteEnabled = enabled; + + return this.getText(rteEnabled); + }).then((text) => { + + this.text = text; + + if (!this.canEdit && !this.edit) { + // User cannot edit the comment. Show it full when clicked. + this.element.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.text) { + // Open a new state with the text. + this.textUtils.expandText(this.plugin.name, this.text, this.component, this.assign.cmid); + } + }); + } else if (this.edit) { + this.control = this.fb.control(text); + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Edit the comment. + */ + editComment(): void { + this.editFeedback().then((inputData) => { + const text = AddonModAssignFeedbackCommentsHandler.getTextFromInputData(this.textUtils, this.plugin, inputData); + + // Update the text and save it as draft. + this.isSent = false; + this.text = text; + this.feedbackDelegate.saveFeedbackDraft(this.assign.id, this.userId, this.plugin, { + text: text, + format: 1 + }); + }).catch(() => { + // User cancelled, nothing to do. + }); + } + + /** + * Get the text for the plugin. + * + * @param {boolean} rteEnabled Whether Rich Text Editor is enabled. + * @return {Promise} Promise resolved with the text. + */ + protected getText(rteEnabled: boolean): Promise { + // Check if the user already modified the comment. + return this.feedbackDelegate.getPluginDraftData(this.assign.id, this.userId, this.plugin).then((draft) => { + if (draft) { + this.isSent = false; + + return draft.text; + } else { + // There is no draft saved. Check if we have anything offline. + return this.assignOfflineProvider.getSubmissionGrade(this.assign.id, this.userId).catch(() => { + // No offline data found. + }).then((offlineData) => { + if (offlineData && offlineData.plugindata && offlineData.plugindata.assignfeedbackcomments_editor) { + // Save offline as draft. + this.isSent = false; + this.feedbackDelegate.saveFeedbackDraft(this.assign.id, this.userId, this.plugin, + offlineData.plugindata.assignfeedbackcomments_editor); + + return offlineData.plugindata.assignfeedbackcomments_editor.text; + } + + // No offline data found, return online text. + this.isSent = true; + + return this.assignProvider.getSubmissionPluginText(this.plugin, this.edit && !rteEnabled); + }); + } + }); + } +} diff --git a/src/addon/mod/assign/feedback/comments/lang/en.json b/src/addon/mod/assign/feedback/comments/lang/en.json new file mode 100644 index 000000000..637363859 --- /dev/null +++ b/src/addon/mod/assign/feedback/comments/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Feedback comments" +} \ No newline at end of file diff --git a/src/addon/mod/assign/feedback/comments/providers/handler.ts b/src/addon/mod/assign/feedback/comments/providers/handler.ts new file mode 100644 index 000000000..d6c3f6bd3 --- /dev/null +++ b/src/addon/mod/assign/feedback/comments/providers/handler.ts @@ -0,0 +1,224 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignOfflineProvider } from '../../../providers/assign-offline'; +import { AddonModAssignFeedbackHandler } from '../../../providers/feedback-delegate'; +import { AddonModAssignFeedbackCommentsComponent } from '../component/comments'; + +/** + * Handler for comments feedback plugin. + */ +@Injectable() +export class AddonModAssignFeedbackCommentsHandler implements AddonModAssignFeedbackHandler { + name = 'AddonModAssignFeedbackCommentsHandler'; + type = 'comments'; + + protected drafts = {}; // Store the data in this service so it isn't lost if the user performs a PTR in the page. + + constructor(private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, + private textUtils: CoreTextUtilsProvider, private assignProvider: AddonModAssignProvider, + private assignOfflineProvider: AddonModAssignOfflineProvider) { } + + /** + * Discard the draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + discardDraft(assignId: number, userId: number, siteId?: string): void | Promise { + const id = this.getDraftId(assignId, userId, siteId); + + if (typeof this.drafts[id] != 'undefined') { + delete this.drafts[id]; + } + } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any): any | Promise { + return AddonModAssignFeedbackCommentsComponent; + } + + /** + * Return the draft saved data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any|Promise} Data (or promise resolved with the data). + */ + getDraft(assignId: number, userId: number, siteId?: string): any | Promise { + const id = this.getDraftId(assignId, userId, siteId); + + if (typeof this.drafts[id] != 'undefined') { + return this.drafts[id]; + } + } + + /** + * Get a draft ID. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {string} Draft ID. + */ + protected getDraftId(assignId: number, userId: number, siteId?: string): string { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return siteId + '#' + assignId + '#' + userId; + } + + /** + * Get the text to submit. + * + * @param {CoreTextUtilsProvider} textUtils Text utils instance. + * @param {any} plugin Plugin. + * @param {any} inputData Data entered in the feedback edit form. + * @return {string} Text to submit. + */ + static getTextFromInputData(textUtils: CoreTextUtilsProvider, plugin: any, inputData: any): string { + const files = plugin.fileareas && plugin.fileareas[0] ? plugin.fileareas[0].files : []; + let text = inputData.assignfeedbackcomments_editor; + + // The input data can have a string or an object with text and format. Get the text. + if (text && text.text) { + text = text.text; + } + + return textUtils.restorePluginfileUrls(text, files); + } + + /** + * Check if the feedback data has changed for this plugin. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {any} inputData Data entered by the user for the feedback. + * @param {number} userId User ID of the submission. + * @return {boolean|Promise} Boolean (or promise resolved with boolean): whether the data has changed. + */ + hasDataChanged(assign: any, submission: any, plugin: any, inputData: any, userId: number): boolean | Promise { + // Get it from plugin or offline. + return this.assignOfflineProvider.getSubmissionGrade(assign.id, userId).catch(() => { + // No offline data found. + }).then((data) => { + if (data && data.plugindata && data.plugindata.assignfeedbackcomments_editor) { + return data.plugindata.assignfeedbackcomments_editor.text; + } + + // No offline data found, get text from plugin. + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + return this.assignProvider.getSubmissionPluginText(plugin, !enabled); + }); + }).then((initialText) => { + const newText = AddonModAssignFeedbackCommentsHandler.getTextFromInputData(this.textUtils, plugin, inputData); + + if (typeof newText == 'undefined') { + return false; + } + + // Check if text has changed. + return initialText != newText; + }); + } + + /** + * Check whether the plugin has draft data stored. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {boolean|Promise} Boolean or promise resolved with boolean: whether the plugin has draft data. + */ + hasDraftData(assignId: number, userId: number, siteId?: string): boolean | Promise { + const draft = this.getDraft(assignId, userId, siteId); + + return !!draft; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Whether or not the handler is enabled for edit on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. + */ + isEnabledForEdit(): boolean | Promise { + return true; + } + + /** + * Prepare and add to pluginData the data to send to the server based on the draft data saved. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} pluginData Object where to store the data to send. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + prepareFeedbackData(assignId: number, userId: number, plugin: any, pluginData: any, siteId?: string): void | Promise { + const draft = this.getDraft(assignId, userId, siteId); + + if (draft) { + return this.domUtils.isRichTextEditorEnabled().then((enabled) => { + if (!enabled) { + // Rich text editor not enabled, add some HTML to the text if needed. + draft.text = this.textUtils.formatHtmlLines(draft.text); + } + + pluginData.assignfeedbackcomments_editor = draft; + }); + } + } + + /** + * Save draft data of the feedback plugin. + * + * @param {number} assignId The assignment ID. + * @param {number} userId User ID. + * @param {any} plugin The plugin object. + * @param {any} data The data to save. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {void|Promise} If the function is async, it should return a Promise resolved when done. + */ + saveDraft(assignId: number, userId: number, plugin: any, data: any, siteId?: string): void | Promise { + if (data) { + this.drafts[this.getDraftId(assignId, userId, siteId)] = data; + } + } +} diff --git a/src/addon/mod/assign/feedback/editpdf/component/editpdf.html b/src/addon/mod/assign/feedback/editpdf/component/editpdf.html new file mode 100644 index 000000000..fc9633296 --- /dev/null +++ b/src/addon/mod/assign/feedback/editpdf/component/editpdf.html @@ -0,0 +1,7 @@ + + +

{{plugin.name}}

+
+ +
+
diff --git a/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts b/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts new file mode 100644 index 000000000..4ccf24e5b --- /dev/null +++ b/src/addon/mod/assign/feedback/editpdf/component/editpdf.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; + +/** + * Component to render a edit pdf feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-edit-pdf', + templateUrl: 'editpdf.html' +}) +export class AddonModAssignFeedbackEditPdfComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + files: any[]; + + constructor(modalCtrl: ModalController, protected assignProvider: AddonModAssignProvider) { + super(modalCtrl); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (this.plugin) { + this.files = this.assignProvider.getSubmissionPluginAttachments(this.plugin); + } + } +} diff --git a/src/addon/mod/assign/feedback/editpdf/editpdf.module.ts b/src/addon/mod/assign/feedback/editpdf/editpdf.module.ts new file mode 100644 index 000000000..defcb5e86 --- /dev/null +++ b/src/addon/mod/assign/feedback/editpdf/editpdf.module.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignFeedbackEditPdfHandler } from './providers/handler'; +import { AddonModAssignFeedbackEditPdfComponent } from './component/editpdf'; +import { AddonModAssignFeedbackDelegate } from '../../providers/feedback-delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackEditPdfComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModAssignFeedbackEditPdfHandler + ], + exports: [ + AddonModAssignFeedbackEditPdfComponent + ], + entryComponents: [ + AddonModAssignFeedbackEditPdfComponent + ] +}) +export class AddonModAssignFeedbackEditPdfModule { + constructor(feedbackDelegate: AddonModAssignFeedbackDelegate, handler: AddonModAssignFeedbackEditPdfHandler) { + feedbackDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/feedback/editpdf/lang/en.json b/src/addon/mod/assign/feedback/editpdf/lang/en.json new file mode 100644 index 000000000..a98c70fd9 --- /dev/null +++ b/src/addon/mod/assign/feedback/editpdf/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Annotate PDF" +} \ No newline at end of file diff --git a/src/addon/mod/assign/feedback/editpdf/providers/handler.ts b/src/addon/mod/assign/feedback/editpdf/providers/handler.ts new file mode 100644 index 000000000..1758fb8fe --- /dev/null +++ b/src/addon/mod/assign/feedback/editpdf/providers/handler.ts @@ -0,0 +1,65 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignFeedbackHandler } from '../../../providers/feedback-delegate'; +import { AddonModAssignFeedbackEditPdfComponent } from '../component/editpdf'; + +/** + * Handler for edit pdf feedback plugin. + */ +@Injectable() +export class AddonModAssignFeedbackEditPdfHandler implements AddonModAssignFeedbackHandler { + name = 'AddonModAssignFeedbackEditPdfHandler'; + type = 'editpdf'; + + constructor(private assignProvider: AddonModAssignProvider) { } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any): any | Promise { + return AddonModAssignFeedbackEditPdfComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return this.assignProvider.getSubmissionPluginAttachments(plugin); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/assign/feedback/feedback.module.ts b/src/addon/mod/assign/feedback/feedback.module.ts new file mode 100644 index 000000000..ef504bc2a --- /dev/null +++ b/src/addon/mod/assign/feedback/feedback.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { AddonModAssignFeedbackCommentsModule } from './comments/comments.module'; +import { AddonModAssignFeedbackEditPdfModule } from './editpdf/editpdf.module'; +import { AddonModAssignFeedbackFileModule } from './file/file.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonModAssignFeedbackCommentsModule, + AddonModAssignFeedbackEditPdfModule, + AddonModAssignFeedbackFileModule + ], + providers: [ + ], + exports: [] +}) +export class AddonModAssignFeedbackModule { } diff --git a/src/addon/mod/assign/feedback/file/component/file.html b/src/addon/mod/assign/feedback/file/component/file.html new file mode 100644 index 000000000..fc9633296 --- /dev/null +++ b/src/addon/mod/assign/feedback/file/component/file.html @@ -0,0 +1,7 @@ + + +

{{plugin.name}}

+
+ +
+
diff --git a/src/addon/mod/assign/feedback/file/component/file.ts b/src/addon/mod/assign/feedback/file/component/file.ts new file mode 100644 index 000000000..6074bc2f2 --- /dev/null +++ b/src/addon/mod/assign/feedback/file/component/file.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, OnInit } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignFeedbackPluginComponent } from '../../../classes/feedback-plugin-component'; + +/** + * Component to render a file feedback plugin. + */ +@Component({ + selector: 'addon-mod-assign-feedback-file', + templateUrl: 'file.html' +}) +export class AddonModAssignFeedbackFileComponent extends AddonModAssignFeedbackPluginComponent implements OnInit { + + component = AddonModAssignProvider.COMPONENT; + files: any[]; + + constructor(modalCtrl: ModalController, protected assignProvider: AddonModAssignProvider) { + super(modalCtrl); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (this.plugin) { + this.files = this.assignProvider.getSubmissionPluginAttachments(this.plugin); + } + } +} diff --git a/src/addon/mod/assign/feedback/file/file.module.ts b/src/addon/mod/assign/feedback/file/file.module.ts new file mode 100644 index 000000000..3ce4230db --- /dev/null +++ b/src/addon/mod/assign/feedback/file/file.module.ts @@ -0,0 +1,50 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonModAssignFeedbackFileHandler } from './providers/handler'; +import { AddonModAssignFeedbackFileComponent } from './component/file'; +import { AddonModAssignFeedbackDelegate } from '../../providers/feedback-delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonModAssignFeedbackFileComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonModAssignFeedbackFileHandler + ], + exports: [ + AddonModAssignFeedbackFileComponent + ], + entryComponents: [ + AddonModAssignFeedbackFileComponent + ] +}) +export class AddonModAssignFeedbackFileModule { + constructor(feedbackDelegate: AddonModAssignFeedbackDelegate, handler: AddonModAssignFeedbackFileHandler) { + feedbackDelegate.registerHandler(handler); + } +} diff --git a/src/addon/mod/assign/feedback/file/lang/en.json b/src/addon/mod/assign/feedback/file/lang/en.json new file mode 100644 index 000000000..e5e6aeb98 --- /dev/null +++ b/src/addon/mod/assign/feedback/file/lang/en.json @@ -0,0 +1,3 @@ +{ + "pluginname": "File feedback" +} \ No newline at end of file diff --git a/src/addon/mod/assign/feedback/file/providers/handler.ts b/src/addon/mod/assign/feedback/file/providers/handler.ts new file mode 100644 index 000000000..fb3936a26 --- /dev/null +++ b/src/addon/mod/assign/feedback/file/providers/handler.ts @@ -0,0 +1,65 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { AddonModAssignProvider } from '../../../providers/assign'; +import { AddonModAssignFeedbackHandler } from '../../../providers/feedback-delegate'; +import { AddonModAssignFeedbackFileComponent } from '../component/file'; + +/** + * Handler for file feedback plugin. + */ +@Injectable() +export class AddonModAssignFeedbackFileHandler implements AddonModAssignFeedbackHandler { + name = 'AddonModAssignFeedbackFileHandler'; + type = 'file'; + + constructor(private assignProvider: AddonModAssignProvider) { } + + /** + * Return the Component to use to display the plugin data, either in read or in edit mode. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} plugin The plugin object. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, plugin: any): any | Promise { + return AddonModAssignFeedbackFileComponent; + } + + /** + * Get files used by this plugin. + * The files returned by this function will be prefetched when the user prefetches the assign. + * + * @param {any} assign The assignment. + * @param {any} submission The submission. + * @param {any} plugin The plugin object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {any[]|Promise} The files (or promise resolved with the files). + */ + getPluginFiles(assign: any, submission: any, plugin: any, siteId?: string): any[] | Promise { + return this.assignProvider.getSubmissionPluginAttachments(plugin); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } +} diff --git a/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.html b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.html new file mode 100644 index 000000000..649bd289e --- /dev/null +++ b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.html @@ -0,0 +1,16 @@ + + + + + + + + + +
+ + +
+
diff --git a/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.module.ts b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.module.ts new file mode 100644 index 000000000..f7e67463d --- /dev/null +++ b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { AddonModAssignEditFeedbackModalPage } from './edit-feedback-modal'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModAssignComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + AddonModAssignEditFeedbackModalPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + AddonModAssignComponentsModule, + IonicPageModule.forChild(AddonModAssignEditFeedbackModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModAssignEditFeedbackModalPageModule {} diff --git a/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.ts b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.ts new file mode 100644 index 000000000..01b24e5de --- /dev/null +++ b/src/addon/mod/assign/pages/edit-feedback-modal/edit-feedback-modal.ts @@ -0,0 +1,103 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component, Input } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModAssignFeedbackDelegate } from '../../providers/feedback-delegate'; + +/** + * Modal that allows editing a feedback plugin. + */ +@IonicPage({ segment: 'addon-mod-assign-edit-feedback-modal' }) +@Component({ + selector: 'page-addon-mod-assign-edit-feedback-modal', + templateUrl: 'edit-feedback-modal.html', +}) +export class AddonModAssignEditFeedbackModalPage { + + @Input() assign: any; // The assignment. + @Input() submission: any; // The submission. + @Input() plugin: any; // The plugin object. + @Input() userId: number; // The user ID of the submission. + + protected forceLeave = false; // To allow leaving the page without checking for changes. + + constructor(params: NavParams, protected viewCtrl: ViewController, protected domUtils: CoreDomUtilsProvider, + protected translate: TranslateService, protected feedbackDelegate: AddonModAssignFeedbackDelegate) { + + this.assign = params.get('assign'); + this.submission = params.get('submission'); + this.plugin = params.get('plugin'); + this.userId = params.get('userId'); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + return this.hasDataChanged().then((changed) => { + if (changed) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + }); + } + + /** + * Close modal. + * + * @param {any} data Data to return to the page. + */ + closeModal(data: any): void { + this.viewCtrl.dismiss(data); + } + + /** + * Done editing. + */ + done(): void { + // Close the modal, sending the input data. + this.forceLeave = true; + this.closeModal(this.getInputData()); + } + + /** + * Get the input data. + * + * @return {any} Object with the data. + */ + protected getInputData(): any { + return this.domUtils.getDataFromForm(document.forms['addon-mod_assign-edit-feedback-form']); + } + + /** + * Check if data has changed. + * + * @return {Promise} Promise resolved with boolean: whether the data has changed. + */ + protected hasDataChanged(): Promise { + return this.feedbackDelegate.hasPluginDataChanged(this.assign, this.userId, this.plugin, this.getInputData(), this.userId) + .catch(() => { + // Ignore errors. + return true; + }); + } +} From ae290c3c05a3b6a117bf277c48f7809f7599bb5d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 19 Apr 2018 13:28:22 +0200 Subject: [PATCH 13/16] MOBILE-2334 assign: Fix errors with offline and sync --- .../mod/assign/components/index/index.ts | 3 +++ .../components/submission/submission.ts | 24 ++++++++++--------- .../feedback/comments/component/comments.ts | 6 ++--- .../feedback/comments/providers/handler.ts | 14 ++--------- src/addon/mod/assign/pages/edit/edit.html | 5 +++- src/addon/mod/assign/pages/edit/edit.ts | 20 +++++++++------- .../pages/submission-list/submission-list.ts | 2 +- .../mod/assign/providers/assign-offline.ts | 12 +++++----- src/addon/mod/assign/providers/assign-sync.ts | 2 +- src/addon/mod/assign/providers/assign.ts | 9 ++++++- .../providers/default-feedback-handler.ts | 8 ------- .../mod/assign/providers/feedback-delegate.ts | 17 ------------- src/addon/mod/assign/providers/helper.ts | 21 ++++++++++------ .../mod/assign/providers/prefetch-handler.ts | 24 ++++++++++++------- .../assign/submission/file/component/file.ts | 6 ++--- .../submission/file/providers/handler.ts | 6 ++--- .../onlinetext/component/onlinetext.ts | 4 ++-- .../onlinetext/providers/handler.ts | 4 ++-- src/core/course/components/module/module.ts | 4 ++-- src/providers/file.ts | 13 ++++++++++ 20 files changed, 107 insertions(+), 97 deletions(-) diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index 2a132806f..08c9b44e4 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -105,6 +105,9 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo if (this.assign && data.assignmentId == this.assign.id && data.userId == this.userId) { // Assignment submitted, check completion. this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + + // Reload data since it can have offline data now. + this.showLoadingAndRefresh(true, false); } }, this.siteId); diff --git a/src/addon/mod/assign/components/submission/submission.ts b/src/addon/mod/assign/components/submission/submission.ts index 978c2b391..89a9d34c1 100644 --- a/src/addon/mod/assign/components/submission/submission.ts +++ b/src/addon/mod/assign/components/submission/submission.ts @@ -200,7 +200,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { */ copyPrevious(): void { if (!this.appProvider.isOnline()) { - this.domUtils.showErrorModal('mm.core.networkerrormsg', true); + this.domUtils.showErrorModal('core.networkerrormsg', true); return; } @@ -229,9 +229,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { // Now go to edit. this.goToEdit(); - // Invalidate and refresh data to update this view. - this.invalidateAndRefresh(); - if (!this.assign.submissiondrafts) { // No drafts allowed, so it was submitted. Trigger event. this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { @@ -239,12 +236,17 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { submissionId: this.userSubmission.id, userId: this.currentUserId }, this.siteId); + } else { + // Invalidate and refresh data to update this view. + this.invalidateAndRefresh(); } }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.error', true); }).finally(() => { modal.dismiss(); }); + }).catch(() => { + // Cancelled. }); } @@ -382,7 +384,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { // Check if there's any offline data for this submission. promises.push(this.assignOfflineProvider.getSubmission(assign.id, this.submitId).then((data) => { - this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + this.hasOffline = data && data.pluginData && Object.keys(data.pluginData).length > 0; this.submittedOffline = data && data.submitted; }).catch(() => { // No offline data found. @@ -505,6 +507,9 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { return; } + // Make sure outcomes is an array. + gradeInfo.outcomes = gradeInfo.outcomes || []; + if (!this.isDestroyed) { // Block the assignment. this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id); @@ -556,8 +561,8 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { this.originalGrades.grade = this.grade.grade; } - this.grade.applyToAll = data.applytoall; - this.grade.addAttempt = data.addattempt; + this.grade.applyToAll = data.applyToAll; + this.grade.addAttempt = data.addAttempt; this.originalGrades.applyToAll = this.grade.applyToAll; this.originalGrades.addAttempt = this.grade.addAttempt; @@ -600,7 +605,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { * @param {any} status Submission status. */ protected setStatusNameAndClass(status: any): void { - if (this.hasOffline) { + if (this.hasOffline || this.submittedOffline) { // Offline data. this.statusTranslated = this.translate.instant('core.notsent'); this.statusColor = 'warning'; @@ -669,9 +674,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy { this.assignProvider.submitForGrading(this.assign.id, this.courseId, acceptStatement, this.userSubmission.timemodified, this.hasOffline).then(() => { - // Invalidate and refresh data. - this.invalidateAndRefresh(); - // Submitted, trigger event. this.eventsProvider.trigger(AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT, { assignmentId: this.assign.id, diff --git a/src/addon/mod/assign/feedback/comments/component/comments.ts b/src/addon/mod/assign/feedback/comments/component/comments.ts index 23ddedaf8..8c4aec3ae 100644 --- a/src/addon/mod/assign/feedback/comments/component/comments.ts +++ b/src/addon/mod/assign/feedback/comments/component/comments.ts @@ -128,13 +128,13 @@ export class AddonModAssignFeedbackCommentsComponent extends AddonModAssignFeedb return this.assignOfflineProvider.getSubmissionGrade(this.assign.id, this.userId).catch(() => { // No offline data found. }).then((offlineData) => { - if (offlineData && offlineData.plugindata && offlineData.plugindata.assignfeedbackcomments_editor) { + if (offlineData && offlineData.pluginData && offlineData.pluginData.assignfeedbackcomments_editor) { // Save offline as draft. this.isSent = false; this.feedbackDelegate.saveFeedbackDraft(this.assign.id, this.userId, this.plugin, - offlineData.plugindata.assignfeedbackcomments_editor); + offlineData.pluginData.assignfeedbackcomments_editor); - return offlineData.plugindata.assignfeedbackcomments_editor.text; + return offlineData.pluginData.assignfeedbackcomments_editor.text; } // No offline data found, return online text. diff --git a/src/addon/mod/assign/feedback/comments/providers/handler.ts b/src/addon/mod/assign/feedback/comments/providers/handler.ts index d6c3f6bd3..3b2804a3e 100644 --- a/src/addon/mod/assign/feedback/comments/providers/handler.ts +++ b/src/addon/mod/assign/feedback/comments/providers/handler.ts @@ -46,7 +46,6 @@ export class AddonModAssignFeedbackCommentsHandler implements AddonModAssignFeed */ discardDraft(assignId: number, userId: number, siteId?: string): void | Promise { const id = this.getDraftId(assignId, userId, siteId); - if (typeof this.drafts[id] != 'undefined') { delete this.drafts[id]; } @@ -129,8 +128,8 @@ export class AddonModAssignFeedbackCommentsHandler implements AddonModAssignFeed return this.assignOfflineProvider.getSubmissionGrade(assign.id, userId).catch(() => { // No offline data found. }).then((data) => { - if (data && data.plugindata && data.plugindata.assignfeedbackcomments_editor) { - return data.plugindata.assignfeedbackcomments_editor.text; + if (data && data.pluginData && data.pluginData.assignfeedbackcomments_editor) { + return data.pluginData.assignfeedbackcomments_editor.text; } // No offline data found, get text from plugin. @@ -172,15 +171,6 @@ export class AddonModAssignFeedbackCommentsHandler implements AddonModAssignFeed return true; } - /** - * Whether or not the handler is enabled for edit on a site level. - * - * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. - */ - isEnabledForEdit(): boolean | Promise { - return true; - } - /** * Prepare and add to pluginData the data to send to the server based on the draft data saved. * diff --git a/src/addon/mod/assign/pages/edit/edit.html b/src/addon/mod/assign/pages/edit/edit.html index 58dbf25aa..dced40957 100644 --- a/src/addon/mod/assign/pages/edit/edit.html +++ b/src/addon/mod/assign/pages/edit/edit.html @@ -17,7 +17,10 @@ - + + + + diff --git a/src/addon/mod/assign/pages/edit/edit.ts b/src/addon/mod/assign/pages/edit/edit.ts index 9fcfc67cc..a3513e908 100644 --- a/src/addon/mod/assign/pages/edit/edit.ts +++ b/src/addon/mod/assign/pages/edit/edit.ts @@ -40,6 +40,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { userSubmission: any; // The user submission. allowOffline: boolean; // Whether offline is allowed. submissionStatement: string; // The submission statement. + submissionStatementAccepted: boolean; // Whether submission statement is accepted. loaded: boolean; // Whether data has been loaded. protected moduleId: number; // Module ID the submission belongs to. @@ -125,14 +126,17 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { return this.assignProvider.getSubmissionStatus(this.assign.id, this.userId, this.isBlind).then((response) => { const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt); - if (this.assignHelper.canEditSubmissionOffline(this.assign, userSubmission)) { - return response; - } + // Check if the user can edit it in offline. + return this.assignHelper.canEditSubmissionOffline(this.assign, userSubmission).then((canEditOffline) => { + if (canEditOffline) { + return response; + } - // Submission cannot be edited in offline, reject. - this.allowOffline = false; + // Submission cannot be edited in offline, reject. + this.allowOffline = false; - return Promise.reject(err); + return Promise.reject(err); + }); }); }).then((response) => { if (!response.lastattempt.canedit) { @@ -152,7 +156,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { // Check if there's any offline data for this submission. return this.assignOfflineProvider.getSubmission(this.assign.id, this.userId).then((data) => { - this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0; + this.hasOffline = data && data.pluginData && Object.keys(data.pluginData).length > 0; }).catch(() => { // No offline data found. this.hasOffline = false; @@ -262,7 +266,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { protected saveSubmission(): Promise { const inputData = this.getInputData(); - if (this.submissionStatement && !inputData.submissionstatement) { + if (this.submissionStatement && (!inputData.submissionstatement || inputData.submissionstatement === 'false')) { return Promise.reject(this.translate.instant('addon.mod_assign.acceptsubmissionstatement')); } diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.ts b/src/addon/mod/assign/pages/submission-list/submission-list.ts index 5f947416d..3f661eb37 100644 --- a/src/addon/mod/assign/pages/submission-list/submission-list.ts +++ b/src/addon/mod/assign/pages/submission-list/submission-list.ts @@ -218,7 +218,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { * @param {any} submission The submission to load. */ loadSubmission(submission: any): void { - if (this.selectedSubmissionId === submission.id) { + if (this.selectedSubmissionId === submission.id && this.splitviewCtrl.isOn()) { // Already selected. return; } diff --git a/src/addon/mod/assign/providers/assign-offline.ts b/src/addon/mod/assign/providers/assign-offline.ts index f3596b594..c79a4aa30 100644 --- a/src/addon/mod/assign/providers/assign-offline.ts +++ b/src/addon/mod/assign/providers/assign-offline.ts @@ -412,7 +412,6 @@ export class AddonModAssignOfflineProvider { return { assignId: assignId, courseId: courseId, - pluginData: '{}', userId: userId, onlineTimemodified: timemodified, timecreated: now, @@ -420,8 +419,9 @@ export class AddonModAssignOfflineProvider { }; }).then((submission) => { // Mark the submission. - submission.submitted = !!submitted; - submission.submissionstatement = !!acceptStatement; + submission.submitted = submitted ? 1 : 0; + submission.submissionStatement = acceptStatement ? 1 : 0; + submission.pluginData = submission.pluginData ? JSON.stringify(submission.pluginData) : '{}'; return site.getDb().insertRecord(this.SUBMISSIONS_TABLE, submission); }); @@ -452,7 +452,7 @@ export class AddonModAssignOfflineProvider { courseId: courseId, pluginData: pluginData ? JSON.stringify(pluginData) : '{}', userId: userId, - submitted: !!submitted, + submitted: submitted ? 1 : 0, timecreated: now, timemodified: now, onlineTimemodified: timemodified @@ -489,9 +489,9 @@ export class AddonModAssignOfflineProvider { courseId: courseId, grade: grade, attemptNumber: attemptNumber, - addAttempt: !!addAttempt, + addAttempt: addAttempt ? 1 : 0, workflowState: workflowState, - applyToAll: !!applyToAll, + applyToAll: applyToAll ? 1 : 0, outcomes: outcomes ? JSON.stringify(outcomes) : '{}', pluginData: pluginData ? JSON.stringify(pluginData) : '{}', timemodified: now diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts index af891b29c..69aa22a6e 100644 --- a/src/addon/mod/assign/providers/assign-sync.ts +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -213,7 +213,7 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { return Promise.reject(null); } - courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid; + courseId = submissions.length > 0 ? submissions[0].courseId : grades[0].courseId; return this.assignProvider.getAssignmentById(courseId, assignId, siteId).then((assignData) => { assign = assignData; diff --git a/src/addon/mod/assign/providers/assign.ts b/src/addon/mod/assign/providers/assign.ts index 651926410..a945bc914 100644 --- a/src/addon/mod/assign/providers/assign.ts +++ b/src/addon/mod/assign/providers/assign.ts @@ -200,7 +200,12 @@ export class AddonModAssignProvider { return site.read('mod_assign_get_user_mappings', params, preSets).then((response) => { // Search the user. - if (userId && userId > 0 && response.assignments && response.assignments.length) { + if (response.assignments && response.assignments.length) { + if (!userId || userId < 0) { + // User not valid, stop. + return -1; + } + const assignment = response.assignments[0]; if (assignment.assignmentid == assignId) { @@ -212,6 +217,8 @@ export class AddonModAssignProvider { } } } + } else if (response.warnings && response.warnings.length) { + return Promise.reject(response.warnings[0]); } return Promise.reject(null); diff --git a/src/addon/mod/assign/providers/default-feedback-handler.ts b/src/addon/mod/assign/providers/default-feedback-handler.ts index 021bdb564..05466f140 100644 --- a/src/addon/mod/assign/providers/default-feedback-handler.ts +++ b/src/addon/mod/assign/providers/default-feedback-handler.ts @@ -95,12 +95,4 @@ export class AddonModAssignDefaultFeedbackHandler implements AddonModAssignFeedb isEnabled(): boolean | Promise { return true; } - - /** - * Whether or not the handler is enabled for edit on a site level. - * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. - */ - isEnabledForEdit(): boolean | Promise { - return false; - } } diff --git a/src/addon/mod/assign/providers/feedback-delegate.ts b/src/addon/mod/assign/providers/feedback-delegate.ts index 38f09e6ec..6598bf4e9 100644 --- a/src/addon/mod/assign/providers/feedback-delegate.ts +++ b/src/addon/mod/assign/providers/feedback-delegate.ts @@ -102,13 +102,6 @@ export interface AddonModAssignFeedbackHandler extends CoreDelegateHandler { */ hasDraftData?(assignId: number, userId: number, siteId?: string): boolean | Promise; - /** - * Whether or not the handler is enabled for edit on a site level. - * - * @return {boolean|Promise} Whether or not the handler is enabled for edit on a site level. - */ - isEnabledForEdit?(): boolean | Promise; - /** * Prefetch any required data for the plugin. * This should NOT prefetch files. Files to be prefetched should be returned by the getPluginFiles function. @@ -258,16 +251,6 @@ export class AddonModAssignFeedbackDelegate extends CoreDelegate { return this.hasHandler(pluginType, true); } - /** - * Check if a feedback plugin is supported for edit. - * - * @param {string} pluginType Type of the plugin. - * @return {Promise} Whether it's supported for edit. - */ - isPluginSupportedForEdit(pluginType: string): Promise { - return Promise.resolve(this.executeFunctionOnEnabled(pluginType, 'isEnabledForEdit')); - } - /** * Prefetch any required data for a feedback plugin. * diff --git a/src/addon/mod/assign/providers/helper.ts b/src/addon/mod/assign/providers/helper.ts index 2fa522672..d227634de 100644 --- a/src/addon/mod/assign/providers/helper.ts +++ b/src/addon/mod/assign/providers/helper.ts @@ -46,25 +46,32 @@ export class AddonModAssignHelperProvider { * @param {any} submission Submission. * @return {boolean} Whether it can be edited offline. */ - canEditSubmissionOffline(assign: any, submission: any): boolean { + canEditSubmissionOffline(assign: any, submission: any): Promise { if (!submission) { - return false; + return Promise.resolve(false); } if (submission.status == AddonModAssignProvider.SUBMISSION_STATUS_NEW || submission.status == AddonModAssignProvider.SUBMISSION_STATUS_REOPENED) { // It's a new submission, allow creating it in offline. - return true; + return Promise.resolve(true); } + const promises = []; + let canEdit = true; + for (let i = 0; i < submission.plugins.length; i++) { const plugin = submission.plugins[i]; - if (!this.submissionDelegate.canPluginEditOffline(assign, submission, plugin)) { - return false; - } + promises.push(this.submissionDelegate.canPluginEditOffline(assign, submission, plugin).then((canEditPlugin) => { + if (!canEditPlugin) { + canEdit = false; + } + })); } - return true; + return Promise.all(promises).then(() => { + return canEdit; + }); } /** diff --git a/src/addon/mod/assign/providers/prefetch-handler.ts b/src/addon/mod/assign/providers/prefetch-handler.ts index e3d104265..b409fb1ec 100644 --- a/src/addon/mod/assign/providers/prefetch-handler.ts +++ b/src/addon/mod/assign/providers/prefetch-handler.ts @@ -153,7 +153,7 @@ export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandl if (response.lastattempt) { const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(assign, response.lastattempt); - if (userSubmission) { + if (userSubmission && userSubmission.plugins) { // Add submission plugin files. userSubmission.plugins.forEach((plugin) => { promises.push(this.submissionDelegate.getPluginFiles(assign, userSubmission, plugin, siteId)); @@ -161,7 +161,7 @@ export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandl } } - if (response.feedback) { + if (response.feedback && response.feedback.plugins) { // Add feedback plugin files. response.feedback.plugins.forEach((plugin) => { promises.push(this.feedbackDelegate.getPluginFiles(assign, response, plugin, siteId)); @@ -237,7 +237,9 @@ export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandl blindMarking = assign.blindmarking && !assign.revealidentities; if (blindMarking) { - subPromises.push(this.assignProvider.getAssignmentUserMappings(assign.id, undefined, siteId)); + subPromises.push(this.assignProvider.getAssignmentUserMappings(assign.id, undefined, siteId).catch(() => { + // Ignore errors. + })); } subPromises.push(this.prefetchSubmissions(assign, courseId, module.id, userId, siteId)); @@ -342,9 +344,11 @@ export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandl if (userSubmission && userSubmission.id) { // Prefetch submission plugins data. - userSubmission.plugins.forEach((plugin) => { - promises.push(this.submissionDelegate.prefetch(assign, userSubmission, plugin, siteId)); - }); + if (userSubmission.plugins) { + userSubmission.plugins.forEach((plugin) => { + promises.push(this.submissionDelegate.prefetch(assign, userSubmission, plugin, siteId)); + }); + } // Get ID of the user who did the submission. if (userSubmission.userid) { @@ -365,9 +369,11 @@ export class AddonModAssignPrefetchHandler extends CoreCourseModulePrefetchHandl } // Prefetch feedback plugins data. - submission.feedback.plugins.forEach((plugin) => { - promises.push(this.feedbackDelegate.prefetch(assign, submission, plugin, siteId)); - }); + if (submission.feedback.plugins) { + submission.feedback.plugins.forEach((plugin) => { + promises.push(this.feedbackDelegate.prefetch(assign, submission, plugin, siteId)); + }); + } } // Prefetch user profiles. diff --git a/src/addon/mod/assign/submission/file/component/file.ts b/src/addon/mod/assign/submission/file/component/file.ts index a25ac1bbb..ed14b8240 100644 --- a/src/addon/mod/assign/submission/file/component/file.ts +++ b/src/addon/mod/assign/submission/file/component/file.ts @@ -47,10 +47,10 @@ export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmiss this.assignOfflineProvider.getSubmission(this.assign.id).catch(() => { // Error getting data, assume there's no offline submission. }).then((offlineData) => { - if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) { + if (offlineData && offlineData.pluginData && offlineData.pluginData.files_filemanager) { // It has offline data. let promise; - if (offlineData.plugindata.files_filemanager.offline) { + if (offlineData.pluginData.files_filemanager.offline) { promise = this.assignHelper.getStoredSubmissionFiles(this.assign.id, AddonModAssignSubmissionFileHandler.FOLDER_NAME); } else { @@ -58,7 +58,7 @@ export class AddonModAssignSubmissionFileComponent extends AddonModAssignSubmiss } return promise.then((offlineFiles) => { - const onlineFiles = offlineData.plugindata.files_filemanager.online || []; + const onlineFiles = offlineData.pluginData.files_filemanager.online || []; offlineFiles = this.fileUploaderProvider.markOfflineFiles(offlineFiles); this.files = onlineFiles.concat(offlineFiles); diff --git a/src/addon/mod/assign/submission/file/providers/handler.ts b/src/addon/mod/assign/submission/file/providers/handler.ts index 1e013db7a..316712a1c 100644 --- a/src/addon/mod/assign/submission/file/providers/handler.ts +++ b/src/addon/mod/assign/submission/file/providers/handler.ts @@ -237,9 +237,9 @@ export class AddonModAssignSubmissionFileHandler implements AddonModAssignSubmis return this.assignOfflineProvider.getSubmission(assign.id, submission.userid).catch(() => { // No offline data found. }).then((offlineData) => { - if (offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager) { + if (offlineData && offlineData.pluginData && offlineData.pluginData.files_filemanager) { // Has offline data, return the number of files. - return offlineData.plugindata.files_filemanager.offline + offlineData.plugindata.files_filemanager.online.length; + return offlineData.pluginData.files_filemanager.offline + offlineData.pluginData.files_filemanager.online.length; } // No offline data, return the number of online files. @@ -333,7 +333,7 @@ export class AddonModAssignSubmissionFileHandler implements AddonModAssignSubmis prepareSyncData(assign: any, submission: any, plugin: any, offlineData: any, pluginData: any, siteId?: string) : void | Promise { - const filesData = offlineData && offlineData.plugindata && offlineData.plugindata.files_filemanager; + const filesData = offlineData && offlineData.pluginData && offlineData.pluginData.files_filemanager; if (filesData) { // Has some data to sync. let files = filesData.online || [], diff --git a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts index b2c96f266..3172d3c73 100644 --- a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts +++ b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.ts @@ -68,8 +68,8 @@ export class AddonModAssignSubmissionOnlineTextComponent extends AddonModAssignS return this.assignOfflineProvider.getSubmission(this.assign.id).catch(() => { // No offline data found. }).then((offlineData) => { - if (offlineData && offlineData.plugindata && offlineData.plugindata.onlinetext_editor) { - return offlineData.plugindata.onlinetext_editor.text; + if (offlineData && offlineData.pluginData && offlineData.pluginData.onlinetext_editor) { + return offlineData.pluginData.onlinetext_editor.text; } // No offline data found, return online text. diff --git a/src/addon/mod/assign/submission/onlinetext/providers/handler.ts b/src/addon/mod/assign/submission/onlinetext/providers/handler.ts index 4f06dc018..370b6f28b 100644 --- a/src/addon/mod/assign/submission/onlinetext/providers/handler.ts +++ b/src/addon/mod/assign/submission/onlinetext/providers/handler.ts @@ -188,8 +188,8 @@ export class AddonModAssignSubmissionOnlineTextHandler implements AddonModAssign return this.assignOfflineProvider.getSubmission(assign.id, submission.userid).catch(() => { // No offline data found. }).then((data) => { - if (data && data.plugindata && data.plugindata.onlinetext_editor) { - return data.plugindata.onlinetext_editor.text; + if (data && data.pluginData && data.pluginData.onlinetext_editor) { + return data.pluginData.onlinetext_editor.text; } // No offline data found, get text from plugin. diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index 8d9bb1d0d..b3140f8fc 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -128,12 +128,12 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.spinner = true; // Get download size to ask for confirm if it's high. - this.prefetchHandler.getDownloadSize(module, this.courseId).then((size) => { + this.prefetchHandler.getDownloadSize(this.module, this.courseId).then((size) => { return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh); }).catch((error) => { // Error, hide spinner. this.spinner = false; - if (!this.isDestroyed && error) { + if (!this.isDestroyed) { this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); } }); diff --git a/src/providers/file.ts b/src/providers/file.ts index 3d625d8f3..06a65452e 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -426,6 +426,7 @@ export class CoreFileProvider { return new Promise((resolve, reject): void => { const reader = new FileReader(); + reader.onloadend = (evt): void => { const target = evt.target; // Convert to to be able to use non-standard properties. if (target.result !== undefined || target.result !== null) { @@ -437,6 +438,18 @@ export class CoreFileProvider { } }; + // Check if the load starts. If it doesn't start in 3 seconds, reject. + // Sometimes in Android the read doesn't start for some reason, so the promise never finishes. + let hasStarted = false; + reader.onloadstart = (evt): void => { + hasStarted = true; + }; + setTimeout(() => { + if (!hasStarted) { + reject(); + } + }, 3000); + switch (format) { case CoreFileProvider.FORMATDATAURL: reader.readAsDataURL(fileData); From 602312e6212e426d6e941226616ff085ca88f2eb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 19 Apr 2018 15:27:00 +0200 Subject: [PATCH 14/16] MOBILE-2334 core: Fix webpack errors with recaptcha --- src/components/components.module.ts | 3 +- src/components/recaptcha/recaptcha.ts | 71 +++------------------- src/components/recaptcha/recaptchamodal.ts | 68 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 64 deletions(-) create mode 100644 src/components/recaptcha/recaptchamodal.ts diff --git a/src/components/components.module.ts b/src/components/components.module.ts index 4c50dea20..2e834aa3b 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -41,7 +41,8 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons'; import { CoreDynamicComponent } from './dynamic-component/dynamic-component'; import { CoreSendMessageFormComponent } from './send-message-form/send-message-form'; import { CoreTimerComponent } from './timer/timer'; -import { CoreRecaptchaComponent, CoreRecaptchaModalComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal'; import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar'; import { CoreAttachmentsComponent } from './attachments/attachments'; diff --git a/src/components/recaptcha/recaptcha.ts b/src/components/recaptcha/recaptcha.ts index d9ccfa674..f95152044 100644 --- a/src/components/recaptcha/recaptcha.ts +++ b/src/components/recaptcha/recaptcha.ts @@ -13,34 +13,29 @@ // limitations under the License. import { Component, Input } from '@angular/core'; -import { ModalController, ViewController, NavParams } from 'ionic-angular'; +import { ModalController } from 'ionic-angular'; import { CoreSitesProvider } from '@providers/sites'; import { CoreLangProvider } from '@providers/lang'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreRecaptchaModalComponent } from './recaptchamodal'; /** - * Directive to display a reCaptcha. - * - * Accepts the following attributes: - * @param {any} model The model where to store the recaptcha response. - * @param {string} publicKey The site public key. - * @param {string} [modelValueName] Name of the model property where to store the response. Defaults to 'recaptcharesponse'. - * @param {string} [siteUrl] The site URL. If not defined, current site. + * Component that allows answering a recaptcha. */ @Component({ selector: 'core-recaptcha', templateUrl: 'recaptcha.html' }) export class CoreRecaptchaComponent { + @Input() model: any; // The model where to store the recaptcha response. + @Input() publicKey: string; // The site public key. + @Input() modelValueName = 'recaptcharesponse'; // Name of the model property where to store the response. + @Input() siteUrl?: string; // The site URL. If not defined, current site. + expired = false; protected lang: string; - @Input() model: any; - @Input() publicKey: string; - @Input() modelValueName = 'recaptcharesponse'; - @Input() siteUrl?: string; - constructor(private sitesProvider: CoreSitesProvider, langProvider: CoreLangProvider, private textUtils: CoreTextUtilsProvider, private modalCtrl: ModalController) { @@ -75,53 +70,3 @@ export class CoreRecaptchaComponent { modal.present(); } } - -@Component({ - selector: 'core-recaptcha-modal', - templateUrl: 'recaptchamodal.html' -}) -export class CoreRecaptchaModalComponent { - - expired = false; - value = ''; - src: string; - - constructor(protected viewCtrl: ViewController, params: NavParams) { - this.src = params.get('src'); - } - - /** - * Close modal. - */ - closeModal(): void { - this.viewCtrl.dismiss({ - expired: this.expired, - value: this.value - }); - } - - /** - * The iframe with the recaptcha was loaded. - * - * @param {HTMLIFrameElement} iframe Iframe element. - */ - loaded(iframe: HTMLIFrameElement): void { - // Search the iframe content. - const contentWindow = iframe && iframe.contentWindow; - - if (contentWindow) { - // Set the callbacks we're interested in. - contentWindow['recaptchacallback'] = (value): void => { - this.expired = false; - this.value = value; - this.closeModal(); - }; - - contentWindow['recaptchaexpiredcallback'] = (): void => { - // Verification expired. Check the checkbox again. - this.expired = true; - this.value = ''; - }; - } - } -} diff --git a/src/components/recaptcha/recaptchamodal.ts b/src/components/recaptcha/recaptchamodal.ts new file mode 100644 index 000000000..9423b211c --- /dev/null +++ b/src/components/recaptcha/recaptchamodal.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Component } from '@angular/core'; +import { ViewController, NavParams } from 'ionic-angular'; + +/** + * Component to display a the recaptcha in a modal. + */ +@Component({ + selector: 'core-recaptcha-modal', + templateUrl: 'recaptchamodal.html' +}) +export class CoreRecaptchaModalComponent { + expired = false; + value = ''; + src: string; + + constructor(protected viewCtrl: ViewController, params: NavParams) { + this.src = params.get('src'); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss({ + expired: this.expired, + value: this.value + }); + } + + /** + * The iframe with the recaptcha was loaded. + * + * @param {HTMLIFrameElement} iframe Iframe element. + */ + loaded(iframe: HTMLIFrameElement): void { + // Search the iframe content. + const contentWindow = iframe && iframe.contentWindow; + + if (contentWindow) { + // Set the callbacks we're interested in. + contentWindow['recaptchacallback'] = (value): void => { + this.expired = false; + this.value = value; + this.closeModal(); + }; + + contentWindow['recaptchaexpiredcallback'] = (): void => { + // Verification expired. Check the checkbox again. + this.expired = true; + this.value = ''; + }; + } + } +} From 6ca648c46d9575eaf860c8c544055aee2a7b164d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Wed, 25 Apr 2018 12:25:40 +0200 Subject: [PATCH 15/16] MOBILE-2334 assign: Fix styles --- .../mod/assign/components/index/index.html | 3 +- .../components/submission/submission.html | 336 +++++++++--------- .../feedback/comments/component/comments.html | 6 +- src/addon/mod/assign/providers/assign.ts | 2 +- src/components/file/file.scss | 11 +- src/components/local-file/local-file.html | 49 ++- src/components/local-file/local-file.ts | 10 +- 7 files changed, 213 insertions(+), 204 deletions(-) diff --git a/src/addon/mod/assign/components/index/index.html b/src/addon/mod/assign/components/index/index.html index 8f0e96a3a..900d3bb60 100644 --- a/src/addon/mod/assign/components/index/index.html +++ b/src/addon/mod/assign/components/index/index.html @@ -14,10 +14,9 @@ - + - {{ note }} diff --git a/src/addon/mod/assign/components/submission/submission.html b/src/addon/mod/assign/components/submission/submission.html index f9cf275bc..8d4cb9ffc 100644 --- a/src/addon/mod/assign/components/submission/submission.html +++ b/src/addon/mod/assign/components/submission/submission.html @@ -26,205 +26,199 @@ - - + - - -

{{ 'addon.mod_assign.timemodified' | translate }}

-

{{ userSubmission.timemodified * 1000 | coreFormatDate:"dfmediumdate" }}

-
+ + +

{{ 'addon.mod_assign.timemodified' | translate }}

+

{{ userSubmission.timemodified * 1000 | coreFormatDate:"dfmediumdate" }}

+
- -

{{ 'addon.mod_assign.timeremaining' | translate }}

-

-
+ +

{{ 'addon.mod_assign.timeremaining' | translate }}

+

+
- -

-

-
+ +

+

+
- -

{{ 'addon.mod_assign.duedate' | translate }}

-

{{ assign.duedate * 1000 | coreFormatDate:"dfmediumdate" }}

-

{{ 'addon.mod_assign.duedateno' | translate }}

-
+ +

{{ 'addon.mod_assign.duedate' | translate }}

+

{{ assign.duedate * 1000 | coreFormatDate:"dfmediumdate" }}

+

{{ 'addon.mod_assign.duedateno' | translate }}

+
- -

{{ 'addon.mod_assign.cutoffdate' | translate }}

-

{{ assign.cutoffdate * 1000 | coreFormatDate:"dfmediumdate" }}

-
+ +

{{ 'addon.mod_assign.cutoffdate' | translate }}

+

{{ assign.cutoffdate * 1000 | coreFormatDate:"dfmediumdate" }}

+
- -

{{ 'addon.mod_assign.extensionduedate' | translate }}

-

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate:"dfmediumdate" }}

-
+ +

{{ 'addon.mod_assign.extensionduedate' | translate }}

+

{{ lastAttempt.extensionduedate * 1000 | coreFormatDate:"dfmediumdate" }}

+
- -

{{ 'addon.mod_assign.attemptnumber' | translate }}

-

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

-

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

-
+ +

{{ 'addon.mod_assign.attemptnumber' | translate }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

+
- - - -
-

{{ 'addon.mod_assign.erroreditpluginsnotsupported' | translate }}

-

{{ name }}

-
-
-

{{ 'addon.mod_assign.cannoteditduetostatementsubmission' | translate }}

-
-
- - -
- - - - - - - - {{ 'addon.mod_assign.submitassignment' | translate }} -

{{ 'addon.mod_assign.submitassignment_help' | translate }}

-
- - -

{{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }}

-
+ + + +
+

{{ 'addon.mod_assign.erroreditpluginsnotsupported' | translate }}

+

{{ name }}

+
+
+

{{ 'addon.mod_assign.cannoteditduetostatementsubmission' | translate }}

+
+
- - -

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate }}

-
- - - - -

{{ user.fullname }}

-
-

- {{ 'addon.mod_assign.hiddenuser' | translate }} -

-
+ +
+ + + + + + + {{ 'addon.mod_assign.submitassignment' | translate }} +

{{ 'addon.mod_assign.submitassignment_help' | translate }}

+
+ + +

{{ 'addon.mod_assign.cannotsubmitduetostatementsubmission' | translate }}

+
+
- - -

{{ 'addon.mod_assign.submissionslocked' | translate }}

-
+ + +

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate }}

+
+ + + + +

{{ user.fullname }}

+
+

+ {{ 'addon.mod_assign.hiddenuser' | translate }} +

+
+
- - -

{{ 'addon.mod_assign.editingstatus' | translate }}

-

{{ 'addon.mod_assign.submissioneditable' | translate }}

-

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

-
- + + +

{{ 'addon.mod_assign.submissionslocked' | translate }}

+
+ + + +

{{ 'addon.mod_assign.editingstatus' | translate }}

+

{{ 'addon.mod_assign.submissioneditable' | translate }}

+

{{ 'addon.mod_assign.submissionnoteditable' | translate }}

+
- - - -

{{ 'addon.mod_assign.currentgrade' | translate }}

-

- - - -
+ + +

{{ 'addon.mod_assign.currentgrade' | translate }}

+

+ + + +
- - - {{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo.grade} }} - - + + + {{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo.grade} }} + + - - - {{ 'addon.mod_assign.grade' | translate }} - - {{grade.label}} - - + + + {{ 'addon.mod_assign.grade' | translate }} + + {{grade.label}} + + - - - {{ outcome.name }} - - {{grade.label}} - -

{{ outcome.selected }}

-
+ + + {{ outcome.name }} + + {{grade.label}} + +

{{ outcome.selected }}

+
- + - - -

{{ 'addon.mod_assign.markingworkflowstate' | translate }}

-

{{ workflowStatusTranslationId | translate }}

-
+ + +

{{ 'addon.mod_assign.markingworkflowstate' | translate }}

+

{{ workflowStatusTranslationId | translate }}

+
- - -

{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}

- {{ 'addon.mod_assign.applytoteam' | translate }} - -
+ + +

{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}

+ {{ 'addon.mod_assign.applytoteam' | translate }} + +
- - -

{{ 'addon.mod_assign.attemptsettings' | translate }}

-

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

-

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

-

{{ 'addon.mod_assign.attemptreopenmethod' | translate }}: {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }}

- - {{ 'addon.mod_assign.addattempt' | translate }} - - -
+ + +

{{ 'addon.mod_assign.attemptsettings' | translate }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}

+

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': assign.maxattempts} } }}

+

{{ 'addon.mod_assign.attemptreopenmethod' | translate }}: {{ 'addon.mod_assign.attemptreopenmethod_' + assign.attemptreopenmethod | translate }}

+ + {{ 'addon.mod_assign.addattempt' | translate }} + + +
- - -

{{ 'addon.mod_assign.gradedby' | translate }}

- - - - -

{{ grader.fullname }}

-

{{ feedback.gradeddate * 1000 | coreFormatDate:"dfmediumdate" }}

-
-
+ + + + + +

{{ 'addon.mod_assign.gradedby' | translate }}

+

{{ grader.fullname }}

+

{{ feedback.gradeddate * 1000 | coreFormatDate:"dfmediumdate" }}

+
- -
- -

{{ 'addon.mod_assign.cannotgradefromapp' | translate:{$a: moduleName} }}

- - {{ 'core.openinbrowser' | translate }} - - -
-
+ +
+ +

{{ 'addon.mod_assign.cannotgradefromapp' | translate:{$a: moduleName} }}

+ + {{ 'core.openinbrowser' | translate }} + + +
diff --git a/src/addon/mod/assign/feedback/comments/component/comments.html b/src/addon/mod/assign/feedback/comments/component/comments.html index 8c68abdb8..1a0fcbcad 100644 --- a/src/addon/mod/assign/feedback/comments/component/comments.html +++ b/src/addon/mod/assign/feedback/comments/component/comments.html @@ -6,11 +6,11 @@

- + {{ 'core.notsent' | translate }} diff --git a/src/addon/mod/assign/providers/assign.ts b/src/addon/mod/assign/providers/assign.ts index a945bc914..fbaec2622 100644 --- a/src/addon/mod/assign/providers/assign.ts +++ b/src/addon/mod/assign/providers/assign.ts @@ -488,7 +488,7 @@ export class AddonModAssignProvider { case 'nosubmission': return 'danger'; default: - return ''; + return 'light'; } } diff --git a/src/components/file/file.scss b/src/components/file/file.scss index 95127e438..d39844487 100644 --- a/src/components/file/file.scss +++ b/src/components/file/file.scss @@ -1,2 +1,11 @@ -core-file { +.card-md core-file + core-file > .item-md.item-block > .item-inner { + border-top: 1px solid $list-md-border-color; +} + +.card-ios core-file + core-file > .item-ios.item-block > .item-inner { + border-top: $hairlines-width solid $list-ios-border-color; +} + +.card-wp core-file + core-file > .item-wp.item-block > .item-inner { + border-top: 1px solid $list-wp-border-color; } \ No newline at end of file diff --git a/src/components/local-file/local-file.html b/src/components/local-file/local-file.html index 62eeafe76..cad6c9347 100644 --- a/src/components/local-file/local-file.html +++ b/src/components/local-file/local-file.html @@ -1,29 +1,28 @@ - - {{fileExtension}} +
+ + {{fileExtension}} - -

- {{fileName}} - - - -

+ +

{{fileName}}

+ +

{{ size }}

+

{{ timemodified }}

- - - - -
+ + - -

{{ size }}

-

{{ timemodified }}

+
+ -
- -
- + + + +
+ + diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts index ac7284306..47ab54c6e 100644 --- a/src/components/local-file/local-file.ts +++ b/src/components/local-file/local-file.ts @@ -96,6 +96,10 @@ export class CoreLocalFileComponent implements OnInit { * @param {Event} e Click event. */ fileClicked(e: Event): void { + if (this.editMode) { + return; + } + e.preventDefault(); e.stopPropagation(); @@ -127,8 +131,12 @@ export class CoreLocalFileComponent implements OnInit { * Rename the file. * * @param {string} newName New name. + * @param {Event} e Click event. */ - changeName(newName: string): void { + changeName(newName: string, e: Event): void { + e.preventDefault(); + e.stopPropagation(); + if (newName == this.file.name) { // Name hasn't changed, stop. this.editMode = false; From 88c05ac1572fae793315b4d0c58d3f6247994303 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 26 Apr 2018 15:59:23 +0200 Subject: [PATCH 16/16] MOBILE-2334 assign: Fix errors with file submission plugin --- .../mod/assign/providers/submission-delegate.ts | 2 +- src/app/app.module.ts | 10 +++++++++- src/components/attachments/attachments.ts | 6 +++--- src/components/local-file/local-file.ts | 9 ++------- src/core/emulator/providers/file.ts | 8 ++++++++ src/directives/auto-focus.ts | 16 ++++++++-------- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/addon/mod/assign/providers/submission-delegate.ts b/src/addon/mod/assign/providers/submission-delegate.ts index 8ad8666d5..26d5da2f4 100644 --- a/src/addon/mod/assign/providers/submission-delegate.ts +++ b/src/addon/mod/assign/providers/submission-delegate.ts @@ -224,7 +224,7 @@ export class AddonModAssignSubmissionDelegate extends CoreDelegate { * @param {any} inputData Data entered by the user for the submission. */ clearTmpData(assign: any, submission: any, plugin: any, inputData: any): void { - return this.executeFunctionOnEnabled(plugin.type, 'return', [assign, submission, plugin, inputData]); + return this.executeFunctionOnEnabled(plugin.type, 'clearTmpData', [assign, submission, plugin, inputData]); } /** diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 42107168e..90831a8a8 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -212,7 +212,7 @@ export const CORE_PROVIDERS: any[] = [ }) export class AppModule { constructor(platform: Platform, initDelegate: CoreInitDelegate, updateManager: CoreUpdateManagerProvider, - sitesProvider: CoreSitesProvider) { + sitesProvider: CoreSitesProvider, fileProvider: CoreFileProvider) { // Register a handler for platform ready. initDelegate.registerProcess({ name: 'CorePlatformReady', @@ -232,6 +232,14 @@ export class AppModule { load: sitesProvider.restoreSession.bind(sitesProvider) }); + // Register clear app tmp folder. + initDelegate.registerProcess({ + name: 'CoreClearTmpFolder', + priority: CoreInitDelegate.MAX_RECOMMENDED_PRIORITY + 150, + blocking: false, + load: fileProvider.clearTmpFolder.bind(fileProvider) + }); + // Execute the init processes. initDelegate.executeInitProcesses(); } diff --git a/src/components/attachments/attachments.ts b/src/components/attachments/attachments.ts index 71f120dc9..79593fcda 100644 --- a/src/components/attachments/attachments.ts +++ b/src/components/attachments/attachments.ts @@ -127,9 +127,9 @@ export class CoreAttachmentsComponent implements OnInit { * A file was renamed. * * @param {number} index Index of the file. - * @param {any} file The new file entry. + * @param {any} data The data received. */ - renamed(index: number, file: any): void { - this.files[index] = file; + renamed(index: number, data: any): void { + this.files[index] = data.file; } } diff --git a/src/components/local-file/local-file.ts b/src/components/local-file/local-file.ts index 47ab54c6e..7695e8871 100644 --- a/src/components/local-file/local-file.ts +++ b/src/components/local-file/local-file.ts @@ -120,11 +120,6 @@ export class CoreLocalFileComponent implements OnInit { e.stopPropagation(); this.editMode = true; this.newFileName = this.file.name; - - // @todo For some reason core-auto-focus isn't working right. Focus the input manually. - // $timeout(function() { - // $mmUtil.focusElement(element[0].querySelector('input')); - // }); } /** @@ -159,8 +154,8 @@ export class CoreLocalFileComponent implements OnInit { this.file = fileEntry; this.loadFileBasicData(); this.onRename.emit({ file: this.file }); - }).catch(() => { - this.domUtils.showErrorModal('core.errorrenamefile', true); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errorrenamefile', true); }); }).finally(() => { modal.dismiss(); diff --git a/src/core/emulator/providers/file.ts b/src/core/emulator/providers/file.ts index d25c69b7a..27a64c645 100644 --- a/src/core/emulator/providers/file.ts +++ b/src/core/emulator/providers/file.ts @@ -74,6 +74,8 @@ export class FileMock extends File { */ private copyMock(srce: Entry, destDir: DirectoryEntry, newName: string): Promise { return new Promise((resolve, reject): void => { + newName = newName.replace(/%20/g, ' '); // Replace all %20 with spaces. + srce.copyTo(destDir, newName, (deste) => { resolve(deste); }, (err) => { @@ -212,6 +214,8 @@ export class FileMock extends File { getDirectory(directoryEntry: DirectoryEntry, directoryName: string, flags: Flags): Promise { return new Promise((resolve, reject): void => { try { + directoryName = directoryName.replace(/%20/g, ' '); // Replace all %20 with spaces. + directoryEntry.getDirectory(directoryName, flags, (de) => { resolve(de); }, (err) => { @@ -235,6 +239,8 @@ export class FileMock extends File { getFile(directoryEntry: DirectoryEntry, fileName: string, flags: Flags): Promise { return new Promise((resolve, reject): void => { try { + fileName = fileName.replace(/%20/g, ' '); // Replace all %20 with spaces. + directoryEntry.getFile(fileName, flags, resolve, (err) => { this.fillErrorMessageMock(err); reject(err); @@ -375,6 +381,8 @@ export class FileMock extends File { */ private moveMock(srce: Entry, destDir: DirectoryEntry, newName: string): Promise { return new Promise((resolve, reject): void => { + newName = newName.replace(/%20/g, ' '); // Replace all %20 with spaces. + srce.moveTo(destDir, newName, (deste) => { resolve(deste); }, (err) => { diff --git a/src/directives/auto-focus.ts b/src/directives/auto-focus.ts index c5f0db88e..5d6e2446d 100644 --- a/src/directives/auto-focus.ts +++ b/src/directives/auto-focus.ts @@ -56,16 +56,16 @@ export class CoreAutoFocusDirective implements OnInit { protected autoFocus(): void { const autoFocus = this.utils.isTrueOrOne(this.coreAutoFocus); if (autoFocus) { - // If it's a ion-input or ion-textarea, search the right input to use. - let element = this.element; - if (this.element.tagName == 'ION-INPUT') { - element = this.element.querySelector('input') || element; - } else if (this.element.tagName == 'ION-TEXTAREA') { - element = this.element.querySelector('textarea') || element; - } - // Wait a bit to make sure the view is loaded. setTimeout(() => { + // If it's a ion-input or ion-textarea, search the right input to use. + let element = this.element; + if (this.element.tagName == 'ION-INPUT') { + element = this.element.querySelector('input') || element; + } else if (this.element.tagName == 'ION-TEXTAREA') { + element = this.element.querySelector('textarea') || element; + } + this.domUtils.focusElement(element); }, 200); }