diff --git a/scripts/langindex.json b/scripts/langindex.json index 92ad0f979..1d6bf8eae 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1590,6 +1590,15 @@ "core.question.questionno": "question", "core.question.requiresgrading": "question", "core.quotausage": "moodle", + "core.rating.aggregateavg": "moodle", + "core.rating.aggregatecount": "moodle", + "core.rating.aggregatemax": "moodle", + "core.rating.aggregatemin": "moodle", + "core.rating.aggregatesum": "moodle", + "core.rating.norating": "local_moodlemobileapp", + "core.rating.noratings": "moodle", + "core.rating.rating": "moodle", + "core.rating.ratings": "moodle", "core.redirectingtosite": "local_moodlemobileapp", "core.refresh": "moodle", "core.remove": "moodle", diff --git a/src/addon/mod/forum/components/components.module.ts b/src/addon/mod/forum/components/components.module.ts index e9b656899..0f3bf1b10 100644 --- a/src/addon/mod/forum/components/components.module.ts +++ b/src/addon/mod/forum/components/components.module.ts @@ -20,6 +20,7 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CorePipesModule } from '@pipes/pipes.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { CoreRatingComponentsModule } from '@core/rating/components/components.module'; import { AddonModForumIndexComponent } from './index/index'; import { AddonModForumPostComponent } from './post/post'; @@ -35,7 +36,8 @@ import { AddonModForumPostComponent } from './post/post'; CoreComponentsModule, CoreDirectivesModule, CorePipesModule, - CoreCourseComponentsModule + CoreCourseComponentsModule, + CoreRatingComponentsModule ], providers: [ ], diff --git a/src/addon/mod/forum/components/index/addon-mod-forum-index.html b/src/addon/mod/forum/components/index/addon-mod-forum-index.html index e4d9b273d..9eab4d526 100644 --- a/src/addon/mod/forum/components/index/addon-mod-forum-index.html +++ b/src/addon/mod/forum/components/index/addon-mod-forum-index.html @@ -21,7 +21,7 @@ - + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts index 774c8ae4d..036697410 100644 --- a/src/addon/mod/forum/components/index/index.ts +++ b/src/addon/mod/forum/components/index/index.ts @@ -19,6 +19,9 @@ import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreGroupsProvider } from '@providers/groups'; +import { CoreRatingProvider } from '@core/rating/providers/rating'; +import { CoreRatingOfflineProvider } from '@core/rating/providers/offline'; +import { CoreRatingSyncProvider } from '@core/rating/providers/sync'; import { AddonModForumProvider } from '../../providers/forum'; import { AddonModForumHelperProvider } from '../../providers/helper'; import { AddonModForumOfflineProvider } from '../../providers/offline'; @@ -56,6 +59,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected newDiscObserver: any; protected viewDiscObserver: any; + hasOfflineRatings: boolean; + protected ratingOfflineObserver: any; + protected ratingSyncObserver: any; + constructor(injector: Injector, @Optional() protected content: Content, protected navCtrl: NavController, @@ -66,7 +73,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected forumOffline: AddonModForumOfflineProvider, protected forumSync: AddonModForumSyncProvider, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, - protected prefetchHandler: AddonModForumPrefetchHandler) { + protected prefetchHandler: AddonModForumPrefetchHandler, + protected ratingOffline: CoreRatingOfflineProvider) { super(injector); } @@ -100,6 +108,22 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom } }, this.sitesProvider.getCurrentSiteId()); + // Listen for offline ratings saved and synced. + this.ratingOfflineObserver = this.eventsProvider.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => { + if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' && + data.contextLevel == 'module' && data.instanceId == this.forum.cmid) { + this.hasOfflineRatings = true; + } + }); + this.ratingSyncObserver = this.eventsProvider.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => { + if (this.forum && data.component == 'mod_forum' && data.ratingArea == 'post' && + data.contextLevel == 'module' && data.instanceId == this.forum.cmid) { + this.ratingOffline.hasRatings('mod_forum', 'post', 'module', this.forum.cmid).then((hasRatings) => { + this.hasOfflineRatings = hasRatings; + }); + } + }); + this.loadContent(false, true).then(() => { if (!this.forum) { return; @@ -178,6 +202,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom return Promise.all([ this.fetchOfflineDiscussion(), this.fetchDiscussions(refresh), + this.ratingOffline.hasRatings('mod_forum', 'post', 'module', this.forum.cmid).then((hasRatings) => { + this.hasOfflineRatings = hasRatings; + }) ]); }).catch((message) => { if (!refresh) { @@ -351,21 +378,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected sync(): Promise { const promises = []; - promises.push(this.forumSync.syncForumDiscussions(this.forum.id).then((result) => { - if (result.warnings && result.warnings.length) { - this.domUtils.showErrorModal(result.warnings[0]); - } - - return result; - })); - - promises.push(this.forumSync.syncForumReplies(this.forum.id).then((result) => { - if (result.warnings && result.warnings.length) { - this.domUtils.showErrorModal(result.warnings[0]); - } - - return result; - })); + promises.push(this.forumSync.syncForumDiscussions(this.forum.id)); + promises.push(this.forumSync.syncForumReplies(this.forum.id)); + promises.push(this.forumSync.syncRatings(this.forum.cmid)); return Promise.all(promises).then((results) => { return results.reduce((a, b) => ({ @@ -476,5 +491,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.newDiscObserver && this.newDiscObserver.off(); this.replyObserver && this.replyObserver.off(); this.viewDiscObserver && this.viewDiscObserver.off(); + this.ratingOfflineObserver && this.ratingOfflineObserver.off(); + this.ratingSyncObserver && this.ratingSyncObserver.off(); } } diff --git a/src/addon/mod/forum/components/post/addon-mod-forum-post.html b/src/addon/mod/forum/components/post/addon-mod-forum-post.html index cad199ead..253e7208a 100644 --- a/src/addon/mod/forum/components/post/addon-mod-forum-post.html +++ b/src/addon/mod/forum/components/post/addon-mod-forum-post.html @@ -23,6 +23,8 @@ + + + + + + + + + + + + + + + {{ rating.timemodified | coreDateDayOrTime }} + +

+

{{ rating.rating }}

+
+
+ +
+
diff --git a/src/core/rating/pages/ratings/ratings.module.ts b/src/core/rating/pages/ratings/ratings.module.ts new file mode 100644 index 000000000..62bf4821b --- /dev/null +++ b/src/core/rating/pages/ratings/ratings.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreRatingRatingsPage } from './ratings'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; + +@NgModule({ + declarations: [ + CoreRatingRatingsPage + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(CoreRatingRatingsPage), + TranslateModule.forChild() + ], +}) +export class CoreRatingRatingsPageModule {} diff --git a/src/core/rating/pages/ratings/ratings.ts b/src/core/rating/pages/ratings/ratings.ts new file mode 100644 index 000000000..1114562ca --- /dev/null +++ b/src/core/rating/pages/ratings/ratings.ts @@ -0,0 +1,95 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams, ViewController } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreRatingProvider, CoreRatingItemRating } from '@core/rating/providers/rating'; + +/** + * Page that displays individual ratings + */ +@IonicPage({ segment: 'core-rating-ratings' }) +@Component({ + selector: 'page-core-rating-ratings', + templateUrl: 'ratings.html', +}) +export class CoreRatingRatingsPage { + contextLevel: string; + instanceId: number; + component: string; + ratingArea: string; + aggregateMethod: number; + itemId: number; + scaleId: number; + courseId: number; + loaded = false; + ratings: CoreRatingItemRating[] = []; + + constructor(navParams: NavParams, private viewCtrl: ViewController, private domUtils: CoreDomUtilsProvider, + private ratingProvider: CoreRatingProvider) { + this.contextLevel = navParams.get('contextLevel'); + this.instanceId = navParams.get('instanceId'); + this.component = navParams.get('ratingComponent'); + this.ratingArea = navParams.get('ratingArea'); + this.aggregateMethod = navParams.get('aggregateMethod'); + this.itemId = navParams.get('itemId'); + this.scaleId = navParams.get('scaleId'); + this.courseId = navParams.get('courseId'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Fetch all the data required for the view. + * + * @return {Promise} Resolved when done. + */ + fetchData(): Promise { + return this.ratingProvider.getItemRatings(this.contextLevel, this.instanceId, this.component, this.ratingArea, this.itemId, + this.scaleId, undefined, this.courseId).then((ratings) => { + this.ratings = ratings; + }).catch((error) => { + this.domUtils.showErrorModal(error); + }); + } + + /** + * Refresh data. + * + * @param {any} refresher Refresher. + */ + refreshRatings(refresher: any): void { + this.ratingProvider.invalidateRatingItems(this.contextLevel, this.instanceId, this.component, this.ratingArea, this.itemId, + this.scaleId).finally(() => { + return this.fetchData().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/core/rating/providers/offline.ts b/src/core/rating/providers/offline.ts new file mode 100644 index 000000000..6f033da1c --- /dev/null +++ b/src/core/rating/providers/offline.ts @@ -0,0 +1,305 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; + +/** + * Structure of offline ratings. + */ +export interface CoreRatingOfflineRating { + component: string; + ratingarea: string; + contextlevel: string; + instanceid: number; + itemid: number; + itemsetid: number; + courseid: number; + scaleid: number; + rating: number; + rateduserid: number; + aggregation: number; +} + +/** + * Structure of item sets. + */ +export interface CoreRatingItemSet { + component: string; + ratingArea: string; + contextLevel: string; + instanceId: number; + itemSetId: number; + courseId: number; +} + +/** + * Service to handle offline data for rating. + */ +@Injectable() +export class CoreRatingOfflineProvider { + + // Variables for database. + static RATINGS_TABLE = 'rating_ratings'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreCourseOfflineProvider', + version: 1, + tables: [ + { + name: CoreRatingOfflineProvider.RATINGS_TABLE, + columns: [ + { + name: 'component', + type: 'TEXT' + }, + { + name: 'ratingarea', + type: 'TEXT' + }, + { + name: 'contextlevel', + type: 'INTEGER', + }, + { + name: 'instanceid', + type: 'INTEGER' + }, + { + name: 'itemid', + type: 'INTEGER' + }, + { + name: 'itemsetid', + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'scaleid', + type: 'INTEGER' + }, + { + name: 'rating', + type: 'INTEGER' + }, + { + name: 'rateduserid', + type: 'INTEGER' + }, + { + name: 'aggregation', + type: 'INTEGER' + } + ], + primaryKeys: ['component', 'ratingarea', 'contextlevel', 'instanceid', 'itemid'] + } + ] + }; + + constructor(private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Get an offline rating. + * + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {numnber} instanceId Context instance id. + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {number} itemId Item id. Example: forum post id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the saved rating, rejected if not found. + */ + getRating(contextLevel: string, instanceId: number, component: string, ratingArea: string, itemId: number, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + ratingarea: ratingArea, + itemid: itemId + }; + + return site.getDb().getRecord(CoreRatingOfflineProvider.RATINGS_TABLE, conditions); + }); + } + + /** + * Add an offline rating. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {numnber} instanceId Context instance id. + * @param {number} itemId Item id. Example: forum post id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @param {number} courseId Course id. + * @param {number} scaleId Scale id. + * @param {number} rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param {number} ratedUserId Rated user id. + * @param {number} aggregateMethod Aggregate method. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the rating is saved. + */ + addRating(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemId: number, itemSetId: number, + courseId: number, scaleId: number, rating: number, ratedUserId: number, aggregateMethod: number, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data: CoreRatingOfflineRating = { + component: component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemid: itemId, + itemsetid: itemSetId, + courseid: courseId, + scaleid: scaleId, + rating: rating, + rateduserid: ratedUserId, + aggregation: aggregateMethod + }; + + return site.getDb().insertRecord(CoreRatingOfflineProvider.RATINGS_TABLE, data); + }); + } + + /** + * Delete offline rating. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} itemId Item id. Example: forum post id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the rating is saved. + */ + deleteRating(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemId: number, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + component: component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemid: itemId + }; + + return site.getDb().deleteRecords(CoreRatingOfflineProvider.RATINGS_TABLE, conditions); + }); + } + + /** + * Get the list of item sets in a component or instance. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} [contextLevel] Context level: course, module, user, etc. + * @param {numnber} [instanceId] Context instance id. + * @param {number} [itemSetId] Item set id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of item set ids. + */ + getItemSets(component: string, ratingArea: string, contextLevel?: string, instanceId?: number, itemSetId?: number, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const fields = 'DISTINCT contextlevel, instanceid, itemsetid, courseid'; + const conditions: any = { + component, + ratingarea: ratingArea + }; + if (contextLevel != null && instanceId != null) { + conditions.contextlevel = contextLevel; + conditions.instanceId = instanceId; + } + if (itemSetId != null) { + conditions.itemSetId = itemSetId; + } + + return site.getDb().getRecords(CoreRatingOfflineProvider.RATINGS_TABLE, conditions, undefined, fields) + .then((records: any[]) => { + return records.map((record) => { + return { + component, + ratingArea, + contextLevel: record.contextlevel, + instanceId: record.instanceid, + itemSetId: record.itemsetid, + courseId: record.courseid + }; + }); + }); + }); + } + + /** + * Get offline ratings of an item set. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} itemId Item id. Example: forum post id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of ratings. + */ + getRatings(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemSetId: number, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemsetid: itemSetId + }; + + return site.getDb().getRecords(CoreRatingOfflineProvider.RATINGS_TABLE, conditions); + }); + } + + /** + * Return whether a component, instance or item set has offline ratings. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} [contextLevel] Context level: course, module, user, etc. + * @param {number} [instanceId] Context instance id. + * @param {number} [itemSetId] Item set id. Example: forum discussion id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a boolean. + */ + hasRatings(component: string, ratingArea: string, contextLevel?: string, instanceId?: number, itemSetId?: number, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions: any = { + component, + ratingarea: ratingArea + }; + if (contextLevel != null && instanceId != null) { + conditions.contextlevel = contextLevel; + conditions.instanceId = instanceId; + } + if (itemSetId != null) { + conditions.itemsetid = itemSetId; + } + + return this.utils.promiseWorks(site.getDb().recordExists(CoreRatingOfflineProvider.RATINGS_TABLE, conditions)); + }); + } +} diff --git a/src/core/rating/providers/rating.ts b/src/core/rating/providers/rating.ts new file mode 100644 index 000000000..8756daec7 --- /dev/null +++ b/src/core/rating/providers/rating.ts @@ -0,0 +1,352 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreRatingOfflineProvider } from './offline'; + +/** + * Structure of the rating info returned by web services. + */ +export interface CoreRatingInfo { + contextid: number; + component: string; + ratingarea: string; + canviewall: boolean; + canviewany: boolean; + scales?: CoreRatingScale[]; + ratings?: CoreRatingInfoItem[]; +} + +/** + * Structure of scales in the rating info. + */ +export interface CoreRatingScale { + id: number; + courseid?: number; + name?: string; + max: number; + isnumeric: boolean; + items?: {value: number, name: string}[]; +} + +/** + * Structure of items in the rating info. + */ +export interface CoreRatingInfoItem { + itemid: number; + scaleid?: number; + scale?: CoreRatingScale; + userid?: number; + aggregate?: number; + aggregatestr?: string; + count?: number; + rating?: number; + canrate?: boolean; + canviewaggregate?: boolean; +} + +/** + * Structure of a rating returned by the item ratings web service. + */ +export interface CoreRatingItemRating { + id: number; + userid: number; + userpictureurl: string; + userfullname: string; + rating: string; + timemodified: number; +} + +/** + * Service to handle ratings. + */ +@Injectable() +export class CoreRatingProvider { + + static AGGREGATE_NONE = 0; // No ratings. + static AGGREGATE_AVERAGE = 1; + static AGGREGATE_COUNT = 2; + static AGGREGATE_MAXIMUM = 3; + static AGGREGATE_MINIMUM = 4; + static AGGREGATE_SUM = 5; + + static UNSET_RATING = -999; + + static AGGREGATE_CHANGED_EVENT = 'core_rating_aggregate_changed'; + static RATING_SAVED_EVENT = 'core_rating_rating_saved'; + + protected ROOT_CACHE_KEY = 'CoreRating:'; + + constructor(private appProvider: CoreAppProvider, + private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider, + private userProvider: CoreUserProvider, + private utils: CoreUtilsProvider, + private ratingOffline: CoreRatingOfflineProvider) {} + + /** + * Returns whether the web serivce to add ratings is available. + * + * @return {boolean} If WS is abalaible. + * @since 3.2 + */ + isAddRatingWSAvailable(): boolean { + return this.sitesProvider.wsAvailableInCurrentSite('core_rating_add_rating'); + } + + /** + * Add a rating to an item. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {number} itemId Item id. Example: forum post id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @param {number} courseId Course id. + * @param {number} scaleId Scale id. + * @param {number} rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param {number} ratedUserId Rated user id. + * @param {number} aggregateMethod Aggregate method. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the aggregated rating or null if stored offline. + * @since 3.2 + */ + addRating(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemId: number, itemSetId: number, + courseId: number, scaleId: number, rating: number, ratedUserId: number, aggregateMethod: number, siteId?: string): + Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a rating to be synchronized later. + const storeOffline = (): Promise => { + return this.ratingOffline.addRating(component, ratingArea, contextLevel, instanceId, itemId, itemSetId, courseId, + scaleId, rating, ratedUserId, aggregateMethod, siteId).then(() => { + this.eventsProvider.trigger(CoreRatingProvider.RATING_SAVED_EVENT, { + component, + ratingArea, + contextLevel, + instanceId, + itemSetId, + itemId + }, siteId); + + return null; + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + return this.ratingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId).then(() => { + return this.addRatingOnline(component, ratingArea, contextLevel, instanceId, itemId, scaleId, rating, ratedUserId, + aggregateMethod, siteId).catch((error) => { + + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(error); + } + + // Couldn't connect to server, store offline. + return storeOffline(); + }); + }); + } + + /** + * Add a rating to an item. It will fail if offline or cannot connect. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {number} itemId Item id. Example: forum post id. + * @param {number} scaleId Scale id. + * @param {number} rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param {number} ratedUserId Rated user id. + * @param {number} aggregateMethod Aggregate method. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the aggregated rating. + * @since 3.2 + */ + addRatingOnline(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemId: number, + scaleId: number, rating: number, ratedUserId: number, aggregateMethod: number, siteId?: string): + Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + ratingarea: ratingArea, + itemid: itemId, + scaleid: scaleId, + rating: rating, + rateduserid: ratedUserId, + aggregation: aggregateMethod + }; + + return site.write('core_rating_add_rating', params).then((response) => { + return this.invalidateRatingItems(contextLevel, instanceId, component, ratingArea, itemId, scaleId).then(() => { + this.eventsProvider.trigger(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, { + contextLevel, + instanceId, + component, + ratingArea, + itemId, + aggregate: response.aggregate, + count: response.count + }); + + return response; + }); + }); + }); + } + + /** + * Get item ratings. + * + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {number} itemId Item id. Example: forum post id. + * @param {number} scaleId Scale id. + * @param {string} [sort="timemodified"] Sort field. + * @param {number} [courseId] Course id. Used for fetching user profiles. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it will always fail in offline or server down). + * @return {Promise} Promise resolved with the list of ratings. + */ + getItemRatings(contextLevel: string, instanceId: number, component: string, ratingArea: string, itemId: number, + scaleId: number, sort: string = 'timemodified', courseId?: number, siteId?: string, ignoreCache: boolean = false): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + ratingarea: ratingArea, + itemid: itemId, + scaleid: scaleId, + sort: sort + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort) + }; + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_rating_get_item_ratings', params, preSets).then((response) => { + if (!response || !response.ratings) { + return Promise.reject(null); + } + + // We need to fetch profiles because the returned profile pictures are incorrect. + const promises = response.ratings.map((rating: CoreRatingItemRating) => { + return this.userProvider.getProfile(rating.userid, courseId, true, site.id).then((user) => { + rating.userpictureurl = user.profileimageurl; + }).catch(() => { + // Ignore error. + rating.userpictureurl = null; + }); + }); + + return Promise.all(promises).then(() => { + return response.ratings; + }); + }); + }); + } + + /** + * Invalidate item ratings. + * + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {number} itemId Item id. Example: forum post id. + * @param {number} scaleId Scale id. + * @param {string} [sort="timemodified"] Sort field. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateRatingItems(contextLevel: string, instanceId: number, component: string, ratingArea: string, + itemId: number, scaleId: number, sort: string = 'timemodified', siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Prefetch individual ratings. + * + * This function should be called from the prefetch handler of activities with ratings. + * + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Instance id. + * @param {string} [siteId] Site id. If not defined, current site. + * @param {number} [courseId] Course id. Used for prefetching user profiles. + * @param {CoreRatingInfo} [ratingInfo] Rating info returned by web services. + * @return {Promise} Promise resolved when done. + */ + prefetchRatings(contextLevel: string, instanceId: number, scaleId: number, courseId?: number, ratingInfo?: CoreRatingInfo, + siteId?: string): Promise { + if (!ratingInfo || !ratingInfo.ratings) { + return Promise.resolve(); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const promises = ratingInfo.ratings.map((item) => { + return this.getItemRatings(contextLevel, instanceId, ratingInfo.component, ratingInfo.ratingarea, item.itemid, + scaleId, undefined, courseId, site.id, true).then((ratings) => { + const userIds = ratings.map((rating: CoreRatingItemRating) => rating.userid); + + return this.userProvider.prefetchProfiles(userIds, courseId, site.id); + }); + }); + + return Promise.all(promises); + }); + } + + /** + * Get cache key for rating items WS calls. + * + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating area. Example: "post". + * @param {number} itemId Item id. Example: forum post id. + * @param {number} scaleId Scale id. + * @param {string} sort Sort field. + * @return {string} Cache key. + */ + protected getItemRatingsCacheKey(contextLevel: string, instanceId: number, component: string, ratingArea: string, + itemId: number, scaleId: number, sort: string): string { + return `${this.ROOT_CACHE_KEY}${contextLevel}:${instanceId}:${component}:${ratingArea}:${itemId}:${scaleId}:${sort}`; + } +} diff --git a/src/core/rating/providers/sync.ts b/src/core/rating/providers/sync.ts new file mode 100644 index 000000000..cffa42d42 --- /dev/null +++ b/src/core/rating/providers/sync.ts @@ -0,0 +1,196 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreRatingProvider } from './rating'; +import { CoreRatingOfflineProvider, CoreRatingItemSet } from './offline'; +import { CoreEventsProvider } from '@providers/events'; + +/** + * Service to sync ratings. + */ +@Injectable() +export class CoreRatingSyncProvider extends CoreSyncBaseProvider { + + static SYNCED_EVENT = 'core_rating_synced'; + + constructor(translate: TranslateService, + appProvider: CoreAppProvider, + private eventsProvider: CoreEventsProvider, + loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + syncProvider: CoreSyncProvider, + textUtils: CoreTextUtilsProvider, + timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, + private ratingProvider: CoreRatingProvider, + private ratingOffline: CoreRatingOfflineProvider) { + + super('CoreRatingSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); + } + + /** + * Try to synchronize all the ratings of a certain component, instance or item set. + * + * This function should be called from the sync provider of activities with ratings. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} [contextLevel] Context level: course, module, user, etc. + * @param {numnber} [instanceId] Context instance id. + * @param {number} [itemSetId] Item set id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncRatings(component: string, ratingArea: string, contextLevel?: string, instanceId?: number, itemSetId?: number, + siteId?: string): Promise<{itemSet: CoreRatingItemSet, updated: boolean, warnings: string[]}[]> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.ratingOffline.getItemSets(component, ratingArea, contextLevel, instanceId, itemSetId, siteId) + .then((itemSets) => { + const results = []; + const promises = itemSets.map((itemSet) => { + return this.syncItemSetIfNeeded(component, ratingArea, itemSet.contextLevel, itemSet.instanceId, + itemSet.itemSetId, siteId).then((result) => { + if (result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(CoreRatingSyncProvider.SYNCED_EVENT, { + ...itemSet, + warnings: result.warnings + }, siteId); + } + + results.push({itemSet, ...result}); + }); + }); + + return Promise.all(promises).then(() => { + return results; + }); + }); + } + + /** + * Sync ratings of an item set only if a certain time has passed since the last time. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when ratings are synced or if it doesn't need to be synced. + */ + protected syncItemSetIfNeeded(component: string, ratingArea: string, contextLevel: string, instanceId: number, + itemSetId: number, siteId?: string): Promise<{updated: boolean, warnings: string[]}> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncItemSet(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); + } + }); + } + + /** + * Synchronize all offline ratings of an item set. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + protected syncItemSet(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemSetId: number, + siteId?: string): Promise<{updated: boolean, warnings: string[]}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this item set, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + this.logger.debug(`Try to sync ratings of component '${component}' rating area '${ratingArea}'` + + ` context level '${contextLevel}' instance ${instanceId} item set ${itemSetId}`); + + let updated = false; + const warnings = []; + + return this.ratingOffline.getRatings(component, ratingArea, contextLevel, instanceId, itemSetId, siteId).then((ratings) => { + if (!ratings.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = ratings.map((rating) => { + return this.ratingProvider.addRatingOnline(component, ratingArea, rating.contextlevel, rating.instanceid, + rating.itemid, rating.scaleid, rating.rating, rating.rateduserid, rating.aggregation, siteId) + .catch((error) => { + if (this.utils.isWebServiceError(error)) { + warnings.push(this.textUtils.getErrorMessageFromError(error)); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }).then(() => { + updated = true; + + return this.ratingOffline.deleteRating(component, ratingArea, rating.contextlevel, rating.instanceid, + rating.itemid, siteId).finally(() => { + return this.ratingProvider.invalidateRatingItems(rating.contextlevel, rating.instanceid, component, + ratingArea, rating.itemid, rating.scaleid, undefined, siteId); + }); + }); + }); + + return Promise.all(promises).then(() => { + // All done, return the warnings. + return { updated, warnings }; + }); + }); + } + + /** + * Get the sync id of an item set. + * + * @param {string} component Component. Example: "mod_forum". + * @param {string} ratingArea Rating Area. Example: "post". + * @param {string} contextLevel Context level: course, module, user, etc. + * @param {number} instanceId Context instance id. + * @param {number} itemSetId Item set id. Example: forum discussion id. + * @return {string} Sync id. + */ + protected getItemSetSyncId(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemSetId: number): + string { + return `itemSet#${component}#${ratingArea}#${contextLevel}#${instanceId}#${itemSetId}`; + } +} diff --git a/src/core/rating/rating.module.ts b/src/core/rating/rating.module.ts new file mode 100644 index 000000000..e7c512467 --- /dev/null +++ b/src/core/rating/rating.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreRatingProvider } from './providers/rating'; +import { CoreRatingOfflineProvider } from './providers/offline'; +import { CoreRatingSyncProvider } from './providers/sync'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + CoreRatingProvider, + CoreRatingOfflineProvider, + CoreRatingSyncProvider + ] +}) +export class CoreRatingModule {}