Merge pull request #1790 from albertgasset/MOBILE-1633

Mobile 1633
main
Juan Leyva 2019-03-04 14:14:58 +01:00 committed by GitHub
commit 7eaf05766a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1495 additions and 39 deletions

View File

@ -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",

View File

@ -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: [
],

View File

@ -21,7 +21,7 @@
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId" [note]="descriptionNote"></core-course-module-description>
<!-- Forum discussions found to be synchronized -->
<ion-card class="core-warning-card" icon-start *ngIf="hasOffline">
<ion-card class="core-warning-card" icon-start *ngIf="hasOffline || hasOfflineRatings">
<ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: moduleName} }}
</ion-card>

View File

@ -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<boolean> {
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();
}
}

View File

@ -23,6 +23,8 @@
</ng-container>
</div>
</ion-card-content>
<core-rating-rate *ngIf="forum && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id" [itemSetId]="discussionId" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale" [userId]="post.userid"></core-rating-rate>
<core-rating-aggregate *ngIf="forum && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" [instanceId]="componentId" [itemId]="post.id" [courseId]="courseId" [aggregateMethod]="forum.assessed" [scaleId]="forum.scale"></core-rating-aggregate>
<ion-item no-padding text-end *ngIf="post.id && post.canreply" class="addon-forum-reply-button">
<button ion-button icon-left clear small (click)="showReply()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.id">
<ion-icon name="undo"></ion-icon> {{ 'addon.mod_forum.reply' | translate }}

View File

@ -25,6 +25,7 @@ import { AddonModForumProvider } from '../../providers/forum';
import { AddonModForumHelperProvider } from '../../providers/helper';
import { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumSyncProvider } from '../../providers/sync';
import { CoreRatingInfo } from '@core/rating/providers/rating';
/**
* Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.).
@ -44,6 +45,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy {
@Input() trackPosts: boolean; // True if post is being tracked.
@Input() forum: any; // The forum the post belongs to. Required for attachments and offline posts.
@Input() defaultSubject: string; // Default subject to set to new posts.
@Input() ratingInfo?: CoreRatingInfo; // Rating info item.
@Output() onPostChange: EventEmitter<void>; // Event emitted when a reply is posted or modified.
messageControl = new FormControl();

View File

@ -22,7 +22,7 @@
<core-loading [hideUntil]="discussionLoaded">
<!-- Discussion replies found to be synchronized -->
<ion-card class="core-warning-card" *ngIf="postHasOffline">
<ion-card class="core-warning-card" *ngIf="postHasOffline || hasOfflineRatings">
<ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}
</ion-card>
@ -31,13 +31,13 @@
</ion-card>
<ion-card *ngIf="discussion" margin-bottom class="highlight">
<addon-mod-forum-post [post]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<addon-mod-forum-post [post]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ion-card>
<ion-card *ngIf="sort != 'nested'">
<ng-container *ngFor="let post of posts; first as first">
<ion-item-divider *ngIf="!first"></ion-item-divider>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ng-container>
</ion-card>
@ -49,7 +49,7 @@
<ng-template #nestedPosts let-post="post">
<ion-card>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
<addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="cmId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" (onPostChange)="postListChanged()"></addon-mod-forum-post>
</ion-card>
<div padding-left *ngIf="post.children.length && post.children[0].subject">
<ng-container *ngFor="let child of post.children">

View File

@ -23,6 +23,9 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreRatingProvider, CoreRatingInfo } 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 { AddonModForumOfflineProvider } from '../../providers/offline';
import { AddonModForumHelperProvider } from '../../providers/helper';
@ -77,6 +80,11 @@ export class AddonModForumDiscussionPage implements OnDestroy {
protected syncObserver: any;
protected syncManualObserver: any;
ratingInfo?: CoreRatingInfo;
hasOfflineRatings: boolean;
protected ratingOfflineObserver: any;
protected ratingSyncObserver: any;
constructor(navParams: NavParams,
network: Network,
zone: NgZone,
@ -91,6 +99,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
private forumOffline: AddonModForumOfflineProvider,
private forumHelper: AddonModForumHelperProvider,
private forumSync: AddonModForumSyncProvider,
private ratingOffline: CoreRatingOfflineProvider,
@Optional() private svComponent: CoreSplitViewComponent) {
this.courseId = navParams.get('courseId');
this.cmId = navParams.get('cmId');
@ -147,6 +156,20 @@ export class AddonModForumDiscussionPage implements OnDestroy {
forumId: this.forumId,
discussion: this.discussionId
}, this.sitesProvider.getCurrentSiteId());
// Listen for offline ratings saved and synced.
this.ratingOfflineObserver = this.eventsProvider.on(CoreRatingProvider.RATING_SAVED_EVENT, (data) => {
if (data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == 'module' &&
data.instanceId == this.cmId && data.itemSetId == this.discussionId) {
this.hasOfflineRatings = true;
}
});
this.ratingSyncObserver = this.eventsProvider.on(CoreRatingSyncProvider.SYNCED_EVENT, (data) => {
if (data.component == 'mod_forum' && data.ratingArea == 'post' && data.contextLevel == 'module' &&
data.instanceId == this.cmId && data.itemSetId == this.discussionId) {
this.hasOfflineRatings = false;
}
});
}
/**
@ -210,9 +233,9 @@ export class AddonModForumDiscussionPage implements OnDestroy {
let hasUnreadPosts = false;
return syncPromise.then(() => {
return this.forumProvider.getDiscussionPosts(this.discussionId).then((posts) => {
onlinePosts = posts;
return this.forumProvider.getDiscussionPosts(this.discussionId).then((response) => {
onlinePosts = response.posts;
this.ratingInfo = response.ratinginfo;
}).then(() => {
// Check if there are responses stored in offline.
return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => {
@ -290,6 +313,10 @@ export class AddonModForumDiscussionPage implements OnDestroy {
// Ignore errors.
this.forum = {};
});
}).then(() => {
return this.ratingOffline.hasRatings('mod_forum', 'post', 'module', this.cmId, this.discussionId).then((hasRatings) => {
this.hasOfflineRatings = hasRatings;
});
}).catch((message) => {
this.domUtils.showErrorModal(message);
}).finally(() => {
@ -319,7 +346,9 @@ export class AddonModForumDiscussionPage implements OnDestroy {
* @return {Promise<any>} Promise resolved when done.
*/
protected syncDiscussion(showErrors: boolean): Promise<any> {
return this.forumSync.syncDiscussionReplies(this.discussionId).then((result) => {
const promises = [];
promises.push(this.forumSync.syncDiscussionReplies(this.forumId, this.discussionId).then((result) => {
if (result.warnings && result.warnings.length) {
this.domUtils.showErrorModal(result.warnings[0]);
}
@ -334,7 +363,15 @@ export class AddonModForumDiscussionPage implements OnDestroy {
}
return result.updated;
}).catch((error) => {
}));
promises.push(this.forumSync.syncRatings(this.cmId, this.discussionId).then((result) => {
if (result.warnings && result.warnings.length) {
this.domUtils.showErrorModal(result.warnings[0]);
}
}));
return Promise.all(promises).catch((error) => {
if (showErrors) {
this.domUtils.showErrorModalDefault(error, 'core.errorsync', true);
}
@ -419,6 +456,8 @@ export class AddonModForumDiscussionPage implements OnDestroy {
ionViewWillLeave(): void {
this.syncObserver && this.syncObserver.off();
this.syncManualObserver && this.syncManualObserver.off();
this.ratingOfflineObserver && this.ratingOfflineObserver.off();
this.ratingSyncObserver && this.ratingSyncObserver.off();
}
/**

View File

@ -22,6 +22,7 @@ import { CoreUserProvider } from '@core/user/providers/user';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { AddonModForumOfflineProvider } from './offline';
import { CoreRatingInfo } from '@core/rating/providers/rating';
/**
* Service that provides some features for forums.
@ -369,9 +370,9 @@ export class AddonModForumProvider {
*
* @param {number} discussionId Discussion ID.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any[]>} Promise resolved with forum posts.
* @return {Promise<{posts: any[], ratinginfo?: CoreRatingInfo}>} Promise resolved with forum posts and rating info.
*/
getDiscussionPosts(discussionId: number, siteId?: string): Promise<any> {
getDiscussionPosts(discussionId: number, siteId?: string): Promise<{posts: any[], ratinginfo?: CoreRatingInfo}> {
const params = {
discussionid: discussionId
};
@ -384,7 +385,7 @@ export class AddonModForumProvider {
if (response) {
this.storeUserData(response.posts);
return response.posts;
return response;
} else {
return Promise.reject(null);
}

View File

@ -24,6 +24,7 @@ import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/acti
import { CoreGroupsProvider } from '@providers/groups';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModForumProvider } from './forum';
import { CoreRatingProvider } from '@core/rating/providers/rating';
/**
* Handler to prefetch forums.
@ -44,7 +45,8 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand
domUtils: CoreDomUtilsProvider,
private groupsProvider: CoreGroupsProvider,
private userProvider: CoreUserProvider,
private forumProvider: AddonModForumProvider) {
private forumProvider: AddonModForumProvider,
private ratingProvider: CoreRatingProvider) {
super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils);
}
@ -62,7 +64,7 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand
const files = this.getIntroFilesFromInstance(module, forum);
// Get posts.
return this.getPostsForPrefetch(forum.id).then((posts) => {
return this.getPostsForPrefetch(forum).then((posts) => {
// Add posts attachments and embedded files.
return files.concat(this.getPostsFiles(posts));
});
@ -99,12 +101,12 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand
/**
* Get the posts to be prefetched.
*
* @param {number} forumId Forum ID
* @param {any} forum Forum instance.
* @return {Promise<any[]>} Promise resolved with array of posts.
*/
protected getPostsForPrefetch(forumId: number): Promise<any[]> {
protected getPostsForPrefetch(forum: any): Promise<any[]> {
// Get discussions in first 2 pages.
return this.forumProvider.getDiscussionsInPages(forumId, false, 2).then((response) => {
return this.forumProvider.getDiscussionsInPages(forum.id, false, 2).then((response) => {
if (response.error) {
return Promise.reject(null);
}
@ -113,8 +115,11 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand
let posts = [];
response.discussions.forEach((discussion) => {
promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((ps) => {
posts = posts.concat(ps);
promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((response) => {
posts = posts.concat(response.posts);
return this.ratingProvider.prefetchRatings('module', forum.cmid, forum.scale, forum.course,
response.ratinginfo);
}));
});
@ -179,7 +184,7 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand
// Get the forum data.
return this.forumProvider.getForum(courseId, module.id).then((forum) => {
// Prefetch the posts.
return this.getPostsForPrefetch(forum.id).then((posts) => {
return this.getPostsForPrefetch(forum).then((posts) => {
const promises = [];
// Prefetch user profiles.

View File

@ -29,6 +29,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModForumProvider } from './forum';
import { AddonModForumHelperProvider } from './helper';
import { AddonModForumOfflineProvider } from './offline';
import { CoreRatingSyncProvider } from '@core/rating/providers/sync';
/**
* Service to sync forums.
@ -55,7 +56,8 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider {
private forumProvider: AddonModForumProvider,
private forumHelper: AddonModForumHelperProvider,
private forumOffline: AddonModForumOfflineProvider,
private logHelper: CoreCourseLogHelperProvider) {
private logHelper: CoreCourseLogHelperProvider,
private ratingSync: CoreRatingSyncProvider) {
super('AddonModForumSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate,
timeUtils);
@ -135,6 +137,8 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider {
return Promise.all(this.utils.objectToArray(promises));
}));
sitePromises.push(this.syncRatings(undefined, undefined, siteId));
return Promise.all(sitePromises);
}
@ -273,6 +277,50 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider {
return this.addOngoingSync(syncId, syncPromise, siteId);
}
/**
* Synchronize forum offline ratings.
*
* @param {number} [cmId] Course module to be synced. If not defined, sync all forums.
* @param {number} [discussionId] Discussion id to be synced. If not defined, sync all discussions.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved if sync is successful, rejected otherwise.
*/
syncRatings(cmId?: number, discussionId?: number, siteId?: string): Promise<any> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
return this.ratingSync.syncRatings('mod_forum', 'post', 'module', cmId, discussionId, siteId).then((results) => {
let updated = false;
const warnings = [];
const promises = [];
results.forEach((result) => {
if (result.updated) {
updated = true;
// Invalidate discussions of updated ratings.
promises.push(this.forumProvider.invalidateDiscussionPosts(result.itemSet.itemSetId, siteId));
}
if (result.warnings.length) {
// Fetch forum to construct the warning message.
promises.push(this.forumProvider.getForum(result.itemSet.courseId, result.itemSet.instanceId, siteId)
.then((forum) => {
result.warnings.forEach((warning) => {
warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
component: this.componentTranslate,
name: forum.name,
error: warning
}));
});
}));
}
});
return this.utils.allPromises(promises).then(() => {
return { updated, warnings };
});
});
}
/**
* Synchronize all offline discussion replies of a forum.
*

View File

@ -78,6 +78,7 @@ import { CoreCompileModule } from '@core/compile/compile.module';
import { CoreQuestionModule } from '@core/question/question.module';
import { CoreCommentsModule } from '@core/comments/comments.module';
import { CoreBlockModule } from '@core/block/block.module';
import { CoreRatingModule } from '@core/rating/rating.module';
// Addon modules.
import { AddonBadgesModule } from '@addon/badges/badges.module';
@ -198,6 +199,7 @@ export const CORE_PROVIDERS: any[] = [
CoreQuestionModule,
CoreCommentsModule,
CoreBlockModule,
CoreRatingModule,
AddonBadgesModule,
AddonBlogModule,
AddonCalendarModule,

View File

@ -1590,6 +1590,15 @@
"core.question.questionno": "Question {{$a}}",
"core.question.requiresgrading": "Requires grading",
"core.quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.",
"core.rating.aggregateavg": "Average of ratings",
"core.rating.aggregatecount": "Count of ratings",
"core.rating.aggregatemax": "Maximum rating",
"core.rating.aggregatemin": "Minimum rating",
"core.rating.aggregatesum": "Sum of ratings",
"core.rating.norating": "No rating",
"core.rating.noratings": "No ratings submitted",
"core.rating.rating": "Rating",
"core.rating.ratings": "Ratings",
"core.redirectingtosite": "You will be redirected to the site.",
"core.refresh": "Refresh",
"core.remove": "Remove",

View File

@ -0,0 +1,112 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnChanges, SimpleChange } from '@angular/core';
import { ModalController } from 'ionic-angular';
import { CoreEventsProvider } from '@providers/events';
import { CoreRatingProvider, CoreRatingInfo, CoreRatingInfoItem } from '@core/rating/providers/rating';
/**
* Component that displays the aggregation of a rating item.
*/
@Component({
selector: 'core-rating-aggregate',
templateUrl: 'core-rating-aggregate.html'
})
export class CoreRatingAggregateComponent implements OnChanges {
@Input() ratingInfo: CoreRatingInfo;
@Input() contextLevel: string;
@Input() instanceId: number;
@Input() itemId: number;
@Input() aggregateMethod: number;
@Input() scaleId: number;
@Input() courseId?: number;
protected labelKey: string;
protected showCount: boolean;
protected item: CoreRatingInfoItem;
protected aggregateObserver;
constructor(private eventsProvider: CoreEventsProvider, private modalCtrl: ModalController) {}
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
this.aggregateObserver && this.aggregateObserver.off();
this.item = (this.ratingInfo.ratings || []).find((rating) => rating.itemid == this.itemId);
if (!this.item) {
return;
}
if (this.aggregateMethod == CoreRatingProvider.AGGREGATE_AVERAGE) {
this.labelKey = 'core.rating.aggregateavg';
} else if (this.aggregateMethod == CoreRatingProvider.AGGREGATE_COUNT) {
this.labelKey = 'core.rating.aggregatecount';
} else if (this.aggregateMethod == CoreRatingProvider.AGGREGATE_MAXIMUM) {
this.labelKey = 'core.rating.aggregatemax';
} else if (this.aggregateMethod == CoreRatingProvider.AGGREGATE_MINIMUM) {
this.labelKey = 'core.rating.aggregatemin';
} else if (this.aggregateMethod == CoreRatingProvider.AGGREGATE_SUM) {
this.labelKey = 'core.rating.aggregatesum';
} else {
this.labelKey = '';
return;
}
this.showCount = (this.aggregateMethod != CoreRatingProvider.AGGREGATE_COUNT);
// Update aggrgate when the user adds or edits a rating.
this.aggregateObserver = this.eventsProvider.on(CoreRatingProvider.AGGREGATE_CHANGED_EVENT, (data) => {
if (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;
}
});
}
/**
* Open the individual ratings page.
*/
openRatings(): void {
if (!this.ratingInfo.canviewall || !this.item.count) {
return;
}
const params = {
contextLevel: this.contextLevel,
instanceId: this.instanceId,
ratingComponent: this.ratingInfo.component,
ratingArea: this.ratingInfo.ratingarea,
itemId: this.itemId,
scaleId: this.scaleId,
courseId: this.courseId
};
const modal = this.modalCtrl.create('CoreRatingRatingsPage', params);
modal.present();
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.aggregateObserver && this.aggregateObserver.off();
}
}

View File

@ -0,0 +1,4 @@
<a *ngIf="item && item.canviewaggregate && labelKey" ion-item text-wrap [attr.detail-none]="ratingInfo.canviewall && item.count ? null : true" (click)="openRatings()">
{{ labelKey | translate }}{{ 'core.labelsep' | translate }} {{ item.aggregatestr || '-' }}
<span *ngIf="showCount && item.count > 0">({{ item.count }})</span>
</a>

View File

@ -0,0 +1,39 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreRatingAggregateComponent } from './aggregate/aggregate';
import { CoreRatingRateComponent } from './rate/rate';
@NgModule({
declarations: [
CoreRatingAggregateComponent,
CoreRatingRateComponent
],
imports: [
CommonModule,
IonicModule,
TranslateModule.forChild()
],
providers: [
],
exports: [
CoreRatingAggregateComponent,
CoreRatingRateComponent
]
})
export class CoreRatingComponentsModule {}

View File

@ -0,0 +1,6 @@
<ion-item text-wrap *ngIf="item && (item.canrate || item.rating != null)">
<ion-label>{{ 'core.rating.rating' | translate }}</ion-label>
<ion-select text-start [(ngModel)]="rating" (ngModelChange)="userRatingChanged()" interface="action-sheet" [disabled]="!item.canrate">
<ion-option *ngFor="let scaleItem of scale.items" [value]="scaleItem.value">{{ scaleItem.name }}</ion-option>
</ion-select>
</ion-item>

View File

@ -0,0 +1,106 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnChanges, SimpleChange } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreRatingProvider, CoreRatingInfo, CoreRatingInfoItem, CoreRatingScale } from '@core/rating/providers/rating';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreRatingOfflineProvider } from '@core/rating/providers/offline';
/**
* Component that displays the user rating select.
*/
@Component({
selector: 'core-rating-rate',
templateUrl: 'core-rating-rate.html'
})
export class CoreRatingRateComponent implements OnChanges {
@Input() ratingInfo: CoreRatingInfo;
@Input() contextLevel: string; // Context level: course, module, user, etc.
@Input() instanceId: number; // Context instance id.
@Input() itemId: number; // Item id. Example: forum post id.
@Input() itemSetId: number; // Item set id. Example: forum discussion id.
@Input() courseId: number;
@Input() aggregateMethod: number;
@Input() scaleId: number;
@Input() userId: number;
item: CoreRatingInfoItem;
scale: CoreRatingScale;
rating: number;
constructor(private domUtils: CoreDomUtilsProvider, private translate: TranslateService,
private ratingProvider: CoreRatingProvider, private ratingOffline: CoreRatingOfflineProvider) {}
/**
* Detect changes on input properties.
*/
ngOnChanges(changes: {[name: string]: SimpleChange}): void {
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 || !this.ratingProvider.isAddRatingWSAvailable()) {
this.item = null;
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: this.translate.instant('core.rating.norating'),
value: CoreRatingProvider.UNSET_RATING
});
}
this.ratingOffline.getRating(this.contextLevel, this.instanceId, this.ratingInfo.component, this.ratingInfo.ratingarea,
this.itemId).then((rating) => {
this.rating = rating.rating;
}).catch(() => {
if (this.item && this.item.rating != null) {
this.rating = this.item.rating;
} else {
this.rating = CoreRatingProvider.UNSET_RATING;
}
});
}
/**
* Send or save the user rating when changed.
*/
protected userRatingChanged(): void {
const modal = this.domUtils.showModalLoading('core.sending', true);
this.ratingProvider.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)
.then((response) => {
if (response == null) {
this.domUtils.showToast('core.datastoredoffline', true, 3000);
}
}).catch((error) => {
this.domUtils.showErrorModal(error);
}).finally(() => {
modal.dismiss();
});
}
}

View File

@ -0,0 +1,11 @@
{
"aggregateavg": "Average of ratings",
"aggregatecount": "Count of ratings",
"aggregatemax": "Maximum rating",
"aggregatemin": "Minimum rating",
"aggregatesum": "Sum of ratings",
"norating": "No rating",
"noratings": "No ratings submitted",
"rating": "Rating",
"ratings": "Ratings"
}

View File

@ -0,0 +1,28 @@
<ion-header>
<ion-navbar core-back-button>
<ion-title>{{ 'core.rating.ratings' | translate }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="refreshRatings($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="ratings.length > 0">
<ion-item text-wrap *ngFor="let rating of ratings">
<ion-avatar core-user-avatar [user]="rating" [courseId]="courseId" item-start></ion-avatar>
<ion-note item-end padding-left *ngIf="rating.timemodified">
{{ rating.timemodified | coreDateDayOrTime }}
</ion-note>
<h2><core-format-text [text]="rating.userfullname"></core-format-text></h2>
<p>{{ rating.rating }}</p>
</ion-item>
</ion-list>
<core-empty-box *ngIf="ratings.length == 0" icon="stats" [message]="'core.rating.noratings' | translate"></core-empty-box>
</core-loading>
</ion-content>

View File

@ -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 {}

View File

@ -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<any>} Resolved when done.
*/
fetchData(): Promise<any> {
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();
}
}

View File

@ -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<CoreRatingOfflineRating>} Promise resolved with the saved rating, rejected if not found.
*/
getRating(contextLevel: string, instanceId: number, component: string, ratingArea: string, itemId: number, siteId?: string):
Promise<CoreRatingOfflineRating> {
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<any>} 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<any> {
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<any>} Promise resolved when the rating is saved.
*/
deleteRating(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemId: number, siteId?: string):
Promise<any> {
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<CoreRatingItemSet[]>} Promise resolved with the list of item set ids.
*/
getItemSets(component: string, ratingArea: string, contextLevel?: string, instanceId?: number, itemSetId?: number,
siteId?: string): Promise<CoreRatingItemSet[]> {
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<CoreRatingOfflineRating[]>} Promise resolved with the list of ratings.
*/
getRatings(component: string, ratingArea: string, contextLevel: string, instanceId: number, itemSetId: number, siteId?: string):
Promise<CoreRatingOfflineRating[]> {
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<boolean>} Promise resolved with a boolean.
*/
hasRatings(component: string, ratingArea: string, contextLevel?: string, instanceId?: number, itemSetId?: number,
siteId?: string): Promise<boolean> {
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));
});
}
}

View File

@ -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<CoreRatingItemRating|null>} 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<CoreRatingItemRating[]> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
// Convenience function to store a rating to be synchronized later.
const storeOffline = (): Promise<any> => {
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<CoreRatingItemRating>} 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<CoreRatingItemRating> {
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<CoreRatingItemRating[]>} 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<CoreRatingItemRating[]> {
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<any>} 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<any> {
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<any>} Promise resolved when done.
*/
prefetchRatings(contextLevel: string, instanceId: number, scaleId: number, courseId?: number, ratingInfo?: CoreRatingInfo,
siteId?: string): Promise<any> {
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}`;
}
}

View File

@ -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<any>} 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<any>} 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<any>} 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}`;
}
}

View File

@ -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 {}