Merge pull request #2634 from moodlehq/integration

Integration
main
Juan Leyva 2020-11-30 19:36:05 +01:00 committed by GitHub
commit 5e220c4d03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
363 changed files with 15484 additions and 8645 deletions

1
.gitignore vendored
View File

@ -46,3 +46,4 @@ e2e/build
!/desktop/assets/
!/desktop/electron.js
src/configconstants.ts
.moodleapp-dev-config

View File

@ -52,7 +52,7 @@ jobs:
script: scripts/aot.sh
- stage: build
name: "Build Android"
if: env(DEPLOY) IS NOT blank AND ((env(DEPLOY) = 1 AND NOT branch = desktop) OR (env(DEPLOY) IN (2,3) AND tag IS NOT blank))
if: env(DEPLOY) IS NOT blank AND ((env(DEPLOY) = 1 AND branch != desktop) OR (env(DEPLOY) IN (2,3) AND tag IS NOT blank))
os: linux
dist: trusty
group: edge
@ -69,9 +69,9 @@ jobs:
script: scripts/aot.sh
- stage: build
name: "Build iOS"
if: env(DEPLOY) IS NOT blank AND ((env(DEPLOY) = 1 AND NOT branch = desktop) OR (env(DEPLOY) IN (2,3) AND tag IS NOT blank))
if: env(DEPLOY) IS NOT blank AND ((env(DEPLOY) = 1 AND branch != desktop) OR (env(DEPLOY) IN (2,3) AND tag IS NOT blank))
os: osx
osx_image: xcode11.3
osx_image: xcode12u
env:
- BUILD_PLATFORM='ios'
script: scripts/aot.sh
@ -88,7 +88,7 @@ jobs:
name: "Build MacOS"
if: env(DEPLOY) IS NOT blank AND ((env(DEPLOY) = 1 AND branch = desktop) OR (env(DEPLOY) = 3 AND tag IS NOT blank))
os: osx
osx_image: xcode11.3
osx_image: xcode12u
env:
- ELECTRON_CACHE=$HOME/.cache/electron
- ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder

View File

@ -13,3 +13,5 @@ jszip has problems with "lie" dependency on greater versions than 3.1
promise.prototype.finally has problems on greater versions than 3.1
cordova-ios: should remain on 5.1 because of: https://github.com/apache/cordova-ios/pull/801

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="39200" id="com.moodle.moodlemobile" ios-CFBundleVersion="3.9.2.0" version="3.9.2" versionCode="39200" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<widget android-versionCode="39300" id="com.moodle.moodlemobile" ios-CFBundleVersion="3.9.3.0" version="3.9.3" versionCode="39300" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Moodle</name>
<description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -56,7 +56,7 @@
<resource-file src="resources/android/icon/drawable-hdpi-smallicon.png" target="app/src/main/res/mipmap-hdpi/smallicon.png" />
<resource-file src="resources/android/icon/drawable-xhdpi-smallicon.png" target="app/src/main/res/mipmap-xhdpi/smallicon.png" />
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application/activity[@android:name='MainActivity']">
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" android:debuggable="true" />
<activity android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|screenLayout|smallestScreenSize" />
</edit-config>
<edit-config file="AndroidManifest.xml" mode="merge" target="/manifest/application">
<application android:largeHeap="true" android:usesCleartextTraffic="true" />
@ -112,11 +112,6 @@
<param name="android-package" value="io.github.pwlin.cordova.plugins.fileopener2.FileOpener2" />
</feature>
</config-file>
<config-file parent="/manifest/application" target="AndroidManifest.xml">
<provider android:authorities="${applicationId}.opener.provider" android:exported="false" android:grantUriPermissions="true" android:name="io.github.pwlin.cordova.plugins.fileopener2.FileProvider">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/opener_paths" />
</provider>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="FileTransfer">
<param name="android-package" value="org.apache.cordova.filetransfer.FileTransfer" />
@ -219,6 +214,14 @@
</intent-filter>
</service>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Media">
<param name="android-package" value="org.apache.cordova.media.AudioHandler" />
</feature>
</config-file>
<config-file parent="/*" target="AndroidManifest.xml">
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
</config-file>
</platform>
<platform name="ios">
<resource-file src="GoogleService-Info.plist" />
@ -241,7 +244,7 @@
<true />
</edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>3.9.2</string>
<string>3.9.3</string>
</edit-config>
<config-file parent="FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED" target="*-Info.plist">
<string>YES</string>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
</dict>
</plist>

View File

@ -1,17 +1,26 @@
#!/bin/bash
#
# Script to sign macOSX pkg.
# https://www.electronjs.org/docs/tutorial/mac-app-store-submission-guide
#
# Name of your app.
APP="Moodle Desktop"
# The path of your app to sign.
APP_PATH="desktop/dist/mas/Moodle Desktop.app"
# The path to the location you want to put the signed package.
RESULT_PATH="desktop/dist/mas/$APP.pkg"
# The name of certificates you requested.
APP_KEY="3rd Party Mac Developer Application: Moodle Pty Ltd (2NU57U5PAW)"
INSTALLER_KEY="3rd Party Mac Developer Installer: Moodle Pty Ltd (2NU57U5PAW)"
BASEPATH="desktop/dist/mas"
# The path of your app to sign.
APP_PATH="${BASEPATH}/${APP}.app"
# The path to the location you want to put the signed package.
RESULT_PATH="${BASEPATH}/${APP}.pkg"
# The path of your plist files.
CHILD_PLIST="desktop/assets/mac/child.plist"
PARENT_PLIST="desktop/assets/mac/parent.plist"
LOGINHELPER_PLIST="desktop/assets/mac/loginhelper.plist"
FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks"
@ -21,11 +30,9 @@ codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electr
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/Contents/MacOS/$APP Helper"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper EH.app/Contents/MacOS/$APP Helper EH"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper EH.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper NP.app/Contents/MacOS/$APP Helper NP"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper NP.app/"
codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/Contents/MacOS/$APP Login Helper"
codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$APP_PATH/Contents/MacOS/$APP"
codesign -s "$APP_KEY" -f --entitlements "$PARENT_PLIST" "$APP_PATH"
productbuild --component "$APP_PATH" /Applications --sign "$INSTALLER_KEY" "$RESULT_PATH"
productbuild --component "$APP_PATH" /Applications --sign "$INSTALLER_KEY" "$RESULT_PATH"

View File

@ -6,7 +6,7 @@
<Identity Name="3312ADB7.MoodleDesktop"
ProcessorArchitecture="x64"
Publisher="CN=33CDCDF6-1EB5-4827-9897-ED25C91A32F6"
Version="3.9.2.0" />
Version="3.9.3.0" />
<Properties>
<DisplayName>Moodle Desktop</DisplayName>
<PublisherDisplayName>Moodle Pty Ltd.</PublisherDisplayName>

69
gulp/dev-config.js 100644
View File

@ -0,0 +1,69 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const fs = require('fs');
const DEV_CONFIG_FILE = '.moodleapp-dev-config';
/**
* Class to read and write dev-config data from a file.
*/
class DevConfig {
constructor() {
this.loadFileData();
}
/**
* Get a setting.
*
* @param name Name of the setting to get.
* @param defaultValue Value to use if not found.
*/
get(name, defaultValue) {
return typeof this.config[name] != 'undefined' ? this.config[name] : defaultValue;
}
/**
* Load file data to memory.
*/
loadFileData() {
if (!fs.existsSync(DEV_CONFIG_FILE)) {
this.config = {};
return;
}
try {
this.config = JSON.parse(fs.readFileSync(DEV_CONFIG_FILE));
} catch (error) {
console.error('Error reading dev config file.', error);
this.config = {};
}
}
/**
* Save some settings.
*
* @param settings Object with the settings to save.
*/
save(settings) {
this.config = Object.assign(this.config, settings);
// Save the data in the dev file.
fs.writeFileSync(DEV_CONFIG_FILE, JSON.stringify(this.config, null, 4));
}
}
module.exports = new DevConfig();

237
gulp/git.js 100644
View File

@ -0,0 +1,237 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const exec = require('child_process').exec;
const fs = require('fs');
const DevConfig = require('./dev-config');
const Utils = require('./utils');
/**
* Class to run git commands.
*/
class Git {
/**
* Create a patch.
*
* @param range Show only commits in the specified revision range.
* @param saveTo Path to the file to save the patch to. If not defined, the patch contents will be returned.
* @return Promise resolved when done. If saveTo not provided, it will return the patch contents.
*/
createPatch(range, saveTo) {
return new Promise((resolve, reject) => {
exec(`git format-patch ${range} --stdout`, (err, result) => {
if (err) {
reject(err || 'Cannot create patch.');
return;
}
if (!saveTo) {
resolve(result);
return;
}
// Save it to a file.
const directory = saveTo.substring(0, saveTo.lastIndexOf('/'));
if (directory && directory != '.' && directory != '..' && !fs.existsSync(directory)) {
fs.mkdirSync(directory);
}
fs.writeFileSync(saveTo, result);
resolve();
});
});
}
/**
* Get current branch.
*
* @return Promise resolved with the branch name.
*/
getCurrentBranch() {
return new Promise((resolve, reject) => {
exec('git branch --show-current', (err, branch) => {
if (branch) {
resolve(branch.replace('\n', ''));
} else {
reject (err || 'Current branch not found.');
}
});
});
}
/**
* Get the HEAD commit for a certain branch.
*
* @param branch Name of the branch.
* @param branchData Parsed branch data. If not provided it will be calculated.
* @return HEAD commit.
*/
async getHeadCommit(branch, branchData) {
if (!branchData) {
// Parse the branch to get the project and issue number.
branchData = Utils.parseBranch(branch);
}
// Loop over the last commits to find the first commit messages that doesn't belong to the issue.
const commitsString = await this.log(50, branch, '%s_____%H');
const commits = commitsString.split('\n');
commits.pop(); // Remove last element, it's an empty string.
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
const match = Utils.getIssueFromCommitMessage(commit) == branchData.issue;
if (i === 0 && !match) {
// Most recent commit doesn't belong to the issue. Stop looking.
break;
}
if (!match) {
// The commit does not match any more, we found it!
return commit.split('_____')[1];
}
}
// Couldn't find the commit using the commit names, get the last commit in the integration branch.
const remote = DevConfig.get('upstreamRemote', 'origin');
console.log(`Head commit not found using commit messages. Get last commit from ${remote}/integration`);
const hashes = await this.hashes(1, `${remote}/integration`);
return hashes[0];
}
/**
* Get the URL of a certain remote.
*
* @param remote Remote name.
* @return Promise resolved with the remote URL.
*/
getRemoteUrl(remote) {
return new Promise((resolve, reject) => {
exec(`git remote get-url ${remote}`, (err, url) => {
if (url) {
resolve(url.replace('\n', ''));
} else {
reject (err || 'Remote not found.');
}
});
});
}
/**
* Return the latest hashes from git log.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param format Pretty-print the contents of the commit logs in a given format.
* @return Promise resolved with the list of hashes.
*/
async hashes(count, range, format) {
format = format || '%H';
const hashList = await this.log(count, range, format);
const hashes = hashList.split('\n');
hashes.pop(); // Remove last element, it's an empty string.
return hashes;
}
/**
* Calls the log command and returns the raw output.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param format Pretty-print the contents of the commit logs in a given format.
* @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
* @return Promise resolved with the result.
*/
log(count, range, format, path) {
if (typeof count == 'undefined') {
count = 10;
}
let command = 'git log';
if (count > 0) {
command += ` -n ${count} `;
}
if (format) {
command += ` --format=${format} `;
}
if (range){
command += ` ${range} `;
}
if (path) {
command += ` -- ${path}`;
}
return new Promise((resolve, reject) => {
exec(command, (err, result, stderr) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
/**
* Return the latest titles of the commit messages.
*
* @param count Number of commits to display.
* @param range Show only commits in the specified revision range.
* @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
* @return Promise resolved with the list of titles.
*/
async messages(count, range, path) {
count = typeof count != 'undefined' ? count : 10;
const messageList = await this.log(count, range, '%s', path);
const messages = messageList.split('\n');
messages.pop(); // Remove last element, it's an empty string.
return messages;
}
/**
* Push a branch.
*
* @param remote Remote to use.
* @param branch Branch to push.
* @param force Whether to force the push.
* @return Promise resolved when done.
*/
push(remote, branch, force) {
return new Promise((resolve, reject) => {
let command = `git push ${remote} ${branch}`;
if (force) {
command += ' -f';
}
exec(command, (err, result, stderr) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}
module.exports = new Git();

475
gulp/jira.js 100644
View File

@ -0,0 +1,475 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const exec = require('child_process').exec;
const https = require('https');
const inquirer = require('inquirer');
const fs = require('fs');
const request = require('request'); // This lib is deprecated, but it was the only way I found to make upload files work.
const DevConfig = require('./dev-config');
const Git = require('./git');
const Url = require('./url');
const Utils = require('./utils');
const apiVersion = 2;
/**
* Class to interact with Jira.
*/
class Jira {
/**
* Ask the password to the user.
*
* @return Promise resolved with the password.
*/
async askPassword() {
const data = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: `Please enter the password for the username ${this.username}.`,
},
]);
return data.password;
}
/**
* Ask the user the tracker data.
*
* @return Promise resolved with the data, rejected if cannot get.
*/
async askTrackerData() {
const data = await inquirer.prompt([
{
type: 'input',
name: 'url',
message: 'Please enter the tracker URL.',
default: 'https://tracker.moodle.org/',
},
{
type: 'input',
name: 'username',
message: 'Please enter your tracker username.',
},
]);
DevConfig.save({
'tracker.url': data.url,
'tracker.username': data.username,
});
return data;
}
/**
* Build URL to perform requests to Jira.
*
* @param uri URI to add the the Jira URL.
* @return URL.
*/
buildRequestUrl(uri) {
return Utils.concatenatePaths([this.url, this.uri, '/rest/api/', apiVersion, uri]);
}
/**
* Delete an attachment.
*
* @param attachmentId Attachment ID.
* @return Promise resolved when done.
*/
async deleteAttachment(attachmentId) {
const response = await this.request(`attachment/${attachmentId}`, 'DELETE');
if (response.status != 204) {
throw new Error('Could not delete the attachment');
}
}
/**
* Load the issue info from jira server using a REST API call.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @param fields Fields to get.
* @return Promise resolved with the issue data.
*/
async getIssue(key, fields) {
fields = fields || '*all,-comment';
await this.init(); // Initialize data if needed.
const response = await this.request(`issue/${key}`, 'GET', {'fields': fields, 'expand': 'names'});
if (response.status == 404) {
throw new Error('Issue could not be found.');
} else if (response.status != 200) {
throw new Error('The tracker is not available.')
}
const issue = response.data;
issue.named = {};
// Populate the named fields in a separate key. Allows us to easily find them without knowing the field ID.
const nameList = issue.names || {};
for (const fieldKey in issue.fields) {
if (nameList[fieldKey]) {
issue.named[nameList[fieldKey]] = issue.fields[fieldKey];
}
}
return issue
}
/**
* Load the version info from the jira server using a rest api call.
*
* @return Promise resolved when done.
*/
async getServerInfo() {
const response = await this.request('serverInfo');
if (response.status != 200) {
throw new Error(`Unexpected response code: ${response.status}`, response);
}
this.version = response.data;
}
/**
* Get tracker data to push an issue.
*
* @return Promise resolved with the data.
*/
async getTrackerData() {
// Check dev-config file first.
let data = this.getTrackerDataFromDevConfig();
if (data) {
console.log('Using tracker data from dev-config file');
return data;
}
// Try to use mdk now.
try {
data = await this.getTrackerDataFromMdk();
console.log('Using tracker data from mdk');
return data;
} catch (error) {
// MDK not available or not configured. Ask for the data.
const data = await this.askTrackerData();
data.fromInput = true;
return data;
}
}
/**
* Get tracker data from dev config file.
*
* @return Data, undefined if cannot get.
*/
getTrackerDataFromDevConfig() {
const url = DevConfig.get('tracker.url');
const username = DevConfig.get('tracker.username');
if (url && username) {
return {
url,
username,
};
}
}
/**
* Get tracker URL and username from mdk.
*
* @return Promise resolved with the data, rejected if cannot get.
*/
getTrackerDataFromMdk() {
return new Promise((resolve, reject) => {
exec('mdk config show tracker.url', (err, url) => {
if (!url) {
reject(err || 'URL not found.');
return;
}
exec('mdk config show tracker.username', (err, username) => {
if (username) {
resolve({
url: url.replace('\n', ''),
username: username.replace('\n', ''),
});
} else {
reject(err | 'Username not found.');
}
});
});
});
}
/**
* Initialize some data.
*
* @return Promise resolved when done.
*/
async init() {
if (this.initialized) {
// Already initialized.
return;
}
// Get tracker URL and username.
const trackerData = await this.getTrackerData();
this.url = trackerData.url;
this.username = trackerData.username;
const parsed = Url.parse(this.url);
this.ssl = parsed.protocol == 'https';
this.host = parsed.domain;
this.uri = parsed.path;
// Get the password.
const keytar = require('keytar');
this.password = await keytar.getPassword('mdk-jira-password', this.username); // Use same service name as mdk.
if (!this.password) {
// Ask the user.
this.password = await this.askPassword();
}
while (!this.initialized) {
try {
await this.getServerInfo();
this.initialized = true;
keytar.setPassword('mdk-jira-password', this.username, this.password);
} catch (error) {
console.log('Error connecting to the server. Please make sure you entered the data correctly.', error);
if (trackerData.fromInput) {
// User entered the data manually, ask him again.
trackerData = await this.askTrackerData();
this.url = trackerData.url;
this.username = trackerData.username;
}
this.password = await this.askPassword();
}
}
}
/**
* Check if a certain issue could be a security issue.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @return Promise resolved with boolean: whether it's a security issue.
*/
async isSecurityIssue(key) {
const issue = await this.getIssue(key, 'security');
return issue.fields && !!issue.fields.security;
}
/**
* Sends a request to the server and returns the data.
*
* @param uri URI to add the the Jira URL.
* @param method Method to use. Defaults to 'GET'.
* @param params Params to send as GET params (in the URL).
* @param data JSON string with the data to send as POST/PUT params.
* @param headers Headers to send.
* @return Promise resolved with the result.
*/
request(uri, method, params, data, headers) {
uri = uri || '';
method = (method || 'GET').toUpperCase();
data = data || '';
params = params || {};
headers = headers || {};
headers['Content-Type'] = 'application/json';
return new Promise((resolve, reject) => {
// Build the request URL.
const url = Url.addParamsToUrl(this.buildRequestUrl(uri), params);
// Initialize the request.
const options = {
method: method,
auth: `${this.username}:${this.password}`,
headers: headers,
};
const request = https.request(url, options);
// Add data.
if (data) {
request.write(data);
}
// Treat response.
request.on('response', (response) => {
// Read the result.
let result = '';
response.on('data', (chunk) => {
result += chunk;
});
response.on('end', () => {
try {
result = JSON.parse(result);
} catch (error) {
// Leave it as text.
}
resolve({
status: response.statusCode,
data: result,
});
});
});
request.on('error', (e) => {
reject(e);
});
// Send the request.
request.end();
});
}
/**
* Sets a set of fields for a certain issue in Jira.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @param updates Object with the fields to update.
* @return Promise resolved when done.
*/
async setCustomFields(key, updates) {
const issue = await this.getIssue(key);
const update = {'fields': {}};
// Detect which fields have changed.
for (const updateName in updates) {
const updateValue = updates[updateName];
const remoteValue = issue.named[updateName];
if (!remoteValue || remoteValue != updateValue) {
// Map the label of the field with the field code.
let fieldKey;
for (const key in issue.names) {
if (issue.names[key] == updateName) {
fieldKey = key;
break;
}
}
if (!fieldKey) {
throw new Error(`Could not find the field named ${updateName}.`);
}
update.fields[fieldKey] = updateValue;
}
}
if (!Object.keys(update.fields).length) {
// No fields to update.
console.log('No updates required.')
return;
}
const response = await this.request(`issue/${key}`, 'PUT', null, JSON.stringify(update));
if (response.status != 204) {
throw new Error(`Issue was not updated: ${response.status}`, response.data);
}
console.log('Issue updated successfully.');
}
/**
* Upload a new attachment to an issue.
*
* @param key Key to identify the issue. E.g. MOBILE-1234.
* @param filePath Path to the file to upload.
* @return Promise resolved when done.
*/
async upload(key, filePath) {
const uri = `issue/${key}/attachments`;
const headers = {
'X-Atlassian-Token': 'nocheck',
}
const response = await this.uploadFile(uri, 'file', filePath, headers);
if (response.status != 200) {
throw new Error('Could not upload file to Jira issue');
}
console.log('File successfully uploaded.')
}
/**
* Upload a file to Jira.
*
* @param uri URI to add the the Jira URL.
* @param fieldName Name of the form field where to put the file.
* @param filePath Path to the file.
* @param headers Headers.
* @return Promise resolved with the result.
*/
async uploadFile(uri, fieldName, filePath, headers) {
uri = uri || '';
headers = headers || {};
headers['Content-Type'] = 'multipart/form-data';
return new Promise((resolve, reject) => {
// Add the file to the form data.
const formData = {};
formData[fieldName] = {
value: fs.createReadStream(filePath),
options: {
filename: filePath.substr(filePath.lastIndexOf('/') + 1),
contentType: 'multipart/form-data',
},
};
// Perform the request.
const options = {
url: this.buildRequestUrl(uri),
method: 'POST',
headers: headers,
auth: {
user: this.username,
pass: this.password,
},
formData: formData,
};
request(options, (err, httpResponse, body) => {
resolve({
status: httpResponse.statusCode,
data: body,
});
});
});
}
}
module.exports = new Jira();

View File

@ -0,0 +1,138 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const gulp = require('gulp');
const through = require('through');
const bufferFrom = require('buffer-from');
const rename = require('gulp-rename');
const exec = require('child_process').exec;
const LICENSE = '' +
'// (C) Copyright 2015 Moodle Pty Ltd.\n' +
'//\n' +
'// Licensed under the Apache License, Version 2.0 (the "License");\n' +
'// you may not use this file except in compliance with the License.\n' +
'// You may obtain a copy of the License at\n' +
'//\n' +
'// http://www.apache.org/licenses/LICENSE-2.0\n' +
'//\n' +
'// Unless required by applicable law or agreed to in writing, software\n' +
'// distributed under the License is distributed on an "AS IS" BASIS,\n' +
'// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n' +
'// See the License for the specific language governing permissions and\n' +
'// limitations under the License.\n\n';
/**
* Task to convert config.json into a TypeScript class.
*/
class BuildConfigTask {
/**
* Run the task.
*
* @param path Path to the config file.
* @param done Function to call when done.
*/
run(path, done) {
const self = this;
// Get the last commit.
exec('git log -1 --pretty=format:"%H"', (err, commit, stderr) => {
if (err) {
console.error('An error occurred while getting the last commit: ' + err);
} else if (stderr) {
console.error('An error occurred while getting the last commit: ' + stderr);
}
gulp.src(path)
.pipe(through(function(file) {
// Convert the contents of the file into a TypeScript class.
// Disable the rule variable-name in the file.
const config = JSON.parse(file.contents.toString());
let contents = LICENSE + '// tslint:disable: variable-name\n' + 'export class CoreConfigConstants {\n';
for (let key in config) {
let value = self.transformValue(config[key]);
if (typeof config[key] != 'number' && typeof config[key] != 'boolean' && typeof config[key] != 'string') {
key = key + ': any';
}
// If key has quotation marks, remove them.
if (key[0] == '"') {
key = key.substr(1, key.length - 2);
}
contents += ' static ' + key + ' = ' + value + ';\n';
}
// Add compilation info.
contents += ' static compilationtime = ' + Date.now() + ';\n';
contents += ' static lastcommit = \'' + commit + '\';\n';
contents += '}\n';
file.contents = bufferFrom(contents);
this.emit('data', file);
}))
.pipe(rename('configconstants.ts'))
.pipe(gulp.dest('./src'))
.on('end', done);
});
}
/**
* Recursively transform a config value into personalized TS.
*
* @param value Value to convert
* @return Converted value.
*/
transformValue(value) {
if (typeof value == 'string') {
// Wrap the string in ' and escape them.
return "'" + value.replace(/([^\\])'/g, "$1\\'") + "'";
}
if (typeof value != 'number' && typeof value != 'boolean') {
const isArray = Array.isArray(value);
let contents = '';
let quoteKeys = false;
if (!isArray) {
for (let key in value) {
if (key.indexOf('-') >= 0) {
quoteKeys = true;
break;
}
}
}
for (let key in value) {
value[key] = this.transformValue(value[key]);
const quotedKey = quoteKeys ? "'" + key + "'" : key;
contents += ' ' + (isArray ? '' : quotedKey + ': ') + value[key] + ",\n";
}
contents += (isArray ? ']' : '}');
return (isArray ? '[' : '{') + "\n" + contents.replace(/^/gm, ' ');
}
return value;
}
}
module.exports = BuildConfigTask;

View File

@ -0,0 +1,176 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const gulp = require('gulp');
const slash = require('gulp-slash');
const clipEmptyFiles = require('gulp-clip-empty-files');
const through = require('through');
const bufferFrom = require('buffer-from');
const File = require('vinyl');
const pathLib = require('path');
/**
* Task to build the language files into a single file per language.
*/
class BuildLangTask {
/**
* Copy a property from one object to another, adding a prefix to the key if needed.
*
* @param target Object to copy the properties to.
* @param source Object to copy the properties from.
* @param prefix Prefix to add to the keys.
*/
addProperties(target, source, prefix) {
for (let property in source) {
target[prefix + property] = source[property];
}
}
/**
* Run the task.
*
* @param language Language to treat.
* @param langPaths Paths to the possible language files.
* @param done Function to call when done.
*/
run(language, langPaths, done) {
const filename = language + '.json';
const data = {};
let firstFile = null;
const self = this;
const paths = langPaths.map((path) => {
if (path.slice(-1) != '/') {
path = path + '/';
}
return path + language + '.json';
});
gulp.src(paths, { allowEmpty: true })
.pipe(slash())
.pipe(clipEmptyFiles())
.pipe(through(function(file) {
if (!firstFile) {
firstFile = file;
}
return self.treatFile(file, data);
}, function() {
/* This implementation is based on gulp-jsoncombine module.
* https://github.com/reflog/gulp-jsoncombine */
if (firstFile) {
const joinedPath = pathLib.join(firstFile.base, language + '.json');
const joinedFile = new File({
cwd: firstFile.cwd,
base: firstFile.base,
path: joinedPath,
contents: self.treatMergedData(data),
});
this.emit('data', joinedFile);
}
this.emit('end');
}))
.pipe(gulp.dest(pathLib.join('./src/assets', 'lang')))
.on('end', done);
}
/**
* Treats a file to merge JSONs. This function is based on gulp-jsoncombine module.
* https://github.com/reflog/gulp-jsoncombine
*
* @param file File treated.
* @param data Object where to store the data.
*/
treatFile(file, data) {
if (file.isNull() || file.isStream()) {
return; // ignore
}
try {
let srcPos = file.path.lastIndexOf('/src/');
if (srcPos == -1) {
// It's probably a Windows environment.
srcPos = file.path.lastIndexOf('\\src\\');
}
const path = file.path.substr(srcPos + 5);
data[path] = JSON.parse(file.contents.toString());
} catch (err) {
console.log('Error parsing JSON: ' + err);
}
}
/**
* Treats the merged JSON data, adding prefixes depending on the component.
*
* @param data Merged data.
* @return Buffer with the treated data.
*/
treatMergedData(data) {
const merged = {};
const mergedOrdered = {};
for (let filepath in data) {
const pathSplit = filepath.split(/[\/\\]/);
let prefix;
pathSplit.pop();
switch (pathSplit[0]) {
case 'lang':
prefix = 'core';
break;
case 'core':
if (pathSplit[1] == 'lang') {
// Not used right now.
prefix = 'core';
} else {
prefix = 'core.' + pathSplit[1];
}
break;
case 'addon':
// Remove final item 'lang'.
pathSplit.pop();
// Remove first item 'addon'.
pathSplit.shift();
// For subplugins. We'll use plugin_subfolder_subfolder2_...
// E.g. 'mod_assign_feedback_comments'.
prefix = 'addon.' + pathSplit.join('_');
break;
case 'assets':
prefix = 'assets.' + pathSplit[1];
break;
}
if (prefix) {
this.addProperties(merged, data[filepath], prefix + '.');
}
}
// Force ordering by string key.
Object.keys(merged).sort().forEach((key) => {
mergedOrdered[key] = merged[key];
});
return bufferFrom(JSON.stringify(mergedOrdered, null, 4));
}
}
module.exports = BuildLangTask;

View File

@ -0,0 +1,164 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const gulp = require('gulp');
const through = require('through');
const bufferFrom = require('buffer-from');
const concat = require('gulp-concat');
const pathLib = require('path');
const fs = require('fs');
/**
* Task to combine scss into a single file.
*/
class CombineScssTask {
/**
* Finds the file and returns its content.
*
* @param capture Import file path.
* @param baseDir Directory where the file was found.
* @param paths Alternative paths where to find the imports.
* @param parsedFiles Already parsed files to reduce size of the result.
* @return Partially combined scss.
*/
getReplace(capture, baseDir, paths, parsedFiles) {
let parse = pathLib.parse(pathLib.resolve(baseDir, capture + '.scss'));
let file = parse.dir + '/' + parse.name;
if (file.slice(-3) === '.wp') {
console.log('Windows Phone not supported "' + capture);
// File was already parsed, leave the import commented.
return '// @import "' + capture + '";';
}
if (!fs.existsSync(file + '.scss')) {
// File not found, might be a partial file.
file = parse.dir + '/_' + parse.name;
}
// If file still not found, try to find the file in the alternative paths.
let x = 0;
while (!fs.existsSync(file + '.scss') && paths.length > x) {
parse = pathLib.parse(pathLib.resolve(paths[x], capture + '.scss'));
file = parse.dir + '/' + parse.name;
x++;
}
file = file + '.scss';
if (!fs.existsSync(file)) {
// File not found. Leave the import there.
console.log('File "' + capture + '" not found');
return '@import "' + capture + '";';
}
if (parsedFiles.indexOf(file) >= 0) {
console.log('File "' + capture + '" already parsed');
// File was already parsed, leave the import commented.
return '// @import "' + capture + '";';
}
parsedFiles.push(file);
const text = fs.readFileSync(file);
// Recursive call.
return this.scssCombine(text, parse.dir, paths, parsedFiles);
}
/**
* Run the task.
*
* @param done Function to call when done.
*/
run(done) {
const paths = [
'node_modules/ionic-angular/themes/',
'node_modules/font-awesome/scss/',
'node_modules/ionicons/dist/scss/'
];
const parsedFiles = [];
const self = this;
gulp.src([
'./src/theme/variables.scss',
'./node_modules/ionic-angular/themes/ionic.globals.*.scss',
'./node_modules/ionic-angular/themes/ionic.components.scss',
'./src/**/*.scss',
]).pipe(through(function(file) { // Combine them based on @import and save it to stream.
if (file.isNull()) {
return;
}
parsedFiles.push(file);
file.contents = bufferFrom(self.scssCombine(
file.contents, pathLib.dirname(file.path), paths, parsedFiles));
this.emit('data', file);
})).pipe(concat('combined.scss')) // Concat the stream output in single file.
.pipe(gulp.dest('.')) // Save file to destination.
.on('end', done);
}
/**
* Combine scss files with its imports
*
* @param content Scss string to treat.
* @param baseDir Directory where the file was found.
* @param paths Alternative paths where to find the imports.
* @param parsedFiles Already parsed files to reduce size of the result.
* @return Scss string with the replaces done.
*/
scssCombine(content, baseDir, paths, parsedFiles) {
// Content is a Buffer, convert to string.
if (typeof content != "string") {
content = content.toString();
}
// Search of single imports.
let regex = /@import[ ]*['"](.*)['"][ ]*;/g;
if (regex.test(content)) {
return content.replace(regex, (m, capture) => {
if (capture == "bmma") {
return m;
}
return this.getReplace(capture, baseDir, paths, parsedFiles);
});
}
// Search of multiple imports.
regex = /@import(?:[ \n]+['"](.*)['"][,]?[ \n]*)+;/gm;
if (regex.test(content)) {
return content.replace(regex, (m, capture) => {
let text = '';
// Divide the import into multiple files.
const captures = m.match(/['"]([^'"]*)['"]/g);
for (let x in captures) {
text += this.getReplace(captures[x].replace(/['"]+/g, ''), baseDir, paths, parsedFiles) + '\n';
}
return text;
});
}
return content;
}
}
module.exports = CombineScssTask;

View File

@ -0,0 +1,79 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const fs = require('fs');
const gulp = require('gulp');
const flatten = require('gulp-flatten');
const htmlmin = require('gulp-htmlmin');
const pathLib = require('path');
const TEMPLATES_SRC = [
'./src/components/**/*.html',
'./src/core/**/components/**/*.html',
'./src/core/**/component/**/*.html',
// Copy all addon components because any component can be injected using extraImports.
'./src/addon/**/components/**/*.html',
'./src/addon/**/component/**/*.html'
];
const TEMPLATES_DEST = './www/templates';
/**
* Task to copy component templates to www to make compile-html work in AOT.
*/
class CopyComponentTemplatesTask {
/**
* Delete a folder and all its contents.
*
* @param path [description]
* @return {[type]} [description]
*/
deleteFolderRecursive(path) {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach((file) => {
var curPath = pathLib.join(path, file);
if (fs.lstatSync(curPath).isDirectory()) {
this.deleteFolderRecursive(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
}
/**
* Run the task.
*
* @param done Callback to call once done.
*/
run(done) {
this.deleteFolderRecursive(TEMPLATES_DEST);
gulp.src(TEMPLATES_SRC, { allowEmpty: true })
.pipe(flatten())
// Check options here: https://github.com/kangax/html-minifier
.pipe(htmlmin({
collapseWhitespace: true,
removeComments: true,
caseSensitive: true
}))
.pipe(gulp.dest(TEMPLATES_DEST))
.on('end', done);
}
}
module.exports = CopyComponentTemplatesTask;

280
gulp/task-push.js 100644
View File

@ -0,0 +1,280 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const gulp = require('gulp');
const inquirer = require('inquirer');
const DevConfig = require('./dev-config');
const Git = require('./git');
const Jira = require('./jira');
const Utils = require('./utils');
/**
* Task to push a git branch and update tracker issue.
*/
class PushTask {
/**
* Ask the user whether he wants to continue.
*
* @return Promise resolved with boolean: true if he wants to continue.
*/
async askConfirmContinue() {
const answer = await inquirer.prompt([
{
type: 'input',
name: 'confirm',
message: 'Are you sure you want to continue?',
default: 'n',
},
]);
return answer.confirm == 'y';
}
/**
* Push a patch to the tracker and remove the previous one.
*
* @param branch Branch name.
* @param branchData Parsed branch data.
* @param remote Remote used.
* @return Promise resolved when done.
*/
async pushPatch(branch, branchData, remote) {
const headCommit = await Git.getHeadCommit(branch, branchData);
if (!headCommit) {
throw new Error('Head commit not resolved, abort pushing patch.');
}
// Create the patch file.
const fileName = branch + '.patch';
const tmpPatchPath = `./tmp/${fileName}`;
await Git.createPatch(`${headCommit}...${branch}`, tmpPatchPath);
console.log('Git patch created');
// Check if there is an attachment with same name in the issue.
const issue = await Jira.getIssue(branchData.issue, 'attachment');
let existingAttachmentId;
const attachments = (issue.fields && issue.fields.attachment) || [];
for (const i in attachments) {
if (attachments[i].filename == fileName) {
// Found an existing attachment with the same name, we keep track of it.
existingAttachmentId = attachments[i].id;
break
}
}
// Push the patch to the tracker.
console.log(`Uploading patch ${fileName} to the tracker...`);
await Jira.upload(branchData.issue, tmpPatchPath);
if (existingAttachmentId) {
// On success, deleting file that was there before.
try {
console.log('Deleting older patch...')
await Jira.deleteAttachment(existingAttachmentId);
} catch (error) {
console.log('Could not delete older attachment.');
}
}
}
/**
* Run the task.
*
* @param args Command line arguments.
* @param done Function to call when done.
*/
async run(args, done) {
try {
const remote = args.remote || DevConfig.get('upstreamRemote', 'origin');
let branch = args.branch;
const force = !!args.force;
if (!branch) {
branch = await Git.getCurrentBranch();
}
if (!branch) {
throw new Error('Cannot determine the current branch. Please make sure youu aren\'t in detached HEAD state');
} else if (branch == 'HEAD') {
throw new Error('Cannot push HEAD branch');
}
// Parse the branch to get the project and issue number.
const branchData = Utils.parseBranch(branch);
const keepRunning = await this.validateCommitMessages(branchData);
if (!keepRunning) {
// Last commit not valid, stop.
console.log('Exiting...');
done();
return;
}
if (!args.patch) {
// Check if it's a security issue to force patch mode.
try {
args.patch = await Jira.isSecurityIssue(branchData.issue);
if (args.patch) {
console.log(`${branchData.issue} appears to be a security issue, switching to patch mode...`);
}
} catch (error) {
console.log(`Could not check if ${branchData.issue} is a security issue.`);
}
}
if (args.patch) {
// Create and upload a patch file.
await this.pushPatch(branch, branchData, remote);
} else {
// Push the branch.
console.log(`Pushing branch ${branch} to remote ${remote}...`);
await Git.push(remote, branch, force);
// Update tracker info.
console.log(`Branch pushed, update tracker info...`);
await this.updateTrackerGitInfo(branch, branchData, remote);
}
} catch (error) {
console.error(error);
}
done();
}
/**
* Update git info in the tracker issue.
*
* @param branch Branch name.
* @param branchData Parsed branch data.
* @param remote Remote used.
* @return Promise resolved when done.
*/
async updateTrackerGitInfo(branch, branchData, remote) {
// Get the repository data for the project.
let repositoryUrl = DevConfig.get(branchData.project + '.repositoryUrl');
let diffUrlTemplate = DevConfig.get(branchData.project + '.diffUrlTemplate', '');
if (!repositoryUrl) {
// Calculate the repositoryUrl based on the remote URL.
repositoryUrl = await Git.getRemoteUrl(remote);
}
// Make sure the repository URL uses the regular format.
repositoryUrl = repositoryUrl.replace(/^(git@|git:\/\/)/, 'https://')
.replace(/\.git$/, '')
.replace('github.com:', 'github.com/');
if (!diffUrlTemplate) {
diffUrlTemplate = Utils.concatenatePaths([repositoryUrl, 'compare/%headcommit%...%branch%']);
}
// Now create the git URL for the repository.
const repositoryGitUrl = repositoryUrl.replace(/^https?:\/\//, 'git://') + '.git';
// Search HEAD commit to put in the diff URL.
console.log ('Searching for head commit...');
let headCommit = await Git.getHeadCommit(branch, branchData);
if (!headCommit) {
throw new Error('Head commit not resolved, aborting update of tracker fields');
}
headCommit = headCommit.substr(0, 10);
console.log(`Head commit resolved to ${headCommit}`);
// Calculate last properties needed.
const diffUrl = diffUrlTemplate.replace('%branch%', branch).replace('%headcommit%', headCommit);
const fieldRepositoryUrl = DevConfig.get('tracker.fieldnames.repositoryurl', 'Pull from Repository');
const fieldBranch = DevConfig.get('tracker.fieldnames.branch', 'Pull Master Branch');
const fieldDiffUrl = DevConfig.get('tracker.fieldnames.diffurl', 'Pull Master Diff URL');
// Update tracker fields.
const updates = {};
updates[fieldRepositoryUrl] = repositoryGitUrl;
updates[fieldBranch] = branch;
updates[fieldDiffUrl] = diffUrl;
console.log('Setting tracker fields...');
await Jira.setCustomFields(branchData.issue, updates);
}
/**
* Validate commit messages comparing them with the branch name.
*
* @param branchData Parsed branch data.
* @return True if value is ok or the user wants to continue anyway, false to stop.
*/
async validateCommitMessages(branchData) {
const messages = await Git.messages(30);
let numConsecutive = 0;
let wrongCommitCandidate = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const issue = Utils.getIssueFromCommitMessage(message);
if (!issue || issue != branchData.issue) {
if (i === 0) {
// Last commit is wrong, it shouldn't happen. Ask the user if he wants to continue.
if (!issue) {
console.log('The issue number could not be found in the last commit message.');
console.log(`Commit: ${message}`);
} else if (issue != branchData.issue) {
console.log('The issue number in the last commit does not match the branch being pushed to.');
console.log(`Branch: ${branchData.issue} vs. commit: ${issue}`);
}
return this.askConfirmContinue();
}
numConsecutive++;
if (numConsecutive > 2) {
// 3 consecutive commits with different branch, probably the branch commits are over. Everything OK.
return true;
// Don't treat a merge pull request commit as a wrong commit between right commits.
// The current push could be a quick fix after a merge.
} else if (!wrongCommitCandidate && message.indexOf('Merge pull request') == -1) {
wrongCommitCandidate = {
message: message,
issue: issue,
index: i,
};
}
} else if (wrongCommitCandidate) {
// We've found a commit with the branch name after a commit with a different branch. Probably wrong commit.
if (!wrongCommitCandidate.issue) {
console.log('The issue number could not be found in one of the commit messages.');
console.log(`Commit: ${wrongCommitCandidate.message}`);
} else {
console.log('The issue number in a certain commit does not match the branch being pushed to.');
console.log(`Branch: ${branchData.issue} vs. commit: ${wrongCommitCandidate.issue}`);
console.log(`Commit message: ${wrongCommitCandidate.message}`);
}
return this.askConfirmContinue();
}
}
return true;
}
}
module.exports = PushTask;

79
gulp/url.js 100644
View File

@ -0,0 +1,79 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Class with helper functions for urls.
*/
class Url {
/**
* Add params to a URL.
*
* @param url URL to add the params to.
* @param params Object with the params to add.
* @return URL with params.
*/
static addParamsToUrl(url, params) {
let separator = url.indexOf('?') != -1 ? '&' : '?';
for (const key in params) {
let value = params[key];
// Ignore objects.
if (typeof value != 'object') {
url += separator + key + '=' + value;
separator = '&';
}
}
return url;
}
/**
* Parse parts of a url, using an implicit protocol if it is missing from the url.
*
* @param url Url.
* @return Url parts.
*/
static parse(url) {
// Parse url with regular expression taken from RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B.
const match = url.trim().match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/);
if (!match) {
return null;
}
const host = match[4] || '';
// Get the credentials and the port from the host.
const [domainAndPort, credentials] = host.split('@').reverse();
const [domain, port] = domainAndPort.split(':');
const [username, password] = credentials ? credentials.split(':') : [];
// Prepare parts replacing empty strings with undefined.
return {
protocol: match[2] || undefined,
domain: domain || undefined,
port: port || undefined,
credentials: credentials || undefined,
username: username || undefined,
password: password || undefined,
path: match[5] || undefined,
query: match[7] || undefined,
fragment: match[9] || undefined,
};
}
}
module.exports = Url;

119
gulp/utils.js 100644
View File

@ -0,0 +1,119 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const DevConfig = require('./dev-config');
const DEFAULT_ISSUE_REGEX = '^(MOBILE)[-_]([0-9]+)';
/**
* Class with some utility functions.
*/
class Utils {
/**
* Concatenate several paths, adding a slash between them if needed.
*
* @param paths List of paths.
* @return Concatenated path.
*/
static concatenatePaths(paths) {
if (!paths.length) {
return '';
}
// Remove all slashes between paths.
for (let i = 0; i < paths.length; i++) {
if (!paths[i]) {
continue;
}
if (i === 0) {
paths[i] = String(paths[i]).replace(/\/+$/g, '');
} else if (i === paths.length - 1) {
paths[i] = String(paths[i]).replace(/^\/+/g, '');
} else {
paths[i] = String(paths[i]).replace(/^\/+|\/+$/g, '');
}
}
// Remove empty paths.
paths = paths.filter(path => !!path);
return paths.join('/');
}
/**
* Get command line arguments.
*
* @return Object with command line arguments.
*/
static getCommandLineArguments() {
let args = {}, opt, thisOpt, curOpt;
for (let a = 0; a < process.argv.length; a++) {
thisOpt = process.argv[a].trim();
opt = thisOpt.replace(/^\-+/, '');
if (opt === thisOpt) {
// argument value
if (curOpt) {
args[curOpt] = opt;
}
curOpt = null;
}
else {
// Argument name.
curOpt = opt;
args[curOpt] = true;
}
}
return args;
}
/**
* Given a commit message, return the issue name (e.g. MOBILE-1234).
*
* @param commit Commit message.
* @return Issue name.
*/
static getIssueFromCommitMessage(commit) {
const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
const matches = commit.match(regex);
return matches && matches[0];
}
/**
* Parse a branch name to extract some data.
*
* @param branch Branch name to parse.
* @return Data.
*/
static parseBranch(branch) {
const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
const matches = branch.match(regex);
if (!matches || matches.length < 3) {
throw new Error(`Error parsing branch ${branch}`);
}
return {
issue: matches[0],
project: matches[1],
issueNumber: matches[2],
};
}
}
module.exports = Utils;

View File

@ -1,437 +1,68 @@
var gulp = require('gulp'),
fs = require('fs'),
through = require('through'),
rename = require('gulp-rename'),
path = require('path'),
slash = require('gulp-slash'),
clipEmptyFiles = require('gulp-clip-empty-files'),
File = require('vinyl'),
flatten = require('gulp-flatten'),
npmPath = require('path'),
concat = require('gulp-concat'),
htmlmin = require('gulp-htmlmin'),
bufferFrom = require('buffer-from'),
exec = require('child_process').exec,
license = '' +
'// (C) Copyright 2015 Moodle Pty Ltd.\n' +
'//\n' +
'// Licensed under the Apache License, Version 2.0 (the "License");\n' +
'// you may not use this file except in compliance with the License.\n' +
'// You may obtain a copy of the License at\n' +
'//\n' +
'// http://www.apache.org/licenses/LICENSE-2.0\n' +
'//\n' +
'// Unless required by applicable law or agreed to in writing, software\n' +
'// distributed under the License is distributed on an "AS IS" BASIS,\n' +
'// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n' +
'// See the License for the specific language governing permissions and\n' +
'// limitations under the License.\n\n';
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Copy a property from one object to another, adding a prefix to the key if needed.
* @param {Object} target Object to copy the properties to.
* @param {Object} source Object to copy the properties from.
* @param {String} prefix Prefix to add to the keys.
*/
function addProperties(target, source, prefix) {
for (var property in source) {
target[prefix + property] = source[property];
}
}
const BuildConfigTask = require('./gulp/task-build-config');
const BuildLangTask = require('./gulp/task-build-lang');
const CombineScssTask = require('./gulp/task-combine-scss');
const CopyComponentTemplatesTask = require('./gulp/task-copy-component-templates');
const PushTask = require('./gulp/task-push');
const Utils = require('./gulp/utils');
const gulp = require('gulp');
const pathLib = require('path');
/**
* Treats a file to merge JSONs. This function is based on gulp-jsoncombine module.
* https://github.com/reflog/gulp-jsoncombine
* @param {Object} file File treated.
*/
function treatFile(file, data) {
if (file.isNull() || file.isStream()) {
return; // ignore
}
try {
var srcPos = file.path.lastIndexOf('/src/');
if (srcPos == -1) {
// It's probably a Windows environment.
srcPos = file.path.lastIndexOf('\\src\\');
}
const paths = {
lang: [
'./src/lang/',
'./src/core/**/lang/',
'./src/addon/**/lang/',
'./src/assets/countries/',
'./src/assets/mimetypes/'
],
config: './src/config.json',
};
var path = file.path.substr(srcPos + 5);
data[path] = JSON.parse(file.contents.toString());
} catch (err) {
console.log('Error parsing JSON: ' + err);
}
}
/**
* Treats the merged JSON data, adding prefixes depending on the component. Used in lang tasks.
*
* @param {Object} data Merged data.
* @return {Buffer} Buffer with the treated data.
*/
function treatMergedData(data) {
var merged = {};
var mergedOrdered = {};
for (var filepath in data) {
var pathSplit = filepath.split(/[\/\\]/),
prefix;
pathSplit.pop();
switch (pathSplit[0]) {
case 'lang':
prefix = 'core';
break;
case 'core':
if (pathSplit[1] == 'lang') {
// Not used right now.
prefix = 'core';
} else {
prefix = 'core.' + pathSplit[1];
}
break;
case 'addon':
// Remove final item 'lang'.
pathSplit.pop();
// Remove first item 'addon'.
pathSplit.shift();
// For subplugins. We'll use plugin_subfolder_subfolder2_...
// E.g. 'mod_assign_feedback_comments'.
prefix = 'addon.' + pathSplit.join('_');
break;
case 'assets':
prefix = 'assets.' + pathSplit[1];
break;
}
if (prefix) {
addProperties(merged, data[filepath], prefix + '.');
}
}
// Force ordering by string key.
Object.keys(merged).sort().forEach(function(k){
mergedOrdered[k] = merged[k];
});
return bufferFrom(JSON.stringify(mergedOrdered, null, 4));
}
/**
* Build lang file.
*
* @param {String} language Language to translate.
* @param {String[]} langPaths Paths to the possible language files.
* @param {String} buildDest Path where to leave the built files.
* @param {Function} done Function to call when done.
* @return {Void}
*/
function buildLang(language, langPaths, buildDest, done) {
var filename = language + '.json',
data = {},
firstFile = null;
var paths = langPaths.map(function(path) {
if (path.slice(-1) != '/') {
path = path + '/';
}
return path + language + '.json';
});
gulp.src(paths, { allowEmpty: true })
.pipe(slash())
.pipe(clipEmptyFiles())
.pipe(through(function(file) {
if (!firstFile) {
firstFile = file;
}
return treatFile(file, data);
}, function() {
/* This implementation is based on gulp-jsoncombine module.
* https://github.com/reflog/gulp-jsoncombine */
if (firstFile) {
var joinedPath = path.join(firstFile.base, language+'.json');
var joinedFile = new File({
cwd: firstFile.cwd,
base: firstFile.base,
path: joinedPath,
contents: treatMergedData(data)
});
this.emit('data', joinedFile);
}
this.emit('end');
}))
.pipe(gulp.dest(buildDest))
.on('end', done);
}
// Delete a folder and all its contents.
function deleteFolderRecursive(path) {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach(function(file) {
var curPath = npmPath.join(path, file);
if (fs.lstatSync(curPath).isDirectory()) {
deleteFolderRecursive(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
}
// List of app lang files. To be used only if cannot get it from filesystem.
var paths = {
src: './src',
assets: './src/assets',
lang: [
'./src/lang/',
'./src/core/**/lang/',
'./src/addon/**/lang/',
'./src/assets/countries/',
'./src/assets/mimetypes/'
],
config: './src/config.json',
};
const args = Utils.getCommandLineArguments();
// Build the language files into a single file per language.
gulp.task('lang', function(done) {
buildLang('en', paths.lang, path.join(paths.assets, 'lang'), done);
gulp.task('lang', (done) => {
new BuildLangTask().run('en', paths.lang, done);
});
// Convert config.json into a TypeScript class.
gulp.task('config', function(done) {
// Get the last commit.
exec('git log -1 --pretty=format:"%H"', function (err, commit, stderr) {
if (err) {
console.error('An error occurred while getting the last commit: ' + err);
} else if (stderr) {
console.error('An error occurred while getting the last commit: ' + stderr);
}
gulp.task('config', (done) => {
new BuildConfigTask().run(paths.config, done);
});
gulp.src(paths.config)
.pipe(through(function(file) {
// Convert the contents of the file into a TypeScript class.
// Disable the rule variable-name in the file.
var config = JSON.parse(file.contents.toString()),
contents = license + '// tslint:disable: variable-name\n' + 'export class CoreConfigConstants {\n',
that = this;
// Copy component templates to www to make compile-html work in AOT.
gulp.task('copy-component-templates', (done) => {
new CopyComponentTemplatesTask().run(done);
});
for (var key in config) {
var value = config[key];
if (typeof value == 'string') {
// Wrap the string in ' and scape them.
value = "'" + value.replace(/([^\\])'/g, "$1\\'") + "'";
} else if (typeof value != 'number' && typeof value != 'boolean') {
// Stringify with 4 spaces of indentation, and then add 4 more spaces in each line.
value = JSON.stringify(value, null, 4).replace(/^(?: )/gm, ' ').replace(/^(?:})/gm, ' }');
// Replace " by ' in values.
value = value.replace(/: "([^"]*)"/g, ": '$1'");
// Combine SCSS files.
gulp.task('combine-scss', (done) => {
new CombineScssTask().run(done);
});
// Check if the keys have "-" in it.
var matches = value.match(/"([^"]*\-[^"]*)":/g);
if (matches) {
// Replace " by ' in keys. We cannot remove them because keys have chars like '-'.
value = value.replace(/"([^"]*)":/g, "'$1':");
} else {
// Remove ' in keys.
value = value.replace(/"([^"]*)":/g, "$1:");
}
// Add type any to the key.
key = key + ': any';
}
// If key has quotation marks, remove them.
if (key[0] == '"') {
key = key.substr(1, key.length - 2);
}
contents += ' static ' + key + ' = ' + value + ';\n';
}
// Add compilation info.
contents += ' static compilationtime = ' + Date.now() + ';\n';
contents += ' static lastcommit = \'' + commit + '\';\n';
contents += '}\n';
file.contents = bufferFrom(contents);
this.emit('data', file);
}))
.pipe(rename('configconstants.ts'))
.pipe(gulp.dest(paths.src))
.on('end', done);
});
gulp.task('push', (done) => {
new PushTask().run(args, done);
});
gulp.task('default', gulp.parallel('lang', 'config'));
gulp.task('watch', function() {
var langsPaths = paths.lang.map(function(path) {
return path + 'en.json';
});
gulp.task('watch', () => {
const langsPaths = paths.lang.map(path => path + 'en.json');
gulp.watch(langsPaths, { interval: 500 }, gulp.parallel('lang'));
gulp.watch(paths.config, { interval: 500 }, gulp.parallel('config'));
});
var templatesSrc = [
'./src/components/**/*.html',
'./src/core/**/components/**/*.html',
'./src/core/**/component/**/*.html',
// Copy all addon components because any component can be injected using extraImports.
'./src/addon/**/components/**/*.html',
'./src/addon/**/component/**/*.html'
],
templatesDest = './www/templates';
// Copy component templates to www to make compile-html work in AOT.
gulp.task('copy-component-templates', function(done) {
deleteFolderRecursive(templatesDest);
gulp.src(templatesSrc, { allowEmpty: true })
.pipe(flatten())
// Check options here: https://github.com/kangax/html-minifier
.pipe(htmlmin({
collapseWhitespace: true,
removeComments: true,
caseSensitive: true
}))
.pipe(gulp.dest(templatesDest))
.on('end', done);
});
/**
* Finds the file and returns its content.
*
* @param {string} capture Import file path.
* @param {string} baseDir Directory where the file was found.
* @param {string} paths Alternative paths where to find the imports.
* @param {Array} parsedFiles Yet parsed files to reduce size of the result.
* @return {string} Partially combined scss.
*/
function getReplace(capture, baseDir, paths, parsedFiles) {
var parse = path.parse(path.resolve(baseDir, capture + '.scss'));
var file = parse.dir + '/' + parse.name;
if (file.slice(-3) === '.wp') {
console.log('Windows Phone not supported "' + capture);
// File was already parsed, leave the import commented.
return '// @import "' + capture + '";';
}
if (!fs.existsSync(file + '.scss')) {
// File not found, might be a partial file.
file = parse.dir + '/_' + parse.name;
}
// If file still not found, try to find the file in the alternative paths.
var x = 0;
while (!fs.existsSync(file + '.scss') && paths.length > x) {
parse = path.parse(path.resolve(paths[x], capture + '.scss'));
file = parse.dir + '/' + parse.name;
x++;
}
file = file + '.scss';
if (!fs.existsSync(file)) {
// File not found. Leave the import there.
console.log('File "' + capture + '" not found');
return '@import "' + capture + '";';
}
if (parsedFiles.indexOf(file) >= 0) {
console.log('File "' + capture + '" already parsed');
// File was already parsed, leave the import commented.
return '// @import "' + capture + '";';
}
parsedFiles.push(file);
var text = fs.readFileSync(file);
// Recursive call.
return scssCombine(text, parse.dir, paths, parsedFiles);
}
/**
* Combine scss files with its imports
*
* @param {string} content Scss string to read.
* @param {string} baseDir Directory where the file was found.
* @param {string} paths Alternative paths where to find the imports.
* @param {Array} parsedFiles Yet parsed files to reduce size of the result.
* @return {string} Scss string with the replaces done.
*/
function scssCombine(content, baseDir, paths, parsedFiles) {
// Content is a Buffer, convert to string.
if (typeof content != "string") {
content = content.toString();
}
// Search of single imports.
var regex = /@import[ ]*['"](.*)['"][ ]*;/g;
if (regex.test(content)) {
return content.replace(regex, function(m, capture) {
if (capture == "bmma") {
return m;
}
return getReplace(capture, baseDir, paths, parsedFiles);
});
}
// Search of multiple imports.
regex = /@import(?:[ \n]+['"](.*)['"][,]?[ \n]*)+;/gm;
if (regex.test(content)) {
return content.replace(regex, function(m, capture) {
var text = "";
// Divide the import into multiple files.
regex = /['"]([^'"]*)['"]/g;
var captures = m.match(regex);
for (var x in captures) {
text += getReplace(captures[x].replace(/['"]+/g, ''), baseDir, paths, parsedFiles) + "\n";
}
return text;
});
}
return content;
}
gulp.task('combine-scss', function(done) {
var paths = [
'node_modules/ionic-angular/themes/',
'node_modules/font-awesome/scss/',
'node_modules/ionicons/dist/scss/'
];
var parsedFiles = [];
gulp.src([
'./src/theme/variables.scss',
'./node_modules/ionic-angular/themes/ionic.globals.*.scss',
'./node_modules/ionic-angular/themes/ionic.components.scss',
'./src/**/*.scss']) // define a source files
.pipe(through(function(file, encoding, callback) {
if (file.isNull()) {
return;
}
parsedFiles.push(file);
file.contents = bufferFrom(scssCombine(file.contents, path.dirname(file.path), paths, parsedFiles));
this.emit('data', file);
})) // combine them based on @import and save it to stream
.pipe(concat('combined.scss')) // concat the stream output in single file
.pipe(gulp.dest('.')) // save file to destination.
.on('end', done);
});

5986
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "moodlemobile",
"version": "3.9.2",
"version": "3.9.3",
"description": "The official app for Moodle.",
"author": {
"name": "Moodle Pty Ltd.",
@ -57,19 +57,21 @@
"@angular/platform-browser-dynamic": "5.2.11",
"@ionic-native/badge": "4.20.0",
"@ionic-native/camera": "4.20.0",
"@ionic-native/chooser": "^4.20.0",
"@ionic-native/chooser": "4.20.0",
"@ionic-native/clipboard": "4.20.0",
"@ionic-native/core": "4.20.0",
"@ionic-native/device": "4.20.0",
"@ionic-native/diagnostic": "4.2.0",
"@ionic-native/file": "4.20.0",
"@ionic-native/file-opener": "4.20.0",
"@ionic-native/file-transfer": "4.20.0",
"@ionic-native/geolocation": "4.20.0",
"@ionic-native/globalization": "4.20.0",
"@ionic-native/http": "^4.20.0",
"@ionic-native/http": "4.20.0",
"@ionic-native/in-app-browser": "4.20.0",
"@ionic-native/keyboard": "4.20.0",
"@ionic-native/local-notifications": "4.20.0",
"@ionic-native/media": "4.20.0",
"@ionic-native/media-capture": "4.20.0",
"@ionic-native/network": "4.20.0",
"@ionic-native/push": "4.20.0",
@ -85,7 +87,7 @@
"ajv": "6.11.0",
"chart.js": "2.9.3",
"com-darryncampbell-cordova-plugin-intent": "1.3.0",
"cordova": "9.0.0",
"cordova": "10.0.0",
"cordova-android": "8.1.0",
"cordova-android-support-gradle-release": "3.0.1",
"cordova-clipboard": "1.3.0",
@ -93,23 +95,24 @@
"cordova-plugin-advanced-http": "2.4.1",
"cordova-plugin-badge": "0.8.8",
"cordova-plugin-camera": "4.1.0",
"cordova-plugin-chooser": "1.3.1",
"cordova-plugin-customurlscheme": "5.0.0",
"cordova-plugin-chooser": "1.3.2",
"cordova-plugin-customurlscheme": "5.0.1",
"cordova-plugin-device": "2.0.3",
"cordova-plugin-file": "6.0.2",
"cordova-plugin-file-opener2": "3.0.0",
"cordova-plugin-file-opener2": "3.0.4",
"cordova-plugin-file-transfer": "1.7.1",
"cordova-plugin-geolocation": "git+https://github.com/apache/cordova-plugin-geolocation.git#89cf51d222e8f225bdfb661965b3007d669c40ff",
"cordova-plugin-globalization": "1.11.0",
"cordova-plugin-inappbrowser": "4.0.0",
"cordova-plugin-inappbrowser": "git+https://github.com/moodlemobile/cordova-plugin-inappbrowser.git#moodle",
"cordova-plugin-ionic-keyboard": "2.1.3",
"cordova-plugin-ionic-webview": "4.1.3",
"cordova-plugin-ionic-webview": "git+https://github.com/moodlemobile/cordova-plugin-ionic-webview.git#500-moodle",
"cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle",
"cordova-plugin-media": "5.0.3",
"cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "2.0.2",
"cordova-plugin-qrscanner": "git+https://github.com/moodlemobile/cordova-plugin-qrscanner.git#dist",
"cordova-plugin-screen-orientation": "3.0.2",
"cordova-plugin-splashscreen": "5.0.3",
"cordova-plugin-splashscreen": "6.0.0",
"cordova-plugin-statusbar": "2.4.3",
"cordova-plugin-whitelist": "1.3.4",
"cordova-plugin-wkuserscript": "git+https://github.com/moodlemobile/cordova-plugin-wkuserscript.git",
@ -119,6 +122,7 @@
"cordova-support-google-services": "1.3.2",
"es6-promise-plugin": "4.2.2",
"font-awesome": "4.7.0",
"inquirer": "^7.3.2",
"ionic-angular": "3.9.9",
"ionicons": "3.0.0",
"jszip": "3.1.5",
@ -136,31 +140,36 @@
},
"devDependencies": {
"@ionic/app-scripts": "3.2.3",
"@ionic/cli": "^6.9.3",
"@types/cordova": "0.0.34",
"@types/cordova-plugin-file-transfer": "0.0.3",
"@types/cordova-plugin-globalization": "0.0.3",
"@types/cordova-plugin-network-information": "0.0.3",
"@types/node": "8.10.59",
"@types/promise.prototype.finally": "2.0.4",
"@ionic/cli": "^6.11.7",
"@types/cordova": "^0.0.34",
"@types/cordova-plugin-file-transfer": "^0.0.3",
"@types/cordova-plugin-globalization": "^0.0.3",
"@types/cordova-plugin-network-information": "^0.0.3",
"@types/node": "^8.10.59",
"@types/promise.prototype.finally": "^2.0.4",
"acorn": "^5.7.4",
"electron-builder-lib": "20.23.1",
"electron-rebuild": "1.10.0",
"cordova.plugins.diagnostic": "^5.0.2",
"electron-builder-lib": "^20.23.1",
"electron-rebuild": "^1.10.0",
"gulp": "4.0.2",
"gulp-clip-empty-files": "0.1.2",
"gulp-concat": "2.6.1",
"gulp-flatten": "0.4.0",
"gulp-htmlmin": "5.0.1",
"gulp-rename": "2.0.0",
"gulp-slash": "1.1.3",
"lodash.template": "4.5.0",
"gulp-clip-empty-files": "^0.1.2",
"gulp-concat": "^2.6.1",
"gulp-flatten": "^0.4.0",
"gulp-htmlmin": "^5.0.1",
"gulp-rename": "^2.0.0",
"gulp-slash": "^1.1.3",
"lodash.template": "^4.5.0",
"minimist": "^1.2.5",
"native-run": "^1.0.0",
"node-loader": "0.6.0",
"through": "2.3.8",
"typescript": "2.6.2",
"vinyl": "2.2.0",
"webpack-merge": "4.2.2"
"node-loader": "^0.6.0",
"request": "^2.88.2",
"through": "^2.3.8",
"typescript": "~2.6.2",
"vinyl": "^2.2.0",
"webpack-merge": "^4.2.2"
},
"optionalDependencies": {
"keytar": "^6.0.1"
},
"browser": {
"electron": false
@ -213,7 +222,11 @@
"cordova-plugin-wkwebview-cookies": {},
"cordova-plugin-qrscanner": {},
"cordova-plugin-chooser": {},
"cordova-plugin-wkuserscript": {}
"cordova-plugin-wkuserscript": {},
"cordova-plugin-media": {
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
},
"cordova.plugins.diagnostic": {}
}
},
"main": "desktop/electron.js",
@ -248,14 +261,19 @@
}
],
"compression": "maximum",
"electronVersion": "4.2.5",
"electronVersion": "8.0.2",
"mac": {
"category": "public.app-category.education",
"icon": "resources/desktop/icon.icns",
"target": "mas",
"bundleVersion": "3.9.2",
"bundleVersion": "3.9.3",
"extendInfo": {
"ElectronTeamID": "2NU57U5PAW"
"ElectronTeamID": "2NU57U5PAW",
"NSLocationWhenInUseUsageDescription": "We need your location so you can attach it as part of your submissions.",
"NSLocationAlwaysUsageDescription": "We need your location so you can attach it as part of your submissions.",
"NSCameraUsageDescription": "We need camera access to take pictures so you can attach them as part of your submissions.",
"NSMicrophoneUsageDescription": "We need microphone access to record sounds so you can attach them as part of your submissions.",
"NSPhotoLibraryUsageDescription": "We need photo library access to get pictures from there so you can attach them as part of your submissions."
}
},
"win": {
@ -274,6 +292,6 @@
}
},
"engines": {
"node": "11.x"
"node": ">=11.x"
}
}
}

View File

@ -258,7 +258,10 @@ function parse_file {
value=`$exec`
guess_file $key "$value"
else
if [ ! -z "$findbetter" ]; then
if [ "$found" == 'donottranslate' ]; then
# Do nothing since is not translatable.
continue
elif [ ! -z "$findbetter" ]; then
exec="jq -r .\"$key\" $1"
value=`$exec`
find_better_file "$key" "$value" "$found"

View File

@ -210,6 +210,18 @@ function build_lang($lang, $keys) {
$string = get_translation_strings($langfoldername, $value->file, $override_langfolder);
// Apply translations.
if (!$string) {
if ($value->file == 'donottranslate') {
// Restore it form the json.
if ($langFile && is_array($langFile) && isset($langFile[$key])) {
$translations[$key] = $langFile[$key];
} else {
// If not present, do not count it in the total.
$total--;
}
continue;
}
if (TOTRANSLATE) {
echo "\n\t\tTo translate $value->string on $value->file";
}
@ -312,6 +324,10 @@ function detect_lang($lang, $keys) {
$string = get_translation_strings($langfoldername, $value->file);
// Apply translations.
if (!$string) {
// Do not count non translatable in the totals.
if ($value->file == 'donottranslate') {
$total--;
}
continue;
}

View File

@ -45,7 +45,6 @@
"addon.block_myoverview.hiddencourses": "block_myoverview",
"addon.block_myoverview.inprogress": "block_myoverview",
"addon.block_myoverview.lastaccessed": "block_myoverview",
"addon.block_myoverview.morecourses": "block_myoverview",
"addon.block_myoverview.nocourses": "block_myoverview",
"addon.block_myoverview.past": "block_myoverview",
"addon.block_myoverview.pluginname": "block_myoverview",
@ -408,6 +407,7 @@
"addon.mod_assign.submitassignment_help": "assign",
"addon.mod_assign.submittedearly": "assign",
"addon.mod_assign.submittedlate": "assign",
"addon.mod_assign.syncblockedusercomponent": "local_moodlemobileapp",
"addon.mod_assign.timemodified": "assign",
"addon.mod_assign.timeremaining": "assign",
"addon.mod_assign.ungroupedusers": "assign",
@ -463,6 +463,7 @@
"addon.mod_choice.errorgetchoice": "local_moodlemobileapp",
"addon.mod_choice.expired": "choice",
"addon.mod_choice.full": "choice",
"addon.mod_choice.limita": "choice",
"addon.mod_choice.modulenameplural": "choice",
"addon.mod_choice.noresultsviewable": "choice",
"addon.mod_choice.notopenyet": "choice",
@ -476,6 +477,7 @@
"addon.mod_choice.publishinfonever": "choice",
"addon.mod_choice.removemychoice": "choice",
"addon.mod_choice.responses": "choice",
"addon.mod_choice.responsesa": "choice",
"addon.mod_choice.responsesresultgraphdescription": "local_moodlemobileapp",
"addon.mod_choice.responsesresultgraphheader": "choice",
"addon.mod_choice.resultsnotsynced": "local_moodlemobileapp",
@ -505,11 +507,13 @@
"addon.mod_data.foundrecords": "data",
"addon.mod_data.gettinglocation": "local_moodlemobileapp",
"addon.mod_data.latlongboth": "data",
"addon.mod_data.locationnotenabled": "local_moodlemobileapp",
"addon.mod_data.locationpermissiondenied": "local_moodlemobileapp",
"addon.mod_data.menuchoose": "data",
"addon.mod_data.modulenameplural": "data",
"addon.mod_data.more": "data",
"addon.mod_data.mylocation": "local_moodlemobileapp",
"addon.mod_data.noaccess": "data",
"addon.mod_data.nomatch": "data",
"addon.mod_data.norecords": "data",
"addon.mod_data.notapproved": "data",
@ -1370,6 +1374,8 @@
"core.cannotconnecttrouble": "local_moodlemobileapp",
"core.cannotconnectverify": "local_moodlemobileapp",
"core.cannotdownloadfiles": "local_moodlemobileapp",
"core.cannotopeninapp": "local_moodlemobileapp",
"core.cannotopeninappdownload": "local_moodlemobileapp",
"core.captureaudio": "local_moodlemobileapp",
"core.capturedimage": "local_moodlemobileapp",
"core.captureimage": "local_moodlemobileapp",
@ -1378,6 +1384,7 @@
"core.choose": "moodle",
"core.choosedots": "moodle",
"core.clearsearch": "local_moodlemobileapp",
"core.clearstoreddata": "local_moodlemobileapp",
"core.clicktohideshow": "moodle",
"core.clicktoseefull": "local_moodlemobileapp",
"core.close": "repository",
@ -1431,6 +1438,7 @@
"core.course.availablespace": "local_moodlemobileapp",
"core.course.cannotdeletewhiledownloading": "local_moodlemobileapp",
"core.course.confirmdeletemodulefiles": "local_moodlemobileapp",
"core.course.confirmdeletestoreddata": "local_moodlemobileapp",
"core.course.confirmdownload": "local_moodlemobileapp",
"core.course.confirmdownloadunknownsize": "local_moodlemobileapp",
"core.course.confirmdownloadzerosize": "local_moodlemobileapp",
@ -1522,6 +1530,7 @@
"core.done": "survey",
"core.download": "moodle",
"core.downloaded": "local_moodlemobileapp",
"core.downloadfile": "moodle",
"core.downloading": "local_moodlemobileapp",
"core.edit": "moodle",
"core.editor.autosavesucceeded": "editor_atto",
@ -1557,6 +1566,7 @@
"core.errorsomedatanotdownloaded": "local_moodlemobileapp",
"core.errorsync": "local_moodlemobileapp",
"core.errorsyncblocked": "local_moodlemobileapp",
"core.errorurlschemeinvalidscheme": "local_moodlemobileapp",
"core.errorurlschemeinvalidsite": "local_moodlemobileapp",
"core.explanationdigitalminor": "moodle",
"core.favourites": "moodle",
@ -1750,6 +1760,7 @@
"core.login.erroraccesscontrolalloworigin": "local_moodlemobileapp",
"core.login.errordeletesite": "local_moodlemobileapp",
"core.login.errorexampleurl": "local_moodlemobileapp",
"core.login.errorqrnoscheme": "local_moodlemobileapp",
"core.login.errorupdatesite": "local_moodlemobileapp",
"core.login.faqcannotconnectanswer": "local_moodlemobileapp",
"core.login.faqcannotconnectquestion": "local_moodlemobileapp",
@ -1826,7 +1837,9 @@
"core.login.selectacountry": "moodle",
"core.login.selectsite": "local_moodlemobileapp",
"core.login.signupplugindisabled": "local_moodlemobileapp",
"core.login.signuprequiredfieldnotsupported": "local_moodlemobileapp",
"core.login.siteaddress": "local_moodlemobileapp",
"core.login.siteaddressplaceholder": "donottranslate",
"core.login.sitehasredirect": "local_moodlemobileapp",
"core.login.siteinmaintenance": "local_moodlemobileapp",
"core.login.sitepolicynotagreederror": "local_moodlemobileapp",
@ -1848,6 +1861,7 @@
"core.mainmenu.help": "moodle",
"core.mainmenu.logout": "moodle",
"core.mainmenu.website": "local_moodlemobileapp",
"core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle",
"core.min": "moodle",
"core.mins": "moodle",
@ -1899,6 +1913,7 @@
"core.noresults": "moodle",
"core.noselection": "form",
"core.notapplicable": "local_moodlemobileapp",
"core.notavailable": "moodle",
"core.notenrolledprofile": "moodle",
"core.notice": "moodle",
"core.notingroup": "moodle",
@ -1909,6 +1924,7 @@
"core.offline": "message",
"core.ok": "moodle",
"core.online": "message",
"core.openfile": "local_moodlemobileapp",
"core.openfullimage": "local_moodlemobileapp",
"core.openinbrowser": "local_moodlemobileapp",
"core.openmodinbrowser": "local_moodlemobileapp",
@ -1929,8 +1945,8 @@
"core.question.certainty": "qbehaviour_deferredcbm",
"core.question.complete": "question",
"core.question.correct": "question",
"core.question.errorattachmentsnotsupported": "local_moodlemobileapp",
"core.question.errorinlinefilesnotsupported": "local_moodlemobileapp",
"core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorquestionnotsupported": "local_moodlemobileapp",
"core.question.feedback": "question",
"core.question.howtodraganddrop": "local_moodlemobileapp",
@ -1981,6 +1997,7 @@
"core.settings.about": "local_moodlemobileapp",
"core.settings.appsettings": "local_moodlemobileapp",
"core.settings.appversion": "local_moodlemobileapp",
"core.settings.cannotsyncloggedout": "local_moodlemobileapp",
"core.settings.cannotsyncoffline": "local_moodlemobileapp",
"core.settings.cannotsyncwithoutwifi": "local_moodlemobileapp",
"core.settings.colorscheme": "local_moodlemobileapp",

View File

@ -17,16 +17,7 @@ export MOODLE_DOCKER_APP_PATH=$basedir
print_title "Preparing dependencies"
git clone --branch master --depth 1 git://github.com/moodle/moodle $HOME/moodle
git clone --branch master --depth 1 git://github.com/moodlehq/moodle-local_moodlemobileapp $HOME/moodle/local/moodlemobileapp
# git clone --branch master --depth 1 git://github.com/moodlehq/moodle-docker $HOME/moodle-docker
# TODO replace with commented line above once https://github.com/moodlehq/moodle-docker/pull/126 is merged
mkdir $HOME/moodle-docker
cd $HOME/moodle-docker
git init
git remote add origin git://github.com/moodlehq/moodle-docker
git fetch --depth 1 origin c604d5f9792c72fb9d83f6fec1f4b1defd778e9a
git checkout FETCH_HEAD
cd -
git clone --branch master --depth 1 git://github.com/moodlehq/moodle-docker $HOME/moodle-docker
cp $HOME/moodle-docker/config.docker-template.php $HOME/moodle/config.php
@ -50,7 +41,7 @@ print_title "Running e2e tests"
# Run tests
for tags in "$@"
do
$dockercompose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --tags=\"$tags\""
$dockercompose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --tags=\"$tags\" --auto-rerun"
notify_on_error_exit "Some e2e tests are failing, please review"
done

View File

@ -42,7 +42,7 @@
<div class="safe-area-page">
<ion-grid no-padding>
<ion-row no-padding>
<ion-col *ngFor="let course of filteredCourses" no-padding col-12 col-sm-6 col-md-6 col-lg-4 col-xl-4 align-self-stretch>
<ion-col *ngFor="let course of filteredCourses" no-padding col-12 col-sm-6 col-md-6 col-lg-4 col-xl-3 align-self-stretch>
<core-courses-course-progress [course]="course" class="core-courseoverview" showAll="true" [showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
</ion-col>
</ion-row>

View File

@ -77,6 +77,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
downloadCourseEnabled: boolean;
downloadCoursesEnabled: boolean;
protected FILTER_PRIORITY = ['all', 'allincludinghidden', 'inprogress', 'future', 'past', 'favourite', 'hidden', 'custom'];
protected prefetchIconsInitialized = false;
protected isDestroyed;
protected coursesObserver;
@ -202,9 +203,6 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
this.initCourseFilters(courses);
this.showSelectorFilter = courses.length > 0 && (this.courses.past.length > 0 || this.courses.future.length > 0 ||
typeof courses[0].enddate != 'undefined');
this.courses.filter = '';
this.showFilter = false;
@ -250,10 +248,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
this.showSelectorFilter = Object.keys(this.showFilters).some((key) => {
return this.showFilters[key] == 'show';
});
if (!this.showSelectorFilter) {
// All filters disabled, display all the courses.
this.showFilters.all = 'show';
}
}
if (!this.showSelectorFilter || (this.selectedFilter === 'inprogress' && this.showFilters.inprogress == 'disabled')) {
// No selector, or the default option is disabled, show all.
if (!this.showSelectorFilter) {
// No selector, display all the courses.
this.selectedFilter = 'all';
}
this.setCourseFilter(this.selectedFilter);
@ -388,7 +391,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
if (this.showFilters[filter] == 'show') {
this.filteredCourses = this.courses[filter];
} else {
const activeFilter = Object.keys(this.showFilters).find((name) => {
const activeFilter = this.FILTER_PRIORITY.find((name) => {
return this.showFilters[name] == 'show';
});

View File

@ -6,7 +6,6 @@
"hiddencourses": "Removed from view",
"inprogress": "In progress",
"lastaccessed": "Last accessed",
"morecourses": "More courses",
"nocourses": "No courses",
"past": "Past",
"pluginname": "Course overview",

View File

@ -64,12 +64,12 @@
</a>
</ng-container>
</ion-list>
<!-- Create a calendar event. -->
<ion-fab core-fab bottom end *ngIf="canCreate">
<button ion-fab (click)="openEdit()" [attr.aria-label]="'addon.calendar.newevent' | translate">
<ion-icon name="add"></ion-icon>
</button>
</ion-fab>
</core-loading>
<!-- Create a calendar event. -->
<ion-fab core-fab bottom end *ngIf="canCreate && loaded">
<button ion-fab (click)="openEdit()" [attr.aria-label]="'addon.calendar.newevent' | translate">
<ion-icon name="add"></ion-icon>
</button>
</ion-fab>
</ion-content>

View File

@ -1606,61 +1606,55 @@ export class AddonCalendarProvider {
* @param siteId Site ID the event belongs to. If not defined, use current site.
* @return Promise resolved when the notification is scheduled.
*/
protected scheduleEventNotification(event: AddonCalendarAnyEvent, reminderId: number, time: number, siteId?: string)
protected async scheduleEventNotification(event: AddonCalendarAnyEvent, reminderId: number, time: number, siteId?: string)
: Promise<void> {
if (this.localNotificationsProvider.isAvailable()) {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (!this.localNotificationsProvider.isAvailable()) {
return;
}
if (time === 0) {
// Cancel if it was scheduled.
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (time === 0) {
// Cancel if it was scheduled.
return this.localNotificationsProvider.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
}
if (time == -1) {
// If time is -1, get event default time to calculate the notification time.
time = await this.getDefaultNotificationTime(siteId);
if (time == 0) {
// Default notification time is disabled, do not show.
return this.localNotificationsProvider.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
}
let promise;
if (time == -1) {
// If time is -1, get event default time to calculate the notification time.
promise = this.getDefaultNotificationTime(siteId).then((time) => {
if (time == 0) {
// Default notification time is disabled, do not show.
return this.localNotificationsProvider.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
}
return event.timestart - (time * 60);
});
} else {
promise = Promise.resolve(time);
}
return promise.then((time) => {
time = time * 1000;
if (time <= new Date().getTime()) {
// This reminder is over, don't schedule. Cancel if it was scheduled.
return this.localNotificationsProvider.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
}
const notification: ILocalNotification = {
id: reminderId,
title: event.name,
text: this.timeUtils.userDate(event.timestart * 1000, 'core.strftimedaydatetime', true),
icon: 'file://assets/img/icons/calendar.png',
trigger: {
at: new Date(time)
},
data: {
eventid: event.id,
reminderid: reminderId,
siteid: siteId
}
};
return this.localNotificationsProvider.schedule(notification, AddonCalendarProvider.COMPONENT, siteId);
});
} else {
return Promise.resolve();
time = event.timestart - (time * 60);
}
time = time * 1000;
if (time <= Date.now()) {
// This reminder is over, don't schedule. Cancel if it was scheduled.
return this.localNotificationsProvider.cancel(reminderId, AddonCalendarProvider.COMPONENT, siteId);
}
const notification: ILocalNotification = {
id: reminderId,
title: event.name,
text: this.timeUtils.userDate(event.timestart * 1000, 'core.strftimedaydatetime', true),
icon: 'file://assets/img/icons/calendar.png',
trigger: {
at: new Date(time)
},
data: {
eventid: event.id,
reminderid: reminderId,
siteid: siteId
}
};
return this.localNotificationsProvider.schedule(notification, AddonCalendarProvider.COMPONENT, siteId);
}
/**

View File

@ -62,6 +62,39 @@ export class AddonCompetencyProvider {
});
}
/**
* Returns whether current user can see another user competencies in a course.
*
* @param courseId Course ID.
* @param userId User ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether the user can view the competencies.
*/
canViewUserCompetenciesInCourse(courseId: number, userId?: number, siteId?: string): Promise<boolean> {
if (!this.sitesProvider.isLoggedIn()) {
return Promise.resolve(false);
}
return this.getCourseCompetenciesPage(courseId, siteId).then((response) => {
if (!response.competencies.length) {
// No competencies.
return false;
}
if (!userId || userId == this.sitesProvider.getCurrentSiteUserId()) {
// Current user.
return true;
}
// Check if current user can view any competency of the user.
return this.getCompetencyInCourse(courseId, response.competencies[0].competency.id, userId, siteId).then(() => {
return true;
});
}).catch(() => {
return false;
});
}
/**
* Get cache key for user learning plans data WS calls.
*
@ -333,7 +366,7 @@ export class AddonCompetencyProvider {
}
/**
* Get all competencies in a course.
* Get all competencies in a course for a certain user.
*
* @param courseId ID of the course.
* @param userId ID of the user.
@ -344,6 +377,39 @@ export class AddonCompetencyProvider {
getCourseCompetencies(courseId: number, userId?: number, siteId?: string, ignoreCache?: boolean)
: Promise<AddonCompetencyDataForCourseCompetenciesPageResult> {
return this.getCourseCompetenciesPage(courseId, siteId, ignoreCache).then((response) => {
if (!userId || userId == this.sitesProvider.getCurrentSiteUserId()) {
return response;
}
let promises: Promise<AddonCompetencyUserCompetencySummaryInCourse>[];
promises = response.competencies.map((competency) =>
this.getCompetencyInCourse(courseId, competency.competency.id, userId, siteId)
);
return Promise.all(promises).then((responses: AddonCompetencyUserCompetencySummaryInCourse[]) => {
responses.forEach((resp, index) => {
response.competencies[index].usercompetencycourse = resp.usercompetencysummary.usercompetencycourse;
});
return response;
});
});
}
/**
* Get all competencies in a course.
*
* @param courseId ID of the course.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the course competencies are retrieved.
*/
getCourseCompetenciesPage(courseId: number, siteId?: string, ignoreCache?: boolean)
: Promise<AddonCompetencyDataForCourseCompetenciesPageResult> {
return this.sitesProvider.getSite(siteId).then((site) => {
this.logger.debug('Get course competencies for course ' + courseId);
@ -370,26 +436,6 @@ export class AddonCompetencyProvider {
return Promise.reject(null);
});
}).then((response) => {
if (!userId || userId == this.sitesProvider.getCurrentSiteUserId()) {
return response;
}
let promises: Promise<AddonCompetencyUserCompetencySummaryInCourse>[];
promises = response.competencies.map((competency) =>
this.getCompetencyInCourse(courseId, competency.competency.id, userId, siteId)
);
return Promise.all(promises).then((responses: AddonCompetencyUserCompetencySummaryInCourse[]) => {
responses.forEach((resp, index) => {
response.competencies[index].usercompetencycourse = resp.usercompetencysummary.usercompetencycourse;
});
return response;
});
});
}

View File

@ -71,15 +71,10 @@ export class AddonCompetencyUserHandler implements CoreUserProfileHandler {
return this.participantsNavEnabledCache[cacheKey];
}
return this.competencyProvider.getCourseCompetencies(courseId, user.id).then((response) => {
const enabled = response.competencies.length > 0;
return this.competencyProvider.canViewUserCompetenciesInCourse(courseId, user.id).then((enabled) => {
this.participantsNavEnabledCache[cacheKey] = enabled;
return enabled;
}).catch((message) => {
this.participantsNavEnabledCache[cacheKey] = false;
return false;
});
} else {
// Link on a user site profile.

View File

@ -13,6 +13,7 @@
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreEventsProvider } from '@providers/events';
import { AddonMessageOutputDelegate } from '@addon/messageoutput/providers/delegate';
import { AddonMessageOutputAirnotifierProvider } from './providers/airnotifier';
import { AddonMessageOutputAirnotifierHandler } from './providers/handler';
@ -28,7 +29,13 @@ import { AddonMessageOutputAirnotifierHandler } from './providers/handler';
]
})
export class AddonMessageOutputAirnotifierModule {
constructor(messageOutputDelegate: AddonMessageOutputDelegate, airnotifierHandler: AddonMessageOutputAirnotifierHandler) {
constructor(messageOutputDelegate: AddonMessageOutputDelegate, airnotifierHandler: AddonMessageOutputAirnotifierHandler,
eventsProvider: CoreEventsProvider, airnotifierProvider: AddonMessageOutputAirnotifierProvider) {
messageOutputDelegate.registerHandler(airnotifierHandler);
eventsProvider.on(CoreEventsProvider.DEVICE_REGISTERED_IN_MOODLE, async (data) => {
// Get user devices to make Moodle send the devices data to Airnotifier.
airnotifierProvider.getUserDevices(true, data.siteId);
});
}
}

View File

@ -11,7 +11,7 @@
<ion-list>
<ion-item text-wrap *ngFor="let device of devices">
<ion-label [class.core-bold]="device.current">
{{ device.model }}
{{ device.name }} {{ device.model }} {{ device.platform }} {{ device.version }}
<span *ngIf="device.current">({{ 'core.currentdevice' | translate }})</span>
</ion-label>
<ion-spinner *ngIf="device.updating" item-end></ion-spinner>

View File

@ -74,10 +74,11 @@ export class AddonMessageOutputAirnotifierProvider {
/**
* Get user devices.
*
* @param ignoreCache Whether to ignore cache.
* @param siteId Site ID. If not defined, use current site.
* @return Promise resolved with the devices.
*/
getUserDevices(siteId?: string): Promise<AddonMessageOutputAirnotifierDevice[]> {
getUserDevices(ignoreCache?: boolean, siteId?: string): Promise<AddonMessageOutputAirnotifierDevice[]> {
this.logger.debug('Get user devices');
return this.sitesProvider.getSite(siteId).then((site) => {
@ -89,6 +90,11 @@ export class AddonMessageOutputAirnotifierProvider {
updateFrequency: CoreSite.FREQUENCY_RARELY
};
if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
return site.read('message_airnotifier_get_user_devices', data, preSets)
.then((data: AddonMessageOutputAirnotifierGetUserDevicesResult) => {
return data.devices;

View File

@ -581,35 +581,38 @@ export class AddonMessagesDiscussionPage implements OnDestroy {
* @param offset Offset for message list.
* @return Promise resolved with the list of messages.
*/
protected getConversationMessages(pagesToLoad: number, offset: number = 0)
protected async getConversationMessages(pagesToLoad: number, offset: number = 0)
: Promise<AddonMessagesConversationMessageFormatted[]> {
const excludePending = offset > 0;
return this.messagesProvider.getConversationMessages(this.conversationId, excludePending, offset).then((result) => {
pagesToLoad--;
// Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object.
if (result.members) {
result.members.forEach((member) => {
this.members[member.id] = member;
});
}
if (pagesToLoad > 0 && result.canLoadMore) {
offset += AddonMessagesProvider.LIMIT_MESSAGES;
// Get more messages.
return this.getConversationMessages(pagesToLoad, offset).then((nextMessages) => {
return result.messages.concat(nextMessages);
});
} else {
// No more messages to load, return them.
this.canLoadMore = result.canLoadMore;
return result.messages;
}
const result = await this.messagesProvider.getConversationMessages(this.conversationId, {
excludePending: excludePending,
limitFrom: offset,
});
pagesToLoad--;
// Treat members. Don't use CoreUtilsProvider.arrayToObject because we don't want to override the existing object.
if (result.members) {
result.members.forEach((member) => {
this.members[member.id] = member;
});
}
if (pagesToLoad > 0 && result.canLoadMore) {
offset += AddonMessagesProvider.LIMIT_MESSAGES;
// Get more messages.
const nextMessages = await this.getConversationMessages(pagesToLoad, offset);
return result.messages.concat(nextMessages);
} else {
// No more messages to load, return them.
this.canLoadMore = result.canLoadMore;
return result.messages;
}
}
/**

View File

@ -22,7 +22,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper';
import { CoreEventsProvider } from '@providers/events';
import { CoreSite } from '@classes/site';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreWSExternalWarning } from '@providers/ws';
/**
@ -398,7 +398,7 @@ export class AddonMessagesProvider {
* @param userId The other person with whom the current user is having the discussion.
* @return Cache key.
*/
protected getCacheKeyForDiscussion(userId: number): string {
getCacheKeyForDiscussion(userId: number): string {
return this.ROOT_CACHE_KEY + 'discussion:' + userId;
}
@ -889,93 +889,92 @@ export class AddonMessagesProvider {
* Get a conversation by the conversation ID.
*
* @param conversationId Conversation ID to fetch.
* @param excludePending True to exclude messages pending to be sent.
* @param limitFrom Offset for messages list.
* @param limitTo Limit of messages.
* @param newestFirst Whether to order messages by newest first.
* @param timeFrom The timestamp from which the messages were created.
* @param siteId Site ID. If not defined, use current site.
* @param userId User ID. If not defined, current user in the site.
* @param options Options.
* @return Promise resolved with the response.
* @since 3.6
*/
getConversationMessages(conversationId: number, excludePending: boolean, limitFrom: number = 0, limitTo?: number,
newestFirst: boolean = true, timeFrom: number = 0, siteId?: string, userId?: number)
async getConversationMessages(conversationId: number, options?: AddonMessagesGetConversationMessagesOptions)
: Promise<AddonMessagesGetConversationMessagesResult> {
return this.sitesProvider.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
options = options || {};
if (typeof limitTo == 'undefined' || limitTo === null) {
limitTo = this.LIMIT_MESSAGES;
const site = await this.sitesProvider.getSite(options.siteId);
options.userId = options.userId || site.getUserId();
options.limitFrom = options.limitFrom || 0;
options.limitTo = options.limitTo === undefined || options.limitTo === null ? this.LIMIT_MESSAGES : options.limitTo;
options.timeFrom = options.timeFrom || 0;
options.newestFirst = options.newestFirst === undefined || options.newestFirst === null ? true : options.newestFirst;
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getCacheKeyForConversationMessages(options.userId, conversationId),
};
const params = {
currentuserid: options.userId,
convid: conversationId,
limitfrom: options.limitFrom,
limitnum: options.limitTo < 1 ? options.limitTo : options.limitTo + 1, // If there's a limit, get 1 more than requested.
newest: options.newestFirst ? 1 : 0,
timefrom: options.timeFrom,
};
if (options.limitFrom > 0) {
// Do not use cache when retrieving older messages.
// This is to prevent storing too much data and to prevent inconsistencies between "pages" loaded.
preSets.getFromCache = false;
preSets.saveToCache = false;
preSets.emergencyCache = false;
} else if (options.forceCache) {
preSets.omitExpires = true;
} else if (options.ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
const result: AddonMessagesGetConversationMessagesResult =
await site.read('core_message_get_conversation_messages', params, preSets);
if (options.limitTo < 1) {
result.canLoadMore = false;
result.messages = result.messages;
} else {
result.canLoadMore = result.messages.length > options.limitTo;
result.messages = result.messages.slice(0, options.limitTo);
}
let lastReceived;
result.messages.forEach((message) => {
// Convert time to milliseconds.
message.timecreated = message.timecreated ? message.timecreated * 1000 : 0;
if (!lastReceived && message.useridfrom != options.userId) {
lastReceived = message;
}
const preSets = {
cacheKey: this.getCacheKeyForConversationMessages(userId, conversationId)
},
params: any = {
currentuserid: userId,
convid: conversationId,
limitfrom: limitFrom,
limitnum: limitTo < 1 ? limitTo : limitTo + 1, // If there is a limit, get 1 more than requested.
newest: newestFirst ? 1 : 0,
timefrom: timeFrom
};
if (limitFrom > 0) {
// Do not use cache when retrieving older messages.
// This is to prevent storing too much data and to prevent inconsistencies between "pages" loaded.
preSets['getFromCache'] = false;
preSets['saveToCache'] = false;
preSets['emergencyCache'] = false;
}
return site.read('core_message_get_conversation_messages', params, preSets)
.then((result: AddonMessagesGetConversationMessagesResult) => {
if (limitTo < 1) {
result.canLoadMore = false;
result.messages = result.messages;
} else {
result.canLoadMore = result.messages.length > limitTo;
result.messages = result.messages.slice(0, limitTo);
}
let lastReceived;
result.messages.forEach((message) => {
// Convert time to milliseconds.
message.timecreated = message.timecreated ? message.timecreated * 1000 : 0;
if (!lastReceived && message.useridfrom != userId) {
lastReceived = message;
}
});
if (this.appProvider.isDesktop() && limitFrom === 0 && lastReceived) {
// Store the last received message (we cannot know if it's unread or not). Don't block the user for this.
this.storeLastReceivedMessageIfNeeded(conversationId, lastReceived, site.getId());
}
if (excludePending) {
// No need to get offline messages, return the ones we have.
return result;
}
// Get offline messages.
return this.messagesOffline.getConversationMessages(conversationId).then((offlineMessages) => {
// Mark offline messages as pending.
offlineMessages.forEach((message) => {
message.pending = true;
message.useridfrom = userId;
});
result.messages = result.messages.concat(offlineMessages);
return result;
});
});
});
if (this.appProvider.isDesktop() && options.limitFrom === 0 && lastReceived) {
// Store the last received message (we cannot know if it's unread or not). Don't block the user for this.
this.storeLastReceivedMessageIfNeeded(conversationId, lastReceived, site.getId());
}
if (options.excludePending) {
// No need to get offline messages, return the ones we have.
return result;
}
// Get offline messages.
const offlineMessages = await this.messagesOffline.getConversationMessages(conversationId);
// Mark offline messages as pending.
offlineMessages.forEach((message) => {
message.pending = true;
message.useridfrom = options.userId;
});
result.messages = result.messages.concat(offlineMessages);
return result;
}
/**
@ -1412,7 +1411,7 @@ export class AddonMessagesProvider {
* @param siteId Site ID. If not defined, use current site.
* @return Promise resolved with the data.
*/
protected getRecentMessages(params: any, preSets: any, limitFromUnread: number = 0, limitFromRead: number = 0,
getRecentMessages(params: any, preSets: any, limitFromUnread: number = 0, limitFromRead: number = 0,
toDisplay: boolean = true, siteId?: string): Promise<AddonMessagesGetMessagesMessage[]> {
limitFromUnread = limitFromUnread || 0;
limitFromRead = limitFromRead || 0;
@ -1962,7 +1961,8 @@ export class AddonMessagesProvider {
* @since 3.2
*/
isMarkAllMessagesReadEnabled(): boolean {
return this.sitesProvider.wsAvailableInCurrentSite('core_message_mark_all_messages_as_read');
return this.sitesProvider.wsAvailableInCurrentSite('core_message_mark_all_conversation_messages_as_read') ||
this.sitesProvider.wsAvailableInCurrentSite('core_message_mark_all_messages_as_read');
}
/**
@ -2787,6 +2787,21 @@ export class AddonMessagesProvider {
}
}
/**
* Options to pass to getConversationMessages.
*/
export type AddonMessagesGetConversationMessagesOptions = {
excludePending?: boolean; // True to exclude messages pending to be sent.
limitFrom?: number; // Offset for messages list. Defaults to 0.
limitTo?: number; // Limit of messages.
newestFirst?: boolean; // Whether to order messages by newest first.
timeFrom?: number; // The timestamp from which the messages were created (in seconds). Defaults to 0.
siteId?: string; // Site ID. If not defined, use current site.
userId?: number; // User ID. If not defined, current user in the site.
forceCache?: boolean; // True if it should return cached data. Has priority over ignoreCache.
ignoreCache?: boolean; // True if it should ignore cached data (it will always fail in offline or server down).
};
/**
* Conversation.
*/

View File

@ -32,8 +32,10 @@ export class AddonMessagesSettingsHandler implements CoreSettingsHandler {
*
* @return Whether or not the handler is enabled on a site level.
*/
isEnabled(): boolean | Promise<boolean> {
return this.messagesProvider.isMessagePreferencesEnabled();
async isEnabled(): Promise<boolean> {
const messagingEnabled = await this.messagesProvider.isPluginEnabled();
return messagingEnabled && this.messagesProvider.isMessagePreferencesEnabled();
}
/**

View File

@ -26,6 +26,7 @@ import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { TranslateService } from '@ngx-translate/core';
import { CoreSyncProvider } from '@providers/sync';
import { CoreConstants } from '@core/constants';
/**
* Service to sync messages.
@ -134,112 +135,165 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider {
*
* @param conversationId Conversation ID.
* @param userId User ID talking to (if no conversation ID).
* @return Promise resolved if sync is successful, rejected otherwise.
* @param siteId Site ID.
* @return Promise resolved with the list of warnings if sync is successful, rejected otherwise.
*/
syncDiscussion(conversationId: number, userId: number, siteId?: string): Promise<any> {
syncDiscussion(conversationId: number, userId: number, siteId?: string): Promise<string[]> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const syncId = this.getSyncId(conversationId, userId),
groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled();
const syncId = this.getSyncId(conversationId, userId);
if (this.isSyncing(syncId, siteId)) {
// There's already a sync ongoing for this conversation, return the promise.
return this.getOngoingSync(syncId, siteId);
}
const warnings = [];
return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId);
}
/**
* Perform the synchronization of a discussion.
*
* @param conversationId Conversation ID.
* @param userId User ID talking to (if no conversation ID).
* @param siteId Site ID.
* @return Promise resolved with the list of warnings if sync is successful, rejected otherwise.
*/
protected async performSyncDiscussion(conversationId: number, userId: number, siteId: string): Promise<string[]> {
const groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled();
let messages: any[];
const errors = [];
const warnings: string[] = [];
if (conversationId) {
this.logger.debug(`Try to sync conversation '${conversationId}'`);
messages = await this.messagesOffline.getConversationMessages(conversationId, siteId);
} else {
this.logger.debug(`Try to sync discussion with user '${userId}'`);
messages = await this.messagesOffline.getMessages(userId, siteId);
}
// Get offline messages to be sent.
let syncPromise;
if (!messages.length) {
// Nothing to sync.
return [];
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline. Mark messages as device offline.
this.messagesOffline.setMessagesDeviceOffline(messages, true);
if (conversationId) {
syncPromise = this.messagesOffline.getConversationMessages(conversationId, siteId);
} else {
syncPromise = this.messagesOffline.getMessages(userId, siteId);
return Promise.reject(null);
}
syncPromise = syncPromise.then((messages) => {
if (!messages.length) {
// Nothing to sync.
return [];
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline. Mark messages as device offline.
this.messagesOffline.setMessagesDeviceOffline(messages, true);
// Order message by timecreated.
messages = this.messagesProvider.sortMessages(messages);
return Promise.reject(null);
}
// Get messages sent by the user after the first offline message was sent.
// We subtract some time because the message could've been saved in server before it was in the app.
const timeFrom = Math.floor((messages[0].timecreated - CoreConstants.WS_TIMEOUT - 1000) / 1000);
const onlineMessages = await this.getMessagesSentAfter(timeFrom, conversationId, userId, siteId);
let promise: Promise<any> = Promise.resolve();
const errors = [];
// Send the messages. Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed.
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const textFieldName = conversationId ? 'text' : 'smallmessage';
const wrappedText = message[textFieldName][0] != '<' ? '<p>' + message[textFieldName] + '</p>' : message[textFieldName];
// Order message by timecreated.
messages = this.messagesProvider.sortMessages(messages);
// Send the messages.
// Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed.
messages.forEach((message, index) => {
// Chain message sending. If 1 message fails to be sent we'll stop sending.
promise = promise.then(() => {
let subPromise;
if (conversationId) {
subPromise = this.messagesProvider.sendMessageToConversationOnline(conversationId, message.text, siteId);
} else {
subPromise = this.messagesProvider.sendMessageOnline(userId, message.smallmessage, siteId);
try {
if (onlineMessages.indexOf(wrappedText) != -1) {
// Message already sent, ignore it to prevent duplicates.
} else if (conversationId) {
await this.messagesProvider.sendMessageToConversationOnline(conversationId, message.text, siteId);
} else {
await this.messagesProvider.sendMessageOnline(userId, message.smallmessage, siteId);
}
} catch (error) {
if (!this.utils.isWebServiceError(error)) {
// Error sending, stop execution.
if (this.appProvider.isOnline()) {
// App is online, unmark deviceoffline if marked.
this.messagesOffline.setMessagesDeviceOffline(messages, false);
}
return subPromise.catch((error) => {
if (this.utils.isWebServiceError(error)) {
// Error returned by WS. Store the error to show a warning but keep sending messages.
if (errors.indexOf(error) == -1) {
errors.push(error);
}
throw error;
}
return;
}
// Error returned by WS. Store the error to show a warning but keep sending messages.
if (errors.indexOf(error) == -1) {
errors.push(error);
}
}
// Error sending, stop execution.
if (this.appProvider.isOnline()) {
// App is online, unmark deviceoffline if marked.
this.messagesOffline.setMessagesDeviceOffline(messages, false);
}
// Message was sent, delete it from local DB.
if (conversationId) {
await this.messagesOffline.deleteConversationMessage(conversationId, message.text, message.timecreated, siteId);
} else {
await this.messagesOffline.deleteMessage(userId, message.smallmessage, message.timecreated, siteId);
}
return Promise.reject(error);
}).then(() => {
// Message was sent, delete it from local DB.
if (conversationId) {
return this.messagesOffline.deleteConversationMessage(conversationId, message.text,
message.timecreated, siteId);
} else {
return this.messagesOffline.deleteMessage(userId, message.smallmessage, message.timecreated, siteId);
}
}).then(() => {
// In some Moodle versions, wait 1 second to make sure timecreated is different.
// This is because there was a bug where messages with the same timecreated had a wrong order.
if (!groupMessagingEnabled && index < messages.length - 1) {
return new Promise((resolve, reject): any => {
setTimeout(resolve, 1000);
});
}
});
// In some Moodle versions, wait 1 second to make sure timecreated is different.
// This is because there was a bug where messages with the same timecreated had a wrong order.
if (!groupMessagingEnabled && i < messages.length - 1) {
await this.utils.wait(1000);
}
}
await this.handleSyncErrors(conversationId, userId, errors, warnings);
// All done, return the warnings.
return warnings;
}
/**
* Get messages sent by current user after a certain time.
*
* @param time Time in seconds.
* @param conversationId Conversation ID.
* @param userId User ID talking to (if no conversation ID).
* @param siteId Site ID.
* @return Promise resolved with the messages texts.
*/
protected async getMessagesSentAfter(time: number, conversationId: number, userId: number, siteId: string): Promise<string[]> {
const site = await this.sitesProvider.getSite(siteId);
const siteCurrentUserId = site.getUserId();
if (conversationId) {
try {
const result = await this.messagesProvider.getConversationMessages(conversationId, {
excludePending: true,
ignoreCache: true,
timeFrom: time,
});
});
return promise;
}).then((errors) => {
return this.handleSyncErrors(conversationId, userId, errors, warnings);
}).then(() => {
// All done, return the warnings.
return warnings;
});
const sentMessages = result.messages.filter((message) => message.useridfrom == siteCurrentUserId);
return this.addOngoingSync(syncId, syncPromise, siteId);
return sentMessages.map((message) => message.text);
} catch (error) {
if (error && error.errorcode == 'invalidresponse') {
// There's a bug in Moodle that causes this error if there are no new messages. Return empty array.
return [];
}
throw error;
}
} else {
const params = {
useridto: userId,
useridfrom: siteCurrentUserId,
limitnum: AddonMessagesProvider.LIMIT_MESSAGES,
};
const preSets = {
cacheKey: this.messagesProvider.getCacheKeyForDiscussion(userId),
ignoreCache: true,
};
const messages = await this.messagesProvider.getRecentMessages(params, preSets, 0, 0, false, siteId);
time = time * 1000; // Convert to milliseconds.
const messagesAfterTime = messages.filter((message) => message.timecreated >= time);
return messagesAfterTime.map((message) => message.text);
}
}
/**
@ -251,7 +305,7 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider {
* @param warnings Array where to place the warnings.
* @return Promise resolved when done.
*/
protected handleSyncErrors(conversationId: number, userId: number, errors: any[], warnings: any[]): Promise<any> {
protected handleSyncErrors(conversationId: number, userId: number, errors: any[], warnings: string[]): Promise<any> {
if (errors && errors.length) {
if (conversationId) {

View File

@ -7,7 +7,7 @@
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -175,7 +175,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
this.hasOffline = hasOffline;
// Get assignment submissions.
return this.assignProvider.getSubmissions(this.assign.id).then((data) => {
return this.assignProvider.getSubmissions(this.assign.id, {cmId: this.module.id}).then((data) => {
const time = this.timeUtils.timestamp();
this.canViewAllSubmissions = data.canviewsubmissions;
@ -217,7 +217,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
}
// Check if the user can view their own submission.
return this.assignProvider.getSubmissionStatus(this.assign.id).then(() => {
return this.assignProvider.getSubmissionStatus(this.assign.id, {cmId: this.module.id}).then(() => {
this.canViewOwnSubmission = true;
}).catch((error) => {
this.canViewOwnSubmission = false;
@ -241,7 +241,10 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
setGroup(groupId: number): Promise<any> {
this.group = groupId;
return this.assignProvider.getSubmissionStatus(this.assign.id, undefined, this.group).then((response) => {
return this.assignProvider.getSubmissionStatus(this.assign.id, {
groupId: this.group,
cmId: this.module.id,
}).then((response) => {
this.summary = response.gradingsummary;
if (typeof this.summary.warnofungroupedusers == 'boolean' && this.summary.warnofungroupedusers) {
this.summary.warnofungroupedusers = 'ungroupedusers';
@ -299,7 +302,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
*/
protected hasSyncSucceed(result: any): boolean {
if (result.updated) {
this.submissionComponent && this.submissionComponent.invalidateAndRefresh();
this.submissionComponent && this.submissionComponent.invalidateAndRefresh(false);
}
return result.updated;
@ -324,7 +327,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
}
return Promise.all(promises).finally(() => {
this.submissionComponent && this.submissionComponent.invalidateAndRefresh();
this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true);
});
}

View File

@ -20,7 +20,7 @@
</ion-item>
<!-- Tabs: see the submission or grade it. -->
<core-tabs [selectedIndex]="selectedTab" [hideUntil]="loaded" parentScrollable="true">
<core-tabs [selectedIndex]="selectedTab" [hideUntil]="loaded" parentScrollable="true" (ionChange)="tabSelected($event)">
<!-- View the submission tab. -->
<core-tab [title]="'addon.mod_assign.submission' | translate">
<ng-template>

View File

@ -16,7 +16,7 @@ import { Component, Input, OnInit, OnDestroy, ViewChild, Optional, ViewChildren,
import { NavController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreEventsProvider, CoreEventObserver } from '@providers/events';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreLangProvider } from '@providers/lang';
import { CoreSitesProvider } from '@providers/sites';
@ -35,7 +35,9 @@ import {
} from '../../providers/assign';
import { AddonModAssignHelperProvider } from '../../providers/helper';
import { AddonModAssignOfflineProvider } from '../../providers/assign-offline';
import { AddonModAssignSync, AddonModAssignSyncProvider } from '../../providers/assign-sync';
import { CoreTabsComponent } from '@components/tabs/tabs';
import { CoreTabComponent } from '@components/tabs/tab';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { AddonModAssignSubmissionPluginComponent } from '../submission-plugin/submission-plugin';
@ -107,6 +109,8 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
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.
protected syncObserver: CoreEventObserver;
protected hasOfflineGrade = false;
constructor(protected navCtrl: NavController, protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider,
sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, protected timeUtils: CoreTimeUtilsProvider,
@ -129,7 +133,29 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
this.selectedTab = this.showGrade && this.showGrade !== 'false' ? 1 : 0;
this.isSubmittedForGrading = !!this.submitId;
this.loadData();
this.loadData(true);
// Refresh data if this assign is synchronized and it's grading.
const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED];
this.syncObserver = this.eventsProvider.onMultiple(events, async (data) => {
// Check that user is grading and this grade wasn't blocked when sync was performed.
if (!this.loaded || !this.isGrading || data.gradesBlocked.indexOf(this.submitId) != -1) {
return;
}
if (data.context == 'submission' && data.submitId == this.submitId) {
// Manual sync triggered by this same submission, ignore it.
return;
}
// Don't refresh if the user has modified some data.
const hasDataToSave = await this.hasDataToSave();
if (!hasDataToSave) {
this.invalidateAndRefresh(false);
}
}, this.siteId);
}
/**
@ -241,7 +267,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
}, this.siteId);
} else {
// Invalidate and refresh data to update this view.
this.invalidateAndRefresh();
this.invalidateAndRefresh(true);
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'core.error', true);
@ -281,17 +307,23 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
/**
* Check if there's data to save (grade).
*
* @param isSubmit Whether the user is about to submit the grade.
* @return Promise resolved with boolean: whether there's data to save.
*/
protected hasDataToSave(): Promise<boolean> {
protected async hasDataToSave(isSubmit?: boolean): Promise<boolean> {
if (!this.canSaveGrades || !this.loaded) {
return Promise.resolve(false);
return false;
}
if (isSubmit && this.hasOfflineGrade) {
// Always allow sending if the grade is saved in offline.
return true;
}
// 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);
return true;
}
// Check if outcomes changed.
@ -301,20 +333,21 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
if (this.originalGrades.outcomes[outcome.id] == 'undefined' ||
this.originalGrades.outcomes[outcome.id] != outcome.selectedId) {
return Promise.resolve(true);
return true;
}
}
}
if (this.feedback && this.feedback.plugins) {
return this.assignHelper.hasFeedbackDataChanged(this.assign, this.userSubmission, this.feedback, this.submitId)
.catch(() => {
try {
return this.assignHelper.hasFeedbackDataChanged(this.assign, this.userSubmission, this.feedback, this.submitId);
} catch (error) {
// Error ocurred, consider there are no changes.
return false;
});
}
}
return Promise.resolve(false);
return false;
}
/**
@ -334,9 +367,10 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
/**
* Invalidate and refresh data.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
invalidateAndRefresh(): Promise<any> {
invalidateAndRefresh(sync?: boolean): Promise<any> {
this.loaded = false;
const promises = [];
@ -361,16 +395,17 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
return Promise.all(promises).catch(() => {
// Ignore errors.
}).then(() => {
return this.loadData();
return this.loadData(sync);
});
}
/**
* Load the data to render the submission.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
protected loadData(): Promise<any> {
protected async loadData(sync?: boolean): Promise<any> {
let isBlind = !!this.blindId;
this.previousAttempt = undefined;
@ -381,44 +416,53 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
isBlind = false;
}
// Get the assignment.
return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => {
const time = this.timeUtils.timestamp(),
promises = [];
try {
// Get the assignment.
this.assign = await this.assignProvider.getAssignment(this.courseId, this.moduleId);
this.assign = assign;
if (this.submitId != this.currentUserId && sync) {
// Teacher viewing a student submission. Try to sync the assign, there could be offline grades stored.
try {
const result = await AddonModAssignSync.instance.syncAssign(this.assign.id);
if (assign.allowsubmissionsfromdate && assign.allowsubmissionsfromdate >= time) {
this.fromDate = this.timeUtils.userDate(assign.allowsubmissionsfromdate * 1000);
if (result && result.updated) {
this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, {
assignId: this.assign.id,
warnings: result.warnings,
gradesBlocked: result.gradesBlocked,
context: 'submission',
submitId: this.submitId,
}, this.siteId);
}
} catch (error) {
// Ignore errors, probably user is offline or sync is blocked.
}
}
const time = this.timeUtils.timestamp();
let promises = [];
if (this.assign.allowsubmissionsfromdate && this.assign.allowsubmissionsfromdate >= time) {
this.fromDate = this.timeUtils.userDate(this.assign.allowsubmissionsfromdate * 1000);
}
this.currentAttempt = 0;
this.maxAttemptsText = this.translate.instant('addon.mod_assign.unlimitedattempts');
this.blindMarking = this.isSubmittedForGrading && assign.blindmarking && !assign.revealidentities;
this.blindMarking = this.isSubmittedForGrading && this.assign.blindmarking && !this.assign.revealidentities;
if (!this.blindMarking && this.submitId != this.currentUserId) {
promises.push(this.userProvider.getProfile(this.submitId, this.courseId).then((profile) => {
this.user = profile;
}));
promises.push(this.loadSubmissionUserProfile());
}
// 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;
}));
promises.push(this.loadSubmissionOfflineData());
await Promise.all(promises);
return Promise.all(promises);
}).then(() => {
// Get submission status.
return this.assignProvider.getSubmissionStatusWithRetry(this.assign, this.submitId, undefined, isBlind);
}).then((response) => {
const response = await this.assignProvider.getSubmissionStatusWithRetry(this.assign, {userId: this.submitId, isBlind});
const promises = [];
promises = [];
this.submissionStatusAvailable = true;
this.lastAttempt = response.lastattempt;
@ -450,16 +494,41 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
}
// Get the submission plugins that don't support editing.
promises.push(this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins).then((list) => {
this.unsupportedEditPlugins = list;
}));
promises.push(this.loadUnsupportedPlugins());
return Promise.all(promises);
}).catch((error) => {
await Promise.all(promises);
} catch (error) {
this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.');
}).finally(() => {
} finally {
this.loaded = true;
});
}
}
/**
* Load profile of submission's user.
*
* @return Promise resolved when done.
*/
protected async loadSubmissionUserProfile(): Promise<void> {
this.user = await this.userProvider.getProfile(this.submitId, this.courseId);
}
/**
* Load offline data for the submission (not the submission grade).
*
* @return Promise resolved when done.
*/
protected async loadSubmissionOfflineData(): Promise<void> {
try {
const data = await this.assignOfflineProvider.getSubmission(this.assign.id, this.submitId);
this.hasOffline = data && data.plugindata && Object.keys(data.plugindata).length > 0;
this.submittedOffline = data && data.submitted;
} catch (error) {
// No offline data found.
this.hasOffline = false;
this.submittedOffline = false;
}
}
/**
@ -537,11 +606,6 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
// Make sure outcomes is an array.
gradeInfo.outcomes = gradeInfo.outcomes || [];
if (!this.isDestroyed) {
// Block the assignment.
this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, this.assign.id);
}
// Treat the grade info.
return this.treatGradeInfo();
}).then(() => {
@ -589,11 +653,13 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
return this.assignOfflineProvider.getSubmissionGrade(this.assign.id, this.submitId).catch(() => {
// Grade not found.
}).then((data) => {
this.hasOfflineGrade = false;
// 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.hasOfflineGrade = true;
this.grade.grade = !this.grade.scale ? this.utils.formatFloat(data.grade) : data.grade;
this.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced';
this.gradingColor = '';
@ -627,6 +693,15 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
});
}
/**
* Get the submission plugins that don't support editing.
*
* @return Promise resolved when done.
*/
protected async loadUnsupportedPlugins(): Promise<void> {
this.unsupportedEditPlugins = await this.assignProvider.getUnsupportedEditPlugins(this.userSubmission.plugins);
}
/**
* Set the submission status name and class.
*
@ -725,7 +800,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
*/
submitGrade(): Promise<any> {
// Check if there's something to be saved.
return this.hasDataToSave().then((modified) => {
return this.hasDataToSave(true).then((modified) => {
if (!modified) {
return;
}
@ -764,7 +839,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
return this.discardDrafts();
}).finally(() => {
// Invalidate and refresh data.
this.invalidateAndRefresh();
this.invalidateAndRefresh(true);
this.eventsProvider.trigger(AddonModAssignProvider.GRADED_EVENT, {
assignmentId: this.assign.id,
@ -921,7 +996,9 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
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) => {
promises.push(this.assignProvider.getAssignmentUserMappings(this.assign.id, member, {
cmId: this.moduleId,
}).then((blindId) => {
this.membersToSubmit.push(blindId);
}));
} else {
@ -952,15 +1029,42 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy {
}
}
/**
* Block or unblock the automatic sync of the user grade.
*
* @param block Whether to block or unblock.
*/
protected setGradeSyncBlocked(block?: boolean): void {
if (this.isDestroyed || !this.assign || !this.isGrading) {
return;
}
const syncId = AddonModAssignSync.instance.getGradeSyncId(this.assign.id, this.submitId);
if (block) {
this.syncProvider.blockOperation(AddonModAssignProvider.COMPONENT, syncId);
} else {
this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, syncId);
}
}
/**
* A certain tab has been selected, either manually or automatically.
*
* @param tab The tab that was selected.
*/
tabSelected(tab: CoreTabComponent): void {
// Block sync when selecting grade tab, unblock when leaving it.
this.setGradeSyncBlocked(this.tabs.getIndex(tab) === 1);
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.setGradeSyncBlocked(false);
this.isDestroyed = true;
if (this.assign && this.isGrading) {
this.syncProvider.unblockOperation(AddonModAssignProvider.COMPONENT, this.assign.id);
}
this.syncObserver && this.syncObserver.off();
}
}

View File

@ -89,6 +89,7 @@
"submitassignment": "Submit assignment",
"submittedearly": "Assignment was submitted {{$a}} early",
"submittedlate": "Assignment was submitted {{$a}} late",
"syncblockedusercomponent": "user grade",
"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.",

View File

@ -16,7 +16,7 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/co
import { IonicPage, NavController, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/helper';
@ -125,11 +125,20 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy {
}).then(() => {
// Get submission status. Ignore cache to get the latest data.
return this.assignProvider.getSubmissionStatus(this.assign.id, this.userId, undefined, this.isBlind, false, true)
.catch((err) => {
const options = {
userId: this.userId,
isBlind: this.isBlind,
cmId: this.assign.cmid,
filter: false,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
};
return this.assignProvider.getSubmissionStatus(this.assign.id, options).catch((err) => {
// Cannot connect. Get cached data.
return this.assignProvider.getSubmissionStatus(this.assign.id, this.userId, undefined, this.isBlind)
.then((response) => {
options.filter = true;
options.readingStrategy = CoreSitesReadingStrategy.PreferCache;
return this.assignProvider.getSubmissionStatus(this.assign.id, options).then((response) => {
const userSubmission = this.assignProvider.getSubmissionObjectFromAttempt(this.assign, response.lastattempt);
// Check if the user can edit it in offline.

View File

@ -15,7 +15,7 @@
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 { CoreEventsProvider, CoreEventObserver } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups';
@ -23,6 +23,7 @@ import {
AddonModAssignProvider, AddonModAssignAssign, AddonModAssignGrade, AddonModAssignSubmission
} from '../../providers/assign';
import { AddonModAssignOfflineProvider } from '../../providers/assign-offline';
import { AddonModAssignSyncProvider, AddonModAssignSync } from '../../providers/assign-sync';
import { AddonModAssignHelperProvider, AddonModAssignSubmissionFormatted } from '../../providers/helper';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
@ -54,10 +55,11 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
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.
protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes.
protected syncObserver: CoreEventObserver; // OObserver to refresh data when the async is synchronized.
protected submissionsData: {canviewsubmissions: boolean, submissions?: AddonModAssignSubmission[]};
constructor(navParams: NavParams, protected sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider,
constructor(navParams: NavParams, protected sitesProvider: CoreSitesProvider, protected eventsProvider: CoreEventsProvider,
protected domUtils: CoreDomUtilsProvider, protected translate: TranslateService,
protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider,
protected assignHelper: AddonModAssignHelperProvider, protected groupsProvider: CoreGroupsProvider) {
@ -79,22 +81,37 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
// 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()) {
if (this.loaded && this.assign && data.assignmentId == this.assign.id &&
data.userId == sitesProvider.getCurrentSiteUserId()) {
// Grade changed, refresh the data.
this.loaded = false;
this.refreshAllData().finally(() => {
this.refreshAllData(true).finally(() => {
this.loaded = true;
});
}
}, sitesProvider.getCurrentSiteId());
// Refresh data if this assign is synchronized.
const events = [AddonModAssignSyncProvider.AUTO_SYNCED, AddonModAssignSyncProvider.MANUAL_SYNCED];
this.syncObserver = eventsProvider.onMultiple(events, (data) => {
if (!this.loaded || data.context == 'submission-list') {
return;
}
this.loaded = false;
this.refreshAllData(false).finally(() => {
this.loaded = true;
});
}, this.sitesProvider.getCurrentSiteId());
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.fetchAssignment().finally(() => {
this.fetchAssignment(true).finally(() => {
if (!this.selectedSubmissionId && this.splitviewCtrl.isOn() && this.submissions.length > 0) {
// Take first and load it.
this.loadSubmission(this.submissions[0]);
@ -107,34 +124,49 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
/**
* Fetch assignment data.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
protected fetchAssignment(): Promise<any> {
protected async fetchAssignment(sync?: boolean): Promise<void> {
try {
// Get assignment data.
this.assign = await this.assignProvider.getAssignment(this.courseId, this.moduleId);
// Get assignment data.
return this.assignProvider.getAssignment(this.courseId, this.moduleId).then((assign) => {
this.title = assign.name || this.title;
this.assign = assign;
this.title = this.assign.name || this.title;
// 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);
if (sync) {
try {
// Try to synchronize data.
const result = await AddonModAssignSync.instance.syncAssign(this.assign.id);
if (result && result.updated) {
this.eventsProvider.trigger(AddonModAssignSyncProvider.MANUAL_SYNCED, {
assignId: this.assign.id,
warnings: result.warnings,
gradesBlocked: result.gradesBlocked,
context: 'submission-list',
}, this.sitesProvider.getCurrentSiteId());
}
} catch (error) {
// Ignore errors, probably user is offline or sync is blocked.
}
}
this.submissionsData = data;
// Get assignment submissions.
this.submissionsData = await this.assignProvider.getSubmissions(this.assign.id, {cmId: this.assign.cmid});
if (!this.submissionsData.canviewsubmissions) {
// User shouldn't be able to reach here.
throw new Error('Cannot view submissions.');
}
// Check if groupmode is enabled to avoid showing wrong numbers.
return this.groupsProvider.getActivityGroupInfo(this.assign.cmid, false).then((groupInfo) => {
this.groupInfo = groupInfo;
this.groupInfo = await this.groupsProvider.getActivityGroupInfo(this.assign.cmid, false);
return this.setGroup(this.groupsProvider.validateGroupId(this.groupId, groupInfo));
});
}).catch((error) => {
await this.setGroup(this.groupsProvider.validateGroupId(this.groupId, this.groupInfo));
} catch (error) {
this.domUtils.showErrorModalDefault(error, 'Error getting assigment data.');
});
}
}
/**
@ -160,7 +192,8 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
const promises = [
this.assignHelper.getSubmissionsUserData(this.assign, this.submissionsData.submissions, this.groupId),
// Get assignment grades only if workflow is not enabled to check grading date.
!this.assign.markingworkflow ? this.assignProvider.getAssignmentGrades(this.assign.id) : Promise.resolve(null),
!this.assign.markingworkflow ? this.assignProvider.getAssignmentGrades(this.assign.id, {cmId: this.assign.cmid}) :
Promise.resolve(null),
];
return Promise.all(promises).then(([submissions, grades]: [AddonModAssignSubmissionFormatted[], AddonModAssignGrade[]]) => {
@ -265,9 +298,10 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
/**
* Refresh all the data.
*
* @param sync Whether to try to synchronize data.
* @return Promise resolved when done.
*/
protected refreshAllData(): Promise<any> {
protected refreshAllData(sync?: boolean): Promise<any> {
const promises = [];
promises.push(this.assignProvider.invalidateAssignmentData(this.courseId));
@ -279,7 +313,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
}
return Promise.all(promises).finally(() => {
return this.fetchAssignment();
return this.fetchAssignment(sync);
});
}
@ -289,7 +323,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
* @param refresher Refresher.
*/
refreshList(refresher: any): void {
this.refreshAllData().finally(() => {
this.refreshAllData(true).finally(() => {
refresher.complete();
});
}
@ -299,6 +333,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy {
*/
ngOnDestroy(): void {
this.gradedObserver && this.gradedObserver.off();
this.syncObserver && this.syncObserver.off();
}
}

View File

@ -137,7 +137,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit {
}
return Promise.all(promises).finally(() => {
this.submissionComponent && this.submissionComponent.invalidateAndRefresh();
this.submissionComponent && this.submissionComponent.invalidateAndRefresh(true);
return this.fetchSubmission();
});

View File

@ -17,7 +17,7 @@ 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 { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
@ -25,12 +25,14 @@ import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { CoreGradesHelperProvider } from '@core/grades/providers/helper';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { AddonModAssignProvider, AddonModAssignAssign } from './assign';
import { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync';
import { AddonModAssignProvider, AddonModAssignAssign, AddonModAssignSubmission } from './assign';
import { AddonModAssignOfflineProvider } from './assign-offline';
import { AddonModAssignSubmissionDelegate } from './submission-delegate';
import { AddonModAssignFeedbackDelegate } from './feedback-delegate';
import { makeSingleton } from '@singletons/core.singletons';
/**
* Data returned by an assign sync.
*/
@ -44,6 +46,11 @@ export interface AddonModAssignSyncResult {
* Whether data was updated in the site.
*/
updated: boolean;
/**
* Whether some grade couldn't be synced because it was blocked.
*/
gradesBlocked: number[];
}
/**
@ -53,6 +60,7 @@ export interface AddonModAssignSyncResult {
export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_assign_autom_synced';
static MANUAL_SYNCED = 'addon_mod_assign_manual_synced';
protected componentTranslate: string;
@ -79,6 +87,17 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
this.componentTranslate = courseProvider.translateModuleName('assign');
}
/**
* Get the sync ID for a certain user grade.
*
* @param assignId Assign ID.
* @param userId User the grade belongs to.
* @return Sync ID.
*/
getGradeSyncId(assignId: number, userId: number): string {
return 'assignGrade#' + assignId + '#' + userId;
}
/**
* Convenience function to get scale selected option.
*
@ -121,7 +140,7 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllAssignments(siteId?: string, force?: boolean): Promise<any> {
syncAllAssignments(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [force], siteId);
}
@ -132,26 +151,25 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param force Wether to force sync not depending on last execution.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise<any> {
protected async syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise<void> {
// Get all assignments that have offline data.
return this.assignOfflineProvider.getAllAssigns(siteId).then((assignIds) => {
// Sync all assignments that haven't been synced for a while.
const promises = assignIds.map((assignId) => {
const promise = force ? this.syncAssign(assignId, siteId) : this.syncAssignIfNeeded(assignId, siteId);
const assignIds = await this.assignOfflineProvider.getAllAssigns(siteId);
return promise.then((data) => {
if (data && data.updated) {
// Sync done. Send event.
this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, {
assignId: assignId,
warnings: data.warnings
}, siteId);
}
});
});
// Try to sync all assignments.
await Promise.all(assignIds.map(async (assignId) => {
const data = force ? await this.syncAssign(assignId, siteId) : await this.syncAssignIfNeeded(assignId, siteId);
return Promise.all(promises);
});
if (!data || !data.updated) {
// Not updated.
return;
}
this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, {
assignId: assignId,
warnings: data.warnings,
gradesBlocked: data.gradesBlocked,
}, siteId);
}));
}
/**
@ -161,12 +179,12 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the assign is synced or it doesn't need to be synced.
*/
syncAssignIfNeeded(assignId: number, siteId?: string): Promise<void | AddonModAssignSyncResult> {
return this.isSyncNeeded(assignId, siteId).then((needed) => {
if (needed) {
return this.syncAssign(assignId, siteId);
}
});
async syncAssignIfNeeded(assignId: number, siteId?: string): Promise<void | AddonModAssignSyncResult> {
const needed = await this.isSyncNeeded(assignId, siteId);
if (needed) {
return this.syncAssign(assignId, siteId);
}
}
/**
@ -176,18 +194,9 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
async syncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const promises: Promise<any>[] = [],
result: AddonModAssignSyncResult = {
warnings: [],
updated: false
};
let assign: AddonModAssignAssign,
courseId: number,
syncPromise: Promise<any>;
if (this.isSyncing(assignId, siteId)) {
// There's already a sync ongoing for this assign, return the promise.
return this.getOngoingSync(assignId, siteId);
@ -195,79 +204,126 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
// 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.');
this.logger.error('Cannot sync assign ' + assignId + ' because it is blocked.');
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
throw new CoreSyncBlockedError(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
}
return this.addOngoingSync(assignId, this.performSyncAssign(assignId, siteId), siteId);
}
/**
* Perform the assign submission.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
protected async performSyncAssign(assignId: number, siteId?: string): Promise<AddonModAssignSyncResult> {
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 [];
}));
const result: AddonModAssignSyncResult = {
warnings: [],
updated: false,
gradesBlocked: [],
};
// Get offline submission grades to be sent.
promises.push(this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId).catch(() => {
// No offline data found, return empty array.
return [];
}));
// Load offline data and sync offline logs.
const promisesResults = await Promise.all([
this.getOfflineSubmissions(assignId, siteId),
this.getOfflineGrades(assignId, siteId),
this.logHelper.syncIfNeeded(AddonModAssignProvider.COMPONENT, assignId, siteId),
]);
// Sync offline logs.
promises.push(this.logHelper.syncIfNeeded(AddonModAssignProvider.COMPONENT, assignId, siteId));
const submissions = promisesResults[0];
const grades = promisesResults[1];
syncPromise = Promise.all(promises).then((results) => {
const submissions = results[0],
grades = results[1];
if (!submissions.length && !grades.length) {
// Nothing to sync.
await this.utils.ignoreErrors(this.setSyncTime(assignId, siteId));
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, false, 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;
});
} else if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
throw new Error(this.translate.instant('core.cannotconnect'));
}
return this.addOngoingSync(assignId, syncPromise, siteId);
const courseId = submissions.length > 0 ? submissions[0].courseid : grades[0].courseid;
const assign = await this.assignProvider.getAssignmentById(courseId, assignId, {siteId});
let promises = [];
promises = promises.concat(submissions.map(async (submission) => {
await this.syncSubmission(assign, submission, result.warnings, siteId);
result.updated = true;
}));
promises = promises.concat(grades.map(async (grade) => {
try {
await this.syncSubmissionGrade(assign, grade, result.warnings, courseId, siteId);
result.updated = true;
} catch (error) {
if (error instanceof CoreSyncBlockedError) {
// Grade blocked, but allow finish the sync.
result.gradesBlocked.push(grade.userid);
} else {
throw error;
}
}
}));
await Promise.all(promises);
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
await this.utils.ignoreErrors(this.assignProvider.invalidateContent(assign.cmid, courseId, siteId));
}
// Sync finished, set sync time.
await this.utils.ignoreErrors(this.setSyncTime(assignId, siteId));
// All done, return the result.
return result;
}
/**
* Get offline grades to be sent.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise with grades.
*/
protected async getOfflineGrades(assignId: number, siteId: string): Promise<any[]> {
try {
const submissions = await this.assignOfflineProvider.getAssignSubmissionsGrade(assignId, siteId);
return submissions;
} catch (error) {
// No offline data found, return empty array.
return [];
}
}
/**
* Get offline submissions to be sent.
*
* @param assignId Assign ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise with submissions.
*/
protected async getOfflineSubmissions(assignId: number, siteId: string): Promise<any[]> {
try {
const submissions = await this.assignOfflineProvider.getAssignSubmissions(assignId, siteId);
return submissions;
} catch (error) {
// No offline data found, return empty array.
return [];
}
}
/**
@ -279,83 +335,82 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if success, rejected otherwise.
*/
protected syncSubmission(assign: AddonModAssignAssign, offlineData: any, warnings: string[], siteId?: string): Promise<any> {
const userId = offlineData.userid,
pluginData = {};
let discardError,
submission;
protected async syncSubmission(assign: AddonModAssignAssign, offlineData: any, warnings: string[], siteId?: string)
: Promise<void> {
return this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId).then((status) => {
const promises = [];
const userId = offlineData.userid;
const pluginData = {};
const options = {
userId,
cmId: assign.cmid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt);
const status = await this.assignProvider.getSubmissionStatus(assign.id, options);
if (submission.timemodified != offlineData.onlinetimemodified) {
// The submission was modified in Moodle, discard the submission.
discardError = this.translate.instant('addon.mod_assign.warningsubmissionmodified');
const submission = this.assignProvider.getSubmissionObjectFromAttempt(assign, status.lastattempt);
return;
if (submission.timemodified != offlineData.onlinetimemodified) {
// The submission was modified in Moodle, discard the submission.
this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name,
this.translate.instant('addon.mod_assign.warningsubmissionmodified'));
return this.deleteSubmissionData(assign, submission, offlineData, siteId);
}
try {
// Prepare plugins data.
await Promise.all(submission.plugins.map(async (plugin) => {
await this.submissionDelegate.preparePluginSyncData(assign, submission, plugin, offlineData, pluginData, siteId);
}));
// Now save the submission.
if (Object.keys(pluginData).length > 0) {
await this.assignProvider.saveSubmissionOnline(assign.id, pluginData, siteId);
}
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, undefined, 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 = this.textUtils.getErrorMessageFromError(error);
} 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);
}
if (assign.submissiondrafts && offlineData.submitted) {
// The user submitted the assign manually. Submit it for grading.
await this.assignProvider.submitForGradingOnline(assign.id, offlineData.submissionstatement, siteId);
}
});
// Submission data sent, update cached data. No need to block the user for this.
this.assignProvider.getSubmissionStatus(assign.id, options);
} catch (error) {
if (!error || !this.utils.isWebServiceError(error)) {
// Local error, reject.
throw error;
}
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name,
this.textUtils.getErrorMessageFromError(error));
}
// Delete the offline data.
await this.deleteSubmissionData(assign, submission, offlineData, siteId);
}
/**
* Delete the submission offline data (not grades).
*
* @param assign Assign.
* @param submission Submission.
* @param offlineData Offline data.
* @param siteId Site ID.
* @return Promise resolved when done.
*/
protected async deleteSubmissionData(assign: AddonModAssignAssign, submission: AddonModAssignSubmission, offlineData: any,
siteId?: string): Promise<void> {
// Delete the offline data.
await this.assignOfflineProvider.deleteSubmission(assign.id, offlineData.userid, siteId);
// Delete plugins data.
await Promise.all(submission.plugins.map(async (plugin) => {
await this.submissionDelegate.deletePluginOfflineData(assign, submission, plugin, offlineData, siteId);
}));
}
/**
@ -368,90 +423,95 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if success, rejected otherwise.
*/
protected syncSubmissionGrade(assign: AddonModAssignAssign, offlineData: any, warnings: string[], courseId: number,
protected async syncSubmissionGrade(assign: AddonModAssignAssign, offlineData: any, warnings: string[], courseId: number,
siteId?: string): Promise<any> {
const userId = offlineData.userid;
let discardError;
const syncId = this.getGradeSyncId(assign.id, userId);
const options = {
userId,
cmId: assign.cmid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
return this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId).then((status) => {
const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified);
// Check if this grade sync is blocked.
if (this.syncProvider.isBlocked(AddonModAssignProvider.COMPONENT, syncId, siteId)) {
this.logger.error(`Cannot sync grade for assign ${assign.id} and user ${userId} because it is blocked.!!!!`);
if (timemodified > offlineData.timemodified) {
// The submission grade was modified in Moodle, discard it.
discardError = this.translate.instant('addon.mod_assign.warningsubmissiongrademodified');
throw new CoreSyncBlockedError(this.translate.instant('core.errorsyncblocked',
{$a: this.translate.instant('addon.mod_assign.syncblockedusercomponent')}));
}
return;
}
const status = await this.assignProvider.getSubmissionStatus(assign.id, options);
// 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) => {
const timemodified = status.feedback && (status.feedback.gradeddate || status.feedback.grade.timemodified);
// 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);
}
});
}
if (timemodified > offlineData.timemodified) {
// The submission grade was modified in Moodle, discard it.
this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name,
this.translate.instant('addon.mod_assign.warningsubmissiongrademodified'));
return this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId);
}
// If grade has been modified from gradebook, do not use offline.
const grades = await this.gradesHelper.getGradeModuleItems(courseId, assign.cmid, userId, undefined, siteId, true);
const gradeInfo = await this.courseProvider.getModuleBasicGradeInfo(assign.cmid, siteId);
// 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.
// Discard grades drafts.
const promises = [];
if (status.feedback && status.feedback.plugins) {
status.feedback.plugins.forEach((plugin) => {
promises.push(this.feedbackDelegate.discardPluginFeedbackData(assign.id, userId, plugin, siteId));
});
}
// Update cached data.
promises.push(this.assignProvider.getSubmissionStatus(assign.id, userId, undefined, false, true, true, siteId));
return Promise.all(promises);
}).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 = this.textUtils.getErrorMessageFromError(error);
} 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);
}
}
});
try {
// Now submit the grade.
await this.assignProvider.submitGradingFormOnline(assign.id, userId, offlineData.grade, offlineData.attemptnumber,
offlineData.addattempt, offlineData.workflowstate, offlineData.applytoall, offlineData.outcomes,
offlineData.plugindata, siteId);
// Grades sent. Discard grades drafts.
const promises = [];
if (status.feedback && status.feedback.plugins) {
status.feedback.plugins.forEach((plugin) => {
promises.push(this.feedbackDelegate.discardPluginFeedbackData(assign.id, userId, plugin, siteId));
});
}
// Update cached data.
promises.push(this.assignProvider.getSubmissionStatus(assign.id, options));
await Promise.all(promises);
} catch (error) {
if (!error || !this.utils.isWebServiceError(error)) {
// Local error, reject.
throw error;
}
// A WebService has thrown an error, this means it cannot be submitted. Discard the submission.
this.addOfflineDataDeletedWarning(warnings, this.componentTranslate, assign.name,
this.textUtils.getErrorMessageFromError(error));
}
// Delete the offline data.
await this.assignOfflineProvider.deleteSubmissionGrade(assign.id, userId, siteId);
}
}
export class AddonModAssignSync extends makeSingleton(AddonModAssignSyncProvider) {}

View File

@ -16,7 +16,7 @@ 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 { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils';
@ -25,9 +25,10 @@ import { CoreGradesProvider } from '@core/grades/providers/grades';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { AddonModAssignSubmissionDelegate } from './submission-delegate';
import { AddonModAssignOfflineProvider } from './assign-offline';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSite } from '@classes/site';
import { CoreInterceptor } from '@classes/interceptor';
import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
/**
* Service that provides some functions for assign.
@ -143,12 +144,11 @@ export class AddonModAssignProvider {
*
* @param courseId Course ID the assignment belongs to.
* @param cmId Assignment module ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the assignment.
*/
getAssignment(courseId: number, cmId: number, ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignAssign> {
return this.getAssignmentByField(courseId, 'cmid', cmId, ignoreCache, siteId);
getAssignment(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModAssignAssign> {
return this.getAssignmentByField(courseId, 'cmid', cmId, options);
}
/**
@ -157,27 +157,23 @@ export class AddonModAssignProvider {
* @param courseId Course ID.
* @param key Name of the property to check.
* @param value Value to search.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the assignment is retrieved.
*/
protected getAssignmentByField(courseId: number, key: string, value: any, ignoreCache?: boolean, siteId?: string)
protected getAssignmentByField(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {})
: Promise<AddonModAssignAssign> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId],
includenotenrolledcourses: 1
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getAssignmentCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
courseids: [courseId],
includenotenrolledcourses: 1,
};
const preSets = {
cacheKey: this.getAssignmentCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModAssignProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_get_assignments', params, preSets).catch(() => {
// In 3.6 we added a new parameter includenotenrolledcourses that could cause offline data not to be found.
@ -206,13 +202,12 @@ export class AddonModAssignProvider {
* Get an assignment by instance ID.
*
* @param courseId Course ID the assignment belongs to.
* @param cmId Assignment instance ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param id Assignment instance ID.
* @param options Other options.
* @return Promise resolved with the assignment.
*/
getAssignmentById(courseId: number, id: number, ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignAssign> {
return this.getAssignmentByField(courseId, 'id', id, ignoreCache, siteId);
getAssignmentById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModAssignAssign> {
return this.getAssignmentByField(courseId, 'id', id, options);
}
/**
@ -230,24 +225,22 @@ export class AddonModAssignProvider {
*
* @param assignId Assignment Id.
* @param userId User Id to be blinded.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the user blind id.
*/
getAssignmentUserMappings(assignId: number, userId: number, ignoreCache?: boolean, siteId?: string): Promise<number> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
assignmentids: [assignId]
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getAssignmentUserMappingsCacheKey(assignId),
updateFrequency: CoreSite.FREQUENCY_OFTEN
};
getAssignmentUserMappings(assignId: number, userId: number, options: CoreCourseCommonModWSOptions = {}): Promise<number> {
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
assignmentids: [assignId],
};
const preSets = {
cacheKey: this.getAssignmentUserMappingsCacheKey(assignId),
updateFrequency: CoreSite.FREQUENCY_OFTEN,
component: AddonModAssignProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_get_user_mappings', params, preSets)
.then((response: AddonModAssignGetUserMappingsResult): any => {
@ -293,23 +286,21 @@ export class AddonModAssignProvider {
* Returns grade information from assign_grades for the requested assignment id
*
* @param assignId Assignment Id.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Resolved with requested info when done.
*/
getAssignmentGrades(assignId: number, ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignGrade[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
assignmentids: [assignId]
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getAssignmentGradesCacheKey(assignId)
};
getAssignmentGrades(assignId: number, options: CoreCourseCommonModWSOptions = {}): Promise<AddonModAssignGrade[]> {
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
assignmentids: [assignId],
};
const preSets = {
cacheKey: this.getAssignmentGradesCacheKey(assignId),
component: AddonModAssignProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_get_grades', params, preSets).then((response: AddonModAssignGetGradesResult): any => {
// Search the assignment.
@ -455,26 +446,23 @@ export class AddonModAssignProvider {
* Get an assignment submissions.
*
* @param assignId Assignment id.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when done.
*/
getSubmissions(assignId: number, ignoreCache?: boolean, siteId?: string)
getSubmissions(assignId: number, options: CoreCourseCommonModWSOptions = {})
: Promise<{canviewsubmissions: boolean, submissions?: AddonModAssignSubmission[]}> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
assignmentids: [assignId]
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getSubmissionsCacheKey(assignId),
updateFrequency: CoreSite.FREQUENCY_OFTEN
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
assignmentids: [assignId],
};
const preSets = {
cacheKey: this.getSubmissionsCacheKey(assignId),
updateFrequency: CoreSite.FREQUENCY_OFTEN,
component: AddonModAssignProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_get_submissions', params, preSets)
.then((response: AddonModAssignGetSubmissionsResult): any => {
@ -510,46 +498,40 @@ export class AddonModAssignProvider {
* Get information about an assignment submission status for a given user.
*
* @param assignId Assignment instance id.
* @param userId User Id (empty for current user).
* @param groupId Group Id (empty for all participants).
* @param isBlind If blind marking is enabled or not.
* @param filter True to filter WS response and rewrite URLs, false otherwise.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site id (empty for current site).
* @param options Other options.
* @return Promise always resolved with the user submission status.
*/
getSubmissionStatus(assignId: number, userId?: number, groupId?: number, isBlind?: boolean, filter: boolean = true,
ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignGetSubmissionStatusResult> {
getSubmissionStatus(assignId: number, options: AddonModAssignSubmissionStatusOptions = {})
: Promise<AddonModAssignGetSubmissionStatusResult> {
return this.sitesProvider.getSite(siteId).then((site) => {
const fixedParams = this.fixSubmissionStatusParams(site, userId, groupId, isBlind);
if (options.filter === undefined || options.filter === null) {
options.filter = true;
}
return this.sitesProvider.getSite(options.siteId).then((site) => {
const fixedParams = this.fixSubmissionStatusParams(site, options.userId, options.groupId, options.isBlind);
const params = {
assignid: assignId,
userid: fixedParams.userId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getSubmissionStatusCacheKey(assignId, fixedParams.userId, fixedParams.groupId,
fixedParams.isBlind),
getCacheUsingCacheKey: true, // We use the cache key to take isBlind into account.
filter: filter,
rewriteurls: filter
};
assignid: assignId,
userid: fixedParams.userId,
};
if (fixedParams.groupId) {
params['groupid'] = fixedParams.groupId;
}
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
if (!filter) {
const preSets = {
cacheKey: this.getSubmissionStatusCacheKey(assignId, fixedParams.userId, fixedParams.groupId,
fixedParams.isBlind),
getCacheUsingCacheKey: true, // We use the cache key to take isBlind into account.
filter: options.filter,
rewriteurls: options.filter,
component: AddonModAssignProvider.COMPONENT,
componentId: options.cmId,
// Don't cache when getting text without filters.
// @todo Change this to support offline editing.
preSets.saveToCache = false;
}
saveToCache: options.filter,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_get_submission_status', params, preSets);
});
@ -560,23 +542,24 @@ export class AddonModAssignProvider {
* If the data doesn't include the user submission, retry ignoring cache.
*
* @param assign Assignment.
* @param userId User id (empty for current user).
* @param groupId Group Id (empty for all participants).
* @param isBlind If blind marking is enabled or not.
* @param filter True to filter WS response and rewrite URLs, false otherwise.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site id (empty for current site).
* @param options Other options.
* @return Promise always resolved with the user submission status.
*/
getSubmissionStatusWithRetry(assign: any, userId?: number, groupId?: number, isBlind?: boolean, filter: boolean = true,
ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignGetSubmissionStatusResult> {
getSubmissionStatusWithRetry(assign: any, options: AddonModAssignSubmissionStatusOptions = {})
: Promise<AddonModAssignGetSubmissionStatusResult> {
options.cmId = options.cmId || assign.cmid;
return this.getSubmissionStatus(assign.id, userId, groupId, isBlind, filter, ignoreCache, siteId).then((response) => {
return this.getSubmissionStatus(assign.id, options).then((response) => {
const userSubmission = this.getSubmissionObjectFromAttempt(assign, response.lastattempt);
if (!userSubmission) {
// Try again, ignoring cache.
return this.getSubmissionStatus(assign.id, userId, groupId, isBlind, filter, true, siteId).catch(() => {
const newOptions = {
...options, // Include all the original options.
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
};
return this.getSubmissionStatus(assign.id, newOptions).catch(() => {
// Error, return the first result even if it doesn't have the user submission.
return response;
});
@ -650,35 +633,32 @@ export class AddonModAssignProvider {
*
* @param assignId Assignment id.
* @param groupId Group id. If not defined, 0.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the list of participants and summary of submissions.
*/
listParticipants(assignId: number, groupId?: number, ignoreCache?: boolean, siteId?: string)
listParticipants(assignId: number, groupId?: number, options: CoreCourseCommonModWSOptions = {})
: Promise<AddonModAssignParticipant[]> {
groupId = groupId || 0;
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.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: CoreSiteWSPreSets = {
cacheKey: this.listParticipantsCacheKey(assignId, groupId),
updateFrequency: CoreSite.FREQUENCY_OFTEN
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
assignid: assignId,
groupid: groupId,
filter: '',
};
const preSets = {
cacheKey: this.listParticipantsCacheKey(assignId, groupId),
updateFrequency: CoreSite.FREQUENCY_OFTEN,
component: AddonModAssignProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_assign_list_participants', params, preSets);
});
@ -769,7 +749,7 @@ export class AddonModAssignProvider {
invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
return this.getAssignment(courseId, moduleId, false, siteId).then((assign) => {
return this.getAssignment(courseId, moduleId, {siteId}).then((assign) => {
const promises = [];
// Do not invalidate assignment data before getting assignment info, we need it!
@ -1014,7 +994,10 @@ export class AddonModAssignProvider {
}
// We need more data to decide that.
return this.getSubmissionStatus(assignId, submission.submitid, undefined, submission.blindid).then((response) => {
return this.getSubmissionStatus(assignId, {
userId: submission.submitid,
isBlind: !!submission.blindid,
}).then((response) => {
if (!response.feedback || !response.feedback.gradeddate) {
// Not graded.
return true;
@ -1304,6 +1287,16 @@ export class AddonModAssignProvider {
}
}
/**
* Options to pass to get submission status.
*/
export type AddonModAssignSubmissionStatusOptions = CoreCourseCommonModWSOptions & {
userId?: number; // User Id (empty for current user).
groupId?: number; // Group Id (empty for all participants).
isBlind?: boolean; // If blind marking is enabled or not.
filter?: boolean; // True to filter WS response and rewrite URLs, false otherwise. Defaults to true.
};
/**
* Assign data returned by mod_assign_get_assignments.
*/

View File

@ -16,7 +16,7 @@ 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 { CoreSitesProvider, CoreSitesCommonWSOptions } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { AddonModAssignFeedbackDelegate } from './feedback-delegate';
@ -209,29 +209,29 @@ export class AddonModAssignHelperProvider {
*
* @param assign Assignment object.
* @param groupId Group Id.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the list of participants and summary of submissions.
*/
getParticipants(assign: AddonModAssignAssign, groupId?: number, ignoreCache?: boolean, siteId?: string)
getParticipants(assign: AddonModAssignAssign, groupId?: number, options: CoreSitesCommonWSOptions = {})
: Promise<AddonModAssignParticipant[]> {
groupId = groupId || 0;
siteId = siteId || this.sitesProvider.getCurrentSiteId();
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
return this.assignProvider.listParticipants(assign.id, groupId, ignoreCache, siteId).then((participants) => {
const modOptions = {cmId: assign.cmid, ...options}; // Create new options including all existing ones.
return this.assignProvider.listParticipants(assign.id, groupId, modOptions).then((participants) => {
if (groupId || participants && participants.length > 0) {
return participants;
}
// If no participants returned and all groups specified, get participants by groups.
return this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((info) => {
return this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, modOptions.siteId).then((info) => {
const promises = [],
participants: {[id: number]: AddonModAssignParticipant} = {};
info.groups.forEach((userGroup) => {
promises.push(this.assignProvider.listParticipants(assign.id, userGroup.id, ignoreCache, siteId)
.then((parts) => {
promises.push(this.assignProvider.listParticipants(assign.id, userGroup.id, modOptions).then((parts) => {
// Do not get repeated users.
parts.forEach((participant) => {
participants[participant.id] = participant;
@ -355,14 +355,15 @@ export class AddonModAssignHelperProvider {
* @param assign Assignment object.
* @param submissions Submissions to get the data for.
* @param groupId Group Id.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site id (empty for current site).
* @param options Other options.
* @return Promise always resolved. Resolve param is the formatted submissions.
*/
getSubmissionsUserData(assign: AddonModAssignAssign, submissions: AddonModAssignSubmissionFormatted[], groupId?: number,
ignoreCache?: boolean, siteId?: string): Promise<AddonModAssignSubmissionFormatted[]> {
options: CoreSitesCommonWSOptions = {}): Promise<AddonModAssignSubmissionFormatted[]> {
return this.getParticipants(assign, groupId).then((parts) => {
const modOptions = {cmId: assign.cmid, ...options}; // Create new options including all existing ones.
return this.getParticipants(assign, groupId, modOptions).then((parts) => {
const blind = assign.blindmarking && !assign.revealidentities;
const promises = [];
const result: AddonModAssignSubmissionFormatted[] = [];
@ -399,8 +400,8 @@ export class AddonModAssignHelperProvider {
// Blind but not blinded! (Moodle < 3.1.1, 3.2).
delete submission.userid;
promise = this.assignProvider.getAssignmentUserMappings(assign.id, submission.submitid, ignoreCache, siteId).
then((blindId) => {
promise = this.assignProvider.getAssignmentUserMappings(assign.id, submission.submitid, modOptions)
.then((blindId) => {
submission.blindid = blindId;
});
}

View File

@ -17,7 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
@ -80,13 +80,13 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
canUseCheckUpdates(module: any, courseId: number): boolean | Promise<boolean> {
// 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 this.assignProvider.getSubmissions(assign.id, {cmId: module.id}).then((data) => {
if (data.canviewsubmissions) {
return false;
}
// Check if the user can view their own submission.
return this.assignProvider.getSubmissionStatus(assign.id).then(() => {
return this.assignProvider.getSubmissionStatus(assign.id, {cmId: module.id}).then(() => {
return true;
});
});
@ -108,18 +108,18 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
siteId = siteId || this.sitesProvider.getCurrentSiteId();
return this.assignProvider.getAssignment(courseId, module.id, false, siteId).then((assign) => {
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, false, siteId).then((data) => {
return this.assignProvider.getSubmissions(assign.id, {cmId: module.id, siteId}).then((data) => {
const blindMarking = assign.blindmarking && !assign.revealidentities;
if (data.canviewsubmissions) {
// Teacher, get all submissions.
return this.assignHelper.getSubmissionsUserData(assign, data.submissions, 0, false, siteId)
return this.assignHelper.getSubmissionsUserData(assign, data.submissions, 0, {siteId})
.then((submissions: AddonModAssignSubmissionFormatted[]) => {
const promises = [];
@ -172,8 +172,11 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
protected getSubmissionFiles(assign: any, submitId: number, blindMarking: boolean, siteId?: string)
: Promise<any[]> {
return this.assignProvider.getSubmissionStatusWithRetry(assign, submitId, undefined, blindMarking, true, false, siteId)
.then((response) => {
return this.assignProvider.getSubmissionStatusWithRetry(assign, {
userId: submitId,
isBlind: blindMarking,
siteId,
}).then((response) => {
const promises = [];
let userSubmission: AddonModAssignSubmission;
@ -261,20 +264,24 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
* @return Promise resolved when done.
*/
protected prefetchAssign(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
const userId = this.sitesProvider.getCurrentSiteUserId(),
promises = [];
const userId = this.sitesProvider.getCurrentSiteUserId();
const promises = [];
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const options = {
cmId: module.id,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
// Get assignment to retrieve all its submissions.
promises.push(this.assignProvider.getAssignment(courseId, module.id, true, siteId).then((assign) => {
promises.push(this.assignProvider.getAssignment(courseId, module.id, options).then((assign) => {
const subPromises = [],
blindMarking = assign.blindmarking && !assign.revealidentities;
if (blindMarking) {
subPromises.push(this.assignProvider.getAssignmentUserMappings(assign.id, undefined, true, siteId).catch(() => {
// Ignore errors.
}));
subPromises.push(this.utils.ignoreErrors(this.assignProvider.getAssignmentUserMappings(assign.id, -1, options)));
}
subPromises.push(this.prefetchSubmissions(assign, courseId, module.id, userId, siteId));
@ -304,8 +311,14 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
* @return Promise resolved when prefetched, rejected otherwise.
*/
protected prefetchSubmissions(assign: any, courseId: number, moduleId: number, userId: number, siteId: string): Promise<any> {
const options = {
cmId: moduleId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
// Get submissions.
return this.assignProvider.getSubmissions(assign.id, true, siteId).then((data) => {
return this.assignProvider.getSubmissions(assign.id, options).then((data) => {
const promises = [];
promises.push(this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((groupInfo) => {
@ -317,14 +330,22 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
}
groupInfo.groups.forEach((group) => {
groupProms.push(this.assignHelper.getSubmissionsUserData(assign, data.submissions, group.id, true, siteId)
groupProms.push(this.assignHelper.getSubmissionsUserData(assign, data.submissions, group.id, options)
.then((submissions: AddonModAssignSubmissionFormatted[]) => {
const subPromises = [];
submissions.forEach((submission) => {
subPromises.push(this.assignProvider.getSubmissionStatusWithRetry(assign, submission.submitid,
group.id, !!submission.blindid, true, true, siteId).then((subm) => {
const submissionOptions = {
userId: submission.submitid,
groupId: group.id,
isBlind: !!submission.blindid,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
subPromises.push(this.assignProvider.getSubmissionStatusWithRetry(assign, submissionOptions)
.then((subm) => {
return this.prefetchSubmission(assign, courseId, moduleId, subm, submission.submitid, siteId);
}).catch((error) => {
if (error && error.errorcode == 'nopermission') {
@ -338,14 +359,21 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
if (!assign.markingworkflow) {
// Get assignment grades only if workflow is not enabled to check grading date.
subPromises.push(this.assignProvider.getAssignmentGrades(assign.id, true, siteId));
subPromises.push(this.assignProvider.getAssignmentGrades(assign.id, options));
}
// Prefetch the submission of the current user even if it does not exist, this will be create it.
if (!data.submissions ||
!data.submissions.find((subm: AddonModAssignSubmissionFormatted) => subm.submitid == userId)) {
subPromises.push(this.assignProvider.getSubmissionStatusWithRetry(assign, userId, group.id,
false, true, true, siteId).then((subm) => {
const submissionOptions = {
userId,
groupId: group.id,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
subPromises.push(this.assignProvider.getSubmissionStatusWithRetry(assign, submissionOptions)
.then((subm) => {
return this.prefetchSubmission(assign, courseId, moduleId, subm, userId, siteId);
}));
}
@ -353,7 +381,7 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
return Promise.all(subPromises);
}).then(() => {
// Participiants already fetched, we don't need to ignore cache now.
return this.assignHelper.getParticipants(assign, group.id, false, siteId).then((participants) => {
return this.assignHelper.getParticipants(assign, group.id, {siteId}).then((participants) => {
return this.userProvider.prefetchUserAvatars(participants, 'profileimageurl', siteId);
}).catch(() => {
// Fail silently (Moodle < 3.2).
@ -367,8 +395,11 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan
// Prefetch own submission, we need to do this for teachers too so the response with error is cached.
promises.push(
this.assignProvider.getSubmissionStatusWithRetry(assign, userId, undefined, false, true, true, siteId)
.then((subm) => {
this.assignProvider.getSubmissionStatusWithRetry(assign, {
userId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
}).then((subm) => {
return this.prefetchSubmission(assign, courseId, moduleId, subm, userId, siteId);
}).catch((error) => {
// Ignore if the user can't view their own submission.

View File

@ -9,7 +9,7 @@
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'fa-newspaper-o'" (action)="gotoBlog($event)"></core-context-menu-item>
<core-context-menu-item [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="600" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="500" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="500" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -16,7 +16,7 @@ import { Injectable } from '@angular/core';
import { CoreFileProvider } from '@providers/file';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
@ -73,11 +73,11 @@ export class AddonModBookProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the book is retrieved.
*/
getBook(courseId: number, cmId: number, siteId?: string): Promise<AddonModBookBook> {
return this.getBookByField(courseId, 'coursemodule', cmId, siteId);
getBook(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModBookBook> {
return this.getBookByField(courseId, 'coursemodule', cmId, options);
}
/**
@ -89,15 +89,19 @@ export class AddonModBookProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the book is retrieved.
*/
protected getBookByField(courseId: number, key: string, value: any, siteId?: string): Promise<AddonModBookBook> {
return this.sitesProvider.getSite(siteId).then((site) => {
protected getBookByField(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {})
: Promise<AddonModBookBook> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
},
preSets = {
cacheKey: this.getBookDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
courseids: [courseId]
};
const preSets = {
cacheKey: this.getBookDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModBookProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_book_get_books_by_courses', params, preSets)
.then((response: AddonModBookGetBooksByCoursesResult): any => {

View File

@ -127,7 +127,8 @@ export class AddonModChatChatPage {
showChatUsers(): void {
// Create the toc modal.
const modal = this.modalCtrl.create('AddonModChatUsersPage', {
sessionId: this.sessionId
sessionId: this.sessionId,
cmId: this.cmId,
}, { cssClass: 'core-modal-lateral',
showBackdrop: true,
enableBackdropDismiss: true,
@ -168,7 +169,7 @@ export class AddonModChatChatPage {
return Promise.resolve(user.fullname);
}
return this.chatProvider.getChatUsers(this.sessionId).then((data) => {
return this.chatProvider.getChatUsers(this.sessionId, {cmId: this.cmId}).then((data) => {
this.users = data.users;
const user = this.users.find((user) => user.id == id);

View File

@ -60,8 +60,8 @@ export class AddonModChatSessionMessagesPage {
* @return Promise resolved when done.
*/
protected fetchMessages(): Promise<any> {
return this.chatProvider.getSessionMessages(this.chatId, this.sessionStart, this.sessionEnd, this.groupId)
.then((messages) => {
return this.chatProvider.getSessionMessages(this.chatId, this.sessionStart, this.sessionEnd, this.groupId,
{cmId: this.cmId}).then((messages) => {
return this.chatProvider.getMessagesUserData(messages, this.courseId).then((messages) => {
this.messages = <AddonModChatSessionMessageForView[]> messages;

View File

@ -72,7 +72,7 @@ export class AddonModChatSessionsPage {
this.groupInfo = groupInfo;
this.groupId = this.groupsProvider.validateGroupId(this.groupId, groupInfo);
return this.chatProvider.getSessions(this.chatId, this.groupId, this.showAll);
return this.chatProvider.getSessions(this.chatId, this.groupId, this.showAll, {cmId: this.cmId});
}).then((sessions: AddonModChatSessionFormatted[]) => {
// Fetch user profiles.
const promises = [];

View File

@ -36,6 +36,7 @@ export class AddonModChatUsersPage {
isOnline: boolean;
protected sessionId: string;
protected cmId: number;
protected onlineObserver: any;
constructor(navParams: NavParams, network: Network, zone: NgZone, private appProvider: CoreAppProvider,
@ -56,7 +57,7 @@ export class AddonModChatUsersPage {
* View loaded.
*/
ionViewDidLoad(): void {
this.chatProvider.getChatUsers(this.sessionId).then((data) => {
this.chatProvider.getChatUsers(this.sessionId, {cmId: this.cmId}).then((data) => {
this.users = data.users;
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'addon.mod_chat.errorwhilegettingchatusers', true);

View File

@ -14,13 +14,14 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSite } from '@classes/site';
import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws';
import { AddonModChatMessageForView, AddonModChatSessionMessageForView } from './helper';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
/**
* Service that provides some features for chats.
@ -40,17 +41,19 @@ export class AddonModChatProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the chat is retrieved.
*/
getChat(courseId: number, cmId: number, siteId?: string): Promise<AddonModChatChat> {
return this.sitesProvider.getSite(siteId).then((site) => {
getChat(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModChatChat> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
};
const preSets: CoreSiteWSPreSets = {
const preSets = {
cacheKey: this.getChatsCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModChatProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_chat_get_chats_by_courses', params, preSets)
@ -179,17 +182,25 @@ export class AddonModChatProvider {
* Get the actives users of a current chat.
*
* @param sessionId Chat sessiond ID.
* @param options Other options.
* @return Promise resolved when the WS is executed.
*/
getChatUsers(sessionId: string): Promise<AddonModChatGetChatUsersResult> {
const params = {
chatsid: sessionId
};
const preSets = {
getFromCache: false
};
getChatUsers(sessionId: string, options: CoreCourseCommonModWSOptions = {}): Promise<AddonModChatGetChatUsersResult> {
// By default, always try to get the latest data.
options.readingStrategy = options.readingStrategy || CoreSitesReadingStrategy.PreferNetwork;
return this.sitesProvider.getCurrentSite().read('mod_chat_get_chat_users', params, preSets);
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
chatsid: sessionId,
};
const preSets = {
component: AddonModChatProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_chat_get_chat_users', params, preSets);
});
}
/**
@ -210,28 +221,26 @@ export class AddonModChatProvider {
* @param chatId Chat ID.
* @param groupId Group ID, 0 means that the function will determine the user group.
* @param showAll Whether to include incomplete sessions or not.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the list of sessions.
* @since 3.5
*/
getSessions(chatId: number, groupId: number = 0, showAll: boolean = false, ignoreCache: boolean = false, siteId?: string):
getSessions(chatId: number, groupId: number = 0, showAll: boolean = false, options: CoreCourseCommonModWSOptions = {}):
Promise<AddonModChatSession[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
chatid: chatId,
groupid: groupId,
showall: showAll ? 1 : 0
showall: showAll ? 1 : 0,
};
const preSets: CoreSiteWSPreSets = {
const preSets = {
cacheKey: this.getSessionsCacheKey(chatId, groupId, showAll),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModChatProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return site.read('mod_chat_get_sessions', params, preSets).then((response: AddonModChatGetSessionsResult): any => {
if (!response || !response.sessions) {
@ -250,29 +259,27 @@ export class AddonModChatProvider {
* @param sessionStart Session start time.
* @param sessionEnd Session end time.
* @param groupId Group ID, 0 means that the function will determine the user group.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with the list of messages.
* @since 3.5
*/
getSessionMessages(chatId: number, sessionStart: number, sessionEnd: number, groupId: number = 0, ignoreCache: boolean = false,
siteId?: string): Promise<AddonModChatSessionMessage[]> {
getSessionMessages(chatId: number, sessionStart: number, sessionEnd: number, groupId: number = 0,
options: CoreCourseCommonModWSOptions = {}): Promise<AddonModChatSessionMessage[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
chatid: chatId,
sessionstart: sessionStart,
sessionend: sessionEnd,
groupid: groupId
groupid: groupId,
};
const preSets: CoreSiteWSPreSets = {
const preSets = {
cacheKey: this.getSessionMessagesCacheKey(chatId, sessionStart, groupId),
updateFrequency: CoreSite.FREQUENCY_RARELY
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModChatProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return site.read('mod_chat_get_session_messages', params, preSets)
.then((response: AddonModChatGetSessionMessagesResult): any => {

View File

@ -17,7 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
@ -122,9 +122,14 @@ export class AddonModChatPrefetchHandler extends CoreCourseActivityPrefetchHandl
protected prefetchChat(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
// Prefetch chat and group info.
const promises: Promise<any>[] = [
this.chatProvider.getChat(courseId, module.id, siteId),
this.chatProvider.getChat(courseId, module.id, {readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, siteId}),
this.groupsProvider.getActivityGroupInfo(module.id, false, undefined, siteId)
];
const options = {
cmId: module.id,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
return Promise.all(promises).then(([chat, groupInfo]: [AddonModChatChat, CoreGroupInfo]) => {
const promises = [];
@ -136,7 +141,7 @@ export class AddonModChatPrefetchHandler extends CoreCourseActivityPrefetchHandl
groupIds.forEach((groupId) => {
// Prefetch complete sessions.
promises.push(this.chatProvider.getSessions(chat.id, groupId, false, true, siteId).catch((error) => {
promises.push(this.chatProvider.getSessions(chat.id, groupId, false, options).catch((error) => {
// Ignore group error.
if (error.errorcode != 'notingroup') {
return Promise.reject(error);
@ -144,8 +149,9 @@ export class AddonModChatPrefetchHandler extends CoreCourseActivityPrefetchHandl
}));
// Prefetch all sessions.
promises.push(this.chatProvider.getSessions(chat.id, groupId, true, true, siteId).then((sessions) => {
const promises = sessions.map((session) => this.prefetchSession(chat.id, session, 0, courseId, siteId));
promises.push(this.chatProvider.getSessions(chat.id, groupId, true, options).then((sessions) => {
const promises = sessions.map((session) => this.prefetchSession(chat.id, session, 0, courseId, module.id,
siteId));
return Promise.all(promises);
}).catch((error) => {
@ -170,9 +176,13 @@ export class AddonModChatPrefetchHandler extends CoreCourseActivityPrefetchHandl
* @param siteId Site ID.
* @return Promise resolved when done.
*/
protected prefetchSession(chatId: number, session: any, groupId: number, courseId: number, siteId: string): Promise<any> {
return this.chatProvider.getSessionMessages(chatId, session.sessionstart, session.sessionend, groupId, true, siteId)
.then((messages) => {
protected prefetchSession(chatId: number, session: any, groupId: number, courseId: number, cmId: number, siteId: string)
: Promise<any> {
return this.chatProvider.getSessionMessages(chatId, session.sessionstart, session.sessionend, groupId, {
cmId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
}).then((messages) => {
const users = {};
session.sessionusers.forEach((user) => {
users[user.userid] = true;

View File

@ -7,7 +7,7 @@
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>
@ -43,13 +43,17 @@
<ion-card *ngIf="options && options.length">
<ng-container *ngIf="choice.allowmultiple">
<ion-item text-wrap *ngFor="let option of options">
<ion-label><core-format-text [text]="option.text" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-format-text> <span *ngIf="choice.limitanswers && option.countanswers >= option.maxanswers">{{ 'addon.mod_choice.full' | translate }}</span></ion-label>
<ion-label>
<ng-container *ngTemplateOutlet="optionLabelTemplate; context: {option: option}"></ng-container>
</ion-label>
<ion-checkbox item-end [(ngModel)]="option.checked" [disabled]="option.disabled || !canEdit"></ion-checkbox>
</ion-item>
</ng-container>
<ng-container *ngIf="!choice.allowmultiple">
<ion-item text-wrap *ngFor="let option of options" radio-group [(ngModel)]="selectedOption.id">
<ion-label><core-format-text [text]="option.text" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-format-text> <span *ngIf="choice.limitanswers && option.countanswers >= option.maxanswers">{{ 'addon.mod_choice.full' | translate }}</span></ion-label>
<ion-label>
<ng-container *ngTemplateOutlet="optionLabelTemplate; context: {option: option}"></ng-container>
</ion-label>
<ion-radio color="primary" [value]="option.id" [disabled]="option.disabled || !canEdit"></ion-radio>
</ion-item>
</ng-container>
@ -81,6 +85,7 @@
<ion-item-divider text-wrap>
<h2><core-format-text [text]="result.text" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-format-text></h2>
<p>{{ 'addon.mod_choice.numberofuser' | translate }}: {{ result.numberofuser }} ({{ 'core.percentagenumber' | translate: {$a: result.percentageamountfixed} }})</p>
<p *ngIf="choice.limitanswers && choice.showavailable">{{ 'addon.mod_choice.limita' | translate:{$a: result.maxanswer} }}</p>
</ion-item-divider>
<a ion-item *ngFor="let user of result.userresponses" core-user-link [courseId]="courseid" [userId]="user.userid" [title]="user.fullname" text-wrap>
<ion-avatar core-user-avatar [user]="user" item-start [courseId]="courseid"></ion-avatar>
@ -95,3 +100,14 @@
<p>{{ 'addon.mod_choice.noresultsviewable' | translate }}</p>
</ion-card>
</core-loading>
<!-- Template to render a choice option label. -->
<ng-template #optionLabelTemplate let-option="option">
<p>
<core-format-text [text]="option.text" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-format-text> <span *ngIf="choice.limitanswers && option.countanswers >= option.maxanswers">{{ 'addon.mod_choice.full' | translate }}</span>
</p>
<ng-container *ngIf="choice.limitanswers && choice.showavailable">
<p>{{ 'addon.mod_choice.responsesa' | translate:{$a: option.countanswers} }}</p>
<p>{{ 'addon.mod_choice.limita' | translate:{$a: option.maxanswers} }}</p>
</ng-container>
</ng-template>

View File

@ -174,7 +174,7 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo
* @return Promise resolved when done.
*/
protected fetchOptions(hasOffline: boolean): Promise<any> {
return this.choiceProvider.getOptions(this.choice.id).then((options) => {
return this.choiceProvider.getOptions(this.choice.id, {cmId: this.module.id}).then((options) => {
let promise;
// Check if the user has answered (synced) to allow show results.
@ -294,7 +294,7 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo
return Promise.resolve();
}
return this.choiceProvider.getResults(this.choice.id).then((results) => {
return this.choiceProvider.getResults(this.choice.id, {cmId: this.module.id}).then((results) => {
let hasVotes = false;
this.data = [];
this.labels = [];

View File

@ -4,6 +4,7 @@
"errorgetchoice": "Error getting choice data.",
"expired": "This activity closed on {{$a}}.",
"full": "(Full)",
"limita": "Limit: {{$a}}",
"modulenameplural": "Choices",
"noresultsviewable": "The results are not currently viewable.",
"notopenyet": "This activity is not available until {{$a}}.",
@ -17,6 +18,7 @@
"publishinfonever": "The results of this activity will not be published after you answer.",
"removemychoice": "Remove my choice",
"responses": "Responses",
"responsesa": "Responses: {{$a}}",
"responsesresultgraphdescription": "{{number}}% of the users chose the option: {{text}}.",
"responsesresultgraphheader": "Graph display",
"resultsnotsynced": "Your last response must be synchronised before it is included in the results.",

View File

@ -13,14 +13,15 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { AddonModChoiceOfflineProvider } from './offline';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSite } from '@classes/site';
import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
/**
* Service that provides some features for choices.
@ -173,34 +174,26 @@ export class AddonModChoiceProvider {
/**
* Get a choice with key=value. If more than one is found, only the first will be returned.
*
* @param siteId Site ID.
* @param courseId Course ID.
* @param key Name of the property to check.
* @param value Value to search.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the choice is retrieved.
*/
protected getChoiceByDataKey(siteId: string, courseId: number, key: string, value: any, forceCache?: boolean,
ignoreCache?: boolean): Promise<AddonModChoiceChoice> {
protected getChoiceByDataKey(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {})
: Promise<AddonModChoiceChoice> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
};
const preSets: CoreSiteWSPreSets = {
const preSets = {
cacheKey: this.getChoiceDataCacheKey(courseId),
omitExpires: forceCache,
updateFrequency: CoreSite.FREQUENCY_RARELY
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModChoiceProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (forceCache) {
preSets.omitExpires = true;
} else if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return site.read('mod_choice_get_choices_by_courses', params, preSets)
.then((response: AddonModChoiceGetChoicesByCoursesResult): any => {
@ -221,14 +214,11 @@ export class AddonModChoiceProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the choice is retrieved.
*/
getChoice(courseId: number, cmId: number, siteId?: string, forceCache?: boolean, ignoreCache?: boolean)
: Promise<AddonModChoiceChoice> {
return this.getChoiceByDataKey(siteId, courseId, 'coursemodule', cmId, forceCache, ignoreCache);
getChoice(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModChoiceChoice> {
return this.getChoiceByDataKey(courseId, 'coursemodule', cmId, options);
}
/**
@ -236,39 +226,33 @@ export class AddonModChoiceProvider {
*
* @param courseId Course ID.
* @param choiceId Choice ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the choice is retrieved.
*/
getChoiceById(courseId: number, choiceId: number, siteId?: string, forceCache?: boolean, ignoreCache?: boolean)
: Promise<AddonModChoiceChoice> {
return this.getChoiceByDataKey(siteId, courseId, 'id', choiceId, forceCache, ignoreCache);
getChoiceById(courseId: number, choiceId: number, options: CoreSitesCommonWSOptions = {}): Promise<AddonModChoiceChoice> {
return this.getChoiceByDataKey(courseId, 'id', choiceId, options);
}
/**
* Get choice options.
*
* @param choiceId Choice ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with choice options.
*/
getOptions(choiceId: number, ignoreCache?: boolean, siteId?: string): Promise<AddonModChoiceOption[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
getOptions(choiceId: number, options: CoreCourseCommonModWSOptions = {}): Promise<AddonModChoiceOption[]> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
choiceid: choiceId
};
const preSets: CoreSiteWSPreSets = {
const preSets = {
cacheKey: this.getChoiceOptionsCacheKey(choiceId),
updateFrequency: CoreSite.FREQUENCY_RARELY
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModChoiceProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return site.read('mod_choice_get_choice_options', params, preSets)
.then((response: AddonModChoiceGetChoiceOptionsResult): any => {
@ -285,24 +269,21 @@ export class AddonModChoiceProvider {
* Get choice results.
*
* @param choiceId Choice ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with choice results.
*/
getResults(choiceId: number, ignoreCache?: boolean, siteId?: string): Promise<AddonModChoiceResult[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
getResults(choiceId: number, options: CoreCourseCommonModWSOptions = {}): Promise<AddonModChoiceResult[]> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
choiceid: choiceId
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getChoiceResultsCacheKey(choiceId)
const preSets = {
cacheKey: this.getChoiceOptionsCacheKey(choiceId),
component: AddonModChoiceProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return site.read('mod_choice_get_choice_results', params, preSets)
.then((response: AddonModChoiceGetChoiceResults): any => {

View File

@ -16,7 +16,7 @@ import { Injectable, Injector } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
@ -79,12 +79,21 @@ export class AddonModChoicePrefetchHandler extends CoreCourseActivityPrefetchHan
* @return Promise resolved when done.
*/
protected prefetchChoice(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
return this.choiceProvider.getChoice(courseId, module.id, siteId, false, true).then((choice) => {
const commonOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions = {
cmId: module.id,
...commonOptions, // Include all common options.
};
return this.choiceProvider.getChoice(courseId, module.id, commonOptions).then((choice) => {
const promises = [];
// Get the options and results.
promises.push(this.choiceProvider.getOptions(choice.id, true, siteId));
promises.push(this.choiceProvider.getResults(choice.id, true, siteId).then((options) => {
promises.push(this.choiceProvider.getOptions(choice.id, modOptions));
promises.push(this.choiceProvider.getResults(choice.id, modOptions).then((options) => {
// If we can see the users that answered, prefetch their profile and avatar.
const subPromises = [];
options.forEach((option) => {

View File

@ -12,7 +12,7 @@
<core-context-menu-item [priority]="500" *ngIf="canAdd" [content]="'addon.mod_data.addentries' | translate" [iconAction]="'add'" (action)="gotoAddEntries($event)"></core-context-menu-item>
<core-context-menu-item [priority]="400" *ngIf="firstEntry" [content]="'addon.mod_data.single' | translate" [iconAction]="'document'" (action)="gotoEntry(firstEntry)"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="300" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="200" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="200" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -180,69 +180,67 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp
* @param showErrors If show errors to the user of hide them.
* @return Promise resolved when done.
*/
protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
let canAdd = false,
canSearch = false;
return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => {
this.data = data;
this.hasComments = data.comments;
this.data = await this.dataProvider.getDatabase(this.courseId, this.module.id);
this.hasComments = this.data.comments;
this.description = data.intro || data.description;
this.dataRetrieved.emit(data);
this.description = this.data.intro || this.data.description;
this.dataRetrieved.emit(this.data);
if (sync) {
if (sync) {
try {
// Try to synchronize the data.
return this.syncActivity(showErrors).catch(() => {
// Ignore errors.
});
await this.syncActivity(showErrors);
} catch (error) {
// Ignore errors.
}
}).then(() => {
return this.dataProvider.getDatabaseAccessInformation(this.data.id);
}).then((accessData) => {
this.access = accessData;
}
if (!accessData.timeavailable) {
const time = this.timeUtils.timestamp();
this.groupInfo = await this.groupsProvider.getActivityGroupInfo(this.data.coursemodule);
this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, this.groupInfo);
this.timeAvailableFrom = this.data.timeavailablefrom && time < this.data.timeavailablefrom ?
parseInt(this.data.timeavailablefrom, 10) * 1000 : false;
this.timeAvailableFromReadable = this.timeAvailableFrom ? this.timeUtils.userDate(this.timeAvailableFrom) : false;
this.timeAvailableTo = this.data.timeavailableto && time > this.data.timeavailableto ?
parseInt(this.data.timeavailableto, 10) * 1000 : false;
this.timeAvailableToReadable = this.timeAvailableTo ? this.timeUtils.userDate(this.timeAvailableTo) : false;
this.access = await this.dataProvider.getDatabaseAccessInformation(this.data.id, {
cmId: this.module.id,
groupId: this.selectedGroup || undefined
});
this.isEmpty = true;
this.groupInfo = null;
if (!this.access.timeavailable) {
const time = this.timeUtils.timestamp();
return;
}
this.timeAvailableFrom = this.data.timeavailablefrom && time < this.data.timeavailablefrom ?
parseInt(this.data.timeavailablefrom, 10) * 1000 : false;
this.timeAvailableFromReadable = this.timeAvailableFrom ? this.timeUtils.userDate(this.timeAvailableFrom) : false;
this.timeAvailableTo = this.data.timeavailableto && time > this.data.timeavailableto ?
parseInt(this.data.timeavailableto, 10) * 1000 : false;
this.timeAvailableToReadable = this.timeAvailableTo ? this.timeUtils.userDate(this.timeAvailableTo) : false;
this.isEmpty = true;
this.groupInfo = null;
} else {
canSearch = true;
canAdd = accessData.canaddentry;
canAdd = this.access.canaddentry;
}
return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule).then((groupInfo) => {
this.groupInfo = groupInfo;
this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo);
});
}).then(() => {
return this.dataProvider.getFields(this.data.id).then((fields) => {
if (fields.length == 0) {
canSearch = false;
canAdd = false;
}
this.search.advanced = [];
const fields = await this.dataProvider.getFields(this.data.id, {cmId: this.module.id});
this.search.advanced = [];
this.fields = this.utils.arrayToObject(fields, 'id');
this.fieldsArray = this.utils.objectToArray(this.fields);
this.fields = this.utils.arrayToObject(fields, 'id');
this.fieldsArray = this.utils.objectToArray(this.fields);
if (this.fieldsArray.length == 0) {
canSearch = false;
canAdd = false;
}
return this.fetchEntriesData();
});
}).finally(() => {
try {
await this.fetchEntriesData();
} finally {
this.canAdd = canAdd;
this.canSearch = canSearch;
this.fillContextMenu(refresh);
});
}
}
/**
@ -252,15 +250,17 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp
*/
protected fetchEntriesData(): Promise<any> {
return this.dataProvider.getDatabaseAccessInformation(this.data.id, this.selectedGroup).then((accessData) => {
// Update values for current group.
this.access.canaddentry = accessData.canaddentry;
const search = this.search.searching && !this.search.searchingAdvanced ? this.search.text : undefined;
const advSearch = this.search.searching && this.search.searchingAdvanced ? this.search.advanced : undefined;
const search = this.search.searching && !this.search.searchingAdvanced ? this.search.text : undefined;
const advSearch = this.search.searching && this.search.searchingAdvanced ? this.search.advanced : undefined;
return this.dataHelper.fetchEntries(this.data, this.fieldsArray, this.selectedGroup, search, advSearch,
this.search.sortBy, this.search.sortDirection, this.search.page);
return this.dataHelper.fetchEntries(this.data, this.fieldsArray, {
groupId: this.selectedGroup,
search,
advSearch,
sort: Number(this.search.sortBy),
order: this.search.sortDirection,
page: this.search.page,
cmId: this.module.id,
}).then((entries) => {
const numEntries = entries.entries.length;
const numOfflineEntries = entries.offlineEntries.length;
@ -381,18 +381,29 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp
* @param groupId Group ID.
* @return Resolved when new group is selected or rejected if not.
*/
setGroup(groupId: number): Promise<any> {
async setGroup(groupId: number): Promise<void> {
this.selectedGroup = groupId;
this.search.page = 0;
return this.fetchEntriesData().then(() => {
// Only update canAdd if there's any field, otheerwise, canAdd will remain false.
if (this.fieldsArray.length > 0) {
// Update values for current group.
this.access = await this.dataProvider.getDatabaseAccessInformation(this.data.id, {
groupId: this.selectedGroup,
cmId: this.module.id,
});
this.canAdd = this.access.canaddentry;
}
try {
await this.fetchEntriesData();
// Log activity view for coherence with Moodle web.
return this.logView();
}).catch((message) => {
this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true);
return Promise.reject(null);
});
} catch (error) {
this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
}
}
/**

View File

@ -14,10 +14,9 @@
import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Platform } from 'ionic-angular';
import { Geolocation, GeolocationOptions } from '@ionic-native/geolocation';
import { AddonModDataFieldPluginComponent } from '../../../classes/field-plugin-component';
import { CoreAppProvider } from '@providers/app';
import { CoreApp, CoreAppProvider } from '@providers/app';
import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@providers/geolocation';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
/**
@ -33,15 +32,14 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginCo
east: number;
showGeolocation: boolean;
constructor(protected fb: FormBuilder,
protected platform: Platform,
protected geolocation: Geolocation,
constructor(
protected fb: FormBuilder,
protected domUtils: CoreDomUtilsProvider,
protected sanitizer: DomSanitizer,
protected appProvider: CoreAppProvider) {
appProvider: CoreAppProvider) {
super(fb);
this.showGeolocation = !this.appProvider.isDesktop();
this.showGeolocation = !appProvider.isDesktop();
}
/**
@ -73,7 +71,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginCo
const eastFixed = east ? east.toFixed(4) : '0.0000';
let url;
if (this.platform.is('ios')) {
if (CoreApp.instance.isIOS()) {
url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed;
} else {
url = 'geo:' + northFixed + ',' + eastFixed;
@ -115,33 +113,51 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginCo
*
* @param $event The event.
*/
getLocation(event: Event): void {
async getLocation(event: Event): Promise<void> {
event.preventDefault();
const modal = this.domUtils.showModalLoading('addon.mod_data.gettinglocation', true);
const options: GeolocationOptions = {
enableHighAccuracy: true,
timeout: 30000
};
try {
const coordinates = await CoreGeolocation.instance.getCoordinates();
this.geolocation.getCurrentPosition(options).then((result) => {
this.form.controls['f_' + this.field.id + '_0'].setValue(result.coords.latitude);
this.form.controls['f_' + this.field.id + '_1'].setValue(result.coords.longitude);
}).catch((error) => {
if (this.isPermissionDeniedError(error)) {
this.domUtils.showErrorModal('addon.mod_data.locationpermissiondenied', true);
this.form.controls['f_' + this.field.id + '_0'].setValue(coordinates.latitude);
this.form.controls['f_' + this.field.id + '_1'].setValue(coordinates.longitude);
} catch (error) {
this.showLocationErrorModal(error);
}
return;
}
this.domUtils.showErrorModalDefault(error, 'Error getting location');
}).finally(() => {
modal.dismiss();
});
modal.dismiss();
}
protected isPermissionDeniedError(error?: any): boolean {
return error && 'code' in error && 'PERMISSION_DENIED' in error && error.code === error.PERMISSION_DENIED;
/**
* Show the appropriate error modal for the given error getting the location.
*
* @param error Location error.
*/
protected showLocationErrorModal(error: any): void {
if (error instanceof CoreGeolocationError) {
this.domUtils.showErrorModal(this.getGeolocationErrorMessage(error), true);
return;
}
this.domUtils.showErrorModalDefault(error, 'Error getting location');
}
/**
* Get error message from a geolocation error.
*
* @param error Geolocation error.
*/
protected getGeolocationErrorMessage(error: CoreGeolocationError): string {
// tslint:disable-next-line: switch-default
switch (error.reason) {
case CoreGeolocationErrorReason.PermissionDenied:
return 'addon.mod_data.locationpermissiondenied';
case CoreGeolocationErrorReason.LocationNotEnabled:
return 'addon.mod_data.locationnotenabled';
}
}
}

View File

@ -23,10 +23,12 @@
"gettinglocation": "Getting location",
"latlongboth": "Both latitude and longitude are required.",
"locationpermissiondenied": "Permission to access your location has been denied.",
"locationnotenabled": "Location is not enabled",
"menuchoose": "Choose...",
"modulenameplural": "Databases",
"more": "More",
"mylocation": "My location",
"noaccess": "You do not have access to this page",
"nomatch": "No matching entries found!",
"norecords": "No entries in database",
"notapproved": "Entry is not approved yet.",

View File

@ -18,8 +18,8 @@
</ion-select>
</ion-item>
<div class="addon-data-contents addon-data-entries-{{data.id}}" *ngIf="data">
<core-style [css]="data.csstemplate" prefix=".addon-data-entries-{{data.id}}"></core-style>
<div class="addon-data-contents {{cssClass}}" *ngIf="data">
<core-style [css]="data.csstemplate" prefix=".{{cssClass}}"></core-style>
<form (ngSubmit)="save($event)" [formGroup]="editForm" #editFormEl>
<core-compile-html [text]="editFormRender" [jsData]="jsData" [extraImports]="extraImports"></core-compile-html>

View File

@ -52,6 +52,8 @@ export class AddonModDataEditPage {
protected siteId: string;
protected offline: boolean;
protected forceLeave = false; // To allow leaving the page without checking for changes.
protected initialSelectedGroup = null;
protected isEditing = false;
title = '';
component = AddonModDataProvider.COMPONENT;
@ -75,7 +77,10 @@ export class AddonModDataEditPage {
this.module = params.get('module') || {};
this.entryId = params.get('entryId') || null;
this.courseId = params.get('courseId');
this.selectedGroup = params.get('group') || 0;
this.selectedGroup = this.entryId ? null : (params.get('group') || 0);
// If entryId is lower than 0 or null, it is a new entry or an offline entry.
this.isEditing = this.entryId && this.entryId > 0;
this.siteId = sitesProvider.getCurrentSiteId();
@ -88,7 +93,7 @@ export class AddonModDataEditPage {
* View loaded.
*/
ionViewDidLoad(): void {
this.fetchEntryData();
this.fetchEntryData(true);
}
/**
@ -103,7 +108,8 @@ export class AddonModDataEditPage {
const inputData = this.editForm.value;
const changed = await this.dataHelper.hasEditDataChanged(inputData, this.fieldsArray, this.data.id, this.entry.contents);
let changed = await this.dataHelper.hasEditDataChanged(inputData, this.fieldsArray, this.data.id, this.entry.contents);
changed = changed || (!this.isEditing && this.initialSelectedGroup != this.selectedGroup);
if (changed) {
// Show confirmation if some data has been modified.
@ -120,38 +126,78 @@ export class AddonModDataEditPage {
/**
* Fetch the entry data.
*
* @param [refresh] To refresh all downloaded data.
* @return Resolved when done.
*/
protected fetchEntryData(): Promise<any> {
return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => {
this.title = data.name || this.title;
this.data = data;
this.cssClass = 'addon-data-entries-' + data.id;
protected async fetchEntryData(refresh: boolean = false): Promise<void> {
try {
this.data = await this.dataProvider.getDatabase(this.courseId, this.module.id);
this.title = this.data.name || this.title;
this.cssClass = 'addon-data-entries-' + this.data.id;
return this.dataProvider.getDatabaseAccessInformation(data.id);
}).then((accessData) => {
if (this.entryId) {
return this.groupsProvider.getActivityGroupInfo(this.data.coursemodule).then((groupInfo) => {
this.groupInfo = groupInfo;
this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, groupInfo);
});
}
}).then(() => {
return this.dataProvider.getFields(this.data.id);
}).then((fieldsData) => {
this.fieldsArray = fieldsData;
this.fields = this.utils.arrayToObject(fieldsData, 'id');
this.fieldsArray = await this.dataProvider.getFields(this.data.id, {cmId: this.module.id});
this.fields = this.utils.arrayToObject(this.fieldsArray, 'id');
const entry = await this.dataHelper.fetchEntry(this.data, this.fieldsArray, this.entryId);
return this.dataHelper.fetchEntry(this.data, fieldsData, this.entryId);
}).then((entry) => {
this.entry = entry.entry;
// Load correct group.
this.selectedGroup = this.selectedGroup == null ? this.entry.groupid : this.selectedGroup;
// Check permissions when adding a new entry or offline entry.
if (!this.isEditing) {
let haveAccess = false;
if (refresh) {
this.groupInfo = await this.groupsProvider.getActivityGroupInfo(this.data.coursemodule);
this.selectedGroup = this.groupsProvider.validateGroupId(this.selectedGroup, this.groupInfo);
this.initialSelectedGroup = this.selectedGroup;
}
if (this.groupInfo.groups.length > 0) {
if (refresh) {
const canAddGroup = {};
await Promise.all(this.groupInfo.groups.map(async (group) => {
const accessData = await this.dataProvider.getDatabaseAccessInformation(this.data.id, {
cmId: this.module.id, groupId: group.id});
canAddGroup[group.id] = accessData.canaddentry;
}));
this.groupInfo.groups = this.groupInfo.groups.filter((group) => {
return !!canAddGroup[group.id];
});
haveAccess = canAddGroup[this.selectedGroup];
} else {
// Groups already filtered, so it have access.
haveAccess = true;
}
} else {
const accessData = await this.dataProvider.getDatabaseAccessInformation(this.data.id, {cmId: this.module.id});
haveAccess = accessData.canaddentry;
}
if (!haveAccess) {
// You shall not pass, go back.
this.domUtils.showErrorModal('addon.mod_data.noaccess', true);
// Go back to entry list.
this.forceLeave = true;
this.navCtrl.pop();
return;
}
}
this.editFormRender = this.displayEditFields();
}).catch((message) => {
} catch (message) {
this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true);
}).finally(() => {
this.loaded = true;
});
}
this.loaded = true;
}
/**
@ -160,7 +206,7 @@ export class AddonModDataEditPage {
* @param e Event.
* @return Resolved when done.
*/
save(e: Event): Promise<any> {
save(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
@ -169,6 +215,7 @@ export class AddonModDataEditPage {
return this.dataHelper.hasEditDataChanged(inputData, this.fieldsArray, this.data.id,
this.entry.contents).then((changed) => {
changed = changed || (!this.isEditing && this.initialSelectedGroup != this.selectedGroup);
if (!changed) {
if (this.entryId) {
return this.returnToEntryList();
@ -196,7 +243,7 @@ export class AddonModDataEditPage {
return Promise.reject(e);
}).then((editData) => {
if (editData.length > 0) {
if (this.entryId) {
if (this.isEditing) {
return this.dataProvider.editEntry(this.data.id, this.entryId, this.courseId, editData, this.fields,
undefined, this.offline);
}
@ -213,20 +260,20 @@ export class AddonModDataEditPage {
}
// This is done if entry is updated when editing or creating if not.
if ((this.entryId && result.updated) || (!this.entryId && result.newentryid)) {
if ((this.isEditing && result.updated) || (!this.isEditing && result.newentryid)) {
this.domUtils.triggerFormSubmittedEvent(this.formElement, result.sent, this.siteId);
if (result.sent) {
this.eventsProvider.trigger(CoreEventsProvider.ACTIVITY_DATA_SENT, { module: 'data' });
}
const promises = [];
this.entryId = this.entryId || result.newentryid;
if (result.sent) {
this.eventsProvider.trigger(CoreEventsProvider.ACTIVITY_DATA_SENT, { module: 'data' });
promises.push(this.dataProvider.invalidateEntryData(this.data.id, this.entryId, this.siteId));
promises.push(this.dataProvider.invalidateEntriesData(this.data.id, this.siteId));
if (this.isEditing) {
promises.push(this.dataProvider.invalidateEntryData(this.data.id, this.entryId, this.siteId));
}
promises.push(this.dataProvider.invalidateEntriesData(this.data.id, this.siteId));
}
return Promise.all(promises).then(() => {
this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED,
@ -264,7 +311,7 @@ export class AddonModDataEditPage {
* @param groupId Group identifier to set.
* @return Resolved when done.
*/
setGroup(groupId: number): Promise<any> {
setGroup(groupId: number): Promise<void> {
this.selectedGroup = groupId;
this.loaded = false;
@ -322,7 +369,7 @@ export class AddonModDataEditPage {
*
* @return Resolved when done.
*/
protected returnToEntryList(): Promise<any> {
protected returnToEntryList(): Promise<void> {
const inputData = this.editForm.value;
return this.dataHelper.getEditTmpFiles(inputData, this.fieldsArray, this.data.id,

View File

@ -142,13 +142,13 @@ export class AddonModDataEntryPage implements OnDestroy {
this.title = data.name || this.title;
this.data = data;
return this.dataProvider.getFields(this.data.id).then((fieldsData) => {
return this.dataProvider.getFields(this.data.id, {cmId: this.module.id}).then((fieldsData) => {
this.fields = this.utils.arrayToObject(fieldsData, 'id');
this.fieldsArray = fieldsData;
});
}).then(() => {
return this.setEntryFromOffset().then(() => {
return this.dataProvider.getDatabaseAccessInformation(this.data.id);
return this.dataProvider.getDatabaseAccessInformation(this.data.id, {cmId: this.module.id});
});
}).then((accessData) => {
this.access = accessData;
@ -290,8 +290,13 @@ export class AddonModDataEntryPage implements OnDestroy {
const perPage = AddonModDataProvider.PER_PAGE;
const page = !emptyOffset && this.offset >= 0 ? Math.floor(this.offset / perPage) : 0;
return this.dataHelper.fetchEntries(this.data, this.fieldsArray, this.selectedGroup, undefined, undefined, '0', 'DESC',
page, perPage).then((entries) => {
return this.dataHelper.fetchEntries(this.data, this.fieldsArray, {
groupId: this.selectedGroup,
sort: 0,
order: 'DESC',
page,
perPage,
}).then((entries) => {
const pageEntries = entries.offlineEntries.concat(entries.entries);
let pageIndex; // Index of the entry when concatenating offline and online page entries.
@ -321,8 +326,11 @@ export class AddonModDataEntryPage implements OnDestroy {
this.nextOffset = null;
} else {
// Last entry of the page, check if there are more pages.
promise = this.dataProvider.getEntries(this.data.id, this.selectedGroup, '0', 'DESC', page + 1, perPage)
.then((entries) => {
promise = this.dataProvider.getEntries(this.data.id, {
groupId: this.selectedGroup,
page: page + 1,
perPage: perPage,
}).then((entries) => {
this.nextOffset = entries && entries.entries && entries.entries.length > 0 ? this.offset + 1 : null;
});
}
@ -330,7 +338,7 @@ export class AddonModDataEntryPage implements OnDestroy {
return Promise.resolve(promise).then(() => {
if (this.entryId > 0) {
// Online entry, we need to fetch the the rating info.
return this.dataProvider.getEntry(this.data.id, this.entryId).then((entry) => {
return this.dataProvider.getEntry(this.data.id, this.entryId, {cmId: this.module.id}).then((entry) => {
this.ratingInfo = entry.ratinginfo;
});
}

View File

@ -15,7 +15,7 @@
import { Injectable } from '@angular/core';
import { CoreAppProvider } from '@providers/app';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
@ -23,6 +23,7 @@ import { AddonModDataOfflineProvider } from './offline';
import { AddonModDataFieldsDelegate } from './fields-delegate';
import { CoreRatingInfo } from '@core/rating/providers/rating';
import { CoreSite } from '@classes/site';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
/**
* Database entry (online or offline).
@ -116,46 +117,51 @@ export class AddonModDataProvider {
* @param forceOffline Force editing entry in offline.
* @return Promise resolved when the action is done.
*/
addEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], groupId: number = 0,
async addEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], groupId: number = 0,
fields: any, siteId?: string, forceOffline: boolean = false): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a data to be synchronized later.
const storeOffline = (): Promise<any> => {
return this.dataOffline.saveEntry(dataId, entryId, 'add', courseId, groupId, contents, undefined, siteId)
.then((entry) => {
return {
// Return provissional entry Id.
newentryid: entry,
sent: false,
};
});
const storeOffline = async (): Promise<any> => {
const entry = await this.dataOffline.saveEntry(dataId, entryId, 'add', courseId, groupId, contents, undefined, siteId);
return {
// Return provissional entry Id.
newentryid: entry,
sent: false,
};
};
// Checks to store offline.
if (!this.appProvider.isOnline() || forceOffline) {
const notifications = this.checkFields(fields, contents);
if (notifications) {
return Promise.resolve({
fieldnotifications: notifications
});
return { fieldnotifications: notifications };
}
}
// Remove unnecessary not synced actions.
await this.deleteEntryOfflineAction(dataId, entryId, 'add', siteId);
// App is offline, store the action.
if (!this.appProvider.isOnline() || forceOffline) {
return storeOffline();
}
return this.addEntryOnline(dataId, contents, groupId, siteId).then((result) => {
try {
const result = await this.addEntryOnline(dataId, contents, groupId, siteId);
result.sent = true;
return result;
}).catch((error) => {
} catch (error) {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
return Promise.reject(error);
throw error;
}
// Couldn't connect to server, store in offline.
return storeOffline();
});
}
}
/**
@ -192,48 +198,49 @@ export class AddonModDataProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the action is done.
*/
approveEntry(dataId: number, entryId: number, approve: boolean, courseId: number, siteId?: string): Promise<any> {
async approveEntry(dataId: number, entryId: number, approve: boolean, courseId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a data to be synchronized later.
const storeOffline = (): Promise<any> => {
const storeOffline = async (): Promise<any> => {
const action = approve ? 'approve' : 'disapprove';
return this.dataOffline.saveEntry(dataId, entryId, action, courseId, undefined, undefined, undefined, siteId)
.then(() => {
return {
sent: false,
};
});
await this.dataOffline.saveEntry(dataId, entryId, action, courseId, undefined, undefined, undefined, siteId);
return {
sent: false,
};
};
// Get if the opposite action is not synced.
const oppositeAction = approve ? 'disapprove' : 'approve';
return this.dataOffline.getEntry(dataId, entryId, oppositeAction, siteId).then(() => {
// Found. Just delete the action.
return this.dataOffline.deleteEntry(dataId, entryId, oppositeAction, siteId);
}).catch(() => {
const found = await this.deleteEntryOfflineAction(dataId, entryId, oppositeAction, siteId);
if (found) {
// Offline action has been found and deleted. Stop here.
return;
}
if (!this.appProvider.isOnline()) {
// App is offline, store the action.
return storeOffline();
if (!this.appProvider.isOnline()) {
// App is offline, store the action.
return storeOffline();
}
try {
await this.approveEntryOnline(entryId, approve, siteId);
return {
sent: true,
};
} catch (error) {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
throw error;
}
return this.approveEntryOnline(entryId, approve, siteId).then(() => {
return {
sent: true,
};
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
return Promise.reject(error);
}
// Couldn't connect to server, store in offline.
return storeOffline();
});
});
// Couldn't connect to server, store in offline.
return storeOffline();
}
}
/**
@ -297,60 +304,45 @@ export class AddonModDataProvider {
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the action is done.
*/
deleteEntry(dataId: number, entryId: number, courseId: number, siteId?: string): Promise<any> {
async deleteEntry(dataId: number, entryId: number, courseId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a data to be synchronized later.
const storeOffline = (): Promise<any> => {
return this.dataOffline.saveEntry(dataId, entryId, 'delete', courseId, undefined, undefined, undefined, siteId)
.then(() => {
return {
sent: false,
};
});
const storeOffline = async (): Promise<any> => {
await this.dataOffline.saveEntry(dataId, entryId, 'delete', courseId, undefined, undefined, undefined, siteId);
return {
sent: false,
};
};
let justAdded = false;
// Check if the opposite action is not synced and just delete it.
return this.dataOffline.getEntryActions(dataId, entryId, siteId).then((entries) => {
if (entries && entries.length) {
// Found. Delete other actions first.
const proms = entries.map((entry) => {
if (entry.action == 'add') {
justAdded = true;
}
const addedOffline = await this.deleteEntryOfflineAction(dataId, entryId, 'add', siteId);
if (addedOffline) {
// Offline add action found and deleted. Stop here.
return;
}
return this.dataOffline.deleteEntry(dataId, entryId, entry.action, siteId);
});
if (!this.appProvider.isOnline()) {
// App is offline, store the action.
return storeOffline();
}
return Promise.all(proms);
}
}).then(() => {
if (justAdded) {
// The field was added offline, delete and stop.
return;
try {
await this.deleteEntryOnline(entryId, siteId);
return {
sent: true,
};
} catch (error) {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
throw error;
}
if (!this.appProvider.isOnline()) {
// App is offline, store the action.
return storeOffline();
}
return this.deleteEntryOnline(entryId, siteId).then(() => {
return {
sent: true,
};
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
return Promise.reject(error);
}
// Couldn't connect to server, store in offline.
return storeOffline();
});
});
// Couldn't connect to server, store in offline.
return storeOffline();
}
}
/**
@ -370,6 +362,29 @@ export class AddonModDataProvider {
});
}
/**
* Delete entry offline action.
*
* @param dataId Database ID.
* @param entryId Entry ID.
* @param action Action name to delete.
* @param siteId Site ID.
* @return Resolved with true if the action has been found and deleted.
*/
protected async deleteEntryOfflineAction(dataId: number, entryId: number, action: string, siteId: string): Promise<boolean> {
// Get other not not synced actions.
try {
await this.dataOffline.getEntry(dataId, entryId, action, siteId);
await this.dataOffline.deleteEntry(dataId, entryId, action, siteId);
return true;
} catch (error) {
// Not found.
return false;
}
}
/**
* Updates an existing entry.
*
@ -382,82 +397,50 @@ export class AddonModDataProvider {
* @param forceOffline Force editing entry in offline.
* @return Promise resolved when the action is done.
*/
editEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], fields: any, siteId?: string,
forceOffline: boolean = false): Promise<any> {
async editEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], fields: any,
siteId?: string, forceOffline: boolean = false): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a data to be synchronized later.
const storeOffline = (): Promise<any> => {
return this.dataOffline.saveEntry(dataId, entryId, 'edit', courseId, undefined, contents, undefined, siteId)
.then(() => {
return {
updated: true,
sent: false,
};
});
};
const storeOffline = async (): Promise<any> => {
await this.dataOffline.saveEntry(dataId, entryId, 'edit', courseId, undefined, contents, undefined, siteId);
let justAdded = false,
groupId;
return {
updated: true,
sent: false,
};
};
if (!this.appProvider.isOnline() || forceOffline) {
const notifications = this.checkFields(fields, contents);
if (notifications) {
return Promise.resolve({
fieldnotifications: notifications
});
return { fieldnotifications: notifications };
}
}
// Get other not not synced actions.
return this.dataOffline.getEntryActions(dataId, entryId, siteId).then((entries) => {
if (entries && entries.length) {
// Found. Delete add and edit actions first.
const proms = [];
entries.forEach((entry) => {
if (entry.action == 'add') {
justAdded = true;
groupId = entry.groupid;
proms.push(this.dataOffline.deleteEntry(dataId, entryId, entry.action, siteId));
} else if (entry.action == 'edit') {
proms.push(this.dataOffline.deleteEntry(dataId, entryId, entry.action, siteId));
}
});
// Remove unnecessary not synced actions.
await this.deleteEntryOfflineAction(dataId, entryId, 'edit', siteId);
return Promise.all(proms);
}
}).then(() => {
if (justAdded) {
// The field was added offline, add again and stop.
return this.addEntry(dataId, entryId, courseId, contents, groupId, fields, siteId, forceOffline)
.then((result) => {
result.updated = true;
result.sent = true;
if (!this.appProvider.isOnline() || forceOffline) {
// App is offline, store the action.
return storeOffline();
}
return result;
});
try {
const result = await this.editEntryOnline(entryId, contents, siteId);
result.sent = true;
return result;
} catch (error) {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
throw error;
}
if (!this.appProvider.isOnline() || forceOffline) {
// App is offline, store the action.
return storeOffline();
}
return this.editEntryOnline(entryId, contents, siteId).then((result) => {
result.sent = true;
return result;
}).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
return Promise.reject(error);
}
// Couldn't connect to server, store in offline.
return storeOffline();
});
});
}
// Couldn't connect to server, store in offline.
return storeOffline();
}
}
/**
* Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect.
@ -482,49 +465,34 @@ export class AddonModDataProvider {
* Performs the whole fetch of the entries in the database.
*
* @param dataId Data ID.
* @param groupId Group ID.
* @param sort Sort the records by this field id. See AddonModDataProvider#getEntries for more info.
* @param order The direction of the sorting. See AddonModDataProvider#getEntries for more info.
* @param perPage Records per page to fetch. It has to match with the prefetch.
* Default on AddonModDataProvider.PER_PAGE.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when done.
*/
fetchAllEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC',
perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<AddonModDataEntry[]> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
fetchAllEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise<AddonModDataEntry[]> {
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
options.page = 0;
return this.fetchEntriesRecursive(dataId, groupId, sort, order, perPage, forceCache, ignoreCache, [], 0, siteId);
return this.fetchEntriesRecursive(dataId, [], options);
}
/**
* Recursive call on fetch all entries.
*
* @param dataId Data ID.
* @param groupId Group ID.
* @param sort Sort the records by this field id. See AddonModDataProvider#getEntries for more info.
* @param order The direction of the sorting. See AddonModDataProvider#getEntries for more info.
* @param perPage Records per page to fetch. It has to match with the prefetch.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param entries Entries already fetch (just to concatenate them).
* @param page Page of records to return.
* @param siteId Site ID.
* @param options Other options.
* @return Promise resolved when done.
*/
protected fetchEntriesRecursive(dataId: number, groupId: number, sort: string, order: string, perPage: number,
forceCache: boolean, ignoreCache: boolean, entries: any, page: number, siteId: string): Promise<AddonModDataEntry[]> {
return this.getEntries(dataId, groupId, sort, order, page, perPage, forceCache, ignoreCache, siteId)
.then((result) => {
protected fetchEntriesRecursive(dataId: number, entries: any, options: AddonModDataGetEntriesOptions = {})
: Promise<AddonModDataEntry[]> {
return this.getEntries(dataId, options).then((result) => {
entries = entries.concat(result.entries);
const canLoadMore = perPage > 0 && ((page + 1) * perPage) < result.totalcount;
const canLoadMore = options.perPage > 0 && ((options.page + 1) * options.perPage) < result.totalcount;
if (canLoadMore) {
return this.fetchEntriesRecursive(dataId, groupId, sort, order, perPage, forceCache, ignoreCache, entries, page + 1,
siteId);
options.page++;
return this.fetchEntriesRecursive(dataId, entries, options);
}
return entries;
@ -557,23 +525,21 @@ export class AddonModDataProvider {
* @param courseId Course ID.
* @param key Name of the property to check.
* @param value Value to search.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param options Other options.
* @return Promise resolved when the data is retrieved.
*/
protected getDatabaseByKey(courseId: number, key: string, value: any, siteId?: string, forceCache: boolean = false):
protected getDatabaseByKey(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {}):
Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
},
preSets = {
cacheKey: this.getDatabaseDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
if (forceCache) {
preSets['omitExpires'] = true;
}
courseids: [courseId],
};
const preSets = {
cacheKey: this.getDatabaseDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModDataProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_data_get_databases_by_courses', params, preSets).then((response) => {
if (response && response.databases) {
@ -593,12 +559,11 @@ export class AddonModDataProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param options Other options.
* @return Promise resolved when the data is retrieved.
*/
getDatabase(courseId: number, cmId: number, siteId?: string, forceCache: boolean = false): Promise<any> {
return this.getDatabaseByKey(courseId, 'coursemodule', cmId, siteId, forceCache);
getDatabase(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getDatabaseByKey(courseId, 'coursemodule', cmId, options);
}
/**
@ -606,12 +571,11 @@ export class AddonModDataProvider {
*
* @param courseId Course ID.
* @param id Data ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param options Other options.
* @return Promise resolved when the data is retrieved.
*/
getDatabaseById(courseId: number, id: number, siteId?: string, forceCache: boolean = false): Promise<any> {
return this.getDatabaseByKey(courseId, 'id', id, siteId, forceCache);
getDatabaseById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getDatabaseByKey(courseId, 'id', id, options);
}
/**
@ -639,32 +603,23 @@ export class AddonModDataProvider {
* Get access information for a given database.
*
* @param dataId Data ID.
* @param groupId Group ID.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it'll always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the database is retrieved.
*/
getDatabaseAccessInformation(dataId: number, groupId?: number, offline: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getDatabaseAccessInformation(dataId: number, options: AddonModDataAccessInfoOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
options.groupId = options.groupId || 0;
const params = {
databaseid: dataId
},
preSets = {
cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, groupId)
};
if (typeof groupId !== 'undefined') {
params['groupid'] = groupId;
}
if (offline) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
databaseid: dataId,
groupid: options.groupId,
};
const preSets = {
cacheKey: this.getDatabaseAccessInformationDataCacheKey(dataId, options.groupId),
component: AddonModDataProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_data_get_data_access_information', params, preSets);
});
@ -674,48 +629,34 @@ export class AddonModDataProvider {
* Get entries for a specific database and group.
*
* @param dataId Data ID.
* @param groupId Group ID.
* @param sort Sort the records by this field id, reserved ids are:
* 0: timeadded
* -1: firstname
* -2: lastname
* -3: approved
* -4: timemodified.
* Empty for using the default database setting.
* @param order The direction of the sorting: 'ASC' or 'DESC'.
* Empty for using the default database setting.
* @param page Page of records to return.
* @param perPage Records per page to return. Default on PER_PAGE.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it'll always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the database is retrieved.
*/
getEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC', page: number = 0,
perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<AddonModDataEntries> {
return this.sitesProvider.getSite(siteId).then((site) => {
getEntries(dataId: number, options: AddonModDataGetEntriesOptions = {}): Promise<AddonModDataEntries> {
options.groupId = options.groupId || 0;
options.sort = options.sort || 0;
options.order = options.order || 'DESC';
options.page = options.page || 0;
options.perPage = options.perPage || AddonModDataProvider.PER_PAGE;
return this.sitesProvider.getSite(options.siteId).then((site) => {
// Always use sort and order params to improve cache usage (entries are identified by params).
const params = {
databaseid: dataId,
returncontents: 1,
page: page,
perpage: perPage,
groupid: groupId,
sort: sort,
order: order
},
preSets = {
cacheKey: this.getEntriesCacheKey(dataId, groupId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES
};
if (forceCache) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
databaseid: dataId,
returncontents: 1,
page: options.page,
perpage: options.perPage,
groupid: options.groupId,
sort: options.sort,
order: options.order,
};
const preSets = {
cacheKey: this.getEntriesCacheKey(dataId, options.groupId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModDataProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_data_get_entries', params, preSets).then((response) => {
response.entries.forEach((entry) => {
@ -753,26 +694,23 @@ export class AddonModDataProvider {
*
* @param dataId Data ID for caching purposes.
* @param entryId Entry ID.
* @param ignoreCache True if it should ignore cached data (it'll always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the entry is retrieved.
*/
getEntry(dataId: number, entryId: number, ignoreCache: boolean = false, siteId?: string):
getEntry(dataId: number, entryId: number, options: CoreCourseCommonModWSOptions = {}):
Promise<{entry: AddonModDataEntry, ratinginfo: CoreRatingInfo}> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
entryid: entryId,
returncontents: 1
},
preSets = {
cacheKey: this.getEntryCacheKey(dataId, entryId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES
};
if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
entryid: entryId,
returncontents: 1,
};
const preSets = {
cacheKey: this.getEntryCacheKey(dataId, entryId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModDataProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_data_get_entry', params, preSets).then((response) => {
response.entry.contents = this.utils.arrayToObject(response.entry.contents, 'fieldid');
@ -797,27 +735,21 @@ export class AddonModDataProvider {
* Get the list of configured fields for the given database.
*
* @param dataId Data ID.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the fields are retrieved.
*/
getFields(dataId: number, forceCache: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getFields(dataId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
databaseid: dataId
},
preSets = {
cacheKey: this.getFieldsCacheKey(dataId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
if (forceCache) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
databaseid: dataId,
};
const preSets = {
cacheKey: this.getFieldsCacheKey(dataId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModDataProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_data_get_fields', params, preSets).then((response) => {
if (response && response.fields) {
@ -993,46 +925,45 @@ export class AddonModDataProvider {
* Performs search over a database.
*
* @param dataId The data instance id.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param search Search text. It will be used if advSearch is not defined.
* @param advSearch Advanced search data.
* @param sort Sort by this field.
* @param order The direction of the sorting.
* @param page Page of records to return.
* @param perPage Records per page to return. Default on AddonModDataProvider.PER_PAGE.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the action is done.
*/
searchEntries(dataId: number, groupId: number = 0, search?: string, advSearch?: any, sort?: string, order?: string,
page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string): Promise<AddonModDataEntries> {
return this.sitesProvider.getSite(siteId).then((site) => {
searchEntries(dataId: number, options: AddonModDataSearchEntriesOptions = {}): Promise<AddonModDataEntries> {
options.groupId = options.groupId || 0;
options.sort = options.sort || 0;
options.order || options.order || 'DESC';
options.page = options.page || 0;
options.perPage = options.perPage || AddonModDataProvider.PER_PAGE;
options.readingStrategy = options.readingStrategy || CoreSitesReadingStrategy.PreferNetwork;
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
databaseid: dataId,
groupid: groupId,
returncontents: 1,
page: page,
perpage: perPage
},
preSets = {
getFromCache: false,
saveToCache: true,
emergencyCache: true
};
databaseid: dataId,
groupid: options.groupId,
returncontents: 1,
page: options.page,
perpage: options.perPage,
};
const preSets = {
component: AddonModDataProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (typeof sort != 'undefined') {
params['sort'] = sort;
if (typeof options.sort != 'undefined') {
params['sort'] = options.sort;
}
if (typeof order !== 'undefined') {
params['order'] = order;
if (typeof options.order !== 'undefined') {
params['order'] = options.order;
}
if (typeof search !== 'undefined') {
params['search'] = search;
if (typeof options.search !== 'undefined') {
params['search'] = options.search;
}
if (typeof advSearch !== 'undefined') {
params['advsearch'] = advSearch;
if (typeof options.advSearch !== 'undefined') {
params['advsearch'] = options.advSearch;
}
return site.read('mod_data_search_entries', params, preSets).then((response) => {
@ -1045,3 +976,34 @@ export class AddonModDataProvider {
});
}
}
/**
* Options to pass to get access info.
*/
export type AddonModDataAccessInfoOptions = CoreCourseCommonModWSOptions & {
groupId?: number; // Group Id.
};
/**
* Options to pass to get entries.
*/
export type AddonModDataGetEntriesOptions = CoreCourseCommonModWSOptions & {
groupId?: number; // Group Id.
sort?: number; // Sort the records by this field id, defaults to 0. Reserved ids are:
// 0: timeadded
// -1: firstname
// -2: lastname
// -3: approved
// -4: timemodified
order?: string; // The direction of the sorting: 'ASC' or 'DESC'. Defaults to 'DESC'.
page?: number; // Page of records to return. Defaults to 0.
perPage?: number; // Records per page to return. Defaults to AddonModDataProvider.PER_PAGE.
};
/**
* Options to pass to search entries.
*/
export type AddonModDataSearchEntriesOptions = AddonModDataGetEntriesOptions & {
search?: string; // Search text. It will be used if advSearch is not defined.
advSearch?: any; // Advanced search data.
};

View File

@ -18,12 +18,13 @@ import { CoreEventsProvider } from '@providers/events';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { AddonModDataFieldsDelegate } from './fields-delegate';
import { AddonModDataOfflineProvider, AddonModDataOfflineAction } from './offline';
import { AddonModDataProvider, AddonModDataEntry, AddonModDataEntryFields, AddonModDataEntries } from './data';
import {
AddonModDataProvider, AddonModDataEntry, AddonModDataEntryFields, AddonModDataEntries, AddonModDataSearchEntriesOptions
} from './data';
import { CoreRatingInfo } from '@core/rating/providers/rating';
import { CoreRatingOfflineProvider } from '@core/rating/providers/offline';
@ -33,12 +34,19 @@ import { CoreRatingOfflineProvider } from '@core/rating/providers/offline';
@Injectable()
export class AddonModDataHelperProvider {
constructor(private sitesProvider: CoreSitesProvider, protected dataProvider: AddonModDataProvider,
private translate: TranslateService, private fieldsDelegate: AddonModDataFieldsDelegate,
private dataOffline: AddonModDataOfflineProvider, private fileUploaderProvider: CoreFileUploaderProvider,
private textUtils: CoreTextUtilsProvider, private eventsProvider: CoreEventsProvider, private utils: CoreUtilsProvider,
private domUtils: CoreDomUtilsProvider, private courseProvider: CoreCourseProvider,
private ratingOffline: CoreRatingOfflineProvider) {}
constructor(
protected sitesProvider: CoreSitesProvider,
protected dataProvider: AddonModDataProvider,
protected translate: TranslateService,
protected fieldsDelegate: AddonModDataFieldsDelegate,
protected dataOffline: AddonModDataOfflineProvider,
protected fileUploaderProvider: CoreFileUploaderProvider,
protected textUtils: CoreTextUtilsProvider,
protected eventsProvider: CoreEventsProvider,
protected domUtils: CoreDomUtilsProvider,
protected courseProvider: CoreCourseProvider,
protected ratingOffline: CoreRatingOfflineProvider
) {}
/**
* Returns the record with the offline actions applied.
@ -210,33 +218,21 @@ export class AddonModDataHelperProvider {
*
* @param data Database object.
* @param fields The fields that define the contents.
* @param groupId Group ID.
* @param search Search text. It will be used if advSearch is not defined.
* @param advSearch Advanced search data.
* @param sort Sort the records by this field id, reserved ids are:
* 0: timeadded
* -1: firstname
* -2: lastname
* -3: approved
* -4: timemodified.
* Empty for using the default database setting.
* @param order The direction of the sorting: 'ASC' or 'DESC'.
* Empty for using the default database setting.
* @param page Page of records to return.
* @param perPage Records per page to return. Default on PER_PAGE.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the database is retrieved.
*/
fetchEntries(data: any, fields: any[], groupId: number = 0, search?: string, advSearch?: any[], sort: string = '0',
order: string = 'DESC', page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string):
Promise<AddonModDataEntries> {
return this.sitesProvider.getSite(siteId).then((site) => {
fetchEntries(data: any, fields: any[], options: AddonModDataSearchEntriesOptions = {}): Promise<AddonModDataEntries> {
options.groupId = options.groupId || 0;
options.page = options.page || 0;
return this.sitesProvider.getSite(options.siteId).then((site) => {
const offlineActions = {};
const result: AddonModDataEntries = {
entries: [],
totalcount: 0,
offlineEntries: []
};
options.siteId = site.id;
const offlinePromise = this.dataOffline.getDatabaseEntries(data.id, site.id).then((actions) => {
result.hasOfflineActions = !!actions.length;
@ -248,8 +244,8 @@ export class AddonModDataHelperProvider {
offlineActions[action.entryid].push(action);
// We only display new entries in the first page when not searching.
if (action.action == 'add' && page == 0 && !search && !advSearch &&
(!action.groupid || !groupId || action.groupid == groupId)) {
if (action.action == 'add' && options.page == 0 && !options.search && !options.advSearch &&
(!action.groupid || !options.groupId || action.groupid == options.groupId)) {
result.offlineEntries.push({
id: action.entryid,
canmanageentry: true,
@ -275,16 +271,14 @@ export class AddonModDataHelperProvider {
});
let fetchPromise: Promise<void>;
if (search || advSearch) {
fetchPromise = this.dataProvider.searchEntries(data.id, groupId, search, advSearch, sort, order, page, perPage,
site.id).then((fetchResult) => {
if (options.search || options.advSearch) {
fetchPromise = this.dataProvider.searchEntries(data.id, options).then((fetchResult) => {
result.entries = fetchResult.entries;
result.totalcount = fetchResult.totalcount;
result.maxcount = fetchResult.maxcount;
});
} else {
fetchPromise = this.dataProvider.getEntries(data.id, groupId, sort, order, page, perPage, false, false, site.id)
.then((fetchResult) => {
fetchPromise = this.dataProvider.getEntries(data.id, options).then((fetchResult) => {
result.entries = fetchResult.entries;
result.totalcount = fetchResult.totalcount;
});
@ -324,7 +318,7 @@ export class AddonModDataHelperProvider {
if (entryId > 0) {
// Online entry.
promise = this.dataProvider.getEntry(data.id, entryId, false, site.id);
promise = this.dataProvider.getEntry(data.id, entryId, {cmId: data.coursemodule, siteId: site.id});
} else {
// Offline entry or new entry.
promise = Promise.resolve({
@ -644,35 +638,44 @@ export class AddonModDataHelperProvider {
* @param courseId Course ID. It not defined, it will be fetched.
* @param siteId Site ID. If not defined, current site.
*/
showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): void {
async showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): Promise<void> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
this.domUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord').then(() => {
const modal = this.domUtils.showModalLoading();
let modal;
try {
await this.domUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord');
return this.getActivityCourseIdIfNotSet(dataId, courseId, siteId).then((courseId) => {
return this.dataProvider.deleteEntry(dataId, entryId, courseId, siteId);
}).catch((message) => {
modal = this.domUtils.showModalLoading();
try {
if (entryId > 0) {
courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId);
}
this.dataProvider.deleteEntry(dataId, entryId, courseId, siteId);
} catch (message) {
this.domUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true);
return Promise.reject(null);
}).then(() => {
return this.utils.allPromises([
this.dataProvider.invalidateEntryData(dataId, entryId, siteId),
this.dataProvider.invalidateEntriesData(dataId, siteId)
]).catch(() => {
// Ignore errors.
});
}).then(() => {
this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId, entryId, deleted: true}, siteId);
modal && modal.dismiss();
this.domUtils.showToast('addon.mod_data.recorddeleted', true, 3000);
}).finally(() => {
modal.dismiss();
});
}).catch(() => {
return;
}
try {
await this.dataProvider.invalidateEntryData(dataId, entryId, siteId);
await this.dataProvider.invalidateEntriesData(dataId, siteId);
} catch (error) {
// Ignore errors.
}
this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId, entryId, deleted: true}, siteId);
this.domUtils.showToast('addon.mod_data.recorddeleted', true, 3000);
} catch (error) {
// Ignore error, it was already displayed.
});
}
modal && modal.dismiss();
}
/**

View File

@ -16,13 +16,13 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreCommentsProvider } from '@core/comments/providers/comments';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseProvider, CoreCourseCommonModWSOptions } from '@core/course/providers/course';
import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler';
import { AddonModDataProvider, AddonModDataEntry } from './data';
import { AddonModDataSyncProvider } from './sync';
@ -65,16 +65,17 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl
*
* @param dataId Database Id.
* @param groups Array of groups in the activity.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID.
* @param options Other options.
* @return All unique entries.
*/
protected getAllUniqueEntries(dataId: number, groups: any[], forceCache: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<AddonModDataEntry[]> {
protected getAllUniqueEntries(dataId: number, groups: any[], options: CoreSitesCommonWSOptions = {})
: Promise<AddonModDataEntry[]> {
const promises = groups.map((group) => {
return this.dataProvider.fetchAllEntries(dataId, group.id, undefined, undefined, undefined, forceCache, ignoreCache,
siteId);
return this.dataProvider.fetchAllEntries(dataId, {
groupId: group.id,
...options, // Include all options.
});
});
return Promise.all(promises).then((responses) => {
@ -96,31 +97,30 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl
* @param module Module to get the files.
* @param courseId Course ID the module belongs to.
* @param omitFail True to always return even if fails. Default false.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID.
* @param options Other options.
* @return Promise resolved with the info fetched.
*/
protected getDatabaseInfoHelper(module: any, courseId: number, omitFail: boolean = false, forceCache: boolean = false,
ignoreCache: boolean = false, siteId?: string): Promise<any> {
protected getDatabaseInfoHelper(module: any, courseId: number, omitFail: boolean, options: CoreCourseCommonModWSOptions = {})
: Promise<any> {
let database,
groups = [],
entries = [],
files = [];
siteId = siteId || this.sitesProvider.getCurrentSiteId();
options.cmId = options.cmId || module.id;
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
return this.dataProvider.getDatabase(courseId, module.id, siteId, forceCache).then((data) => {
return this.dataProvider.getDatabase(courseId, module.id, options).then((data) => {
files = this.getIntroFilesFromInstance(module, data);
database = data;
return this.groupsProvider.getActivityGroupInfo(module.id, false, undefined, siteId).then((groupInfo) => {
return this.groupsProvider.getActivityGroupInfo(module.id, false, undefined, options.siteId).then((groupInfo) => {
if (!groupInfo.groups || groupInfo.groups.length == 0) {
groupInfo.groups = [{id: 0}];
}
groups = groupInfo.groups;
return this.getAllUniqueEntries(database.id, groups, forceCache, ignoreCache, siteId);
return this.getAllUniqueEntries(database.id, groups, options);
});
}).then((uniqueEntries) => {
entries = uniqueEntries;
@ -229,8 +229,10 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl
* @return Promise resolved with true if downloadable, resolved with false otherwise.
*/
isDownloadable(module: any, courseId: number): boolean | Promise<boolean> {
return this.dataProvider.getDatabase(courseId, module.id, undefined, true).then((database) => {
return this.dataProvider.getDatabaseAccessInformation(database.id).then((accessData) => {
return this.dataProvider.getDatabase(courseId, module.id, {
readingStrategy: CoreSitesReadingStrategy.PreferCache,
}).then((database) => {
return this.dataProvider.getDatabaseAccessInformation(database.id, {cmId: module.id}).then((accessData) => {
// Check if database is restricted by time.
if (!accessData.timeavailable) {
const time = this.timeUtils.timestamp();
@ -281,23 +283,31 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl
* @return Promise resolved when done.
*/
protected prefetchDatabase(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
const options = {
cmId: module.id,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
return this.getDatabaseInfoHelper(module, courseId, false, false, true, siteId).then((info) => {
return this.getDatabaseInfoHelper(module, courseId, false, options).then((info) => {
// Prefetch the database data.
const database = info.database,
commentsEnabled = !this.commentsProvider.areCommentsDisabledInSite(),
promises = [];
promises.push(this.dataProvider.getFields(database.id, false, true, siteId));
promises.push(this.dataProvider.getFields(database.id, options));
promises.push(this.filepoolProvider.addFilesToQueue(siteId, info.files, this.component, module.id));
info.groups.forEach((group) => {
promises.push(this.dataProvider.getDatabaseAccessInformation(database.id, group.id, false, true, siteId));
promises.push(this.dataProvider.getDatabaseAccessInformation(database.id, {
groupId: group.id,
...options, // Include all options.
}));
});
info.entries.forEach((entry) => {
promises.push(this.dataProvider.getEntry(database.id, entry.id, true, siteId));
promises.push(this.dataProvider.getEntry(database.id, entry.id, options));
if (commentsEnabled && database.comments) {
promises.push(this.commentsProvider.getComments('module', database.coursemodule, 'mod_data', entry.id,

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSyncBaseProvider } from '@classes/base-sync';
import { CoreAppProvider } from '@providers/app';
import { CoreUtilsProvider } from '@providers/utils/utils';
@ -188,7 +188,7 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider {
courseId = offlineActions[0].courseid;
// Send the answers.
return this.dataProvider.getDatabaseById(courseId, dataId, siteId).then((database) => {
return this.dataProvider.getDatabaseById(courseId, dataId, {siteId}).then((database) => {
data = database;
const offlineEntries = {};
@ -208,7 +208,7 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider {
}).then(() => {
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
return this.dataProvider.invalidateContent(data.cmid, courseId, siteId).catch(() => {
return this.dataProvider.invalidateContent(data.coursemodule, courseId, siteId).catch(() => {
// Ignore errors.
});
}
@ -233,18 +233,23 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider {
* @return Promise resolved if success, rejected otherwise.
*/
protected syncEntry(data: any, entryActions: AddonModDataOfflineAction[], result: any, siteId?: string): Promise<any> {
let discardError,
timePromise,
entryId = entryActions[0].entryid,
offlineId,
deleted = false;
let discardError;
let timePromise;
let entryId = entryActions[0].entryid;
let offlineId;
let deleted = false;
const editAction = entryActions.find((action) => action.action == 'add' || action.action == 'edit');
const approveAction = entryActions.find((action) => action.action == 'approve' || action.action == 'disapprove');
const deleteAction = entryActions.find((action) => action.action == 'delete');
const options = {
cmId: data.coursemodule,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
if (entryId > 0) {
timePromise = this.dataProvider.getEntry(data.id, entryId, true, siteId).then((entry) => {
timePromise = this.dataProvider.getEntry(data.id, entryId, options).then((entry) => {
return entry.entry.timemodified;
}).catch((error) => {
if (error && this.utils.isWebServiceError(error)) {
@ -402,7 +407,7 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider {
const promises = [];
results.forEach((result) => {
promises.push(this.dataProvider.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, siteId)
promises.push(this.dataProvider.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, {siteId})
.then((data) => {
const promises = [];

View File

@ -7,7 +7,7 @@
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -184,7 +184,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity
}
}).then(() => {
// Check if there are answers stored in offline.
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id);
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, {cmId: this.module.id});
}).then((accessData) => {
this.access = accessData;
this.showTabs = (accessData.canviewreports || accessData.canviewanalysis) && !accessData.isempty;
@ -220,7 +220,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity
const promises = [];
if (accessData.cancomplete && accessData.cansubmit && accessData.isopen) {
promises.push(this.feedbackProvider.getResumePage(this.feedback.id).then((goPage) => {
promises.push(this.feedbackProvider.getResumePage(this.feedback.id, {cmId: this.module.id}).then((goPage) => {
this.goPage = goPage > 0 ? goPage : false;
}));
}
@ -421,7 +421,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity
setGroup(groupId: number): Promise<any> {
this.group = groupId;
return this.feedbackProvider.getAnalysis(this.feedback.id, groupId).then((analysis) => {
return this.feedbackProvider.getAnalysis(this.feedback.id, {groupId, cmId: this.module.id}).then((analysis) => {
this.feedback.completedCount = analysis.completedcount;
this.feedback.itemsCount = analysis.itemscount;

View File

@ -65,7 +65,7 @@ export class AddonModFeedbackAttemptPage {
return this.feedbackProvider.getFeedbackById(this.courseId, this.feedbackId).then((feedback) => {
this.feedback = feedback;
return this.feedbackProvider.getItems(this.feedbackId);
return this.feedbackProvider.getItems(this.feedbackId, {cmId: this.feedback.coursemodule});
}).then((items) => {
// Add responses and format items.
this.items = items.items.map((item) => {

View File

@ -27,7 +27,7 @@ import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseHelperProvider } from '@core/course/providers/helper';
import { CoreLoginHelperProvider } from '@core/login/providers/helper';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
/**
* Page that displays feedback form.
@ -141,6 +141,10 @@ export class AddonModFeedbackFormPage implements OnDestroy {
*/
protected fetchData(): Promise<any> {
this.offline = !this.appProvider.isOnline();
const options = {
cmId: this.module.id,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
};
return this.feedbackProvider.getFeedback(this.courseId, this.module.id).then((feedbackData) => {
this.feedback = feedbackData;
@ -151,8 +155,7 @@ export class AddonModFeedbackFormPage implements OnDestroy {
}).then((accessData) => {
if (!this.preview && accessData.cansubmit && !accessData.isempty) {
return typeof this.currentPage == 'undefined' ?
this.feedbackProvider.getResumePage(this.feedback.id, this.offline, true) :
Promise.resolve(this.currentPage);
this.feedbackProvider.getResumePage(this.feedback.id, options) : Promise.resolve(this.currentPage);
} else {
this.preview = true;
@ -162,8 +165,9 @@ export class AddonModFeedbackFormPage implements OnDestroy {
if (!this.offline && !this.utils.isWebServiceError(error)) {
// If it fails, go offline.
this.offline = true;
options.readingStrategy = CoreSitesReadingStrategy.PreferCache;
return this.feedbackProvider.getResumePage(this.feedback.id, true);
return this.feedbackProvider.getResumePage(this.feedback.id, options);
}
return Promise.reject(error);
@ -186,12 +190,18 @@ export class AddonModFeedbackFormPage implements OnDestroy {
* @return Promise resolved when done.
*/
protected fetchAccessData(): Promise<any> {
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, this.offline, true).catch((error) => {
const options = {
cmId: this.module.id,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
};
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, options).catch((error) => {
if (!this.offline && !this.utils.isWebServiceError(error)) {
// If it fails, go offline.
this.offline = true;
options.readingStrategy = CoreSitesReadingStrategy.PreferCache;
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, true);
return this.feedbackProvider.getFeedbackAccessInformation(this.feedback.id, options);
}
return Promise.reject(error);
@ -203,20 +213,25 @@ export class AddonModFeedbackFormPage implements OnDestroy {
}
protected fetchFeedbackPageData(page: number = 0): Promise<void> {
const options = {
cmId: this.module.id,
readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork,
};
let promise;
this.items = [];
if (this.preview) {
promise = this.feedbackProvider.getItems(this.feedback.id);
promise = this.feedbackProvider.getItems(this.feedback.id, {cmId: this.module.id});
} else {
this.currentPage = page;
promise = this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, this.offline, true).catch((error) => {
promise = this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, options).catch((error) => {
if (!this.offline && !this.utils.isWebServiceError(error)) {
// If it fails, go offline.
this.offline = true;
options.readingStrategy = CoreSitesReadingStrategy.PreferCache;
return this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, true);
return this.feedbackProvider.getPageItemsWithValues(this.feedback.id, page, options);
}
return Promise.reject(error);
@ -262,8 +277,12 @@ export class AddonModFeedbackFormPage implements OnDestroy {
return this.feedbackSync.syncFeedback(this.feedback.id).catch(() => {
// Ignore errors.
}).then(() => {
return this.feedbackProvider.processPage(this.feedback.id, this.currentPage, responses, goPrevious, formHasErrors,
this.courseId).then((response) => {
return this.feedbackProvider.processPage(this.feedback.id, this.currentPage, responses, {
goPrevious,
formHasErrors,
courseId: this.courseId,
cmId: this.module.id,
}).then((response) => {
const jumpTo = parseInt(response.jumpto, 10);
if (response.completed) {

View File

@ -111,7 +111,11 @@ export class AddonModFeedbackNonRespondentsPage {
this.feedbackLoaded = false;
}
return this.feedbackHelper.getNonRespondents(this.feedbackId, this.selectedGroup, this.page).then((response) => {
return this.feedbackHelper.getNonRespondents(this.feedbackId, {
groupId: this.selectedGroup,
page: this.page,
cmId: this.moduleId,
}).then((response) => {
this.total = response.total;
if (this.users.length < response.total) {

View File

@ -134,7 +134,11 @@ export class AddonModFeedbackRespondentsPage {
this.feedbackLoaded = false;
}
return this.feedbackHelper.getResponsesAnalysis(this.feedbackId, this.selectedGroup, this.page).then((responses) => {
return this.feedbackHelper.getResponsesAnalysis(this.feedbackId, {
groupId: this.selectedGroup,
page: this.page,
cmId: this.moduleId,
}).then((responses) => {
this.responses.total = responses.totalattempts;
this.anonResponses.total = responses.totalanonattempts;

View File

@ -14,13 +14,14 @@
import { Injectable } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreAppProvider } from '@providers/app';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { AddonModFeedbackOfflineProvider } from './offline';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSite } from '@classes/site';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
/**
* Service that provides some features for feedbacks.
@ -35,7 +36,7 @@ export class AddonModFeedbackProvider {
static MULTICHOICE_HIDENOSELECT = 'h';
static MULTICHOICERATED_VALUE_SEP = '####';
protected ROOT_CACHE_KEY = this.ROOT_CACHE_KEY + '';
protected ROOT_CACHE_KEY = '';
protected logger;
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider,
@ -130,13 +131,11 @@ export class AddonModFeedbackProvider {
*
* @param feedbackId Feedback ID.
* @param items Item to fill the value.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID.
* @param options Other options.
* @return Resolved with values when done.
*/
protected fillValues(feedbackId: number, items: any[], offline: boolean, ignoreCache: boolean, siteId: string): Promise<any> {
return this.getCurrentValues(feedbackId, offline, ignoreCache, siteId).then((valuesArray) => {
protected fillValues(feedbackId: number, items: any[], options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.getCurrentValues(feedbackId, options).then((valuesArray) => {
const values = {};
valuesArray.forEach((value) => {
@ -152,7 +151,7 @@ export class AddonModFeedbackProvider {
// Ignore errors.
}).then(() => {
// Merge with offline data.
return this.feedbackOffline.getFeedbackResponses(feedbackId, siteId).then((offlineValuesArray) => {
return this.feedbackOffline.getFeedbackResponses(feedbackId, options.siteId).then((offlineValuesArray) => {
const offlineValues = {};
// Merge all values into one array.
@ -203,24 +202,22 @@ export class AddonModFeedbackProvider {
* Returns all the feedback non respondents users.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @param previous Only for recurrent use. Object with the previous fetched info.
* @return Promise resolved when the info is retrieved.
*/
getAllNonRespondents(feedbackId: number, groupId: number, ignoreCache?: boolean, siteId?: string, previous?: any)
: Promise<any> {
getAllNonRespondents(feedbackId: number, options: AddonModFeedbackGroupOptions = {}, previous?: any): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (typeof previous == 'undefined') {
previous = {
page: 0,
users: []
};
}
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
previous = previous || {
page: 0,
users: []
};
return this.getNonRespondents(feedbackId, groupId, previous.page, ignoreCache, siteId).then((response) => {
return this.getNonRespondents(feedbackId, {
page: previous.page,
...options, // Include all options.
}).then((response) => {
if (previous.users.length < response.total) {
previous.users = previous.users.concat(response.users);
}
@ -229,7 +226,7 @@ export class AddonModFeedbackProvider {
// Can load more.
previous.page++;
return this.getAllNonRespondents(feedbackId, groupId, ignoreCache, siteId, previous);
return this.getAllNonRespondents(feedbackId, options, previous);
}
previous.total = response.total;
@ -241,25 +238,23 @@ export class AddonModFeedbackProvider {
* Returns all the feedback user responses.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @param previous Only for recurrent use. Object with the previous fetched info.
* @return Promise resolved when the info is retrieved.
*/
getAllResponsesAnalysis(feedbackId: number, groupId: number, ignoreCache?: boolean, siteId?: string, previous?: any)
: Promise<any> {
getAllResponsesAnalysis(feedbackId: number, options: AddonModFeedbackGroupOptions = {}, previous?: any): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (typeof previous == 'undefined') {
previous = {
page: 0,
attempts: [],
anonattempts: []
};
}
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
previous = previous || {
page: 0,
attempts: [],
anonattempts: []
};
return this.getResponsesAnalysis(feedbackId, groupId, previous.page, ignoreCache, siteId).then((responses) => {
return this.getResponsesAnalysis(feedbackId, {
page: previous.page,
...options, // Include all options.
}).then((responses) => {
if (previous.anonattempts.length < responses.totalanonattempts) {
previous.anonattempts = previous.anonattempts.concat(responses.anonattempts);
}
@ -272,7 +267,7 @@ export class AddonModFeedbackProvider {
// Can load more.
previous.page++;
return this.getAllResponsesAnalysis(feedbackId, groupId, ignoreCache, siteId, previous);
return this.getAllResponsesAnalysis(feedbackId, options, previous);
}
previous.totalattempts = responses.totalattempts;
@ -286,27 +281,23 @@ export class AddonModFeedbackProvider {
* Get analysis information for a given feedback.
*
* @param feedbackId Feedback ID.
* @param groupId Group ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the feedback is retrieved.
*/
getAnalysis(feedbackId: number, groupId?: number, ignoreCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getAnalysis(feedbackId: number, options: AddonModFeedbackGroupOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getAnalysisDataCacheKey(feedbackId, groupId)
};
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getAnalysisDataCacheKey(feedbackId, options.groupId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (groupId) {
params['groupid'] = groupId;
}
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
if (options.groupId) {
params['groupid'] = options.groupId;
}
return site.read('mod_feedback_get_analysis', params, preSets);
@ -339,22 +330,23 @@ export class AddonModFeedbackProvider {
*
* @param feedbackId Feedback ID.
* @param attemptId Attempt id to find.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @param previous Only for recurrent use. Object with the previous fetched info.
* @return Promise resolved when the info is retrieved.
*/
getAttempt(feedbackId: number, attemptId: number, ignoreCache?: boolean, siteId?: string, previous?: any): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (typeof previous == 'undefined') {
previous = {
page: 0,
attemptsLoaded: 0,
anonAttemptsLoaded: 0
};
}
getAttempt(feedbackId: number, attemptId: number, options: CoreCourseCommonModWSOptions = {}, previous?: any): Promise<any> {
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
previous = previous || {
page: 0,
attemptsLoaded: 0,
anonAttemptsLoaded: 0
};
return this.getResponsesAnalysis(feedbackId, 0, previous.page, ignoreCache, siteId).then((responses) => {
return this.getResponsesAnalysis(feedbackId, {
page: previous.page,
groupId: 0,
...options, // Include all options.
}).then((responses) => {
let attempt;
attempt = responses.attempts.find((attempt) => {
@ -385,7 +377,7 @@ export class AddonModFeedbackProvider {
// Can load more. Check there.
previous.page++;
return this.getAttempt(feedbackId, attemptId, ignoreCache, siteId, previous);
return this.getAttempt(feedbackId, attemptId, options, previous);
}
// Not found and all loaded. Reject.
@ -407,23 +399,20 @@ export class AddonModFeedbackProvider {
* Returns the temporary completion timemodified for the current user.
*
* @param feedbackId Feedback ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getCurrentCompletedTimeModified(feedbackId: number, ignoreCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getCurrentCompletedTimeModified(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getCurrentCompletedTimeModifiedDataCacheKey(feedbackId)
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getCurrentCompletedTimeModifiedDataCacheKey(feedbackId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_current_completed_tmp', params, preSets).then((response) => {
if (response && typeof response.feedback != 'undefined' && typeof response.feedback.timemodified != 'undefined') {
@ -452,26 +441,20 @@ export class AddonModFeedbackProvider {
* Returns the temporary responses or responses of the last submission for the current user.
*
* @param feedbackId Feedback ID.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getCurrentValues(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getCurrentValues(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets = {
cacheKey: this.getCurrentValuesDataCacheKey(feedbackId)
};
if (offline) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getCurrentValuesDataCacheKey(feedbackId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_unfinished_responses', params, preSets).then((response) => {
if (!response || typeof response.responses == 'undefined') {
@ -508,27 +491,20 @@ export class AddonModFeedbackProvider {
* Get access information for a given feedback.
*
* @param feedbackId Feedback ID.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the feedback is retrieved.
*/
getFeedbackAccessInformation(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string):
Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getFeedbackAccessInformation(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets = {
cacheKey: this.getFeedbackAccessInformationDataCacheKey(feedbackId)
};
if (offline) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getFeedbackAccessInformationDataCacheKey(feedbackId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_feedback_access_information', params, preSets);
});
@ -570,29 +546,22 @@ export class AddonModFeedbackProvider {
* @param courseId Course ID.
* @param key Name of the property to check.
* @param value Value to search.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the feedback is retrieved.
*/
protected getFeedbackDataByKey(courseId: number, key: string, value: any, siteId?: string, forceCache?: boolean,
ignoreCache?: boolean): Promise<any> {
protected getFeedbackDataByKey(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {})
: Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getFeedbackCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
if (forceCache) {
preSets.omitExpires = true;
} else if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
courseids: [courseId],
};
const preSets = {
cacheKey: this.getFeedbackCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModFeedbackProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_feedbacks_by_courses', params, preSets).then((response) => {
if (response && response.feedbacks) {
@ -614,13 +583,11 @@ export class AddonModFeedbackProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the feedback is retrieved.
*/
getFeedback(courseId: number, cmId: number, siteId?: string, forceCache?: boolean, ignoreCache?: boolean): Promise<any> {
return this.getFeedbackDataByKey(courseId, 'coursemodule', cmId, siteId, forceCache, ignoreCache);
getFeedback(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getFeedbackDataByKey(courseId, 'coursemodule', cmId, options);
}
/**
@ -628,37 +595,32 @@ export class AddonModFeedbackProvider {
*
* @param courseId Course ID.
* @param id Feedback ID.
* @param siteId Site ID. If not defined, current site.
* @param forceCache True to always get the value from cache, false otherwise. Default false.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param options Other options.
* @return Promise resolved when the feedback is retrieved.
*/
getFeedbackById(courseId: number, id: number, siteId?: string, forceCache?: boolean, ignoreCache?: boolean): Promise<any> {
return this.getFeedbackDataByKey(courseId, 'id', id, siteId, forceCache, ignoreCache);
getFeedbackById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getFeedbackDataByKey(courseId, 'id', id, options);
}
/**
* Returns the items (questions) in the given feedback.
*
* @param feedbackId Feedback ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getItems(feedbackId: number, ignoreCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getItems(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getItemsDataCacheKey(feedbackId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getItemsDataCacheKey(feedbackId),
updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_items', params, preSets);
});
@ -678,29 +640,25 @@ export class AddonModFeedbackProvider {
* Retrieves a list of students who didn't submit the feedback.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param page The page of records to return.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getNonRespondents(feedbackId: number, groupId: number = 0, page: number = 0, ignoreCache?: boolean, siteId?: string)
: Promise<any> {
getNonRespondents(feedbackId: number, options: AddonModFeedbackGroupPaginatedOptions = {}): Promise<any> {
options.groupId = options.groupId || 0;
options.page = options.page || 0;
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId,
groupid: groupId,
page: page
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getNonRespondentsDataCacheKey(feedbackId, groupId)
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
feedbackid: feedbackId,
groupid: options.groupId,
page: options.page,
};
const preSets = {
cacheKey: this.getNonRespondentsDataCacheKey(feedbackId, options.groupId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_non_respondents', params, preSets);
});
@ -751,25 +709,22 @@ export class AddonModFeedbackProvider {
*
* @param feedbackId Feedback ID.
* @param page The page to get.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getPageItemsWithValues(feedbackId: number, page: number, offline: boolean = false, ignoreCache: boolean = false,
siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
getPageItemsWithValues(feedbackId: number, page: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
return this.getPageItems(feedbackId, page, siteId).then((response) => {
return this.fillValues(feedbackId, response.items, offline, ignoreCache, siteId).then((items) => {
return this.getPageItems(feedbackId, page, options.siteId).then((response) => {
return this.fillValues(feedbackId, response.items, options).then((items) => {
response.items = items;
return response;
});
}).catch(() => {
// If getPageItems fail we should calculate it using getItems.
return this.getItems(feedbackId, false, siteId).then((response) => {
return this.fillValues(feedbackId, response.items, offline, ignoreCache, siteId).then((items) => {
return this.getItems(feedbackId, options).then((response) => {
return this.fillValues(feedbackId, response.items, options).then((items) => {
// Separate items by pages.
let currentPage = 0;
const previousPageItems = [];
@ -819,11 +774,17 @@ export class AddonModFeedbackProvider {
* @param feedbackId Feedback ID.
* @param page Page where we want to jump.
* @param changePage If page change is forward (1) or backward (-1).
* @param siteId Site ID.
* @param options Other options.
* @return Page number where to jump. Or false if completed or first page.
*/
protected getPageJumpTo(feedbackId: number, page: number, changePage: number, siteId: string): Promise<number | false> {
return this.getPageItemsWithValues(feedbackId, page, true, false, siteId).then((resp) => {
protected getPageJumpTo(feedbackId: number, page: number, changePage: number, options: {cmId?: number, siteId?: string})
: Promise<number | false> {
return this.getPageItemsWithValues(feedbackId, page, {
cmId: options.cmId,
readingStrategy: CoreSitesReadingStrategy.PreferCache,
siteId: options.siteId,
}).then((resp) => {
// The page we are going has items.
if (resp.items.length > 0) {
return page;
@ -831,7 +792,7 @@ export class AddonModFeedbackProvider {
// Check we can jump futher.
if ((changePage == 1 && resp.hasnextpage) || (changePage == -1 && resp.hasprevpage)) {
return this.getPageJumpTo(feedbackId, page + changePage, changePage, siteId);
return this.getPageJumpTo(feedbackId, page + changePage, changePage, options);
}
// Completed or first page.
@ -843,27 +804,25 @@ export class AddonModFeedbackProvider {
* Returns the feedback user responses.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param page The page of records to return.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getResponsesAnalysis(feedbackId: number, groupId: number, page: number, ignoreCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
feedbackid: feedbackId,
groupid: groupId || 0,
page: page || 0
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getResponsesAnalysisDataCacheKey(feedbackId, groupId)
};
getResponsesAnalysis(feedbackId: number, options: AddonModFeedbackGroupPaginatedOptions = {}): Promise<any> {
options.groupId = options.groupId || 0;
options.page = options.page || 0;
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId,
groupid: options.groupId,
page: options.page,
};
const preSets = {
cacheKey: this.getResponsesAnalysisDataCacheKey(feedbackId, options.groupId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_get_responses_analysis', params, preSets);
});
@ -894,26 +853,20 @@ export class AddonModFeedbackProvider {
* Gets the resume page information.
*
* @param feedbackId Feedback ID.
* @param offline True if it should return cached data. Has priority over ignoreCache.
* @param ignoreCache True if it should ignore cached data (it always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getResumePage(feedbackId: number, offline: boolean = false, ignoreCache: boolean = false, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getResumePage(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets = {
cacheKey: this.getResumePageDataCacheKey(feedbackId)
};
if (offline) {
preSets['omitExpires'] = true;
} else if (ignoreCache) {
preSets['getFromCache'] = false;
preSets['emergencyCache'] = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getResumePageDataCacheKey(feedbackId),
component: AddonModFeedbackProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_feedback_launch_feedback', params, preSets).then((response) => {
if (response && typeof response.gopage != 'undefined') {
@ -964,7 +917,7 @@ export class AddonModFeedbackProvider {
/**
* Invalidate the prefetched content.
* To invalidate files, use AddonFeedbackProvider#invalidateFiles.
* To invalidate files, use AddonModFeedbackProvider#invalidateFiles.
*
* @param moduleId The module ID.
* @param courseId Course ID of the module.
@ -976,7 +929,7 @@ export class AddonModFeedbackProvider {
const promises = [];
promises.push(this.getFeedback(courseId, moduleId, siteId).then((feedback) => {
promises.push(this.getFeedback(courseId, moduleId, {siteId}).then((feedback) => {
const ps = [];
// Do not invalidate module data before getting module info, we need it!
@ -1086,23 +1039,20 @@ export class AddonModFeedbackProvider {
* Returns if feedback has been completed
*
* @param feedbackId Feedback ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
isCompleted(feedbackId: number, ignoreCache?: boolean, siteId?: string): Promise<boolean> {
return this.sitesProvider.getSite(siteId).then((site) => {
isCompleted(feedbackId: number, options: CoreCourseCommonModWSOptions = {}): Promise<boolean> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
feedbackid: feedbackId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getCompletedDataCacheKey(feedbackId)
};
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
feedbackid: feedbackId,
};
const preSets = {
cacheKey: this.getCompletedDataCacheKey(feedbackId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModFeedbackProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return this.utils.promiseWorks(site.read('mod_feedback_get_last_completed', params, preSets));
});
@ -1147,19 +1097,15 @@ export class AddonModFeedbackProvider {
* @param feedbackId Feedback ID.
* @param page The page being processed.
* @param responses The data to be processed the key is the field name (usually type[index]_id).
* @param goPrevious Whether we want to jump to previous page.
* @param formHasErrors Whether the form we sent has required but empty fields (only used in offline).
* @param courseId Course ID the feedback belongs to.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
processPage(feedbackId: number, page: number, responses: any, goPrevious: boolean, formHasErrors: boolean, courseId: number,
siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
processPage(feedbackId: number, page: number, responses: any, options: AddonModFeedbackProcessPageOptions = {}): Promise<any> {
options.siteId = options.siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a message to be synchronized later.
const storeOffline = (): Promise<any> => {
return this.feedbackOffline.saveResponses(feedbackId, page, responses, courseId, siteId).then(() => {
return this.feedbackOffline.saveResponses(feedbackId, page, responses, options.courseId, options.siteId).then(() => {
// Simulate process_page response.
const response = {
jumpto: page,
@ -1168,11 +1114,11 @@ export class AddonModFeedbackProvider {
};
let changePage = 0;
if (goPrevious) {
if (options.goPrevious) {
if (page > 0) {
changePage = -1;
}
} else if (!formHasErrors) {
} else if (!options.formHasErrors) {
// We can only go next if it has no errors.
changePage = 1;
}
@ -1181,7 +1127,11 @@ export class AddonModFeedbackProvider {
return response;
}
return this.getPageItemsWithValues(feedbackId, page, true, false, siteId).then((resp) => {
return this.getPageItemsWithValues(feedbackId, page, {
cmId: options.cmId,
readingStrategy: CoreSitesReadingStrategy.PreferCache,
siteId: options.siteId,
}).then((resp) => {
// Check completion.
if (changePage == 1 && !resp.hasnextpage) {
response.completed = true;
@ -1189,7 +1139,7 @@ export class AddonModFeedbackProvider {
return response;
}
return this.getPageJumpTo(feedbackId, page + changePage, changePage, siteId).then((loadPage) => {
return this.getPageJumpTo(feedbackId, page + changePage, changePage, options).then((loadPage) => {
if (loadPage === false) {
// Completed or first page.
if (changePage == -1) {
@ -1215,8 +1165,8 @@ export class AddonModFeedbackProvider {
}
// If there's already a response to be sent to the server, discard it first.
return this.feedbackOffline.deleteFeedbackPageResponses(feedbackId, page, siteId).then(() => {
return this.processPageOnline(feedbackId, page, responses, goPrevious, siteId).catch((error) => {
return this.feedbackOffline.deleteFeedbackPageResponses(feedbackId, page, options.siteId).then(() => {
return this.processPageOnline(feedbackId, page, responses, options.goPrevious, options.siteId).catch((error) => {
if (this.utils.isWebServiceError(error)) {
// The WebService has thrown an error, this means that responses cannot be submitted.
return Promise.reject(error);
@ -1252,7 +1202,7 @@ export class AddonModFeedbackProvider {
}).then((response) => {
// Invalidate and update current values because they will change.
return this.invalidateCurrentValuesData(feedbackId, site.getId()).then(() => {
return this.getCurrentValues(feedbackId, false, false, site.getId());
return this.getCurrentValues(feedbackId, {siteId: site.getId()});
}).catch(() => {
// Ignore errors.
}).then(() => {
@ -1262,3 +1212,28 @@ export class AddonModFeedbackProvider {
});
}
}
/**
* Common options with a group ID.
*/
export type AddonModFeedbackGroupOptions = CoreCourseCommonModWSOptions & {
groupId?: number; // Group id, 0 means that the function will determine the user group. Defaults to 0.
};
/**
* Common options with a group ID and page.
*/
export type AddonModFeedbackGroupPaginatedOptions = AddonModFeedbackGroupOptions & {
page?: number; // The page of records to return. The page of records to return.
};
/**
* Common options with a group ID and page.
*/
export type AddonModFeedbackProcessPageOptions = {
goPrevious?: boolean; // Whether we want to jump to previous page.
formHasErrors?: boolean; // Whether the form we sent has required but empty fields (only used in offline).
cmId?: number; // Module ID.
courseId?: number; // Course ID the feedback belongs to.
siteId?: string; // Site ID. If not defined, current site.;
};

View File

@ -14,11 +14,11 @@
import { Injectable } from '@angular/core';
import { NavController, ViewController } from 'ionic-angular';
import { AddonModFeedbackProvider } from './feedback';
import { AddonModFeedbackProvider, AddonModFeedbackGroupPaginatedOptions } from './feedback';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
@ -86,12 +86,11 @@ export class AddonModFeedbackHelperProvider {
* Retrieves a list of students who didn't submit the feedback with extra info.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param page The page of records to return.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getNonRespondents(feedbackId: number, groupId: number, page: number): Promise<any> {
return this.feedbackProvider.getNonRespondents(feedbackId, groupId, page).then((responses) => {
getNonRespondents(feedbackId: number, options: AddonModFeedbackGroupPaginatedOptions = {}): Promise<any> {
return this.feedbackProvider.getNonRespondents(feedbackId, options).then((responses) => {
return this.addImageProfileToAttempts(responses.users).then((users) => {
responses.users = users;
@ -186,12 +185,11 @@ export class AddonModFeedbackHelperProvider {
* Returns the feedback user responses with extra info.
*
* @param feedbackId Feedback ID.
* @param groupId Group id, 0 means that the function will determine the user group.
* @param page The page of records to return.
* @param options Other options.
* @return Promise resolved when the info is retrieved.
*/
getResponsesAnalysis(feedbackId: number, groupId: number, page: number): Promise<any> {
return this.feedbackProvider.getResponsesAnalysis(feedbackId, groupId, page).then((responses) => {
getResponsesAnalysis(feedbackId: number, options: AddonModFeedbackGroupPaginatedOptions = {}): Promise<any> {
return this.feedbackProvider.getResponsesAnalysis(feedbackId, options).then((responses) => {
return this.addImageProfileToAttempts(responses.attempts).then((attempts) => {
responses.attempts = attempts;
@ -227,7 +225,11 @@ export class AddonModFeedbackHelperProvider {
return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackRespondentsPage', stateParams, siteId);
}
return this.feedbackProvider.getAttempt(module.instance, params.showcompleted, true, siteId).then((attempt) => {
return this.feedbackProvider.getAttempt(module.instance, params.showcompleted, {
cmId: moduleId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
}).then((attempt) => {
stateParams = {
moduleId: module.id,
attempt: attempt,

View File

@ -16,7 +16,7 @@ import { Injectable, Injector } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
@ -143,7 +143,9 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
* @return Promise resolved with true if downloadable, resolved with false otherwise.
*/
isDownloadable(module: any, courseId: number): boolean | Promise<boolean> {
return this.feedbackProvider.getFeedback(courseId, module.id, undefined, true).then((feedback) => {
return this.feedbackProvider.getFeedback(courseId, module.id, {
readingStrategy: CoreSitesReadingStrategy.PreferCache,
}).then((feedback) => {
const now = this.timeUtils.timestamp();
// Check time first if available.
@ -154,7 +156,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
return false;
}
return this.feedbackProvider.getFeedbackAccessInformation(feedback.id).then((accessData) => {
return this.feedbackProvider.getFeedbackAccessInformation(feedback.id, {cmId: module.id}).then((accessData) => {
return accessData.isopen;
});
});
@ -192,15 +194,24 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
* @return Promise resolved when done.
*/
protected prefetchFeedback(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
const commonOptions = {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
const modOptions = {
cmId: module.id,
...commonOptions, // Include all common options.
};
// Prefetch the feedback data.
return this.feedbackProvider.getFeedback(courseId, module.id, siteId, false, true).then((feedback) => {
return this.feedbackProvider.getFeedback(courseId, module.id, commonOptions).then((feedback) => {
let files = (feedback.pageaftersubmitfiles || []).concat(this.getIntroFilesFromInstance(module, feedback));
return this.feedbackProvider.getFeedbackAccessInformation(feedback.id, false, true, siteId).then((accessData) => {
return this.feedbackProvider.getFeedbackAccessInformation(feedback.id, modOptions).then((accessData) => {
const p2 = [];
if (accessData.canedititems || accessData.canviewreports) {
// Get all groups analysis.
p2.push(this.feedbackProvider.getAnalysis(feedback.id, undefined, true, siteId));
p2.push(this.feedbackProvider.getAnalysis(feedback.id, modOptions));
p2.push(this.groupsProvider.getActivityGroupInfo(feedback.coursemodule, true, undefined, siteId, true)
.then((groupInfo) => {
const p3 = [];
@ -209,11 +220,16 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
groupInfo.groups = [{id: 0}];
}
groupInfo.groups.forEach((group) => {
p3.push(this.feedbackProvider.getAnalysis(feedback.id, group.id, true, siteId));
p3.push(this.feedbackProvider.getAllResponsesAnalysis(feedback.id, group.id, true, siteId));
const groupOptions = {
groupId: group.id,
...modOptions, // Include all mod options.
};
p3.push(this.feedbackProvider.getAnalysis(feedback.id, groupOptions));
p3.push(this.feedbackProvider.getAllResponsesAnalysis(feedback.id, groupOptions));
if (!accessData.isanonymous) {
p3.push(this.feedbackProvider.getAllNonRespondents(feedback.id, group.id, true, siteId));
p3.push(this.feedbackProvider.getAllNonRespondents(feedback.id, groupOptions));
}
});
@ -221,7 +237,7 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
}));
}
p2.push(this.feedbackProvider.getItems(feedback.id, true, siteId).then((response) => {
p2.push(this.feedbackProvider.getItems(feedback.id, commonOptions).then((response) => {
response.items.forEach((item) => {
files = files.concat(item.itemfiles);
});
@ -234,8 +250,8 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH
p2.push(this.feedbackProvider.processPageOnline(feedback.id, 0, {}, undefined, siteId).finally(() => {
const p4 = [];
p4.push(this.feedbackProvider.getCurrentValues(feedback.id, false, true, siteId));
p4.push(this.feedbackProvider.getResumePage(feedback.id, false, true, siteId));
p4.push(this.feedbackProvider.getCurrentValues(feedback.id, modOptions));
p4.push(this.feedbackProvider.getResumePage(feedback.id, modOptions));
return Promise.all(p4);
}));

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreAppProvider } from '@providers/app';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreTextUtilsProvider } from '@providers/utils/text';
@ -192,12 +192,12 @@ export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProv
courseId = responses[0].courseid;
return this.feedbackProvider.getFeedbackById(courseId, feedbackId, siteId).then((feedbackData) => {
return this.feedbackProvider.getFeedbackById(courseId, feedbackId, {siteId}).then((feedbackData) => {
feedback = feedbackData;
if (!feedback.multiple_submit) {
// If it does not admit multiple submits, check if it is completed to know if we can submit.
return this.feedbackProvider.isCompleted(feedbackId);
return this.feedbackProvider.isCompleted(feedbackId, {cmId: feedback.coursemodule, siteId});
} else {
return false;
}
@ -220,7 +220,10 @@ export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProv
return Promise.all(promises);
}
return this.feedbackProvider.getCurrentCompletedTimeModified(feedbackId, true, siteId).then((timemodified) => {
return this.feedbackProvider.getCurrentCompletedTimeModified(feedbackId, {
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
}).then((timemodified) => {
// Sort by page.
responses.sort((a, b) => {
return a.page - b.page;

View File

@ -6,7 +6,7 @@
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" [iconAction]="'fa-newspaper-o'" (action)="gotoBlog($event)"></core-context-menu-item>
<core-context-menu-item *ngIf="!subfolder" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="600" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="500" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="500" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -14,7 +14,7 @@
import { Injectable } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions } from '@providers/sites';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
@ -41,11 +41,11 @@ export class AddonModFolderProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the book is retrieved.
*/
getFolder(courseId: number, cmId: number, siteId?: string): Promise<AddonModFolderFolder> {
return this.getFolderByKey(courseId, 'coursemodule', cmId, siteId);
getFolder(courseId: number, cmId: number, options?: CoreSitesCommonWSOptions): Promise<AddonModFolderFolder> {
return this.getFolderByKey(courseId, 'coursemodule', cmId, options);
}
/**
@ -54,18 +54,21 @@ export class AddonModFolderProvider {
* @param courseId Course ID.
* @param key Name of the property to check.
* @param value Value to search.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the book is retrieved.
*/
protected getFolderByKey(courseId: number, key: string, value: any, siteId?: string): Promise<AddonModFolderFolder> {
return this.sitesProvider.getSite(siteId).then((site) => {
protected getFolderByKey(courseId: number, key: string, value: any, options: CoreSitesCommonWSOptions = {})
: Promise<AddonModFolderFolder> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
},
preSets = {
cacheKey: this.getFolderCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
};
courseids: [courseId],
};
const preSets = {
cacheKey: this.getFolderCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModFolderProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_folder_get_folders_by_courses', params, preSets)
.then((response: AddonModFolderGetFoldersByCoursesResult): any => {

View File

@ -49,7 +49,7 @@ export class AddonForumDiscussionOptionsMenuComponent implements OnInit {
ngOnInit(): void {
if (this.forumProvider.isSetPinStateAvailableForSite()) {
// Use the canAddDiscussion WS to check if the user can pin discussions.
this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
this.forumProvider.canAddDiscussionToAll(this.forumId, {cmId: this.cmId}).then((response) => {
this.canPin = !!response.canpindiscussions;
}).catch(() => {
this.canPin = false;

View File

@ -7,7 +7,7 @@
<core-context-menu-item *ngIf="loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.removefiles' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}" [iconDescription]="'cube'" (action)="removeFiles($event)" [iconAction]="'trash'" [closeOnClick]="false"></core-context-menu-item>
<core-context-menu-item *ngIf="sortingAvailable" [priority]="300" [content]="'core.sort' | translate" (action)="showSortOrderSelector($event)" iconAction="fa-sort"></core-context-menu-item>
</core-context-menu>
</core-navbar-buttons>

View File

@ -133,7 +133,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
}
if (typeof data.deleted != 'undefined' && data.deleted) {
if (data.post.parent == 0 && this.splitviewCtrl && this.splitviewCtrl.isOn()) {
if (data.post.parentid == 0 && this.splitviewCtrl && this.splitviewCtrl.isOn()) {
// Discussion deleted, clear details page.
this.splitviewCtrl.emptyDetails();
}
@ -250,7 +250,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
promises.push(this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => {
this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS);
}));
promises.push(this.forumProvider.getAccessInformation(this.forum.id).then((accessInfo) => {
promises.push(this.forumProvider.getAccessInformation(this.forum.id, {cmId: this.module.id}).then((accessInfo) => {
// Disallow adding discussions if cut-off date is reached and the user has not the capability to override it.
// Just in case the forum was fetched from WS when the cut-off date was not reached but it is now.
const cutoffDateReached = this.forumHelper.isCutoffDateReached(this.forum) && !accessInfo.cancanoverridecutoff;
@ -259,7 +259,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
if (this.forumProvider.isSetPinStateAvailableForSite()) {
// Use the canAddDiscussion WS to check if the user can pin discussions.
promises.push(this.forumProvider.canAddDiscussionToAll(this.forum.id).then((response) => {
promises.push(this.forumProvider.canAddDiscussionToAll(this.forum.id, {cmId: this.module.id}).then((response) => {
this.canPin = !!response.canpindiscussions;
}).catch(() => {
this.canPin = false;
@ -354,7 +354,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
this.page = 0;
}
return this.forumProvider.getDiscussions(this.forum.id, this.selectedSortOrder.value, this.page).then((response) => {
return this.forumProvider.getDiscussions(this.forum.id, {
cmId: this.forum.cmid,
sortOrder: this.selectedSortOrder.value,
page: this.page,
}).then((response) => {
let promise;
if (this.usesGroups) {
promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions);

View File

@ -1,12 +1,12 @@
<core-loading [hideUntil]="loaded" class="core-loading-center">
<ion-item text-wrap (click)="editPost()" *ngIf="canEdit">
<ion-item text-wrap (click)="editPost()" *ngIf="offlinePost || (canEdit && isOnline)">
<ion-icon name="create" item-start></ion-icon>
<h2>{{ 'addon.mod_forum.edit' | translate }}</h2>
</ion-item>
<ion-item text-wrap (click)="deletePost()" *ngIf="canDelete">
<ion-item text-wrap (click)="deletePost()" *ngIf="offlinePost || (canDelete && isOnline)">
<ion-icon name="trash" item-start></ion-icon>
<h2 *ngIf="post.id">{{ 'addon.mod_forum.delete' | translate }}</h2>
<h2 *ngIf="!post.id">{{ 'core.discard' | translate }}</h2>
<h2 *ngIf="!offlinePost">{{ 'addon.mod_forum.delete' | translate }}</h2>
<h2 *ngIf="offlinePost">{{ 'core.discard' | translate }}</h2>
</ion-item>
<ion-item text-wrap (click)="dismiss()" *ngIf="wordCount">
<h2>{{ 'core.numwords' | translate: {'$a': wordCount} }}</h2>

View File

@ -12,12 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, NgZone } from '@angular/core';
import { NavParams, ViewController } from 'ionic-angular';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSite } from '@classes/site';
import { AddonModForumProvider } from '../../providers/forum';
import { CoreApp } from '@providers/app';
import { Network } from '@ionic-native/network';
/**
* This component is meant to display a popover with the post options.
@ -34,43 +36,70 @@ export class AddonForumPostOptionsMenuComponent implements OnInit {
canDelete = false;
loaded = false;
url: string;
isOnline: boolean;
offlinePost: boolean;
protected cmId: number;
protected onlineObserver: any;
constructor(navParams: NavParams,
network: Network,
zone: NgZone,
protected viewCtrl: ViewController,
protected domUtils: CoreDomUtilsProvider,
protected forumProvider: AddonModForumProvider,
protected sitesProvider: CoreSitesProvider) {
this.post = navParams.get('post');
this.forumId = navParams.get('forumId');
this.cmId = navParams.get('cmId');
this.isOnline = CoreApp.instance.isOnline();
this.onlineObserver = network.onchange().subscribe(() => {
// Execute the callback in the Angular zone, so change detection doesn't stop working.
zone.run(() => {
this.isOnline = CoreApp.instance.isOnline();
});
});
}
/**
* Component being initialized.
*/
ngOnInit(): void {
if (this.forumId) {
if (this.post.id) {
const site: CoreSite = this.sitesProvider.getCurrentSite();
this.url = site.createSiteUrl('/mod/forum/discuss.php', {d: this.post.discussion}, 'p' + this.post.id);
this.forumProvider.getDiscussionPost(this.forumId, this.post.discussion, this.post.id, true).then((post) => {
this.canDelete = post.capabilities.delete && this.forumProvider.isDeletePostAvailable();
this.canEdit = post.capabilities.edit && this.forumProvider.isUpdatePostAvailable();
this.wordCount = post.wordcount;
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting discussion post.');
}).finally(() => {
this.loaded = true;
});
} else {
// Offline post, you can edit or discard the post.
this.canEdit = true;
this.canDelete = true;
this.loaded = true;
}
async ngOnInit(): Promise<void> {
if (this.post.id > 0) {
const site: CoreSite = this.sitesProvider.getCurrentSite();
this.url = site.createSiteUrl('/mod/forum/discuss.php', {d: this.post.discussionid}, 'p' + this.post.id);
this.offlinePost = false;
} else {
// Offline post, you can edit or discard the post.
this.loaded = true;
this.offlinePost = true;
return;
}
if (typeof this.post.capabilities.delete == 'undefined') {
if (this.forumId) {
try {
this.post =
await this.forumProvider.getDiscussionPost(this.forumId, this.post.discussionid, this.post.id, {
cmId: this.cmId,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
});
} catch (error) {
this.domUtils.showErrorModalDefault(error, 'Error getting discussion post.');
}
} else {
this.loaded = true;
return;
}
}
this.canDelete = this.post.capabilities.delete && this.forumProvider.isDeletePostAvailable();
this.canEdit = this.post.capabilities.edit && this.forumProvider.isUpdatePostAvailable();
this.wordCount = this.post.haswordcount && this.post.wordcount;
this.loaded = true;
}
/**
@ -84,7 +113,7 @@ export class AddonForumPostOptionsMenuComponent implements OnInit {
* Delete a post.
*/
deletePost(): void {
if (this.post.id) {
if (!this.offlinePost) {
this.viewCtrl.dismiss({action: 'delete'});
} else {
this.viewCtrl.dismiss({action: 'deleteoffline'});
@ -95,10 +124,17 @@ export class AddonForumPostOptionsMenuComponent implements OnInit {
* Edit a post.
*/
editPost(): void {
if (this.post.id) {
if (!this.offlinePost) {
this.viewCtrl.dismiss({action: 'edit'});
} else {
this.viewCtrl.dismiss({action: 'editoffline'});
}
}
/**
* Component destroyed.
*/
ngOnDestroy(): void {
this.onlineObserver && this.onlineObserver.unsubscribe();
}
}

View File

@ -3,11 +3,11 @@
<ion-item text-wrap>
<div class="addon-mod-forum-post-title" *ngIf="displaySubject">
<h2 text-wrap>
<core-icon name="fa-map-pin" *ngIf="post.parent == 0 && post.pinned"></core-icon>
<core-icon name="fa-star" class="addon-forum-star" *ngIf="post.parent == 0 && !post.pinned && post.starred"></core-icon>
<core-icon name="fa-map-pin" *ngIf="discussion && !post.parentid && discussion.pinned"></core-icon>
<core-icon name="fa-star" class="addon-forum-star" *ngIf="discussion && !post.parentid && !discussion.pinned && discussion.starred"></core-icon>
<core-format-text [text]="post.subject" contextLevel="module" [contextInstanceId]="forum && forum.cmid" [courseId]="courseId"></core-format-text>
</h2>
<ion-note float-end padding-left text-end *ngIf="trackPosts && !post.postread" [attr.aria-label]="'addon.mod_forum.unread' | translate">
<ion-note float-end padding-left text-end *ngIf="trackPosts && post.unread" [attr.aria-label]="'addon.mod_forum.unread' | translate">
<core-icon name="fa-circle" color="primary"></core-icon>
</ion-note>
<button ion-button icon-only clear color="dark" (click)="showOptionsMenu($event)" *ngIf="optionsMenuEnabled" [attr.aria-label]="('core.displayoptions' | translate)">
@ -15,15 +15,15 @@
</button>
</div>
<div class="addon-mod-forum-post-info">
<ion-avatar *ngIf="post.userfullname" core-user-avatar [user]="post" item-start [courseId]="courseId"></ion-avatar>
<ion-avatar *ngIf="post.author && post.author.fullname" core-user-avatar [user]="post.author" item-start [courseId]="courseId"></ion-avatar>
<div class="addon-mod-forum-post-author">
<h3 *ngIf="post.userfullname">{{post.userfullname}}</h3>
<p *ngIf="post.groupname"><ion-icon name="people"></ion-icon> {{ post.groupname }}</p>
<p *ngIf="post.modified">{{post.modified * 1000 | coreFormatDate: "strftimerecentfull"}}</p>
<p *ngIf="!post.modified"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</p>
<h3 *ngIf="post.author && post.author.fullname">{{post.author.fullname}}</h3>
<p *ngIf="post.author && post.author.groups"><ng-container *ngFor="let group of post.author.groups"><ion-icon name="people"></ion-icon> {{ group.name }} </ng-container></p>
<p *ngIf="post.timecreated">{{post.timecreated * 1000 | coreFormatDate: "strftimerecentfull"}}</p>
<p *ngIf="!post.timecreated"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</p>
</div>
<ng-container *ngIf="!displaySubject">
<ion-note float-end padding-left text-end *ngIf="trackPosts && !post.postread" [attr.aria-label]="'addon.mod_forum.unread' | translate">
<ion-note float-end padding-left text-end *ngIf="trackPosts && post.unread" [attr.aria-label]="'addon.mod_forum.unread' | translate">
<core-icon name="fa-circle" color="primary"></core-icon>
</ion-note>
<button ion-button icon-only clear color="dark" (click)="showOptionsMenu($event)" *ngIf="optionsMenuEnabled" [attr.aria-label]="('core.displayoptions' | translate)">
@ -33,7 +33,7 @@
</div>
</ion-item>
</ion-card-header>
<ion-card-content [attr.padding-top]="post.parent == 0 || null">
<ion-card-content [attr.padding-top]="post.parentid == 0 || null">
<div padding-bottom *ngIf="post.isprivatereply">
<ion-note color="danger">{{ 'addon.mod_forum.postisprivatereply' | translate }}</ion-note>
</div>
@ -47,17 +47,17 @@
<div item-start>{{ 'core.tag.tags' | translate }}:</div>
<core-tag-list [tags]="post.tags"></core-tag-list>
</ion-item>
<core-rating-rate *ngIf="forum && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id" [itemSetId]="discussionId" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale" [userId]="post.userid" (onUpdate)="ratingUpdated()"></core-rating-rate>
<core-rating-rate *ngIf="forum && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id" [itemSetId]="discussionId" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale" [userId]="post.author.id" (onUpdate)="ratingUpdated()"></core-rating-rate>
<core-rating-aggregate *ngIf="forum && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale"></core-rating-aggregate>
<ion-item no-padding text-end *ngIf="post.id && post.canreply && !post.isprivatereply" class="addon-forum-reply-button">
<ion-item no-padding text-end *ngIf="post.id > 0 && post.capabilities.reply && !post.isprivatereply" class="addon-forum-reply-button">
<button ion-button icon-left clear small (click)="showReplyForm()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.id">
<core-icon name="fa-reply"></core-icon> {{ 'addon.mod_forum.reply' | translate }}
</button>
</ion-item>
</div>
<form ion-list [id]="'addon-forum-reply-edit-form-' + uniqueId" *ngIf="(post.id && !replyData.isEditing && replyData.replyingTo == post.id) || (!post.id && replyData.isEditing && replyData.replyingTo == post.parent)" #replyFormEl>
<form ion-list [id]="'addon-forum-reply-edit-form-' + uniqueId" *ngIf="(post.id > 0 && !replyData.isEditing && replyData.replyingTo == post.id) || (post.id <=0 && replyData.isEditing && replyData.replyingTo == post.parentid)" #replyFormEl>
<ion-item>
<ion-label stacked>{{ 'addon.mod_forum.subject' | translate }}</ion-label>
<ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="replyData.subject" name="subject"></ion-input>
@ -70,13 +70,15 @@
<ion-label>{{ 'addon.mod_forum.privatereply' | translate }}</ion-label>
<ion-checkbox item-end [(ngModel)]="replyData.isprivatereply" name="isprivatereply"></ion-checkbox>
</ion-item>
<ion-item-divider text-wrap (click)="toggleAdvanced()" class="core-expandable">
<core-icon *ngIf="!advanced" name="fa-caret-right" item-start></core-icon>
<core-icon *ngIf="advanced" name="fa-caret-down" item-start></core-icon>
{{ 'addon.mod_forum.advanced' | translate }}
</ion-item-divider>
<ng-container *ngIf="advanced">
<core-attachments *ngIf="forum.id && forum.maxattachments > 0" [files]="replyData.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
<ng-container *ngIf="forum.id && forum.maxattachments > 0">
<ion-item-divider text-wrap (click)="toggleAdvanced()" class="core-expandable">
<core-icon *ngIf="!advanced" name="fa-caret-right" item-start></core-icon>
<core-icon *ngIf="advanced" name="fa-caret-down" item-start></core-icon>
{{ 'addon.mod_forum.advanced' | translate }}
</ion-item-divider>
<ng-container *ngIf="advanced">
<core-attachments [files]="replyData.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
</ng-container>
</ng-container>
<ion-grid>
<ion-row>

View File

@ -43,6 +43,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
@Input() post: any; // Post.
@Input() courseId: number; // Post's course ID.
@Input() discussionId: number; // Post's' discussion ID.
@Input() discussion?: any; // Post's' discussion, only for starting posts.
@Input() component: string; // Component this post belong to.
@Input() componentId: number; // Component ID.
@Input() replyData: any; // Object with the new post data. Usually shared between posts.
@ -92,16 +93,16 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
* Component being initialized.
*/
ngOnInit(): void {
this.uniqueId = this.post.id ? 'reply' + this.post.id : 'edit' + this.post.parent;
this.uniqueId = this.post.id > 0 ? 'reply' + this.post.id : 'edit' + this.post.parentid;
const reTranslated = this.translate.instant('addon.mod_forum.re');
this.displaySubject = !this.parentSubject ||
(this.post.subject != this.parentSubject && this.post.subject != `Re: ${this.parentSubject}` &&
this.post.subject != `${reTranslated} ${this.parentSubject}`);
this.defaultReplySubject = (this.post.subject.startsWith('Re: ') || this.post.subject.startsWith(reTranslated))
? this.post.subject : `${reTranslated} ${this.post.subject}`;
this.defaultReplySubject = this.post.replysubject || ((this.post.subject.startsWith('Re: ') ||
this.post.subject.startsWith(reTranslated)) ? this.post.subject : `${reTranslated} ${this.post.subject}`);
this.optionsMenuEnabled = !this.post.id || (this.forumProvider.isGetDiscussionPostAvailable() &&
this.optionsMenuEnabled = this.post.id < 0 || (this.forumProvider.isGetDiscussionPostAvailable() &&
(this.forumProvider.isDeletePostAvailable() || this.forumProvider.isUpdatePostAvailable()));
}
@ -192,7 +193,8 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
const popover = this.popoverCtrl.create(AddonForumPostOptionsMenuComponent, {
post: this.post,
forumId: this.forum.id
forumId: this.forum.id,
cmId: this.forum.cmid,
});
popover.onDidDismiss((data) => {
if (data && data.action) {
@ -328,7 +330,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
this.syncId = this.forumSync.getDiscussionSyncId(this.discussionId);
this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
this.setReplyFormData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments,
this.setReplyFormData(this.post.parentid, true, this.post.subject, this.post.message, this.post.attachments,
this.post.isprivatereply);
}).catch(() => {
// Cancelled.
@ -460,9 +462,9 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy, OnChanges
this.domUtils.showDeleteConfirm().then(() => {
const promises = [];
promises.push(this.forumOffline.deleteReply(this.post.parent));
promises.push(this.forumOffline.deleteReply(this.post.parentid));
if (this.forum.id) {
promises.push(this.forumHelper.deleteReplyStoredFiles(this.forum.id, this.post.parent).catch(() => {
promises.push(this.forumHelper.deleteReplyStoredFiles(this.forum.id, this.post.parentid).catch(() => {
// Ignore errors, maybe there are no files.
}));
}

View File

@ -1,6 +1,6 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title *ngIf="discussion"><core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId"></core-format-text></ion-title>
<ion-title *ngIf="startingPost"><core-format-text [text]="startingPost.subject" contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId"></core-format-text></ion-title>
<ion-buttons end>
<!-- The context menu will be added in here. -->
</ion-buttons>
@ -41,14 +41,14 @@
<core-icon name="fa-lock"></core-icon> {{ 'addon.mod_forum.discussionlocked' | translate }}
</ion-card>
<div *ngIf="discussion" margin-bottom class="highlight">
<addon-mod-forum-post [post]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<div *ngIf="startingPost" margin-bottom class="highlight">
<addon-mod-forum-post [post]="startingPost" [discussion]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</div>
<ion-card *ngIf="sort != 'nested'">
<ng-container *ngFor="let post of posts; first as first">
<ion-item-divider *ngIf="!first"></ion-item-divider>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [parentSubject]="postSubjects[post.parent]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ng-container>
</ion-card>
@ -60,7 +60,7 @@
<ng-template #nestedPosts let-post="post">
<ion-card>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [parentSubject]="postSubjects[post.parent]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ion-card>
<div padding-left *ngIf="post.children.length && post.children[0].subject">
<ng-container *ngFor="let child of post.children">

View File

@ -52,6 +52,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
forum: any = {};
accessInfo: any = {};
discussion: any;
startingPost: any;
posts: any[];
discussionLoaded = false;
postSubjects: { [id: string]: string };
@ -85,6 +86,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
protected forumId: number;
protected postId: number;
protected parent: number;
protected onlineObserver: any;
protected syncObserver: any;
protected syncManualObserver: any;
@ -120,6 +122,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
this.discussionId = this.discussion ? this.discussion.discussion : navParams.get('discussionId');
this.trackPosts = navParams.get('trackPosts');
this.postId = navParams.get('postId');
this.parent = navParams.get('parent');
this.isOnline = this.appProvider.isOnline();
this.onlineObserver = network.onchange().subscribe(() => {
@ -136,47 +139,67 @@ export class AddonModForumDiscussionPage implements OnDestroy {
/**
* View loaded.
*/
ionViewDidLoad(): void {
this.sitesProvider.getCurrentSite().getLocalSiteConfig('AddonModForumDiscussionSort').catch(() => {
this.userProvider.getUserPreference('forum_displaymode').catch(() => {
// Ignore errors.
}).then((value) => {
const sortValue = value && parseInt(value, 10);
async ionViewDidLoad(): Promise<void> {
if (this.parent) {
this.sort = 'nested'; // Force nested order.
} else {
this.sort = await this.getUserSort();
}
switch (sortValue) {
await this.fetchPosts(true, false, true);
const scrollTo = this.postId || this.parent;
if (scrollTo) {
// Scroll to the post.
setTimeout(() => {
this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_forum-post-' + scrollTo);
});
}
}
/**
* Get sort type configured by the current user.
*
* @return Promise resolved with the sort type.
*/
protected async getUserSort(): Promise<SortType> {
try {
const value = await this.sitesProvider.getCurrentSite().getLocalSiteConfig('AddonModForumDiscussionSort');
return value;
} catch (error) {
try {
const value = await this.userProvider.getUserPreference('forum_displaymode');
switch (Number(value)) {
case 1:
this.sort = 'flat-oldest';
break;
return 'flat-oldest';
case -1:
this.sort = 'flat-newest';
break;
return 'flat-newest';
case 3:
this.sort = 'nested';
break;
return 'nested';
case 2: // Threaded not implemented.
default:
// Not set, use default sort.
// @TODO add fallback to $CFG->forum_displaymode.
}
});
}).then((value) => {
this.sort = value;
}).finally(() => {
this.fetchPosts(true, false, true).then(() => {
if (this.postId) {
// Scroll to the post.
setTimeout(() => {
this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_forum-post-' + this.postId);
});
}
});
});
} catch (error) {
// Ignore errors.
}
}
return 'flat-oldest';
}
/**
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
if (this.syncObserver) {
// Already setup.
return;
}
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
if (data.forumId == this.forumId && this.discussionId == data.discussionId
@ -231,7 +254,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
}
if (typeof data.deleted != 'undefined' && data.deleted) {
if (data.post.parent == 0) {
if (!data.post.parentid) {
if (this.svComponent && this.svComponent.isOn()) {
this.svComponent.emptyDetails();
} else {
@ -306,9 +329,11 @@ export class AddonModForumDiscussionPage implements OnDestroy {
let ratingInfo;
return syncPromise.then(() => {
return this.forumProvider.getDiscussionPosts(this.discussionId).then((response) => {
return this.forumProvider.getDiscussionPosts(this.discussionId, {cmId: this.cmId}).then((response) => {
onlinePosts = response.posts;
ratingInfo = response.ratinginfo;
this.courseId = response.courseid || this.courseId;
this.forumId = response.forumid || this.forumId;
}).then(() => {
// Check if there are responses stored in offline.
return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => {
@ -319,7 +344,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
const posts = {};
onlinePosts.forEach((post) => {
posts[post.id] = post;
hasUnreadPosts = hasUnreadPosts || !post.postread;
hasUnreadPosts = hasUnreadPosts || !!post.unread;
});
replies.forEach((offlineReply) => {
@ -335,7 +360,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
offlineReplies.push(reply);
// Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead.
posts[reply.parent].canreply = false;
posts[reply.parentid].capabilities.reply = false;
}));
});
@ -348,18 +373,15 @@ export class AddonModForumDiscussionPage implements OnDestroy {
}).then(() => {
let posts = offlineReplies.concat(onlinePosts);
const startingPost = this.forumProvider.extractStartingPost(posts);
if (startingPost) {
// Update discussion data from first post.
this.discussion = Object.assign(this.discussion || {}, startingPost);
}
this.startingPost = this.forumProvider.extractStartingPost(posts);
// If sort type is nested, normal sorting is disabled and nested posts will be displayed.
if (this.sort == 'nested') {
// Sort first by creation date to make format tree work.
this.forumProvider.sortDiscussionPosts(posts, 'ASC');
posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id);
const rootId = this.startingPost ? this.startingPost.id : (this.discussion ? this.discussion.id : 0);
posts = this.utils.formatTree(posts, 'parentid', 'id', rootId);
} else {
// Set default reply subject.
const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC';
@ -381,50 +403,52 @@ export class AddonModForumDiscussionPage implements OnDestroy {
const promises = [];
promises.push(this.forumProvider.getAccessInformation(this.forumId).then((accessInfo) => {
promises.push(this.forumProvider.getAccessInformation(this.forumId, {cmId: this.cmId}).then((accessInfo) => {
this.accessInfo = accessInfo;
// Disallow replying if cut-off date is reached and the user has not the capability to override it.
// Just in case the posts were fetched from WS when the cut-off date was not reached but it is now.
if (this.forumHelper.isCutoffDateReached(forum) && !accessInfo.cancanoverridecutoff) {
posts.forEach((post) => {
post.canreply = false;
post.capabilities.reply = false;
});
}
}));
// The discussion object was not passed as parameter and there is no starting post. Should not happen.
if (!this.discussion) {
promises.push(this.loadDiscussion(this.forumId, this.discussionId));
promises.push(this.loadDiscussion(this.forumId, this.cmId, this.discussionId));
}
return Promise.all(promises);
}).catch(() => {
// Ignore errors.
}).then(() => {
if (!this.discussion) {
if (!this.discussion && !this.startingPost) {
// The discussion object was not passed as parameter and there is no starting post. Should not happen.
return Promise.reject('Invalid forum discussion.');
}
if (this.discussion.userfullname && this.discussion.parent == 0 && this.forum.type == 'single') {
// Hide author for first post and type single.
this.discussion.userfullname = null;
if (this.startingPost.author && this.forum.type == 'single') {
// Hide author and groups for first post and type single.
this.startingPost.author.fullname = null;
this.startingPost.author.groups = null;
}
this.posts = posts;
this.ratingInfo = ratingInfo;
this.postSubjects = this.getAllPosts().reduce((postSubjects, post) => {
postSubjects[post.id] = post.subject;
return postSubjects;
}, { [this.discussion.id]: this.discussion.subject });
}, { [this.startingPost.id]: this.startingPost.subject });
});
}).then(() => {
if (this.forumProvider.isSetPinStateAvailableForSite()) {
// Use the canAddDiscussion WS to check if the user can pin discussions.
return this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
return this.forumProvider.canAddDiscussionToAll(this.forumId, {cmId: this.cmId}).then((response) => {
this.canPin = !!response.canpindiscussions;
}).catch(() => {
this.canPin = false;
@ -462,13 +486,14 @@ export class AddonModForumDiscussionPage implements OnDestroy {
* Convenience function to load discussion.
*
* @param forumId Forum ID.
* @param cmId Forum cmid.
* @param discussionId Discussion ID.
* @return Promise resolved when done.
*/
protected loadDiscussion(forumId: number, discussionId: number): Promise<void> {
protected loadDiscussion(forumId: number, cmId: number, discussionId: number): Promise<void> {
// Fetch the discussion if not passed as parameter.
if (!this.discussion && forumId) {
return this.forumHelper.getDiscussionById(forumId, discussionId).then((discussion) => {
return this.forumHelper.getDiscussionById(forumId, cmId, discussionId).then((discussion) => {
this.discussion = discussion;
this.discussionId = this.discussion.discussion;
}).catch(() => {
@ -688,6 +713,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
this.ratingOfflineObserver && this.ratingOfflineObserver.off();
this.ratingSyncObserver && this.ratingSyncObserver.off();
this.changeDiscObserver && this.changeDiscObserver.off();
delete this.syncObserver;
}
/**
@ -722,5 +748,4 @@ export class AddonModForumDiscussionPage implements OnDestroy {
return posts;
}
}

View File

@ -112,6 +112,11 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
* User entered the page that contains the component.
*/
ionViewDidEnter(): void {
if (this.syncObserver) {
// Already setup.
return;
}
// Refresh data if this discussion is synchronized automatically.
this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
if (data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
@ -171,7 +176,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
this.newDiscussion.postToAllGroups = false;
// Use the canAddDiscussion WS to check if the user can add attachments and pin discussions.
promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId, {cmId: this.cmId}).then((response) => {
this.canPin = !!response.canpindiscussions;
this.canCreateAttachments = !!response.cancreateattachment;
}).catch(() => {
@ -185,7 +190,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
}));
// Get access information.
promises.push(this.forumProvider.getAccessInformation(this.forumId).then((accessInfo) => {
promises.push(this.forumProvider.getAccessInformation(this.forumId, {cmId: this.cmId}).then((accessInfo) => {
this.accessInfo = accessInfo;
}));
@ -260,7 +265,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
*/
protected validateVisibleGroups(forumGroups: any[]): Promise<any[]> {
// We first check if the user can post to all the groups.
return this.forumProvider.canAddDiscussionToAll(this.forumId).catch(() => {
return this.forumProvider.canAddDiscussionToAll(this.forumId, {cmId: this.cmId}).catch(() => {
// The call failed, let's assume he can't.
return {
status: false,
@ -280,7 +285,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
const filtered = [];
forumGroups.forEach((group) => {
promises.push(this.forumProvider.canAddDiscussion(this.forumId, group.id).catch(() => {
promises.push(this.forumProvider.canAddDiscussion(this.forumId, group.id, {cmId: this.cmId}).catch(() => {
/* The call failed, let's return true so the group is shown. If the user can't post to
it an error will be shown when he tries to add the discussion. */
return {
@ -337,7 +342,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
if (check) {
// We need to check if the user can add a discussion to all participants.
promise = this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
promise = this.forumProvider.canAddDiscussionToAll(this.forumId, {cmId: this.cmId}).then((response) => {
this.canPin = !!response.canpindiscussions;
this.canCreateAttachments = !!response.cancreateattachment;
@ -549,6 +554,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy {
*/
ionViewWillLeave(): void {
this.syncObserver && this.syncObserver.off();
delete this.syncObserver;
}
/**

View File

@ -60,6 +60,9 @@ export class AddonModForumDiscussionLinkHandler extends CoreContentLinksHandlerB
if (data.postid || params.urlHash) {
pageParams.postId = parseInt(data.postid || params.urlHash.replace('p', ''));
}
if (params.parent) {
pageParams.parent = parseInt(params.parent);
}
this.linkHelper.goInSite(navCtrl, 'AddonModForumDiscussionPage', pageParams, siteId);
}

View File

@ -14,16 +14,18 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreSite } from '@classes/site';
import { CoreAppProvider } from '@providers/app';
import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreGroupsProvider } from '@providers/groups';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSitesProvider, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreUserProvider } from '@core/user/providers/user';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { AddonModForumOfflineProvider } from './offline';
import { CoreRatingInfo } from '@core/rating/providers/rating';
import { CoreCourseCommonModWSOptions } from '@core/course/providers/course';
import { CoreUrlUtils } from '@providers/utils/url';
/**
* Service that provides some features for forums.
@ -206,26 +208,29 @@ export class AddonModForumProvider {
*
* @param forumId Forum ID.
* @param groupId Group ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with an object with the following properties:
* - status (boolean)
* - canpindiscussions (boolean)
* - cancreateattachment (boolean)
*/
canAddDiscussion(forumId: number, groupId: number, siteId?: string): Promise<any> {
canAddDiscussion(forumId: number, groupId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
const params = {
forumid: forumId,
groupid: groupId
groupid: groupId,
};
const preSets = {
cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId)
cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId),
component: AddonModForumProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
return site.read('mod_forum_can_add_discussion', params, preSets).then((result) => {
if (result) {
if (typeof result.canpindiscussions == 'undefined') {
// WS doesn't support it yet, default it to false to prevent students from seing the option.
// WS doesn't support it yet, default it to false to prevent students from seeing the option.
result.canpindiscussions = false;
}
if (typeof result.cancreateattachment == 'undefined') {
@ -245,14 +250,14 @@ export class AddonModForumProvider {
* Check if a user can post to all groups.
*
* @param forumId Forum ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with an object with the following properties:
* - status (boolean)
* - canpindiscussions (boolean)
* - cancreateattachment (boolean)
*/
canAddDiscussionToAll(forumId: number, siteId?: string): Promise<any> {
return this.canAddDiscussion(forumId, AddonModForumProvider.ALL_PARTICIPANTS, siteId);
canAddDiscussionToAll(forumId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.canAddDiscussion(forumId, AddonModForumProvider.ALL_PARTICIPANTS, options);
}
/**
@ -280,7 +285,7 @@ export class AddonModForumProvider {
* @return Starting post or undefined if not found.
*/
extractStartingPost(posts: any[]): any {
const index = posts.findIndex((post) => post.parent == 0);
const index = posts.findIndex((post) => !post.parentid);
return index >= 0 ? posts.splice(index, 1).pop() : undefined;
}
@ -305,6 +310,18 @@ export class AddonModForumProvider {
return this.sitesProvider.wsAvailableInCurrentSite('mod_forum_get_discussion_post');
}
/**
* Returns whether or not getDiscussionPost WS available or not.
*
* @param site Site. If not defined, current site.
* @return If WS is avalaible.
* @since 3.7
*/
isGetDiscussionPostsAvailable(site?: CoreSite): boolean {
return site ? site.wsAvailable('mod_forum_get_discussion_posts') :
this.sitesProvider.wsAvailableInCurrentSite('mod_forum_get_discussion_posts');
}
/**
* Returns whether or not deletePost WS available or not.
*
@ -370,17 +387,19 @@ export class AddonModForumProvider {
* Get all course forums.
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the forums are retrieved.
*/
getCourseForums(courseId: number, siteId?: string): Promise<any[]> {
return this.sitesProvider.getSite(siteId).then((site) => {
getCourseForums(courseId: number, options: CoreSitesCommonWSOptions = {}): Promise<any[]> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
courseids: [courseId]
courseids: [courseId],
};
const preSets = {
cacheKey: this.getForumDataCacheKey(courseId),
updateFrequency: CoreSite.FREQUENCY_RARELY
updateFrequency: CoreSite.FREQUENCY_RARELY,
component: AddonModForumProvider.COMPONENT,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_forum_get_forums_by_courses', params, preSets);
@ -393,24 +412,23 @@ export class AddonModForumProvider {
* @param forumId Forum ID.
* @param discussionId Discussion ID.
* @param postId Post ID.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the post is retrieved.
*/
getDiscussionPost(forumId: number, discussionId: number, postId: number, ignoreCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
const params = {
postid: postId
},
preSets: CoreSiteWSPreSets = {
cacheKey: this.getDiscussionPostDataCacheKey(forumId, discussionId, postId),
updateFrequency: CoreSite.FREQUENCY_USUALLY
};
getDiscussionPost(forumId: number, discussionId: number, postId: number, options: CoreCourseCommonModWSOptions = {})
: Promise<any> {
if (ignoreCache) {
preSets.getFromCache = false;
preSets.emergencyCache = false;
}
return this.sitesProvider.getSite(options.siteId).then((site) => {
const params = {
postid: postId,
};
const preSets = {
cacheKey: this.getDiscussionPostDataCacheKey(forumId, discussionId, postId),
updateFrequency: CoreSite.FREQUENCY_USUALLY,
component: AddonModForumProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_forum_get_discussion_post', params, preSets).then((response) => {
if (response.post) {
@ -427,11 +445,11 @@ export class AddonModForumProvider {
*
* @param courseId Course ID.
* @param cmId Course module ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the forum is retrieved.
*/
getForum(courseId: number, cmId: number, siteId?: string): Promise<any> {
return this.getCourseForums(courseId, siteId).then((forums) => {
getForum(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getCourseForums(courseId, options).then((forums) => {
const forum = forums.find((forum) => forum.cmid == cmId);
if (forum) {
return forum;
@ -446,11 +464,11 @@ export class AddonModForumProvider {
*
* @param courseId Course ID.
* @param forumId Forum ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved when the forum is retrieved.
*/
getForumById(courseId: number, forumId: number, siteId?: string): Promise<any> {
return this.getCourseForums(courseId, siteId).then((forums) => {
getForumById(courseId: number, forumId: number, options: CoreSitesCommonWSOptions = {}): Promise<any> {
return this.getCourseForums(courseId, options).then((forums) => {
const forum = forums.find((forum) => forum.id == forumId);
if (forum) {
return forum;
@ -464,24 +482,25 @@ export class AddonModForumProvider {
* Get access information for a given forum.
*
* @param forumId Forum ID.
* @param forceCache True to always get the value from cache. false otherwise.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Object with access information.
* @since 3.7
*/
getAccessInformation(forumId: number, forceCache?: boolean, siteId?: string): Promise<any> {
return this.sitesProvider.getSite(siteId).then((site) => {
getAccessInformation(forumId: number, options: CoreCourseCommonModWSOptions = {}): Promise<any> {
return this.sitesProvider.getSite(options.siteId).then((site) => {
if (!site.wsAvailable('mod_forum_get_forum_access_information')) {
// Access information not available for 3.6 or older sites.
return Promise.resolve({});
}
const params = {
forumid: forumId
forumid: forumId,
};
const preSets = {
cacheKey: this.getAccessInformationCacheKey(forumId),
omitExpires: forceCache
component: AddonModForumProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return site.read('mod_forum_get_forum_access_information', params, preSets);
@ -492,20 +511,91 @@ export class AddonModForumProvider {
* Get forum discussion posts.
*
* @param discussionId Discussion ID.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with forum posts and rating info.
*/
getDiscussionPosts(discussionId: number, siteId?: string): Promise<{posts: any[], ratinginfo?: CoreRatingInfo}> {
const params = {
discussionid: discussionId
getDiscussionPosts(discussionId: number, options: CoreCourseCommonModWSOptions = {}): Promise<{posts: any[], courseid?: number,
forumid?: number, ratinginfo?: CoreRatingInfo}> {
// Convenience function to translate legacy data to new format.
const translateLegacyPostsFormat = (posts: any[]): any[] => {
return posts.map((post) => {
const newPost = {
id: post.id ,
discussionid: post.discussion,
parentid: post.parent,
hasparent: !!post.parent,
author: {
id: post.userid,
fullname: post.userfullname,
urls: { profileimage: post.userpictureurl },
},
timecreated: post.created,
subject: post.subject,
message: post.message,
attachments : post.attachments,
capabilities: {
reply: !!post.canreply,
},
unread: !post.postread,
isprivatereply: !!post.isprivatereply,
tags: post.tags,
};
if (post.groupname) {
newPost.author['groups'] = [{name: post.groupname}];
}
return newPost;
});
};
const preSets = {
cacheKey: this.getDiscussionPostsCacheKey(discussionId)
// For some reason, the new WS doesn't use the tags exporter so it returns a different format than other WebServices.
// Convert the new format to the exporter one so it's the same as in other WebServices.
const translateTagsFormatToLegacy = (posts: any[]): any[] => {
posts.forEach((post) => {
post.tags = post.tags.map((tag) => {
const viewUrl = (tag.urls && tag.urls.view) || '';
const params = CoreUrlUtils.instance.extractUrlParams(viewUrl);
return {
id: tag.tagid,
taginstanceid: tag.id,
flag: tag.flag ? 1 : 0,
isstandard: tag.isstandard,
rawname: tag.displayname,
name: tag.displayname,
tagcollid: params.tc ? Number(params.tc) : undefined,
taginstancecontextid: params.from ? Number(params.from) : undefined,
};
});
});
return posts;
};
return this.sitesProvider.getSite(siteId).then((site) => {
return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => {
const params = {
discussionid: discussionId,
};
const preSets = {
cacheKey: this.getDiscussionPostsCacheKey(discussionId),
component: AddonModForumProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
return this.sitesProvider.getSite(options.siteId).then((site) => {
const wsName = this.isGetDiscussionPostsAvailable(site) ? 'mod_forum_get_discussion_posts' :
'mod_forum_get_forum_discussion_posts';
return site.read(wsName, params, preSets).then((response) => {
if (response) {
if (wsName == 'mod_forum_get_forum_discussion_posts') {
response.posts = translateLegacyPostsFormat(response.posts);
} else {
response.posts = translateTagsFormatToLegacy(response.posts);
}
this.storeUserData(response.posts);
return response;
@ -525,8 +615,13 @@ export class AddonModForumProvider {
sortDiscussionPosts(posts: any[], direction: string): void {
// @todo: Check children when sorting.
posts.sort((a, b) => {
a = parseInt(a.created, 10);
b = parseInt(b.created, 10);
a = parseInt(a.timecreated, 10) || 0;
b = parseInt(b.timecreated, 10) || 0;
if (a == 0 || b == 0) {
// Leave 0 at the end.
return b - a;
}
if (direction == 'ASC') {
return a - b;
} else {
@ -592,32 +687,30 @@ export class AddonModForumProvider {
* Get forum discussions.
*
* @param forumId Forum ID.
* @param sortOrder Sort order.
* @param page Page.
* @param forceCache True to always get the value from cache. false otherwise.
* @param siteId Site ID. If not defined, current site.
* @param options Other options.
* @return Promise resolved with an object with:
* - discussions: List of discussions. Note that for every discussion in the list discussion.id is the main post ID but
* discussion ID is discussion.discussion.
* - canLoadMore: True if there may be more discussions to load.
*/
getDiscussions(forumId: number, sortOrder?: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise<any> {
sortOrder = sortOrder || AddonModForumProvider.SORTORDER_LASTPOST_DESC;
getDiscussions(forumId: number, options: AddonModForumGetDiscussionsOptions = {}): Promise<any> {
options.sortOrder = options.sortOrder || AddonModForumProvider.SORTORDER_LASTPOST_DESC;
options.page = options.page || 0;
return this.sitesProvider.getSite(siteId).then((site) => {
return this.sitesProvider.getSite(options.siteId).then((site) => {
let method = 'mod_forum_get_forum_discussions_paginated';
const params: any = {
forumid: forumId,
page: page,
perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE
page: options.page,
perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE,
};
if (site.wsAvailable('mod_forum_get_forum_discussions')) {
// Since Moodle 3.7.
method = 'mod_forum_get_forum_discussions';
params.sortorder = sortOrder;
params.sortorder = options.sortOrder;
} else {
if (sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) {
if (options.sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) {
params.sortby = 'timemodified';
params.sortdirection = 'DESC';
} else {
@ -625,29 +718,27 @@ export class AddonModForumProvider {
return Promise.reject(null);
}
}
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder)
const preSets = {
cacheKey: this.getDiscussionsListCacheKey(forumId, options.sortOrder),
component: AddonModForumProvider.COMPONENT,
componentId: options.cmId,
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
};
if (forceCache) {
preSets.omitExpires = true;
}
return site.read(method, params, preSets).catch((error) => {
// Try to get the data from cache stored with the old WS method.
if (!this.appProvider.isOnline() && method == 'mod_forum_get_forum_discussion' &&
sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) {
options.sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) {
const params = {
forumid: forumId,
page: page,
page: options.page,
perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE,
sortby: 'timemodified',
sortdirection: 'DESC'
};
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder),
omitExpires: true
};
Object.assign(preSets, this.sitesProvider.getReadingStrategyPreSets(CoreSitesReadingStrategy.PreferCache));
return site.read('mod_forum_get_forum_discussions_paginated', params, preSets);
}
@ -673,6 +764,7 @@ export class AddonModForumProvider {
* If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred.
*
* @param forumId Forum ID.
* @param cmId Forum cmid.
* @param sortOrder Sort order.
* @param forceCache True to always get the value from cache, false otherwise.
* @param numPages Number of pages to get. If not defined, all pages.
@ -682,17 +774,14 @@ export class AddonModForumProvider {
* - discussions: List of discussions.
* - error: True if an error occurred, false otherwise.
*/
getDiscussionsInPages(forumId: number, sortOrder?: number, forceCache?: boolean, numPages?: number, startPage?: number,
siteId?: string): Promise<any> {
if (typeof numPages == 'undefined') {
numPages = -1;
}
startPage = startPage || 0;
getDiscussionsInPages(forumId: number, options: AddonModForumGetDiscussionsInPagesOptions = {}): Promise<any> {
options.page = options.page || 0;
const result = {
discussions: [],
error: false
};
let numPages = typeof options.numPages == 'undefined' ? -1 : options.numPages;
if (!numPages) {
return Promise.resolve(result);
@ -700,7 +789,7 @@ export class AddonModForumProvider {
const getPage = (page: number): Promise<any> => {
// Get page discussions.
return this.getDiscussions(forumId, sortOrder, page, forceCache, siteId).then((response) => {
return this.getDiscussions(forumId, options).then((response) => {
result.discussions = result.discussions.concat(response.discussions);
numPages--;
@ -717,7 +806,7 @@ export class AddonModForumProvider {
});
};
return getPage(startPage);
return getPage(options.page);
}
/**
@ -753,7 +842,11 @@ export class AddonModForumProvider {
this.getAvailableSortOrders().forEach((sortOrder) => {
// We need to get the list of discussions to be able to invalidate their posts.
promises.push(this.getDiscussionsInPages(forum.id, sortOrder.value, true).then((response) => {
promises.push(this.getDiscussionsInPages(forum.id, {
cmId: forum.cmid,
sortOrder: sortOrder.value,
readingStrategy: CoreSitesReadingStrategy.PreferCache,
}).then((response) => {
// Now invalidate the WS calls.
const promises = [];
@ -1045,6 +1138,16 @@ export class AddonModForumProvider {
const users = {};
list.forEach((entry) => {
if (entry.author) {
const authorId = parseInt(entry.author.id);
if (!isNaN(authorId) && !users[authorId]) {
users[authorId] = {
id: entry.author.id,
fullname: entry.author.fullname,
profileimageurl: entry.author.urls.profileimage
};
}
}
const userId = parseInt(entry.userid);
if (!isNaN(userId) && !users[userId]) {
users[userId] = {
@ -1091,3 +1194,18 @@ export class AddonModForumProvider {
});
}
}
/**
* Options to pass to get discussions.
*/
export type AddonModForumGetDiscussionsOptions = CoreCourseCommonModWSOptions & {
sortOrder?: number; // Sort order.
page?: number; // Page. Defaults to 0.
};
/**
* Options to pass to get discussions in pages.
*/
export type AddonModForumGetDiscussionsInPagesOptions = AddonModForumGetDiscussionsOptions & {
numPages?: number; // Number of pages to get. If not defined, all pages.
};

View File

@ -161,24 +161,23 @@ export class AddonModForumHelperProvider {
*/
convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise<any> {
const reply: any = {
attachments: [],
canreply: false,
children: [],
created: offlineReply.timecreated,
discussion: offlineReply.discussionid,
id: false,
mailed: 0,
mailnow: 0,
message: offlineReply.message,
messageformat: 1,
messagetrust: 0,
modified: false,
parent: offlineReply.postid,
postread: false,
id: -offlineReply.timecreated,
discussionid: offlineReply.discussionid,
parentid: offlineReply.postid,
hasparent: !!offlineReply.postid,
author: {
id: offlineReply.userid,
},
timecreated: false,
subject: offlineReply.subject,
totalscore: 0,
userid: offlineReply.userid,
isprivatereply: offlineReply.options && offlineReply.options.private
message: offlineReply.message,
attachments: [],
capabilities: {
reply: false,
},
unread: false,
isprivatereply: offlineReply.options && offlineReply.options.private,
tags: null
},
promises = [];
@ -187,7 +186,7 @@ export class AddonModForumHelperProvider {
reply.attachments = offlineReply.options.attachmentsid.online || [];
if (offlineReply.options.attachmentsid.offline) {
promises.push(this.getReplyStoredFiles(offlineReply.forumid, reply.parent, siteId, reply.userid)
promises.push(this.getReplyStoredFiles(offlineReply.forumid, reply.parentid, siteId, offlineReply.userid)
.then((files) => {
reply.attachments = reply.attachments.concat(files);
}));
@ -196,8 +195,8 @@ export class AddonModForumHelperProvider {
// Get user data.
promises.push(this.userProvider.getProfile(offlineReply.userid, offlineReply.courseid, true).then((user) => {
reply.userfullname = user.fullname;
reply.userpictureurl = user.profileimageurl;
reply.author.fullname = user.fullname;
reply.author.urls = { profileimage: user.profileimageurl };
}).catch(() => {
// Ignore errors.
}));
@ -270,15 +269,20 @@ export class AddonModForumHelperProvider {
* This function is inefficient because it needs to fetch all discussion pages in the worst case.
*
* @param forumId Forum ID.
* @param cmId Forum cmid
* @param discussionId Discussion ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the discussion data.
*/
getDiscussionById(forumId: number, discussionId: number, siteId?: string): Promise<any> {
getDiscussionById(forumId: number, cmId: number, discussionId: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const findDiscussion = (page: number): Promise<any> => {
return this.forumProvider.getDiscussions(forumId, undefined, page, false, siteId).then((response) => {
return this.forumProvider.getDiscussions(forumId, {
cmId,
page,
siteId,
}).then((response) => {
if (response.discussions && response.discussions.length > 0) {
// Note that discussion.id is the main post ID but discussion ID is discussion.discussion.
const discussion = response.discussions.find((discussion) => discussion.discussion == discussionId);

View File

@ -143,7 +143,7 @@ export class AddonModForumModuleHandler implements CoreCourseModuleHandler {
this.forumProvider.invalidateForumData(courseId).finally(() => {
// Handle unread posts.
this.forumProvider.getForum(courseId, moduleId, siteId).then((forumData) => {
this.forumProvider.getForum(courseId, moduleId, {siteId}).then((forumData) => {
data.extraBadgeColor = '';
data.extraBadge = forumData.unreadpostscount ? this.translate.instant('addon.mod_forum.unreadpostsnumber',
{$a : forumData.unreadpostscount }) : '';

Some files were not shown because too many files have changed in this diff Show More