From 7fb67bf2ffcd748dcbaeb0cc7fe9fda48faa6882 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Thu, 28 May 2026 21:17:25 -0500 Subject: [PATCH] Plugin: Apply resubscription check to admin session enrolment --- public/main/admin/settings.lib.php | 1 + public/main/session/add_users_to_session.php | 50 ++++ public/plugin/Resubscription/README.md | 31 ++- public/plugin/Resubscription/config.php | 12 +- public/plugin/Resubscription/index.php | 20 +- public/plugin/Resubscription/install.php | 6 +- public/plugin/Resubscription/lang/en_US.php | 16 +- public/plugin/Resubscription/lang/es.php | 16 +- public/plugin/Resubscription/plugin.php | 6 +- .../ResubscriptionEventSubscriber.php | 97 ++----- .../Resubscription/src/Resubscription.php | 238 ++++++++++++++++-- public/plugin/Resubscription/uninstall.php | 6 +- .../Event/SessionResubscriptionEvent.php | 7 +- 13 files changed, 352 insertions(+), 154 deletions(-) diff --git a/public/main/admin/settings.lib.php b/public/main/admin/settings.lib.php index 16f2604f93a..dbaafd8e687 100644 --- a/public/main/admin/settings.lib.php +++ b/public/main/admin/settings.lib.php @@ -355,6 +355,7 @@ function getStablePluginAllowList(): array 'ShowRegions', 'HelloWorld', 'ExtAuthChamiloLogoutButtonBehaviour', + 'Resubscription', ]; } diff --git a/public/main/session/add_users_to_session.php b/public/main/session/add_users_to_session.php index c911a8477ca..92096b6ba18 100644 --- a/public/main/session/add_users_to_session.php +++ b/public/main/session/add_users_to_session.php @@ -3,6 +3,10 @@ /* For licensing terms, see /license.txt */ use Chamilo\CoreBundle\Enums\ObjectIcon; +use Chamilo\CoreBundle\Event\AbstractEvent; +use Chamilo\CoreBundle\Event\Events; +use Chamilo\CoreBundle\Event\SessionResubscriptionEvent; +use Chamilo\CoreBundle\Framework\Container; // resetting the course id $cidReset = true; @@ -29,6 +33,17 @@ $tbl_user = Database::get_main_table(TABLE_MAIN_USER); $tbl_session_rel_user = Database::get_main_table(TABLE_MAIN_SESSION_USER); +$resubscriptionPlugin = null; +$resubscriptionPluginConfigPath = __DIR__.'/../../plugin/Resubscription/config.php'; +if (is_file($resubscriptionPluginConfigPath)) { + require_once $resubscriptionPluginConfigPath; + + if (class_exists('Resubscription', false)) { + $resubscriptionPlugin = Resubscription::create(); + } +} + + // setting the name of the tool $tool_name = get_lang('Subscribe users to this session'); $add_type = 'unique'; @@ -64,6 +79,41 @@ $data = $form->getSubmitValues(); $users = $data['users'] ?? []; + $subscriptionAllowed = true; + + foreach ($users as $userId) { + $userId = (int) $userId; + + if (empty($userId)) { + continue; + } + + try { + if ($resubscriptionPlugin instanceof Resubscription && $resubscriptionPlugin->isEnabled()) { + $resubscriptionPlugin->assertUserCanResubscribe($userId, $sessionId); + } else { + Container::getEventDispatcher()->dispatch( + new SessionResubscriptionEvent( + [ + 'session_id' => $sessionId, + 'user_id' => $userId, + ], + AbstractEvent::TYPE_PRE + ), + Events::SESSION_RESUBSCRIPTION + ); + } + } catch (Throwable $exception) { + $subscriptionAllowed = false; + Display::addFlash($exception->getMessage()); + } + } + + if (!$subscriptionAllowed) { + header('Location: '.api_get_self().'?id_session='.$sessionId); + exit; + } + SessionManager::subscribeUsersToSession( $sessionId, $users, diff --git a/public/plugin/Resubscription/README.md b/public/plugin/Resubscription/README.md index fc64aea4069..336eaa9232a 100644 --- a/public/plugin/Resubscription/README.md +++ b/public/plugin/Resubscription/README.md @@ -1,4 +1,29 @@ -Resubscription -============== +# Resubscription for Chamilo 2 -Limit session resubscriptions +This plugin limits repeated subscriptions to sessions when the target session contains a course that the learner already followed recently. + +## Behavior + +The plugin listens to the `SESSION_RESUBSCRIPTION` event before the subscription is completed. + +When a learner tries to subscribe to a session, the plugin checks previous learner subscriptions and compares the courses of those sessions with the courses of the target session. + +If a matching course is found inside the configured period, the subscription is blocked with an informational message. + +## Settings + +- `resubscription_limit = calendar_year`: blocks resubscription until the next calendar year. +- `resubscription_limit = natural_year`: blocks resubscription for one year after the previous access end date. + +## Chamilo 2 notes + +The plugin uses the existing Chamilo event and legacy plugin system. It does not create tables and does not modify courses, sessions or users. + +The event subscriber is namespaced under `Chamilo\PluginBundle\Resubscription\EventSubscriber` so it can be registered by the Chamilo 2 plugin event subscriber compiler pass. + +## Admin session subscription screen + +Chamilo 2 also dispatches the same pre-subscription check from `public/main/session/add_users_to_session.php`. +This keeps the legacy administrator workflow aligned with the catalog workflow: adding a learner to a session is blocked when the target session contains a course already followed inside the configured period. + +The `SessionResubscriptionEvent` accepts an optional `user_id` so admin-driven subscriptions can validate the selected learner instead of the current administrator account. diff --git a/public/plugin/Resubscription/config.php b/public/plugin/Resubscription/config.php index d0119a49ab1..30e1d9a3893 100644 --- a/public/plugin/Resubscription/config.php +++ b/public/plugin/Resubscription/config.php @@ -1,9 +1,9 @@ - */ -require_once api_get_path(SYS_PATH).'main/inc/global.inc.php'; + +require_once __DIR__.'/../../main/inc/global.inc.php'; + +if (!class_exists('Resubscription', false)) { + require_once __DIR__.'/src/Resubscription.php'; +} diff --git a/public/plugin/Resubscription/index.php b/public/plugin/Resubscription/index.php index 56414d4c5c5..bca616f0713 100644 --- a/public/plugin/Resubscription/index.php +++ b/public/plugin/Resubscription/index.php @@ -1,9 +1,19 @@ - */ + require_once __DIR__.'/config.php'; + +api_protect_admin_script(true); + +$plugin = Resubscription::create(); + +Display::display_header($plugin->get_title()); + +echo '
'; +echo '

'.htmlspecialchars($plugin->get_title(), ENT_QUOTES, api_get_system_encoding()).'

'; +echo '

'.htmlspecialchars($plugin->get_comment(), ENT_QUOTES, api_get_system_encoding()).'

'; +echo '

'.htmlspecialchars($plugin->get_lang('PluginUsageHelp'), ENT_QUOTES, api_get_system_encoding()).'

'; +echo '
'; + +Display::display_footer(); diff --git a/public/plugin/Resubscription/install.php b/public/plugin/Resubscription/install.php index 9a0096710c6..a0131abbafd 100644 --- a/public/plugin/Resubscription/install.php +++ b/public/plugin/Resubscription/install.php @@ -1,11 +1,7 @@ - */ + require_once __DIR__.'/config.php'; Resubscription::create()->install(); diff --git a/public/plugin/Resubscription/lang/en_US.php b/public/plugin/Resubscription/lang/en_US.php index 5b04bc5b0c1..5472a97e09a 100644 --- a/public/plugin/Resubscription/lang/en_US.php +++ b/public/plugin/Resubscription/lang/en_US.php @@ -1,14 +1,14 @@ - */ + $strings['plugin_title'] = 'Resubscription'; -$strings['plugin_comment'] = 'This plugin limits session resubscription.'; +$strings['plugin_comment'] = 'Limit session resubscriptions.'; $strings['resubscription_limit'] = 'Resubscription limit'; -$strings['resubscription_limit_help'] = 'This limits how often a user can be resubscribed'; -$strings['CanResubscribeFromX'] = 'Subscription available from %s'; +$strings['resubscription_limit_help'] = 'Choose how long a learner must wait before subscribing again to another session that contains a course already followed recently.'; +$strings['CalendarYear'] = 'Calendar year'; +$strings['NaturalYear'] = 'Natural year'; +$strings['CanResubscribeFromX'] = 'Subscription available from %s.'; +$strings['UserCanResubscribeFromX'] = '%s: subscription available from %s.'; +$strings['PluginUsageHelp'] = 'This plugin listens to the session resubscription event and blocks repeated subscription attempts when the target session contains a course already followed within the configured period.'; diff --git a/public/plugin/Resubscription/lang/es.php b/public/plugin/Resubscription/lang/es.php index c39b2bac82a..46475361e18 100644 --- a/public/plugin/Resubscription/lang/es.php +++ b/public/plugin/Resubscription/lang/es.php @@ -1,14 +1,14 @@ - */ + $strings['plugin_title'] = 'Reinscripción'; -$strings['plugin_comment'] = 'Este plugin limita las reinscripiones a sesiones.'; +$strings['plugin_comment'] = 'Limita la reinscripción a sesiones.'; $strings['resubscription_limit'] = 'Límite de reinscripción'; -$strings['resubscription_limit_help'] = 'Esto limita cada cuánto puede reinscribirse un usuario'; -$strings['CanResubscribeFromX'] = 'Inscripción posible a partir del %s'; +$strings['resubscription_limit_help'] = 'Elige cuánto tiempo debe esperar un alumno antes de inscribirse nuevamente en otra sesión que contiene un curso seguido recientemente.'; +$strings['CalendarYear'] = 'Año calendario'; +$strings['NaturalYear'] = 'Año natural'; +$strings['CanResubscribeFromX'] = 'Inscripción posible a partir del %s.'; +$strings['UserCanResubscribeFromX'] = '%s: inscripción posible a partir del %s.'; +$strings['PluginUsageHelp'] = 'Este plugin escucha el evento de reinscripción a sesiones y bloquea intentos repetidos cuando la sesión de destino contiene un curso ya seguido dentro del período configurado.'; diff --git a/public/plugin/Resubscription/plugin.php b/public/plugin/Resubscription/plugin.php index 56b477287b7..99eb2cbfac7 100644 --- a/public/plugin/Resubscription/plugin.php +++ b/public/plugin/Resubscription/plugin.php @@ -1,11 +1,7 @@ - */ + require_once __DIR__.'/config.php'; $plugin_info = Resubscription::create()->get_info(); diff --git a/public/plugin/Resubscription/src/EventSubscriber/ResubscriptionEventSubscriber.php b/public/plugin/Resubscription/src/EventSubscriber/ResubscriptionEventSubscriber.php index 9ca8e4f42ca..a8791e3084c 100644 --- a/public/plugin/Resubscription/src/EventSubscriber/ResubscriptionEventSubscriber.php +++ b/public/plugin/Resubscription/src/EventSubscriber/ResubscriptionEventSubscriber.php @@ -4,18 +4,21 @@ declare(strict_types=1); +namespace Chamilo\PluginBundle\Resubscription\EventSubscriber; + use Chamilo\CoreBundle\Event\AbstractEvent; use Chamilo\CoreBundle\Event\Events; use Chamilo\CoreBundle\Event\SessionResubscriptionEvent; +use Exception; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class ResubscriptionEventSubscriber implements EventSubscriberInterface { - private Resubscription $plugin; + private \Resubscription $plugin; public function __construct() { - $this->plugin = Resubscription::create(); + $this->plugin = \Resubscription::create(); } public static function getSubscribedEvents(): array @@ -26,93 +29,21 @@ public static function getSubscribedEvents(): array } /** - * @throws \Exception + * @throws Exception */ public function onResubscribe(SessionResubscriptionEvent $event): void { - if (!$this->plugin->isEnabled()) { + if (AbstractEvent::TYPE_PRE !== $event->getType()) { return; } - if (AbstractEvent::TYPE_PRE === $event->getType()) { - $resubscriptionLimit = Resubscription::create()->get('resubscription_limit'); - - // Initialize variables as a calendar year by default - $limitDateFormat = 'Y-01-01'; - $limitDate = gmdate($limitDateFormat); - $resubscriptionOffset = "1 year"; - - // No need to use a 'switch' with only two options so an 'if' is enough. - // However, this could change if the number of options increases - if ($resubscriptionLimit === 'natural_year') { - $limitDateFormat = 'Y-m-d'; - $limitDate = gmdate($limitDateFormat); - $limitDate = gmdate($limitDateFormat, strtotime("$limitDate -$resubscriptionOffset")); - } - - $join = " INNER JOIN ".Database::get_main_table(TABLE_MAIN_SESSION)." s ON s.id = su.session_id"; - - // User sessions and courses - $userSessions = Database::select( - 'su.session_id, s.access_end_date', - Database::get_main_table(TABLE_MAIN_SESSION_USER).' su '.$join, - [ - 'where' => [ - 'su.user_id = ? AND s.access_end_date >= ?' => [ - api_get_user_id(), - $limitDate, - ], - ], - 'order' => 'access_end_date DESC', - ] - ); - $userSessionCourses = []; - foreach ($userSessions as $userSession) { - $userSessionCourseResult = Database::select( - 'c_id', - Database::get_main_table(TABLE_MAIN_SESSION_COURSE), - [ - 'where' => [ - 'session_id = ?' => [ - $userSession['session_id'], - ], - ], - ] - ); - foreach ($userSessionCourseResult as $userSessionCourse) { - if (!isset($userSessionCourses[$userSessionCourse['c_id']])) { - $userSessionCourses[$userSessionCourse['c_id']] = $userSession['access_end_date']; - } - } - } + $sessionId = $event->getSessionId(); + $userId = $event->getUserId() ?? api_get_user_id(); - // Current session and courses - $currentSessionCourseResult = Database::select( - 'c_id', - Database::get_main_table(TABLE_MAIN_SESSION_COURSE), - [ - 'where' => [ - 'session_id = ?' => [ - $event->getSessionId(), - ], - ], - ] - ); - - // Check if current course code matches with one of the users - foreach ($currentSessionCourseResult as $currentSessionCourse) { - if (isset($userSessionCourses[$currentSessionCourse['c_id']])) { - $endDate = $userSessionCourses[$currentSessionCourse['c_id']]; - $resubscriptionDate = gmdate($limitDateFormat, strtotime($endDate." +$resubscriptionOffset")); - $icon = Display::getMdiIcon(ObjectIcon::USER, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Learner')); - $canResubscribeFrom = sprintf( - get_plugin_lang('CanResubscribeFromX', 'resubscription'), - $resubscriptionDate - ); - - throw new Exception(Display::label($icon.' '.$canResubscribeFrom, "info")); - } - } + if (empty($sessionId) || empty($userId)) { + return; } + + $this->plugin->assertUserCanResubscribe((int) $userId, (int) $sessionId); } -} \ No newline at end of file +} diff --git a/public/plugin/Resubscription/src/Resubscription.php b/public/plugin/Resubscription/src/Resubscription.php index 7e0d6c1b5d5..554034601f9 100644 --- a/public/plugin/Resubscription/src/Resubscription.php +++ b/public/plugin/Resubscription/src/Resubscription.php @@ -4,54 +4,242 @@ /** * Limit session resubscriptions. - * - * @author Imanol Losada Oriol */ class Resubscription extends Plugin { - /** - * Class constructor. - */ + public const LIMIT_CALENDAR_YEAR = 'calendar_year'; + public const LIMIT_NATURAL_YEAR = 'natural_year'; + protected function __construct() { - $options = [ - 'calendar_year' => get_lang('Calendar year'), - 'natural_year' => get_lang('Natural year'), - ]; $parameters = [ 'resubscription_limit' => [ 'type' => 'select', - 'options' => $options, + 'options' => [ + self::LIMIT_CALENDAR_YEAR => 'CalendarYear', + self::LIMIT_NATURAL_YEAR => 'NaturalYear', + ], + 'translate_options' => true, ], ]; - parent::__construct('0.1', 'Imanol Losada Oriol', $parameters); + + parent::__construct('0.2', 'Imanol Losada Oriol', $parameters); } - /** - * Instance the plugin. - * - * @staticvar null $result - * - * @return Resubscription - */ - public static function create() + public static function create(): self { static $result = null; return $result ?: $result = new self(); } - /** - * Install the plugin. - */ - public function install() + public function install(): void + { + } + + public function uninstall(): void + { + } + + public function getConfiguredLimit(): string { + $limit = (string) $this->get('resubscription_limit'); + + if (self::LIMIT_NATURAL_YEAR === $limit) { + return self::LIMIT_NATURAL_YEAR; + } + + return self::LIMIT_CALENDAR_YEAR; } /** - * Uninstall the plugin. + * Throws an exception when the given user cannot be subscribed to the target session. + * + * This public method is intentionally available for both the Symfony event subscriber + * and the legacy admin enrolment page. Some legacy admin flows do not reliably load + * plugin event subscribers, so the admin page can enforce the same rule directly + * without duplicating SQL logic. + * + * @throws Exception */ - public function uninstall() + public function assertUserCanResubscribe(int $userId, int $targetSessionId): void + { + if (!$this->isEnabled()) { + return; + } + + if ($userId <= 0 || $targetSessionId <= 0) { + return; + } + + $matchingCourseEndDate = $this->findLatestMatchingCourseEndDate($userId, $targetSessionId); + + if (null === $matchingCourseEndDate) { + return; + } + + throw new Exception($this->buildRestrictionMessage($userId, $matchingCourseEndDate)); + } + + public function getRestrictionMessageForUser(int $userId, int $targetSessionId): ?string + { + if (!$this->isEnabled()) { + return null; + } + + if ($userId <= 0 || $targetSessionId <= 0) { + return null; + } + + $matchingCourseEndDate = $this->findLatestMatchingCourseEndDate($userId, $targetSessionId); + + if (null === $matchingCourseEndDate) { + return null; + } + + return $this->buildRestrictionMessage($userId, $matchingCourseEndDate); + } + + private function findLatestMatchingCourseEndDate(int $userId, int $currentSessionId): ?string + { + $limit = $this->getConfiguredLimit(); + $limitDateFormat = 'Y-01-01'; + $limitDate = gmdate($limitDateFormat); + + if (self::LIMIT_NATURAL_YEAR === $limit) { + $limitDateFormat = 'Y-m-d'; + $limitDate = gmdate($limitDateFormat, strtotime(gmdate($limitDateFormat).' -1 year')); + } + + $sessionUserTable = Database::get_main_table(TABLE_MAIN_SESSION_USER); + $sessionTable = Database::get_main_table(TABLE_MAIN_SESSION); + $sessionCourseTable = Database::get_main_table(TABLE_MAIN_SESSION_COURSE); + + /* + * Chamilo sessions can be configured either with fixed access dates + * or with a duration in days. Duration-based sessions do not always store + * an access_end_date in session_rel_user, so the effective end date has to + * be computed from the user registration date plus the session duration. + */ + $effectiveEndDateExpression = " + COALESCE( + su.access_end_date, + s.access_end_date, + CASE + WHEN s.duration IS NOT NULL + AND s.duration > 0 + AND su.registered_at IS NOT NULL + THEN DATE_ADD(su.registered_at, INTERVAL s.duration DAY) + ELSE NULL + END + ) + "; + + $userSessions = Database::select( + 'su.session_id, '.$effectiveEndDateExpression.' AS effective_access_end_date', + $sessionUserTable.' su INNER JOIN '.$sessionTable.' s ON s.id = su.session_id', + [ + 'where' => [ + 'su.user_id = ? AND su.relation_type = ? AND su.session_id <> ? AND '.$effectiveEndDateExpression.' IS NOT NULL AND '.$effectiveEndDateExpression.' >= ?' => [ + $userId, + \Chamilo\CoreBundle\Entity\Session::STUDENT, + $currentSessionId, + $limitDate, + ], + ], + 'order' => 'effective_access_end_date DESC', + ] + ); + + if (empty($userSessions)) { + return null; + } + + $userSessionCourses = []; + + foreach ($userSessions as $userSession) { + $userSessionId = (int) $userSession['session_id']; + $effectiveEndDate = (string) $userSession['effective_access_end_date']; + + $userSessionCourseResult = Database::select( + 'c_id', + $sessionCourseTable, + [ + 'where' => [ + 'session_id = ?' => [ + $userSessionId, + ], + ], + ] + ); + + foreach ($userSessionCourseResult as $userSessionCourse) { + $courseId = (int) $userSessionCourse['c_id']; + + if (!isset($userSessionCourses[$courseId])) { + $userSessionCourses[$courseId] = $effectiveEndDate; + } + } + } + + if (empty($userSessionCourses)) { + return null; + } + + $currentSessionCourseResult = Database::select( + 'c_id', + $sessionCourseTable, + [ + 'where' => [ + 'session_id = ?' => [ + $currentSessionId, + ], + ], + ] + ); + + foreach ($currentSessionCourseResult as $currentSessionCourse) { + $courseId = (int) $currentSessionCourse['c_id']; + + if (isset($userSessionCourses[$courseId])) { + return (string) $userSessionCourses[$courseId]; + } + } + + return null; + } + + private function buildRestrictionMessage(int $userId, string $endDate): string { + $limit = $this->getConfiguredLimit(); + $limitDateFormat = 'Y-01-01'; + + if (self::LIMIT_NATURAL_YEAR === $limit) { + $limitDateFormat = 'Y-m-d'; + } + + $resubscriptionDate = gmdate($limitDateFormat, strtotime($endDate.' +1 year')); + + if ($userId !== api_get_user_id()) { + $userInfo = api_get_user_info($userId); + $userName = $userInfo + ? api_get_person_name($userInfo['firstname'] ?? '', $userInfo['lastname'] ?? '') + : (string) $userId; + + $message = sprintf( + $this->get_lang('UserCanResubscribeFromX'), + $userName, + $resubscriptionDate + ); + + return Display::return_message($message, 'warning', false); + } + + $message = sprintf( + $this->get_lang('CanResubscribeFromX'), + $resubscriptionDate + ); + + return Display::return_message($message, 'info', false); } } diff --git a/public/plugin/Resubscription/uninstall.php b/public/plugin/Resubscription/uninstall.php index a1a92dad9a7..248fb2faa4a 100644 --- a/public/plugin/Resubscription/uninstall.php +++ b/public/plugin/Resubscription/uninstall.php @@ -1,11 +1,7 @@ - */ + require_once __DIR__.'/config.php'; Resubscription::create()->uninstall(); diff --git a/src/CoreBundle/Event/SessionResubscriptionEvent.php b/src/CoreBundle/Event/SessionResubscriptionEvent.php index 1fb0ab8d63b..ad8106adfa1 100644 --- a/src/CoreBundle/Event/SessionResubscriptionEvent.php +++ b/src/CoreBundle/Event/SessionResubscriptionEvent.php @@ -10,6 +10,11 @@ class SessionResubscriptionEvent extends AbstractEvent { public function getSessionId(): ?int { - return $this->data['session_id'] ?? null; + return isset($this->data['session_id']) ? (int) $this->data['session_id'] : null; + } + + public function getUserId(): ?int + { + return isset($this->data['user_id']) ? (int) $this->data['user_id'] : null; } }