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;
}
}