From 93aa30509543b612c1bcfbf9e6f679a7f05601ab Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Tue, 4 Feb 2025 23:50:31 -0500 Subject: [PATCH] Internal: Fix vote handling & course ranking updates --- .../vue/services/trackCourseRankingService.js | 26 -- .../vue/services/userRelCourseVoteService.js | 100 +++++++ assets/vue/views/course/CatalogueCourses.vue | 97 ++++--- public/main/inc/lib/course.lib.php | 271 ++++++------------ public/main/inc/lib/events.lib.php | 9 +- public/main/inc/lib/sessionmanager.lib.php | 5 +- public/main/inc/lib/urlmanager.lib.php | 8 +- src/CoreBundle/Entity/Course.php | 40 ++- src/CoreBundle/Entity/TrackCourseRanking.php | 173 ----------- src/CoreBundle/Entity/UserRelCourseVote.php | 41 ++- .../UserRelCourseVoteListener.php | 41 +++ .../Schema/V200/Version20240204120000.php | 45 +++ src/CoreBundle/Resources/config/listeners.yml | 6 + 13 files changed, 396 insertions(+), 466 deletions(-) delete mode 100644 assets/vue/services/trackCourseRankingService.js create mode 100644 assets/vue/services/userRelCourseVoteService.js delete mode 100644 src/CoreBundle/Entity/TrackCourseRanking.php create mode 100644 src/CoreBundle/EventListener/UserRelCourseVoteListener.php create mode 100644 src/CoreBundle/Migrations/Schema/V200/Version20240204120000.php diff --git a/assets/vue/services/trackCourseRankingService.js b/assets/vue/services/trackCourseRankingService.js deleted file mode 100644 index 3561bf62879..00000000000 --- a/assets/vue/services/trackCourseRankingService.js +++ /dev/null @@ -1,26 +0,0 @@ -import baseService from "./baseService" - -/** - * @param {string} courseIri - * @param {number} urlId - * @param {number} sessionId - * @param {number} totalScore - * @returns {Promise} - */ -export async function saveRanking({ courseIri, urlId, sessionId, totalScore }) { - return await baseService.post("/api/track_course_rankings", { - totalScore, - course: courseIri, - urlId, - sessionId, - }) -} - -/** - * @param {string} iri - * @param {number} totalScore - * @returns {Promise} - */ -export async function updateRanking({ iri, totalScore }) { - return await baseService.put(iri, { totalScore }) -} diff --git a/assets/vue/services/userRelCourseVoteService.js b/assets/vue/services/userRelCourseVoteService.js new file mode 100644 index 00000000000..32756f23b2d --- /dev/null +++ b/assets/vue/services/userRelCourseVoteService.js @@ -0,0 +1,100 @@ +import baseService from "./baseService" + +/** + * Saves a new vote for a course in the catalog. + * + * @param {string} courseIri - IRI of the course + * @param {number} userId - ID of the user who votes + * @param {number} vote - Rating given by the user (1-5) + * @param {number} sessionId - Session ID (optional) + * @param {number} urlId - Access URL ID + * @returns {Promise} + */ +export async function saveVote({ courseIri, userId, vote, sessionId = null, urlId }) { + return await baseService.post("/api/user_rel_course_votes", { + course: courseIri, + user: `/api/users/${userId}`, + vote, + session: sessionId ? `/api/sessions/${sessionId}` : null, + url: `/api/access_urls/${urlId}`, + }) +} + +/** + * Updates an existing vote for a course. + * + * @param {string} iri - IRI of the vote to update + * @param {number} vote - New rating from the user (1-5) + * @param sessionId + * @param urlId + * @returns {Promise} + */ +export async function updateVote({ iri, vote, sessionId = null, urlId }) { + try { + if (!iri) { + throw new Error("Cannot update vote because IRI is missing.") + } + + let payload = { vote } + if (sessionId) payload.session = `/api/sessions/${sessionId}` + if (urlId) payload.url = `/api/access_urls/${urlId}` + + return await baseService.put(iri, payload) + } catch (error) { + console.error("Error updating user vote:", error) + throw error + } +} + +/** + * Retrieves the user's vote for a specific course. + * + * @param {number} userId - ID of the user + * @param {number} courseId - ID of the course + * @param sessionId + * @param urlId + * @returns {Promise} - Returns the vote object if found, otherwise null + */ +export async function getUserVote({ userId, courseId, sessionId = null, urlId }) { + try { + let query = `/api/user_rel_course_votes?user.id=${userId}&course.id=${courseId}` + if (urlId) query += `&url.id=${urlId}` + + // Remove session.id if null + if (sessionId) { + query += `&session.id=${sessionId}` + } + + const response = await baseService.get(query) + + if (response && response["hydra:member"] && response["hydra:member"].length > 0) { + return response["hydra:member"][0] + } + + return null + // eslint-disable-next-line no-unused-vars + } catch (error) { + return null + } +} + +/** + * Retrieves all votes of a user for different courses. + * + * @param {number} userId - User ID + * @param {number} urlId - Access URL ID + * @returns {Promise} - List of user votes + */ +export async function getUserVotes({ userId, urlId }) { + try { + let query = `/api/user_rel_course_votes?user.id=${userId}` + if (urlId) query += `&url.id=${urlId}` + + const response = await baseService.get(query) + + return response && response["hydra:member"] ? response["hydra:member"] : [] + } catch (error) { + console.error("Error retrieving user votes:", error) + return [] + } +} diff --git a/assets/vue/views/course/CatalogueCourses.vue b/assets/vue/views/course/CatalogueCourses.vue index 1d03746d6e8..cc34d423cb7 100644 --- a/assets/vue/views/course/CatalogueCourses.vue +++ b/assets/vue/views/course/CatalogueCourses.vue @@ -132,16 +132,16 @@ @@ -204,10 +204,9 @@ import { usePlatformConfig } from "../../store/platformConfig" import { useSecurityStore } from "../../store/securityStore" import courseService from "../../services/courseService" -import * as trackCourseRanking from "../../services/trackCourseRankingService" - import { useNotification } from "../../composables/notification" import { useLanguage } from "../../composables/language" +import * as userRelCourseVoteService from "../../services/userRelCourseVoteService" const { showErrorNotification } = useNotification() const { findByIsoCode: findLanguageByIsoCode } = useLanguage() @@ -227,10 +226,20 @@ async function load() { try { const { items } = await courseService.listAll() - courses.value = items.map((course) => ({ - ...course, - courseLanguage: findLanguageByIsoCode(course.courseLanguage)?.originalName, - })) + const votes = await userRelCourseVoteService.getUserVotes({ + userId: currentUserId, + urlId: window.access_url_id, + }) + + courses.value = items.map((course) => { + const userVote = votes.find((vote) => vote.course === `/api/courses/${course.id}`) + + return { + ...course, + courseLanguage: findLanguageByIsoCode(course.courseLanguage)?.originalName, + userVote: userVote ? { ...userVote } : { vote: 0 }, + } + }) } catch (error) { showErrorNotification(error) } finally { @@ -238,20 +247,22 @@ async function load() { } } -async function updateRating(id, value) { +async function updateRating(voteIri, value) { status.value = true try { - const response = await trackCourseRanking.updateRanking({ - iri: `/api/track_course_rankings/${id}`, - totalScore: value, + await userRelCourseVoteService.updateVote({ + iri: voteIri, + vote: value, + sessionId: window.session_id, + urlId: window.access_url_id, }) - courses.value.forEach((course) => { - if (course.trackCourseRanking && course.trackCourseRanking.id === id) { - course.trackCourseRanking.realTotalScore = response.realTotalScore - } - }) + courses.value = courses.value.map((course) => + course.userVote && course.userVote["@id"] === voteIri + ? { ...course, userVote: { ...course.userVote, vote: value } } + : course, + ) } catch (e) { showErrorNotification(e) } finally { @@ -263,18 +274,26 @@ const newRating = async function (courseId, value) { status.value = true try { - const response = await trackCourseRanking.saveRanking({ - totalScore: value, - courseIri: `/api/courses/${courseId}`, + const existingVote = await userRelCourseVoteService.getUserVote({ + userId: currentUserId, + courseId, + sessionId: window.session_id || null, urlId: window.access_url_id, - sessionId: 0, }) - courses.value.forEach((course) => { - if (course.id === courseId) { - course.trackCourseRanking = response - } - }) + if (existingVote) { + await updateRating(existingVote["@id"], value) + } else { + await userRelCourseVoteService.saveVote({ + vote: value, + courseIri: `/api/courses/${courseId}`, + userId: currentUserId, + sessionId: window.session_id || null, + urlId: window.access_url_id, + }) + } + + await load() } catch (e) { showErrorNotification(e) } finally { @@ -282,6 +301,20 @@ const newRating = async function (courseId, value) { } } +const onRatingChange = function (event, userVote, courseId) { + let { value } = event + + if (value > 0) { + if (userVote && userVote["@id"]) { + updateRating(userVote["@id"], value) + } else { + newRating(courseId, value) + } + } else { + event.preventDefault() + } +} + const isUserInCourse = (course) => { return course.users.some((user) => user.user.id === currentUserId) } @@ -296,16 +329,6 @@ const initFilters = function () { } } -const onRatingChange = function (event, trackCourseRanking, courseId) { - let { value } = event - if (value > 0) { - if (trackCourseRanking) updateRating(trackCourseRanking.id, value) - else newRating(courseId, value) - } else { - event.preventDefault() - } -} - load() initFilters() diff --git a/public/main/inc/lib/course.lib.php b/public/main/inc/lib/course.lib.php index a1081d8c081..b62931e3d0b 100644 --- a/public/main/inc/lib/course.lib.php +++ b/public/main/inc/lib/course.lib.php @@ -4474,244 +4474,145 @@ public static function get_user_course_vote($user_id, $course_id, $session_id = } /** - * @param int $course_id - * @param int $session_id - * @param int $url_id - * - * @return array + * Gets the course ranking based on user votes. */ public static function get_course_ranking( - $course_id, - $session_id = 0, - $url_id = 0 - ) { - $table_course_ranking = Database::get_main_table(TABLE_STATISTIC_TRACK_COURSE_RANKING); + int $courseId, + int $sessionId = 0, + int $urlId = 0 + ): array + { + $tableUserCourseVote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); - $session_id = empty($session_id) ? api_get_session_id() : intval($session_id); - $url_id = empty($url_id) ? api_get_current_access_url_id() : intval($url_id); - $now = api_get_utc_datetime(); + if (empty($courseId)) { + return []; + } - $params = [ - 'c_id' => $course_id, - 'session_id' => $session_id, - 'url_id' => $url_id, - 'creation_date' => $now, - ]; + $sessionId = empty($sessionId) ? api_get_session_id() : $sessionId; + $urlId = empty($urlId) ? api_get_current_access_url_id() : $urlId; $result = Database::select( - 'c_id, accesses, total_score, users', - $table_course_ranking, - ['where' => ['c_id = ? AND session_id = ? AND url_id = ?' => $params]], + 'COUNT(DISTINCT user_id) AS users, SUM(vote) AS totalScore', + $tableUserCourseVote, + ['where' => ['c_id = ?' => $courseId]], 'first' ); - $point_average_in_percentage = 0; - $point_average_in_star = 0; - $users_who_voted = 0; + $usersWhoVoted = $result ? (int) $result['users'] : 0; + $totalScore = $result ? (int) $result['totalScore'] : 0; - if (!empty($result['users'])) { - $users_who_voted = $result['users']; - $point_average_in_percentage = round($result['total_score'] / $result['users'] * 100 / 5, 2); - $point_average_in_star = round($result['total_score'] / $result['users'], 1); - } - - $result['user_vote'] = false; - if (!api_is_anonymous()) { - $result['user_vote'] = self::get_user_course_vote(api_get_user_id(), $course_id, $session_id, $url_id); - } + $pointAverageInPercentage = $usersWhoVoted > 0 ? round(($totalScore / $usersWhoVoted) * 100 / 5, 2) : 0; + $pointAverageInStar = $usersWhoVoted > 0 ? round($totalScore / $usersWhoVoted, 1) : 0; - $result['point_average'] = $point_average_in_percentage; - $result['point_average_star'] = $point_average_in_star; - $result['users_who_voted'] = $users_who_voted; + $userVote = !api_is_anonymous() && self::get_user_course_vote(api_get_user_id(), $courseId, $sessionId, $urlId); - return $result; + return [ + 'c_id' => $courseId, + 'users' => $usersWhoVoted, + 'total_score' => $totalScore, + 'point_average' => $pointAverageInPercentage, + 'point_average_star' => $pointAverageInStar, + 'user_vote' => $userVote, + ]; } /** - * Updates the course ranking. - * - * @param int course id - * @param int $session_id - * @param int url id - * @param $points_to_add - * @param bool $add_access - * @param bool $add_user - * - * @return array + * Updates the course ranking (popularity) based on unique user votes. */ - public static function update_course_ranking( - $course_id = 0, - $session_id = 0, - $url_id = 0, - $points_to_add = null, - $add_access = true, - $add_user = true - ) { - // Course catalog stats modifications see #4191 - $table_course_ranking = Database::get_main_table(TABLE_STATISTIC_TRACK_COURSE_RANKING); - $now = api_get_utc_datetime(); - $course_id = empty($course_id) ? api_get_course_int_id() : intval($course_id); - $session_id = empty($session_id) ? api_get_session_id() : intval($session_id); - $url_id = empty($url_id) ? api_get_current_access_url_id() : intval($url_id); + public static function update_course_ranking($courseId = 0): void + { + $tableUserCourseVote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); + $tableCourse = Database::get_main_table(TABLE_MAIN_COURSE); - $params = [ - 'c_id' => $course_id, - 'session_id' => $session_id, - 'url_id' => $url_id, - 'creation_date' => $now, - 'total_score' => 0, - 'users' => 0, - ]; + $courseId = intval($courseId); + if (empty($courseId)) { + return; + } $result = Database::select( - 'id, accesses, total_score, users', - $table_course_ranking, - ['where' => ['c_id = ? AND session_id = ? AND url_id = ?' => $params]], + 'COUNT(DISTINCT user_id) AS popularity', + $tableUserCourseVote, + ['where' => ['c_id = ?' => $courseId]], 'first' ); - // Problem here every time we load the courses/XXXX/index.php course home page we update the access - if (empty($result)) { - if ($add_access) { - $params['accesses'] = 1; - } - //The votes and users are empty - if (isset($points_to_add) && !empty($points_to_add)) { - $params['total_score'] = intval($points_to_add); - } - if ($add_user) { - $params['users'] = 1; - } - $result = Database::insert($table_course_ranking, $params); - } else { - $my_params = []; - - if ($add_access) { - $my_params['accesses'] = intval($result['accesses']) + 1; - } - if (isset($points_to_add) && !empty($points_to_add)) { - $my_params['total_score'] = $result['total_score'] + $points_to_add; - } - if ($add_user) { - $my_params['users'] = $result['users'] + 1; - } - - if (!empty($my_params)) { - $result = Database::update( - $table_course_ranking, - $my_params, - ['c_id = ? AND session_id = ? AND url_id = ?' => $params] - ); - } - } + $popularity = $result ? (int) $result['popularity'] : 0; - return $result; + Database::update( + $tableCourse, + ['popularity' => $popularity], + ['id = ?' => $courseId] + ); } /** - * Add user vote to a course. - * - * @param int user id - * @param int vote [1..5] - * @param int course id - * @param int session id - * @param int url id (access_url_id) - * - * @return false|string 'added', 'updated' or 'nothing' + * Add or update user vote for a course and update course ranking. */ public static function add_course_vote( - $user_id, - $vote, - $course_id, - $session_id = 0, - $url_id = 0 - ) { - $table_user_course_vote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); - $course_id = empty($course_id) ? api_get_course_int_id() : intval($course_id); - - if (empty($course_id) || empty($user_id)) { - return false; - } + int $userId, + int $vote, + int $courseId, + int $sessionId = 0, + int $urlId = 0 + ): false|string + { + $tableUserCourseVote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); - if (!in_array($vote, [1, 2, 3, 4, 5])) { + if (empty($courseId) || empty($userId) || !in_array($vote, [1, 2, 3, 4, 5])) { return false; } - $session_id = empty($session_id) ? api_get_session_id() : intval($session_id); - $url_id = empty($url_id) ? api_get_current_access_url_id() : intval($url_id); - $vote = intval($vote); + $sessionId = empty($sessionId) ? api_get_session_id() : $sessionId; + $urlId = empty($urlId) ? api_get_current_access_url_id() : $urlId; $params = [ - 'user_id' => intval($user_id), - 'c_id' => $course_id, - 'session_id' => $session_id, - 'url_id' => $url_id, + 'user_id' => $userId, + 'c_id' => $courseId, + 'session_id' => $sessionId, + 'url_id' => $urlId, 'vote' => $vote, ]; - $action_done = 'nothing'; - $result = Database::select( - 'id, vote', - $table_user_course_vote, - ['where' => ['user_id = ? AND c_id = ? AND session_id = ? AND url_id = ?' => $params]], + $actionDone = 'nothing'; + + $existingVote = Database::select( + 'id', + $tableUserCourseVote, + ['where' => ['user_id = ? AND c_id = ?' => [$userId, $courseId]]], 'first' ); - if (empty($result)) { - Database::insert($table_user_course_vote, $params); - $points_to_add = $vote; - $add_user = true; - $action_done = 'added'; + if (empty($existingVote)) { + Database::insert($tableUserCourseVote, $params); + $actionDone = 'added'; } else { - $my_params = ['vote' => $vote]; - $points_to_add = $vote - $result['vote']; - $add_user = false; - Database::update( - $table_user_course_vote, - $my_params, - ['user_id = ? AND c_id = ? AND session_id = ? AND url_id = ?' => $params] + $tableUserCourseVote, + ['vote' => $vote, 'session_id' => $sessionId, 'url_id' => $urlId], + ['id = ?' => $existingVote['id']] ); - $action_done = 'updated'; + $actionDone = 'updated'; } - // Current points - if (!empty($points_to_add)) { - self::update_course_ranking( - $course_id, - $session_id, - $url_id, - $points_to_add, - false, - $add_user - ); - } + self::update_course_ranking($courseId); - return $action_done; + return $actionDone; } /** - * Remove course ranking + user votes. - * - * @param int $course_id - * @param int $session_id - * @param int $url_id + * Remove all votes for a course and update ranking. */ - public static function remove_course_ranking($course_id, $session_id, $url_id = null) + public static function remove_course_ranking(int $courseId): void { - $table_course_ranking = Database::get_main_table(TABLE_STATISTIC_TRACK_COURSE_RANKING); - $table_user_course_vote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); + $tableUserCourseVote = Database::get_main_table(TABLE_MAIN_USER_REL_COURSE_VOTE); - if (!empty($course_id) && isset($session_id)) { - $url_id = empty($url_id) ? api_get_current_access_url_id() : intval($url_id); - $params = [ - 'c_id' => $course_id, - 'session_id' => $session_id, - 'url_id' => $url_id, - ]; - Database::delete($table_course_ranking, ['c_id = ? AND session_id = ? AND url_id = ?' => $params]); - Database::delete($table_user_course_vote, ['c_id = ? AND session_id = ? AND url_id = ?' => $params]); + if (empty($courseId)) { + return; } + + Database::delete($tableUserCourseVote, ['c_id = ?' => $courseId]); + + self::update_course_ranking($courseId); } /** diff --git a/public/main/inc/lib/events.lib.php b/public/main/inc/lib/events.lib.php index 15cd4a7b767..2623a911b38 100644 --- a/public/main/inc/lib/events.lib.php +++ b/public/main/inc/lib/events.lib.php @@ -1856,14 +1856,7 @@ public static function eventCourseLogin(int $courseId, int $user_id, int $sessio if ($courseAccessId) { // Course catalog stats modifications see #4191 - CourseManager::update_course_ranking( - null, - 0, - null, - null, - true, - false - ); + CourseManager::update_course_ranking(); return true; } diff --git a/public/main/inc/lib/sessionmanager.lib.php b/public/main/inc/lib/sessionmanager.lib.php index 074dfaa439a..1b59d8627dc 100644 --- a/public/main/inc/lib/sessionmanager.lib.php +++ b/public/main/inc/lib/sessionmanager.lib.php @@ -2793,10 +2793,7 @@ public static function add_courses_to_session( $sessionId ); - CourseManager::remove_course_ranking( - $existingCourse['c_id'], - $sessionId - ); + CourseManager::remove_course_ranking($existingCourse['c_id']); $nbr_courses--; } } diff --git a/public/main/inc/lib/urlmanager.lib.php b/public/main/inc/lib/urlmanager.lib.php index d948d9a2a57..3bc70e87962 100644 --- a/public/main/inc/lib/urlmanager.lib.php +++ b/public/main/inc/lib/urlmanager.lib.php @@ -1064,18 +1064,14 @@ public static function update_urls_rel_course($course_list, $urlId) // Adding courses foreach ($course_list as $courseId) { self::add_course_to_url($courseId, $urlId); - CourseManager::update_course_ranking($courseId, 0, $urlId); + CourseManager::update_course_ranking($courseId); } // Deleting old courses foreach ($existing_courses as $courseId) { if (!in_array($courseId, $course_list)) { self::delete_url_rel_course($courseId, $urlId); - CourseManager::update_course_ranking( - $courseId, - 0, - $urlId - ); + CourseManager::update_course_ranking($courseId); } } } diff --git a/src/CoreBundle/Entity/Course.php b/src/CoreBundle/Entity/Course.php index 96edf92b8a7..4945c94f41a 100644 --- a/src/CoreBundle/Entity/Course.php +++ b/src/CoreBundle/Entity/Course.php @@ -166,18 +166,6 @@ class Course extends AbstractResource implements ResourceInterface, ResourceWith )] protected Collection $tools; - #[Groups(['course:read'])] - #[ORM\OneToOne( - mappedBy: 'course', - targetEntity: TrackCourseRanking::class, - cascade: [ - 'persist', - 'remove', - ], - orphanRemoval: true - )] - protected ?TrackCourseRanking $trackCourseRanking = null; - protected Session $currentSession; protected AccessUrl $currentUrl; @@ -347,6 +335,10 @@ class Course extends AbstractResource implements ResourceInterface, ResourceWith #[ORM\Column(type: 'integer', nullable: true)] private ?int $duration = null; + #[Groups(['course:read', 'course:write'])] + #[ORM\Column(name: 'popularity', type: 'integer', nullable: false, options: ['default' => 0])] + protected int $popularity = 0; + public function __construct() { $this->visibility = self::OPEN_PLATFORM; @@ -442,18 +434,6 @@ public function addTool(CTool $tool): self return $this; } - public function getTrackCourseRanking(): ?TrackCourseRanking - { - return $this->trackCourseRanking; - } - - public function setTrackCourseRanking(?TrackCourseRanking $trackCourseRanking): self - { - $this->trackCourseRanking = $trackCourseRanking; - - return $this; - } - public function hasSubscriptionByUser(User $user): bool { return (bool) $this->getSubscriptionByUser($user); @@ -1191,6 +1171,18 @@ public function setDuration(?int $duration): self return $this; } + public function getPopularity(): int + { + return $this->popularity; + } + + public function setPopularity(int $popularity): self + { + $this->popularity = $popularity; + + return $this; + } + public function getResourceIdentifier(): int { return $this->getId(); diff --git a/src/CoreBundle/Entity/TrackCourseRanking.php b/src/CoreBundle/Entity/TrackCourseRanking.php deleted file mode 100644 index 33e5132f264..00000000000 --- a/src/CoreBundle/Entity/TrackCourseRanking.php +++ /dev/null @@ -1,173 +0,0 @@ - ['trackCourseRanking:read'], - ], - denormalizationContext: [ - 'groups' => ['trackCourseRanking:write'], - ], - security: "is_granted('ROLE_USER')" -)] -#[ORM\Table(name: 'track_course_ranking')] -#[ORM\Index(columns: ['c_id'], name: 'idx_tcc_cid')] -#[ORM\Index(columns: ['session_id'], name: 'idx_tcc_sid')] -#[ORM\Index(columns: ['url_id'], name: 'idx_tcc_urlid')] -#[ORM\Index(columns: ['creation_date'], name: 'idx_tcc_creation_date')] -#[ORM\Entity] -class TrackCourseRanking -{ - #[Groups(['course:read', 'trackCourseRanking:read'])] - #[ORM\Column(name: 'id', type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'IDENTITY')] - protected ?int $id = null; - - #[Groups(['course:read', 'trackCourseRanking:read', 'trackCourseRanking:write'])] - #[ORM\OneToOne(inversedBy: 'trackCourseRanking', targetEntity: Course::class)] - #[ORM\JoinColumn(name: 'c_id', referencedColumnName: 'id', nullable: false, onDelete: 'cascade')] - protected Course $course; - - #[Groups(['trackCourseRanking:read', 'trackCourseRanking:write'])] - #[ORM\Column(name: 'session_id', type: 'integer', nullable: false)] - protected int $sessionId; - - #[Groups(['trackCourseRanking:read', 'trackCourseRanking:write'])] - #[ORM\Column(name: 'url_id', type: 'integer', nullable: false)] - protected int $urlId; - - #[Groups(['course:read', 'trackCourseRanking:read'])] - #[ORM\Column(name: 'accesses', type: 'integer', nullable: false)] - protected int $accesses; - - #[Groups(['course:read', 'trackCourseRanking:read', 'trackCourseRanking:write'])] - #[ORM\Column(name: 'total_score', type: 'integer', nullable: false)] - protected int $totalScore; - - #[Groups(['course:read'])] - #[ORM\Column(name: 'users', type: 'integer', nullable: false)] - protected int $users; - - #[ORM\Column(name: 'creation_date', type: 'datetime', nullable: false)] - protected DateTime $creationDate; - - public function __construct() - { - $this->urlId = 0; - $this->accesses = 0; - $this->totalScore = 0; - $this->users = 0; - $this->creationDate = new DateTime(); - } - - public function getCourse(): Course - { - return $this->course; - } - - public function setCourse(Course $course): self - { - $this->course = $course; - - return $this; - } - - public function getSessionId(): int - { - return $this->sessionId; - } - - public function setSessionId(int $sessionId): static - { - $this->sessionId = $sessionId; - - return $this; - } - - public function getUrlId(): int - { - return $this->urlId; - } - - public function setUrlId(int $urlId): static - { - $this->urlId = $urlId; - - return $this; - } - - public function getAccesses(): int - { - return $this->accesses; - } - - public function setAccesses(int $accesses): static - { - $this->accesses = $accesses; - - return $this; - } - - public function getTotalScore(): int - { - return $this->totalScore; - } - - public function setTotalScore(int $totalScore): static - { - $this->users++; - $this->totalScore += $totalScore; - - return $this; - } - - public function getUsers(): int - { - return $this->users; - } - - public function setUsers(int $users): static - { - $this->users = $users; - - return $this; - } - - public function getCreationDate(): DateTime - { - return $this->creationDate; - } - - public function setCreationDate(DateTime $creationDate): static - { - $this->creationDate = $creationDate; - - return $this; - } - - public function getId(): ?int - { - return $this->id; - } - - #[Groups(['course:read', 'trackCourseRanking:read'])] - public function getRealTotalScore(): int - { - if (0 !== $this->totalScore && 0 !== $this->users) { - return (int) round($this->totalScore / $this->users); - } - - return 0; - } -} diff --git a/src/CoreBundle/Entity/UserRelCourseVote.php b/src/CoreBundle/Entity/UserRelCourseVote.php index adb83cb24e3..7140769f319 100644 --- a/src/CoreBundle/Entity/UserRelCourseVote.php +++ b/src/CoreBundle/Entity/UserRelCourseVote.php @@ -6,16 +6,45 @@ namespace Chamilo\CoreBundle\Entity; +use Chamilo\CoreBundle\EventListener\UserRelCourseVoteListener; use Chamilo\CoreBundle\Traits\UserTrait; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\Delete; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiFilter; /** - * UserRelCourseVote. + * UserRelCourseVote Entity - Stores user votes for courses. */ +#[ApiResource( + operations: [ + new Get(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Post(security: "is_granted('ROLE_USER')"), + new Put(security: "is_granted('ROLE_USER')"), + new Delete(security: "is_granted('ROLE_ADMIN')") + ], + normalizationContext: ['groups' => ['userRelCourseVote:read']], + denormalizationContext: ['groups' => ['userRelCourseVote:write']] +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'user.id' => 'exact', + 'course.id' => 'exact', + 'url.id' => 'exact' +])] +#[ApiFilter(OrderFilter::class, properties: ['vote' => 'DESC'], arguments: ['orderParameterName' => 'order'])] #[ORM\Table(name: 'user_rel_course_vote')] #[ORM\Index(columns: ['c_id'], name: 'idx_ucv_cid')] #[ORM\Index(columns: ['user_id'], name: 'idx_ucv_uid')] #[ORM\Index(columns: ['user_id', 'c_id'], name: 'idx_ucv_cuid')] +#[ORM\EntityListeners([UserRelCourseVoteListener::class])] #[ORM\Entity] class UserRelCourseVote { @@ -24,25 +53,31 @@ class UserRelCourseVote #[ORM\Column(name: 'id', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue(strategy: 'IDENTITY')] + #[Groups(['userRelCourseVote:read'])] protected ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'userRelCourseVotes')] #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Groups(['userRelCourseVote:read', 'userRelCourseVote:write'])] protected User $user; #[ORM\ManyToOne(targetEntity: Course::class)] #[ORM\JoinColumn(name: 'c_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Groups(['userRelCourseVote:read', 'userRelCourseVote:write'])] protected Course $course; #[ORM\ManyToOne(targetEntity: Session::class)] - #[ORM\JoinColumn(name: 'session_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\JoinColumn(name: 'session_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + #[Groups(['userRelCourseVote:read', 'userRelCourseVote:write'])] protected ?Session $session = null; - #[ORM\ManyToOne(targetEntity: AccessUrl::class, inversedBy: 'courses')] + #[ORM\ManyToOne(targetEntity: AccessUrl::class)] #[ORM\JoinColumn(name: 'url_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Groups(['userRelCourseVote:read', 'userRelCourseVote:write'])] protected AccessUrl $url; #[ORM\Column(name: 'vote', type: 'integer', nullable: false)] + #[Groups(['userRelCourseVote:read', 'userRelCourseVote:write'])] protected int $vote; public function getId(): ?int diff --git a/src/CoreBundle/EventListener/UserRelCourseVoteListener.php b/src/CoreBundle/EventListener/UserRelCourseVoteListener.php new file mode 100644 index 00000000000..cda24e5bced --- /dev/null +++ b/src/CoreBundle/EventListener/UserRelCourseVoteListener.php @@ -0,0 +1,41 @@ +updateCoursePopularity($vote, $args->getEntityManager()); + } + + public function postUpdate(UserRelCourseVote $vote, LifecycleEventArgs $args): void + { + $this->updateCoursePopularity($vote, $args->getEntityManager()); + } + + private function updateCoursePopularity(UserRelCourseVote $vote, EntityManagerInterface $entityManager): void + { + $course = $vote->getCourse(); + + $uniqueUsers = $entityManager->createQueryBuilder() + ->select('COUNT(DISTINCT v.user)') + ->from(UserRelCourseVote::class, 'v') + ->where('v.course = :course') + ->setParameter('course', $course) + ->getQuery() + ->getSingleScalarResult(); + + $course->setPopularity((int) $uniqueUsers); + $entityManager->persist($course); + $entityManager->flush(); + } +} diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20240204120000.php b/src/CoreBundle/Migrations/Schema/V200/Version20240204120000.php new file mode 100644 index 00000000000..ad1027c5196 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V200/Version20240204120000.php @@ -0,0 +1,45 @@ +addSql('ALTER TABLE course ADD COLUMN popularity INT NOT NULL DEFAULT 0'); + + $this->addSql('DROP TABLE IF EXISTS track_course_ranking'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE course DROP COLUMN popularity'); + + $this->addSql(' + CREATE TABLE track_course_ranking ( + id INT AUTO_INCREMENT NOT NULL, + c_id INT NOT NULL, + session_id INT NOT NULL, + url_id INT NOT NULL, + accesses INT NOT NULL, + total_score INT NOT NULL, + users INT NOT NULL, + creation_date DATETIME NOT NULL, + PRIMARY KEY(id) + ) + '); + } + +} diff --git a/src/CoreBundle/Resources/config/listeners.yml b/src/CoreBundle/Resources/config/listeners.yml index 7e5a5f54987..41b5070681c 100644 --- a/src/CoreBundle/Resources/config/listeners.yml +++ b/src/CoreBundle/Resources/config/listeners.yml @@ -105,3 +105,9 @@ services: Chamilo\CoreBundle\EventListener\MessageStatusListener: ~ Chamilo\CoreBundle\EventListener\ResourceLinkListener: ~ + + Chamilo\CoreBundle\EventListener\UserRelCourseVoteListener: + tags: + - { name: doctrine.orm.entity_listener, event: postPersist, entity_manager: default, lazy: true } + - { name: doctrine.orm.entity_listener, event: postUpdate, entity_manager: default, lazy: true } +