diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index b44a96b8c..e9a5dab79 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -58,6 +58,7 @@ import { CORE_MAINMENU_SERVICES } from '@features/mainmenu/mainmenu.module'; import { CORE_PUSHNOTIFICATIONS_SERVICES } from '@features/pushnotifications/pushnotifications.module'; import { CORE_QUESTION_SERVICES } from '@features/question/question.module'; // @todo import { CORE_SHAREDFILES_SERVICES } from '@features/sharedfiles/sharedfiles.module'; +import { CORE_RATING_SERVICES } from '@features/rating/rating.module'; import { CORE_SEARCH_SERVICES } from '@features/search/search.module'; import { CORE_SETTINGS_SERVICES } from '@features/settings/settings.module'; import { CORE_SITEHOME_SERVICES } from '@features/sitehome/sitehome.module'; @@ -266,6 +267,7 @@ export class CoreCompileProvider { ...CORE_LOGIN_SERVICES, ...CORE_QUESTION_SERVICES, ...CORE_PUSHNOTIFICATIONS_SERVICES, + ...CORE_RATING_SERVICES, ...CORE_SEARCH_SERVICES, ...CORE_SETTINGS_SERVICES, // @todo ...CORE_SHAREDFILES_SERVICES, diff --git a/src/core/features/rating/components/aggregate/aggregate.ts b/src/core/features/rating/components/aggregate/aggregate.ts new file mode 100644 index 000000000..e44935651 --- /dev/null +++ b/src/core/features/rating/components/aggregate/aggregate.ts @@ -0,0 +1,144 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Component, Input, OnChanges, OnDestroy } from '@angular/core'; +import { + CoreRating, + CoreRatingInfo, + CoreRatingInfoItem, + CoreRatingProvider, +} from '@features/rating/services/rating'; +import { CoreSites } from '@services/sites'; +import { ModalController } from '@singletons'; +import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events'; +import { CoreRatingRatingsComponent } from '../ratings/ratings'; + +/** + * Component that displays the aggregation of a rating item. + */ +@Component({ + selector: 'core-rating-aggregate', + templateUrl: 'core-rating-aggregate.html', +}) +export class CoreRatingAggregateComponent implements OnChanges, OnDestroy { + + @Input() ratingInfo!: CoreRatingInfo; + @Input() contextLevel!: ContextLevel; + @Input() instanceId!: number; + @Input() itemId!: number; + @Input() aggregateMethod!: number; + @Input() scaleId!: number; + @Input() courseId?: number; + + item?: CoreRatingInfoItem; + showCount = false; + disabled = false; + labelKey = ''; + + protected aggregateObserver: CoreEventObserver; + protected updateSiteObserver: CoreEventObserver; + + constructor() { + this.disabled = CoreRating.isRatingDisabledInSite(); + + // Update visibility if current site info is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.disabled = CoreRating.isRatingDisabledInSite(); + }, CoreSites.getCurrentSiteId()); + + // Update aggrgate when the user adds or edits a rating. + this.aggregateObserver = + CoreEvents.on(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, (data) => { + if (this.item && + data.contextLevel == this.contextLevel && + data.instanceId == this.instanceId && + data.component == this.ratingInfo.component && + data.ratingArea == this.ratingInfo.ratingarea && + data.itemId == this.itemId) { + this.item.aggregatestr = data.aggregate; + this.item.count = data.count; + } + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(): void { + this.aggregateObserver && this.aggregateObserver.off(); + + this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId); + if (!this.item) { + return; + } + + switch (this.aggregateMethod) { + case CoreRatingProvider.AGGREGATE_AVERAGE: + this.labelKey = 'core.rating.aggregateavg'; + break; + case CoreRatingProvider.AGGREGATE_COUNT: + this.labelKey = 'core.rating.aggregatecount'; + break; + case CoreRatingProvider.AGGREGATE_MAXIMUM: + this.labelKey = 'core.rating.aggregatemax'; + break; + case CoreRatingProvider.AGGREGATE_MINIMUM: + this.labelKey = 'core.rating.aggregatemin'; + break; + case CoreRatingProvider.AGGREGATE_SUM: + this.labelKey = 'core.rating.aggregatesum'; + break; + default: + this.labelKey = ''; + + return; + } + + this.showCount = (this.aggregateMethod != CoreRatingProvider.AGGREGATE_COUNT); + } + + /** + * Open the individual ratings page. + */ + async openRatings(): Promise { + if (!this.ratingInfo.canviewall || !this.item!.count || this.disabled) { + return; + } + + const modal = await ModalController.create({ + component: CoreRatingRatingsComponent, + componentProps: { + contextLevel: this.contextLevel, + instanceId: this.instanceId, + ratingComponent: this.ratingInfo.component, + ratingArea: this.ratingInfo.ratingarea, + itemId: this.itemId, + scaleId: this.scaleId, + courseId: this.courseId, + }, + }); + + await modal.present(); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.aggregateObserver.off(); + this.updateSiteObserver.off(); + } + +} diff --git a/src/core/features/rating/components/aggregate/core-rating-aggregate.html b/src/core/features/rating/components/aggregate/core-rating-aggregate.html new file mode 100644 index 000000000..91783ebc3 --- /dev/null +++ b/src/core/features/rating/components/aggregate/core-rating-aggregate.html @@ -0,0 +1,7 @@ + + + {{ labelKey | translate }}{{ 'core.labelsep' | translate }} {{ item!.aggregatestr || '-' }} + ({{ item!.count! }}) + + diff --git a/src/core/features/rating/components/components.module.ts b/src/core/features/rating/components/components.module.ts new file mode 100644 index 000000000..c6d4578dc --- /dev/null +++ b/src/core/features/rating/components/components.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreRatingAggregateComponent } from './aggregate/aggregate'; +import { CoreRatingRateComponent } from './rate/rate'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreRatingRatingsComponent } from './ratings/ratings'; + +@NgModule({ + declarations: [ + CoreRatingAggregateComponent, + CoreRatingRateComponent, + CoreRatingRatingsComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreRatingAggregateComponent, + CoreRatingRateComponent, + CoreRatingRatingsComponent, + ], + entryComponents: [ + CoreRatingRatingsComponent, + ], +}) +export class CoreRatingComponentsModule {} diff --git a/src/core/features/rating/components/rate/core-rating-rate.html b/src/core/features/rating/components/rate/core-rating-rate.html new file mode 100644 index 000000000..c347d12a3 --- /dev/null +++ b/src/core/features/rating/components/rate/core-rating-rate.html @@ -0,0 +1,7 @@ + + {{ 'core.rating.rating' | translate }} + + {{ scaleItem.name }} + + diff --git a/src/core/features/rating/components/rate/rate.ts b/src/core/features/rating/components/rate/rate.ts new file mode 100644 index 000000000..1dc6f71db --- /dev/null +++ b/src/core/features/rating/components/rate/rate.ts @@ -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. + +import { ContextLevel } from '@/core/constants'; +import { Component, EventEmitter, Input, OnChanges, Output, OnDestroy } from '@angular/core'; +import { + CoreRatingProvider, + CoreRatingInfo, + CoreRatingInfoItem, + CoreRatingScale, + CoreRating, +} from '@features/rating/services/rating'; +import { CoreRatingOffline } from '@features/rating/services/rating-offline'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events'; + +/** + * Component that displays the user rating select. + */ +@Component({ + selector: 'core-rating-rate', + templateUrl: 'core-rating-rate.html', +}) +export class CoreRatingRateComponent implements OnChanges, OnDestroy { + + @Input() protected ratingInfo!: CoreRatingInfo; + @Input() protected contextLevel!: ContextLevel; // Context level: course, module, user, etc. + @Input() protected instanceId!: number; // Context instance id. + @Input() protected itemId!: number; // Item id. Example: forum post id. + @Input() protected itemSetId!: number; // Item set id. Example: forum discussion id. + @Input() protected courseId!: number; + @Input() protected aggregateMethod!: number; + @Input() protected scaleId!: number; + @Input() protected userId!: number; + @Output() protected onLoading: EventEmitter; // Eevent that indicates whether the component is loading data. + @Output() protected onUpdate: EventEmitter; // Event emitted when the rating is updated online. + + item?: CoreRatingInfoItem; + scale?: CoreRatingScale; + rating?: number; + disabled = false; + + protected updateSiteObserver: CoreEventObserver; + + constructor() { + + this.onLoading = new EventEmitter(); + this.onUpdate = new EventEmitter(); + + this.disabled = CoreRating.isRatingDisabledInSite(); + + // Update visibility if current site info is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.disabled = CoreRating.isRatingDisabledInSite(); + }, CoreSites.getCurrentSiteId()); + } + + /** + * Detect changes on input properties. + */ + async ngOnChanges(): Promise { + this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId); + this.scale = (this.ratingInfo.scales || []).find((scale) => scale.id == this.scaleId); + + if (!this.item || !this.scale || !CoreRating.isAddRatingWSAvailable()) { + this.item = undefined; + + return; + } + + // Set numeric scale items. + if (!this.scale.items) { + this.scale.items = []; + if (this.scale.isnumeric) { + for (let n = 0; n <= this.scale.max; n++) { + this.scale.items.push({ name: String(n), value: n }); + } + } + } + + // Add "No rating" item to the scale. + if (!this.scale.items[0] || this.scale.items[0].value != CoreRatingProvider.UNSET_RATING) { + this.scale.items.unshift({ + name: Translate.instant('core.none'), + value: CoreRatingProvider.UNSET_RATING, + }); + } + + this.onLoading.emit(true); + + try { + const rating = await CoreRatingOffline.getRating( + this.contextLevel, + this.instanceId, + this.ratingInfo.component, + this.ratingInfo.ratingarea, + this.itemId, + ); + this.rating = rating.rating; + } catch { + if (this.item && this.item.rating != null) { + this.rating = this.item.rating; + } else { + this.rating = CoreRatingProvider.UNSET_RATING; + } + } finally { + this.onLoading.emit(false); + } + } + + /** + * Send or save the user rating when changed. + */ + async userRatingChanged(): Promise { + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + const response = await CoreRating.addRating( + this.ratingInfo.component, + this.ratingInfo.ratingarea, + this.contextLevel, + this.instanceId, + this.itemId, + this.itemSetId, + this.courseId, + this.scaleId, + this.rating!, + this.userId, + this.aggregateMethod, + ); + + if (typeof response == 'undefined') { + CoreDomUtils.showToast('core.datastoredoffline', true, 3000); + } else { + this.onUpdate.emit(); + } + } catch (error) { + CoreDomUtils.showErrorModal(error); + } finally { + modal.dismiss(); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver.off(); + } + +} diff --git a/src/core/features/rating/components/ratings/ratings-modal.html b/src/core/features/rating/components/ratings/ratings-modal.html new file mode 100644 index 000000000..8051fbc28 --- /dev/null +++ b/src/core/features/rating/components/ratings/ratings-modal.html @@ -0,0 +1,27 @@ + + + {{ 'core.rating.ratings' | translate }} + + + + + + + + + + + + + +

{{ rating.userfullname }}

+

{{ rating.rating }}

+
+ + {{ rating.timemodified | coreDateDayOrTime }} + +
+
+ +
+
diff --git a/src/core/features/rating/components/ratings/ratings.ts b/src/core/features/rating/components/ratings/ratings.ts new file mode 100644 index 000000000..82fae1a91 --- /dev/null +++ b/src/core/features/rating/components/ratings/ratings.ts @@ -0,0 +1,70 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Component, Input, OnInit } from '@angular/core'; +import { CoreRating, CoreRatingItemRating } from '@features/rating/services/rating'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController } from '@singletons'; + +/** + * Modal that displays individual ratings + */ +@Component({ + templateUrl: 'ratings-modal.html', +}) +export class CoreRatingRatingsComponent implements OnInit { + + @Input() protected contextLevel!: ContextLevel; + @Input() protected instanceId!: number; + @Input() protected ratingComponent!: string; + @Input() protected ratingArea!: string; + @Input() protected aggregateMethod!: number; + @Input() protected itemId!: number; + @Input() protected scaleId!: number; + @Input() courseId!: number; + + loaded = false; + ratings: CoreRatingItemRating[] = []; + + /** + * Modal loaded. + */ + async ngOnInit(): Promise { + try { + this.ratings = await CoreRating.getItemRatings( + this.contextLevel, + this.instanceId, + this.ratingComponent, + this.ratingArea, + this.itemId, + this.scaleId, + undefined, + this.courseId, + ); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } finally { + this.loaded = true; + } + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/rating/lang.json b/src/core/features/rating/lang.json new file mode 100644 index 000000000..5b5799c29 --- /dev/null +++ b/src/core/features/rating/lang.json @@ -0,0 +1,10 @@ +{ + "aggregateavg": "Average of ratings", + "aggregatecount": "Count of ratings", + "aggregatemax": "Maximum rating", + "aggregatemin": "Minimum rating", + "aggregatesum": "Sum of ratings", + "noratings": "No ratings submitted", + "rating": "Rating", + "ratings": "Ratings" +} diff --git a/src/core/features/rating/rating.module.ts b/src/core/features/rating/rating.module.ts new file mode 100644 index 000000000..331449676 --- /dev/null +++ b/src/core/features/rating/rating.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule, Type } from '@angular/core'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { RATINGS_SITE_SCHEMA } from './services/database/rating'; +import { CoreRatingProvider } from './services/rating'; +import { CoreRatingOfflineProvider } from './services/rating-offline'; +import { CoreRatingSyncProvider } from './services/rating-sync'; + +export const CORE_RATING_SERVICES: Type[] = [ + CoreRatingProvider, + CoreRatingSyncProvider, + CoreRatingOfflineProvider, +]; + +@NgModule({ + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [RATINGS_SITE_SCHEMA], + multi: true, + }, + ], +}) +export class CoreRatingModule {} diff --git a/src/core/features/rating/services/database/rating.ts b/src/core/features/rating/services/database/rating.ts new file mode 100644 index 000000000..667ecb749 --- /dev/null +++ b/src/core/features/rating/services/database/rating.ts @@ -0,0 +1,101 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreRatingOffline service. + */ +export const RATINGS_TABLE = 'rating_ratings'; +export const RATINGS_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreRatingOfflineProvider', + version: 1, + tables: [ + { + name: 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'], + }, + ], +}; + + +/** + * Primary data to identify a stored rating. + */ +export type CoreRatingDBPrimaryData = { + component: string; + ratingarea: string; + contextlevel: ContextLevel; + instanceid: number; + itemid: number; +}; + +/** + * Rating stored. + */ +export type CoreRatingDBRecord = CoreRatingDBPrimaryData & { + itemsetid: number; + courseid?: number; + scaleid: number; + rating: number; + rateduserid: number; + aggregation: number; +}; diff --git a/src/core/features/rating/services/rating-offline.ts b/src/core/features/rating/services/rating-offline.ts new file mode 100644 index 000000000..88727b45b --- /dev/null +++ b/src/core/features/rating/services/rating-offline.ts @@ -0,0 +1,271 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreRatingDBPrimaryData, CoreRatingDBRecord, RATINGS_TABLE } from './database/rating'; + +/** + * Structure of item sets. + */ +export interface CoreRatingItemSet { + component: string; + ratingArea: string; + contextLevel: ContextLevel; + instanceId: number; + itemSetId: number; + courseId?: number; +} + +/** + * Service to handle offline data for rating. + */ +@Injectable( { providedIn: 'root' }) +export class CoreRatingOfflineProvider { + + /** + * Get an offline rating. + * + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param itemId Item id. Example: forum post id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the saved rating, rejected if not found. + */ + async getRating( + contextLevel: ContextLevel, + instanceId: number, + component: string, + ratingArea: string, + itemId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: CoreRatingDBPrimaryData = { + contextlevel: contextLevel, + instanceid: instanceId, + component: component, + ratingarea: ratingArea, + itemid: itemId, + }; + + return site.getDb().getRecord(RATINGS_TABLE, conditions); + } + + /** + * Add an offline rating. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemId Item id. Example: forum post id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param courseId Course id. + * @param scaleId Scale id. + * @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param ratedUserId Rated user id. + * @param aggregateMethod Aggregate method. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the rating is saved. + */ + async addRating( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemId: number, + itemSetId: number, + courseId: number, + scaleId: number, + rating: number, + ratedUserId: number, + aggregateMethod: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const data: CoreRatingDBRecord = { + component: component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemid: itemId, + itemsetid: itemSetId, + courseid: courseId, + scaleid: scaleId, + rating: rating, + rateduserid: ratedUserId, + aggregation: aggregateMethod, + }; + + await site.getDb().insertRecord(RATINGS_TABLE, data); + } + + /** + * Delete offline rating. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param itemId Item id. Example: forum post id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the rating is saved. + */ + async deleteRating( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: CoreRatingDBPrimaryData = { + component: component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemid: itemId, + }; + + await site.getDb().deleteRecords(RATINGS_TABLE, conditions); + } + + /** + * Get the list of item sets in a component or instance. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of item set ids. + */ + async getItemSets( + component: string, + ratingArea: string, + contextLevel?: ContextLevel, + instanceId?: number, + itemSetId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const fields = 'DISTINCT contextlevel, instanceid, itemsetid, courseid'; + + const conditions: Partial = { + component, + ratingarea: ratingArea, + }; + + if (contextLevel && instanceId) { + conditions.contextlevel = contextLevel; + conditions.instanceid = instanceId; + } + + if (itemSetId) { + conditions.itemsetid = itemSetId; + } + + const records = await site.getDb().getRecords(RATINGS_TABLE, conditions, undefined, fields); + + return records.map((record) => ({ + component, + ratingArea, + contextLevel: record.contextlevel, + instanceId: record.instanceid, + itemSetId: record.itemsetid, + courseId: record.courseid, + })); + } + + /** + * Get offline ratings of an item set. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param itemId Item id. Example: forum post id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of ratings. + */ + async getRatings( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemSetId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + component, + ratingarea: ratingArea, + contextlevel: contextLevel, + instanceid: instanceId, + itemsetid: itemSetId, + }; + + return site.getDb().getRecords(RATINGS_TABLE, conditions); + } + + /** + * Return whether a component, instance or item set has offline ratings. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with a boolean. + */ + async hasRatings( + component: string, + ratingArea: string, + contextLevel?: ContextLevel, + instanceId?: number, + itemSetId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const conditions: Partial = { + component, + ratingarea: ratingArea, + }; + if (contextLevel && instanceId) { + conditions.contextlevel = contextLevel; + conditions.instanceid = instanceId; + } + if (itemSetId) { + conditions.itemsetid = itemSetId; + } + + return CoreUtils.promiseWorks(site.getDb().recordExists(RATINGS_TABLE, conditions)); + } + +} +export const CoreRatingOffline = makeSingleton(CoreRatingOfflineProvider); diff --git a/src/core/features/rating/services/rating-sync.ts b/src/core/features/rating/services/rating-sync.ts new file mode 100644 index 000000000..77b658a22 --- /dev/null +++ b/src/core/features/rating/services/rating-sync.ts @@ -0,0 +1,315 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreRating } from './rating'; +import { CoreRatingItemSet, CoreRatingOffline } from './rating-offline'; + + +/** + * Service to sync ratings. + */ +@Injectable( { providedIn: 'root' }) +export class CoreRatingSyncProvider extends CoreSyncBaseProvider { + + static readonly SYNCED_EVENT = 'core_rating_synced'; + + constructor() { + super('CoreRatingSyncProvider'); + } + + /** + * 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 component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + async syncRatings( + component: string, + ratingArea: string, + contextLevel?: ContextLevel, + instanceId?: number, + itemSetId?: number, + force?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const itemSets = await CoreRatingOffline.getItemSets(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); + + const results: CoreRatingSyncItem[] = []; + await Promise.all(itemSets.map(async (itemSet) => { + const result = force + ? await this.syncItemSet( + component, + ratingArea, + itemSet.contextLevel, + itemSet.instanceId, + itemSet.itemSetId, + siteId, + ) + : await this.syncItemSetIfNeeded( + component, + ratingArea, + itemSet.contextLevel, + itemSet.instanceId, + itemSet.itemSetId, + siteId, + ); + + if (result) { + if (result.updated) { + // Sync successful, send event. + CoreEvents.trigger(CoreRatingSyncProvider.SYNCED_EVENT, { + ...itemSet, + warnings: result.warnings, + }, siteId); + } + + results.push( + { + itemSet, + ...result, + }, + ); + } + })); + + return results; + } + + /** + * Sync ratings of an item set only if a certain time has passed since the last time. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when ratings are synced or if it doesn't need to be synced. + */ + protected async syncItemSetIfNeeded( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemSetId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const syncId = this.getItemSetSyncId(component, ratingArea, contextLevel, instanceId, itemSetId); + + const needed = await this.isSyncNeeded(syncId, siteId); + + if (needed) { + return this.syncItemSet(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); + } + } + + /** + * Synchronize all offline ratings of an item set. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected syncItemSet( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemSetId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.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}`); + + // Get offline events. + const syncPromise = this.performSyncItemSet(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Synchronize all offline ratings of an item set. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncItemSet( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemSetId: number, + siteId: string, + ): Promise { + const result: CoreRatingSyncItem = { + updated: [], + warnings: [], + }; + + const ratings = await CoreRatingOffline.getRatings(component, ratingArea, contextLevel, instanceId, itemSetId, siteId); + + if (!ratings.length) { + // Nothing to sync. + return result; + } + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + const promises = ratings.map(async (rating) => { + try { + await CoreRating.addRatingOnline( + component, + ratingArea, + rating.contextlevel, + rating.instanceid, + rating.itemid, + rating.scaleid, + rating.rating, + rating.rateduserid, + rating.aggregation, + siteId, + ); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, reject. + throw error; + } + + const warning = CoreTextUtils.getErrorMessageFromError(error); + + if (warning) { + result.warnings.push(warning); + } + } + + result.updated.push(rating.itemid); + + try { + return CoreRatingOffline.deleteRating( + component, + ratingArea, + rating.contextlevel, + rating.instanceid, + rating.itemid, + siteId, + ); + } finally { + await CoreRating.invalidateRatingItems( + rating.contextlevel, + rating.instanceid, + component, + ratingArea, + rating.itemid, + rating.scaleid, + undefined, + siteId, + ); + } + }); + + await Promise.all(promises); + + // All done, return the result. + return result; + } + + /** + * Get the sync id of an item set. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating Area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemSetId Item set id. Example: forum discussion id. + * @return Sync id. + */ + protected getItemSetSyncId( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemSetId: number, + ): string { + return `itemSet#${component}#${ratingArea}#${contextLevel}#${instanceId}#${itemSetId}`; + } + +} +export const CoreRatingSync = makeSingleton(CoreRatingSyncProvider); + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [CoreRatingSyncProvider.SYNCED_EVENT]: CoreRatingSyncEventData; + } + +} + +export type CoreRatingSyncItem = { + itemSet?: CoreRatingItemSet; + warnings: string[]; + updated: number[]; +}; + +/** + * Data passed to SYNCED_EVENT event. + */ +export type CoreRatingSyncEventData = CoreRatingItemSet & { + warnings: string[]; +}; diff --git a/src/core/features/rating/services/rating.ts b/src/core/features/rating/services/rating.ts new file mode 100644 index 000000000..5c28c9216 --- /dev/null +++ b/src/core/features/rating/services/rating.ts @@ -0,0 +1,588 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ContextLevel } from '@/core/constants'; +import { Injectable } from '@angular/core'; +import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; +import { CoreUser } from '@features/user/services/user'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreRatingOffline } from './rating-offline'; + +const ROOT_CACHE_KEY = 'CoreRating:'; + +/** + * Service to handle ratings. + */ +@Injectable( { providedIn: 'root' }) +export class CoreRatingProvider { + + static readonly AGGREGATE_NONE = 0; // No ratings. + static readonly AGGREGATE_AVERAGE = 1; + static readonly AGGREGATE_COUNT = 2; + static readonly AGGREGATE_MAXIMUM = 3; + static readonly AGGREGATE_MINIMUM = 4; + static readonly AGGREGATE_SUM = 5; + + static readonly UNSET_RATING = -999; + + static readonly AGGREGATE_CHANGED_EVENT = 'core_rating_aggregate_changed'; + static readonly RATING_SAVED_EVENT = 'core_rating_rating_saved'; + + /** + * Returns whether the web serivce to add ratings is available. + * + * @return If WS is abalaible. + * @since 3.2 + */ + isAddRatingWSAvailable(): boolean { + return CoreSites.wsAvailableInCurrentSite('core_rating_add_rating'); + } + + /** + * Add a rating to an item. + * + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemId Item id. Example: forum post id. + * @param itemSetId Item set id. Example: forum discussion id. + * @param courseId Course id. + * @param scaleId Scale id. + * @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param ratedUserId Rated user id. + * @param aggregateMethod Aggregate method. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the aggregated rating or void if stored offline. + * @since 3.2 + */ + async addRating( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemId: number, + itemSetId: number, + courseId: number, + scaleId: number, + rating: number, + ratedUserId: number, + aggregateMethod: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a rating to be synchronized later. + const storeOffline = async (): Promise => { + await CoreRatingOffline.addRating( + component, + ratingArea, + contextLevel, + instanceId, + itemId, + itemSetId, + courseId, + scaleId, + rating, + ratedUserId, + aggregateMethod, + siteId, + ); + + CoreEvents.trigger(CoreRatingProvider.RATING_SAVED_EVENT, { + component, + ratingArea, + contextLevel, + instanceId, + itemSetId, + itemId, + }, siteId); + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await CoreRatingOffline.deleteRating(component, ratingArea, contextLevel, instanceId, itemId, siteId); + this.addRatingOnline( + component, + ratingArea, + contextLevel, + instanceId, + itemId, + scaleId, + rating, + ratedUserId, + aggregateMethod, + siteId, + ); + } catch (error) { + if (CoreUtils.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 component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param itemId Item id. Example: forum post id. + * @param scaleId Scale id. + * @param rating Rating value. Use CoreRatingProvider.UNSET_RATING to delete rating. + * @param ratedUserId Rated user id. + * @param aggregateMethod Aggregate method. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the aggregated rating. + * @since 3.2 + */ + async addRatingOnline( + component: string, + ratingArea: string, + contextLevel: ContextLevel, + instanceId: number, + itemId: number, + scaleId: number, + rating: number, + ratedUserId: number, + aggregateMethod: number, + siteId?: string, + ): Promise { + + const site = await CoreSites.getSite(siteId); + const params: CoreRatingAddRatingWSParams = { + contextlevel: contextLevel, + instanceid: instanceId, + component, + ratingarea: ratingArea, + itemid: itemId, + scaleid: scaleId, + rating, + rateduserid: ratedUserId, + aggregation: aggregateMethod, + }; + + const response = await site.write('core_rating_add_rating', params); + + await this.invalidateRatingItems(contextLevel, instanceId, component, ratingArea, itemId, scaleId); + + CoreEvents.trigger(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, { + contextLevel, + instanceId, + component, + ratingArea, + itemId, + aggregate: response.aggregate, + count: response.count, + }); + + return response; + } + + /** + * Get item ratings. + * + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param itemId Item id. Example: forum post id. + * @param scaleId Scale id. + * @param sort Sort field. + * @param courseId Course id. Used for fetching user profiles. + * @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 resolved with the list of ratings. + */ + async getItemRatings( + contextLevel: ContextLevel, + instanceId: number, + component: string, + ratingArea: string, + itemId: number, + scaleId: number, + sort: string = 'timemodified', + courseId?: number, + siteId?: string, + ignoreCache: boolean = false, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: CoreRatingGetItemRatingsWSParams = { + contextlevel: contextLevel, + instanceid: instanceId, + component, + ratingarea: ratingArea, + itemid: itemId, + scaleid: scaleId, + sort, + }; + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('core_rating_get_item_ratings', params, preSets); + + if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) { + // MDL-65042 We need to fetch profiles because the returned profile pictures are incorrect. + const promises = response.ratings.map((rating: CoreRatingItemRating) => + CoreUser.getProfile(rating.userid, courseId, true, site.id).then((user) => { + rating.userpictureurl = user.profileimageurl || ''; + + return; + }).catch(() => { + // Ignore error. + rating.userpictureurl = ''; + })); + + await Promise.all(promises); + } + + return response.ratings; + } + + /** + * Invalidate item ratings. + * + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Context instance id. + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param itemId Item id. Example: forum post id. + * @param scaleId Scale id. + * @param sort Sort field. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateRatingItems( + contextLevel: ContextLevel, + instanceId: number, + component: string, + ratingArea: string, + itemId: number, + scaleId: number, + sort: string = 'timemodified', + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const key = this.getItemRatingsCacheKey(contextLevel, instanceId, component, ratingArea, itemId, scaleId, sort); + + await site.invalidateWsCacheForKey(key); + } + + /** + * Check if rating is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isRatingDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.getCurrentSite(); + + return !!site?.isFeatureDisabled('NoDelegate_CoreRating'); + } + + /** + * Check if rating is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isRatingDisabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return this.isRatingDisabledInSite(site); + } + + /** + * Convenience function to merge two or more rating infos of the same instance. + * + * @param ratingInfos Array of rating infos. + * @return Merged rating info or undefined. + */ + mergeRatingInfos(ratingInfos: CoreRatingInfo[]): CoreRatingInfo | undefined { + let result: CoreRatingInfo | undefined; + const scales: Record = {}; + const ratings: Record = {}; + + ratingInfos.forEach((ratingInfo) => { + if (!ratingInfo) { + // Skip null rating infos. + return; + } + + if (!result) { + result = Object.assign({}, ratingInfo); + } + + (ratingInfo.scales || []).forEach((scale) => { + scales[scale.id] = scale; + }); + + (ratingInfo.ratings || []).forEach((rating) => { + ratings[rating.itemid] = rating; + }); + }); + + if (result) { + result.scales = CoreUtils.objectToArray(scales); + result.ratings = CoreUtils.objectToArray(ratings); + } + + return result; + } + + /** + * Prefetch individual ratings. + * + * This function should be called from the prefetch handler of activities with ratings. + * + * @param contextLevel Context level: course, module, user, etc. + * @param instanceId Instance id. + * @param siteId Site id. If not defined, current site. + * @param courseId Course id. Used for prefetching user profiles. + * @param ratingInfo Rating info returned by web services. + * @return Promise resolved when done. + */ + async prefetchRatings( + contextLevel: ContextLevel, + instanceId: number, + scaleId: number, + courseId?: number, + ratingInfo?: CoreRatingInfo, + siteId?: string, + ): Promise { + if (!ratingInfo || !ratingInfo.ratings) { + return; + } + + const site = await CoreSites.getSite(siteId); + const promises = ratingInfo.ratings.map((item) => this.getItemRatings( + contextLevel, + instanceId, + ratingInfo.component, + ratingInfo.ratingarea, + item.itemid, + scaleId, + undefined, + courseId, + site.id, + true, + )); + + if (!site.isVersionGreaterEqualThan([' 3.6.5', '3.7.1', '3.8'])) { + promises.map((promise) => promise.then(async (ratings) => { + const userIds = ratings.map((rating: CoreRatingItemRating) => rating.userid); + + await CoreUser.prefetchProfiles(userIds, courseId, site.id); + + return; + })); + } + + await Promise.all(promises); + } + + /** + * Get cache key for rating items WS calls. + * + * @param contextLevel Context level: course, module, user, etc. + * @param component Component. Example: "mod_forum". + * @param ratingArea Rating area. Example: "post". + * @param itemId Item id. Example: forum post id. + * @param scaleId Scale id. + * @param sort Sort field. + * @return Cache key. + */ + protected getItemRatingsCacheKey( + contextLevel: ContextLevel, + instanceId: number, + component: string, + ratingArea: string, + itemId: number, + scaleId: number, + sort: string, + ): string { + return `${ROOT_CACHE_KEY}${contextLevel}:${instanceId}:${component}:${ratingArea}:${itemId}:${scaleId}:${sort}`; + } + +} +export const CoreRating = makeSingleton(CoreRatingProvider); + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [CoreRatingProvider.AGGREGATE_CHANGED_EVENT]: CoreRatingAggregateChangedEventData; + [CoreRatingProvider.RATING_SAVED_EVENT]: CoreRatingSavedEventData; + } + +} + +/** + * Structure of the rating info returned by web services. + */ +export type CoreRatingInfo = { + contextid: number; // Context id. + component: string; // Context name. + ratingarea: string; // Rating area name. + canviewall?: boolean; // Whether the user can view all the individual ratings. + canviewany?: boolean; // Whether the user can view aggregate of ratings of others. + scales?: CoreRatingScale[]; // Different scales used information. + ratings?: CoreRatingInfoItem[]; // The ratings. +}; + +/** + * Structure of scales in the rating info. + */ +export type CoreRatingScale = { + id: number; // Scale id. + courseid?: number; // Course id. + name?: string; // Scale name (when a real scale is used). + max: number; // Max value for the scale. + isnumeric: boolean; // Whether is a numeric scale. + items?: { // Scale items. Only returned for not numerical scales. + value: number; // Scale value/option id. + name: string; // Scale name. + }[]; +}; + +/** + * Structure of items in the rating info. + */ +export type CoreRatingInfoItem = { + itemid: number; // Item id. + scaleid?: number; // Scale id. + scale?: CoreRatingScale; // Added for rendering purposes. + userid?: number; // User who rated id. + aggregate?: number; // Aggregated ratings grade. + aggregatestr?: string; // Aggregated ratings as string. + aggregatelabel?: string; // The aggregation label. + count?: number; // Ratings count (used when aggregating). + rating?: number; // The rating the user gave. + canrate?: boolean; // Whether the user can rate the item. + canviewaggregate?: boolean; // Whether the user can view the aggregated grade. +}; + +/** + * Structure of a rating returned by the item ratings web service. + */ +export type CoreRatingItemRating = { + id: number; // Rating id. + userid: number; // User id. + userpictureurl: string; // URL user picture. + userfullname: string; // User fullname. + rating: string; // Rating on scale. + timemodified: number; // Time modified (timestamp). +}; + + +/** + * Params of core_rating_get_item_ratings WS. + */ +type CoreRatingGetItemRatingsWSParams = { + contextlevel: ContextLevel; // Context level: course, module, user, etc... + instanceid: number; // The instance id of item associated with the context level. + component: string; // Component. + ratingarea: string; // Rating area. + itemid: number; // Associated id. + scaleid: number; // Scale id. + sort: string; // Sort order (firstname, rating or timemodified). +}; + +/** + * Data returned by core_rating_get_item_ratings WS. + */ +export type CoreRatingGetItemRatingsWSResponse = { + ratings: CoreRatingItemRating[]; + warnings?: CoreWSExternalWarning[]; +}; + + +/** + * Params of core_rating_add_rating WS. + */ +type CoreRatingAddRatingWSParams = { + contextlevel: ContextLevel; // Context level: course, module, user, etc... + instanceid: number; // The instance id of item associated with the context level. + component: string; // Component. + ratingarea: string; // Rating area. + itemid: number; // Associated id. + scaleid: number; // Scale id. + rating: number; // User rating. + rateduserid: number; // Rated user id. + aggregation?: number; // Agreggation method. +}; + +/** + * Data returned by core_rating_add_rating WS. + */ +export type CoreRatingAddRatingWSResponse = { + success: boolean; // Whether the rate was successfully created. + aggregate?: string; // New aggregate. + count?: number; // Ratings count. + itemid?: number; // Rating item id. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data sent by AGGREGATE_CHANGED_EVENT event. + */ +export type CoreRatingAggregateChangedEventData = { + contextLevel: ContextLevel; + instanceId: number; + component: string; + ratingArea: string; + itemId: number; + aggregate?: string; + count?: number; +}; + +/** + * Data sent by RATING_SAVED_EVENT event. + */ +export type CoreRatingSavedEventData = { + component: string; + ratingArea: string; + contextLevel: ContextLevel; + instanceId: number; + itemSetId: number; + itemId: number; +};