Skip to content

Commit 45f83df

Browse files
denphiclaude
andauthored
[PR #1915] Feature/com groups membership expiration
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 02a8dd2 commit 45f83df

22 files changed

Lines changed: 2335 additions & 22 deletions

File tree

core/bootstrap/Install/sql/mysql/schema.sql

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5749,7 +5749,8 @@ CREATE TABLE `#__xgroups_log` (
57495749
`action` varchar(50) DEFAULT NULL,
57505750
`comments` text DEFAULT NULL,
57515751
`actorid` int(11) DEFAULT 0,
5752-
PRIMARY KEY (`id`)
5752+
PRIMARY KEY (`id`),
5753+
KEY `idx_action_timestamp` (`action`,`timestamp`)
57535754
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
57545755

57555756
--
@@ -5794,9 +5795,31 @@ CREATE TABLE `#__xgroups_members` (
57945795
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
57955796
`gidNumber` int(11) NOT NULL,
57965797
`uidNumber` int(11) NOT NULL,
5798+
`expires` datetime DEFAULT NULL,
5799+
`expires_set_by` int(11) DEFAULT NULL,
5800+
`expires_notified` datetime DEFAULT NULL,
57975801
PRIMARY KEY (`id`),
57985802
UNIQUE KEY `id` (`id`),
5799-
UNIQUE KEY `idx_gidNumber_uidNumber` (`gidNumber`,`uidNumber`)
5803+
UNIQUE KEY `idx_gidNumber_uidNumber` (`gidNumber`,`uidNumber`),
5804+
KEY `idx_expires` (`expires`)
5805+
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
5806+
5807+
--
5808+
-- Table structure for table `#__xgroups_member_history`
5809+
--
5810+
5811+
CREATE TABLE `#__xgroups_member_history` (
5812+
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
5813+
`gidNumber` int(11) NOT NULL,
5814+
`uidNumber` int(11) NOT NULL,
5815+
`expires` datetime DEFAULT NULL,
5816+
`revoked` datetime DEFAULT NULL,
5817+
`reason` varchar(32) DEFAULT NULL,
5818+
`was_manager` tinyint(1) DEFAULT 0,
5819+
`actor` int(11) DEFAULT NULL,
5820+
PRIMARY KEY (`id`),
5821+
KEY `idx_gidNumber_uidNumber` (`gidNumber`,`uidNumber`),
5822+
KEY `idx_revoked` (`revoked`)
58005823
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
58015824

58025825
--

core/components/com_groups/admin/controllers/membership.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,51 @@ public function displayTask()
147147

148148
$this->view->group = $group;
149149

150+
// Membership terms for the whole roster in one query, so the list can
151+
// show and filter on them without asking per row
152+
$this->view->expiration_supported = \Hubzero\User\Group\Membership::supported();
153+
$this->view->expirations = $this->view->expiration_supported
154+
? \Hubzero\User\Group\Membership::termsFor($group->get('gidNumber'))
155+
: array();
156+
157+
$this->view->filters['term'] = trim(Request::getState(
158+
$this->_option . '.' . $this->_controller . '.term',
159+
'term',
160+
''
161+
));
162+
163+
// Filtering on the term is done here rather than in the query: the row
164+
// set already includes invite-email pseudo-rows that have no
165+
// membership row to join against.
166+
if ($this->view->expiration_supported && $this->view->filters['term'] != '')
167+
{
168+
$terms = $this->view->expirations;
169+
$want = $this->view->filters['term'];
170+
$soon = time() + (30 * 86400);
171+
172+
$this->view->rows = array_filter((array) $this->view->rows, function ($row) use ($terms, $want, $soon)
173+
{
174+
$uid = isset($row->uidNumber) ? (int) $row->uidNumber : 0;
175+
$expires = ($uid && isset($terms[$uid])) ? $terms[$uid] : null;
176+
177+
switch ($want)
178+
{
179+
case 'limited':
180+
return !is_null($expires);
181+
case 'perpetual':
182+
return is_null($expires);
183+
case 'expiring':
184+
return !is_null($expires) && strtotime($expires . ' UTC') <= $soon;
185+
}
186+
187+
return true;
188+
});
189+
190+
// The count no longer matches the filtered set; pagination is
191+
// against the filtered rows
192+
$this->view->total = count($this->view->rows);
193+
}
194+
150195
// Set any errors
151196
foreach ($this->getErrors() as $error)
152197
{

core/components/com_groups/admin/language/en-GB/en-GB.com_groups.ini

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,3 +623,25 @@ COM_GROUPS_ERROR_UNABLE_TO_DELETE_DIRECTORY="An error occurred while attempting
623623
COM_GROUPS_ERROR_FILE_NOT_FOUND="File not found."
624624
COM_GROUPS_ERROR_UNABLE_TO_DELETE_FILE="An error occurred while attempting to delete the file."
625625
COM_GROUPS_SCHEMA_SAVED="Custom Fields Successfully Saved!"
626+
627+
; Time-limited membership
628+
COM_GROUPS_CONFIG_MEMBERSHIP_EXPIRATION="Allow time-limited memberships"
629+
COM_GROUPS_CONFIG_MEMBERSHIP_EXPIRATION_DESC="Let group managers give a membership an end date, after which it is revoked automatically. Memberships with no end date are unaffected. Turning this off stops new end dates being set; end dates already set are still honored. Requires the 'Group Membership Expiration' cron job to be enabled."
630+
COM_GROUPS_CONFIG_MEMBERSHIP_WARNING_DAYS="Expiration warning days"
631+
COM_GROUPS_CONFIG_MEMBERSHIP_WARNING_DAYS_DESC="Comma-separated list of how many days before the end date to warn the member, e.g. 30,7,1. Enter 0 to send no warnings at all; leaving the field empty restores the default rather than disabling warnings."
632+
COM_GROUPS_CONFIG_MEMBERSHIP_GRACE_HOURS="Grace period (hours)"
633+
COM_GROUPS_CONFIG_MEMBERSHIP_GRACE_HOURS_DESC="How long past the end date to wait before revoking. Access is not extended by the grace period on hubs that filter at read time; it only delays the removal."
634+
COM_GROUPS_CONFIG_MEMBERSHIP_MAX_TERM="Maximum term (days)"
635+
COM_GROUPS_CONFIG_MEMBERSHIP_MAX_TERM_DESC="Longest end date a group manager may set, in days from today. Zero means no limit."
636+
COM_GROUPS_CONFIG_MEMBERSHIP_BATCH="Expiration batch size"
637+
COM_GROUPS_CONFIG_MEMBERSHIP_BATCH_DESC="How many lapsed memberships to revoke in a single cron run. The remainder is picked up on the next run."
638+
COM_GROUPS_MEMBERSHIP_REAPER_STALLED="Membership end dates are not being enforced."
639+
COM_GROUPS_MEMBERSHIP_REAPER_STALLED_DESC="The 'Group Membership Expiration' cron job has not run in the last 24 hours. Until it runs, memberships past their end date keep their access. Check that cron is running and the job is enabled."
640+
COM_GROUPS_MEMBERSHIP_REAPER_OK="Membership expiration last ran %s."
641+
COM_GROUPS_MEMBERSHIP_PAST_DUE="%s membership(s) are past their end date and awaiting the next run."
642+
COM_GROUPS_MEMBER_TERM="Membership"
643+
COM_GROUPS_MEMBER_TERM_ANY="Any membership length"
644+
COM_GROUPS_MEMBER_TERM_LIMITED="Time-limited"
645+
COM_GROUPS_MEMBER_TERM_PERPETUAL="No end date"
646+
COM_GROUPS_MEMBER_TERM_EXPIRING="Ending within 30 days"
647+
COM_GROUPS_MEMBER_TERM_ENDS="ends %s"

core/components/com_groups/admin/views/membership/tmpl/display.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,37 @@
6262
->js();
6363
?>
6464

65+
<?php
66+
// Shown when the feature is on, and also whenever something is still past due,
67+
// so switching the feature off does not hide enforcement that is still running.
68+
$expirationPastDue = \Hubzero\User\Group\Membership::supported()
69+
? \Hubzero\User\Group\Membership::pastDueCount() : 0;
70+
?>
71+
<?php if (\Hubzero\User\Group\Membership::supported()
72+
&& (\Hubzero\User\Group\Membership::enabled() || $expirationPastDue > 0)) : ?>
73+
<?php
74+
// Terms are enforced by a cron job, so a stopped job is the difference
75+
// between end dates being enforced and being decorative. Say so plainly.
76+
$reaperOk = \Hubzero\User\Group\Membership::reaperHealthy();
77+
$lastRun = \Hubzero\User\Group\Membership::lastRun();
78+
$pastDue = $expirationPastDue;
79+
?>
80+
<div class="<?php echo $reaperOk ? 'info' : 'error'; ?>" id="membership-expiration-health">
81+
<?php if (!$reaperOk) : ?>
82+
<strong><?php echo Lang::txt('COM_GROUPS_MEMBERSHIP_REAPER_STALLED'); ?></strong>
83+
<?php echo Lang::txt('COM_GROUPS_MEMBERSHIP_REAPER_STALLED_DESC'); ?>
84+
<?php else : ?>
85+
<?php echo Lang::txt(
86+
'COM_GROUPS_MEMBERSHIP_REAPER_OK',
87+
Date::of($lastRun->timestamp)->toLocal(Lang::txt('DATE_FORMAT_HZ1') . ' g:ia')
88+
); ?>
89+
<?php endif; ?>
90+
<?php if ($pastDue > 0) : ?>
91+
<?php echo ' ' . Lang::txt('COM_GROUPS_MEMBERSHIP_PAST_DUE', $pastDue); ?>
92+
<?php endif; ?>
93+
</div>
94+
<?php endif; ?>
95+
6596
<form action="<?php echo Route::url('index.php?option=' . $this->option . '&controller=' . $this->controller); ?>" method="post" name="adminForm" id="adminForm">
6697
<fieldset id="filter-bar">
6798
<div class="grid">
@@ -78,6 +109,16 @@
78109
<option value="invitee"<?php echo ($this->filters['status'] == 'invitee') ? ' selected="selected"' : ''; ?>><?php echo Lang::txt('Invitee'); ?></option>
79110
</select>
80111

112+
<?php if (!empty($this->expiration_supported)) : ?>
113+
<label for="filter-term"><?php echo Lang::txt('COM_GROUPS_MEMBER_TERM'); ?>:</label>
114+
<select name="term" id="filter-term" class="filter filter-submit">
115+
<option value=""<?php echo (@$this->filters['term'] == '') ? ' selected="selected"' : ''; ?>><?php echo Lang::txt('COM_GROUPS_MEMBER_TERM_ANY'); ?></option>
116+
<option value="limited"<?php echo (@$this->filters['term'] == 'limited') ? ' selected="selected"' : ''; ?>><?php echo Lang::txt('COM_GROUPS_MEMBER_TERM_LIMITED'); ?></option>
117+
<option value="perpetual"<?php echo (@$this->filters['term'] == 'perpetual') ? ' selected="selected"' : ''; ?>><?php echo Lang::txt('COM_GROUPS_MEMBER_TERM_PERPETUAL'); ?></option>
118+
<option value="expiring"<?php echo (@$this->filters['term'] == 'expiring') ? ' selected="selected"' : ''; ?>><?php echo Lang::txt('COM_GROUPS_MEMBER_TERM_EXPIRING'); ?></option>
119+
</select>
120+
<?php endif; ?>
121+
81122
<input type="submit" value="<?php echo Lang::txt('COM_GROUPS_GO'); ?>" />
82123
</div>
83124
<div class="col span4">
@@ -188,6 +229,19 @@
188229
<span class="status <?php echo $status; ?>">
189230
<?php echo $status; ?>
190231
</span>
232+
<?php if (!empty($this->expiration_supported) && isset($row->uidNumber)) : ?>
233+
<?php $rowExpires = isset($this->expirations[(int) $row->uidNumber])
234+
? $this->expirations[(int) $row->uidNumber] : null; ?>
235+
<?php if ($rowExpires) : ?>
236+
<br />
237+
<span class="membership-term<?php echo (strtotime($rowExpires . ' UTC') <= time() + (30 * 86400)) ? ' term-expires-soon' : ''; ?>">
238+
<?php echo Lang::txt(
239+
'COM_GROUPS_MEMBER_TERM_ENDS',
240+
Date::of($rowExpires)->toLocal(Lang::txt('DATE_FORMAT_HZ1'))
241+
); ?>
242+
</span>
243+
<?php endif; ?>
244+
<?php endif; ?>
191245
</td>
192246
<td>
193247
<?php if ($canDo->get('core.edit')) { ?>

core/components/com_groups/config/config.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
<option value="yes">JYES</option>
5151
</field>
5252
<field name="invite_message" type="textarea" menu="hide" default="" label="COM_GROUPS_CONFIG_INVITE_MSG_LABEL" description="COM_GROUPS_CONFIG_INVITE_MSG_DESC" rows="10" cols="35" />
53+
<field name="membership_expiration" type="list" default="0" label="COM_GROUPS_CONFIG_MEMBERSHIP_EXPIRATION" description="COM_GROUPS_CONFIG_MEMBERSHIP_EXPIRATION_DESC">
54+
<option value="0">JNO</option>
55+
<option value="1">JYES</option>
56+
</field>
57+
<field name="membership_expiration_warning_days" type="text" default="30,7,1" label="COM_GROUPS_CONFIG_MEMBERSHIP_WARNING_DAYS" description="COM_GROUPS_CONFIG_MEMBERSHIP_WARNING_DAYS_DESC" size="20" />
58+
<field name="membership_expiration_grace_hours" type="text" default="0" label="COM_GROUPS_CONFIG_MEMBERSHIP_GRACE_HOURS" description="COM_GROUPS_CONFIG_MEMBERSHIP_GRACE_HOURS_DESC" size="10" />
59+
<field name="membership_max_term_days" type="text" default="0" label="COM_GROUPS_CONFIG_MEMBERSHIP_MAX_TERM" description="COM_GROUPS_CONFIG_MEMBERSHIP_MAX_TERM_DESC" size="10" />
60+
<field name="membership_expiration_batch" type="text" default="500" label="COM_GROUPS_CONFIG_MEMBERSHIP_BATCH" description="COM_GROUPS_CONFIG_MEMBERSHIP_BATCH_DESC" size="10" />
5361
</fieldset>
5462

5563
<fieldset name="email" label="COM_GROUPS_CONFIG_FIELDSET_EMAIL">
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
/**
3+
* @package hubzero-cms
4+
* @copyright Copyright (c) 2005-2026 The Regents of the University of California.
5+
* @license http://opensource.org/licenses/MIT MIT
6+
*/
7+
8+
use Hubzero\Content\Migration\Base;
9+
10+
// No direct access
11+
defined('_HZEXEC_') or die();
12+
13+
/**
14+
* Migration script for optional time-limited group membership
15+
*
16+
* Adds the term columns to the membership table and an archive of lapsed
17+
* memberships. Every existing row keeps `expires` NULL, which means
18+
* "never expires" - the behaviour groups have today.
19+
**/
20+
class Migration20260810000000ComGroups extends Base
21+
{
22+
/**
23+
* Is an index present on a table?
24+
*
25+
* The driver exposes tableExists()/tableHasField() but no key equivalent,
26+
* so this goes through getTableKeys(), which returns the SHOW KEYS rows
27+
* indexed by Key_name.
28+
*
29+
* @param string $table
30+
* @param string $key
31+
* @return boolean
32+
**/
33+
protected function hasKey($table, $key)
34+
{
35+
$keys = $this->db->getTableKeys($table);
36+
37+
return is_array($keys) && array_key_exists($key, $keys);
38+
}
39+
40+
/**
41+
* Up
42+
**/
43+
public function up()
44+
{
45+
if ($this->db->tableExists('#__xgroups_members'))
46+
{
47+
if (!$this->db->tableHasField('#__xgroups_members', 'expires'))
48+
{
49+
$query = "ALTER TABLE `#__xgroups_members`
50+
ADD COLUMN `expires` datetime DEFAULT NULL AFTER `uidNumber`";
51+
$this->db->setQuery($query);
52+
$this->db->query();
53+
}
54+
55+
if (!$this->db->tableHasField('#__xgroups_members', 'expires_set_by'))
56+
{
57+
$query = "ALTER TABLE `#__xgroups_members`
58+
ADD COLUMN `expires_set_by` int(11) DEFAULT NULL AFTER `expires`";
59+
$this->db->setQuery($query);
60+
$this->db->query();
61+
}
62+
63+
if (!$this->db->tableHasField('#__xgroups_members', 'expires_notified'))
64+
{
65+
$query = "ALTER TABLE `#__xgroups_members`
66+
ADD COLUMN `expires_notified` datetime DEFAULT NULL AFTER `expires_set_by`";
67+
$this->db->setQuery($query);
68+
$this->db->query();
69+
}
70+
71+
if (!$this->hasKey('#__xgroups_members', 'idx_expires'))
72+
{
73+
$query = "ALTER TABLE `#__xgroups_members` ADD KEY `idx_expires` (`expires`)";
74+
$this->db->setQuery($query);
75+
$this->db->query();
76+
}
77+
}
78+
79+
// The reaper's heartbeat lives in the group log, which is queried by
80+
// action on every admin membership page view and pruned on every run.
81+
// That table carries only a primary key, so both are full scans
82+
// without this - measurably ~14ms per view on a 200k-row log.
83+
if ($this->db->tableExists('#__xgroups_log')
84+
&& !$this->hasKey('#__xgroups_log', 'idx_action_timestamp'))
85+
{
86+
$query = "ALTER TABLE `#__xgroups_log` ADD KEY `idx_action_timestamp` (`action`,`timestamp`)";
87+
$this->db->setQuery($query);
88+
$this->db->query();
89+
}
90+
91+
if (!$this->db->tableExists('#__xgroups_member_history'))
92+
{
93+
$query = "CREATE TABLE `#__xgroups_member_history` (
94+
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
95+
`gidNumber` int(11) NOT NULL,
96+
`uidNumber` int(11) NOT NULL,
97+
`expires` datetime DEFAULT NULL,
98+
`revoked` datetime DEFAULT NULL,
99+
`reason` varchar(32) DEFAULT NULL,
100+
`was_manager` tinyint(1) DEFAULT 0,
101+
`actor` int(11) DEFAULT NULL,
102+
PRIMARY KEY (`id`),
103+
KEY `idx_gidNumber_uidNumber` (`gidNumber`,`uidNumber`),
104+
KEY `idx_revoked` (`revoked`)
105+
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;";
106+
107+
$this->db->setQuery($query);
108+
$this->db->query();
109+
}
110+
}
111+
112+
/**
113+
* Down
114+
**/
115+
public function down()
116+
{
117+
if ($this->db->tableExists('#__xgroups_members'))
118+
{
119+
if ($this->hasKey('#__xgroups_members', 'idx_expires'))
120+
{
121+
$query = "ALTER TABLE `#__xgroups_members` DROP KEY `idx_expires`";
122+
$this->db->setQuery($query);
123+
$this->db->query();
124+
}
125+
126+
foreach (array('expires_notified', 'expires_set_by', 'expires') as $field)
127+
{
128+
if ($this->db->tableHasField('#__xgroups_members', $field))
129+
{
130+
$query = "ALTER TABLE `#__xgroups_members` DROP COLUMN `$field`";
131+
$this->db->setQuery($query);
132+
$this->db->query();
133+
}
134+
}
135+
}
136+
137+
if ($this->db->tableExists('#__xgroups_log')
138+
&& $this->hasKey('#__xgroups_log', 'idx_action_timestamp'))
139+
{
140+
$query = "ALTER TABLE `#__xgroups_log` DROP KEY `idx_action_timestamp`";
141+
$this->db->setQuery($query);
142+
$this->db->query();
143+
}
144+
145+
if ($this->db->tableExists('#__xgroups_member_history'))
146+
{
147+
$query = "DROP TABLE IF EXISTS `#__xgroups_member_history`;";
148+
$this->db->setQuery($query);
149+
$this->db->query();
150+
}
151+
}
152+
}

core/components/com_groups/models/member/role.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ public static function destroyByUser($user_id)
101101
return true;
102102
}
103103

104+
/**
105+
* Remove a user's role assignments within a single group
106+
*
107+
* `#__xgroups_member_roles` carries no gidNumber, so the group is resolved
108+
* through the roles it owns. destroyByUser() drops the user's assignments
109+
* in *every* group, which is only correct when the account itself is going
110+
* away; leaving one group should not cost the user their roles in others.
111+
*
112+
* @param integer $user_id
113+
* @param integer $gidNumber
114+
* @return boolean False if error, True on success
115+
*/
116+
public static function destroyByUserAndGroup($user_id, $gidNumber)
117+
{
118+
return \Hubzero\User\Group\Membership::clearRoles($gidNumber, $user_id);
119+
}
120+
104121
/**
105122
* Remove records by role ID
106123
*

core/components/com_groups/site/controllers/membership.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,7 +823,7 @@ public function cancelTask()
823823
// delete member roles
824824
require_once dirname(dirname(__DIR__)) . DS . 'models' . DS . 'member' . DS . 'role.php';
825825

826-
\Components\Groups\Models\Member\Role::destroyByUser(User::get('id'));
826+
\Components\Groups\Models\Member\Role::destroyByUserAndGroup(User::get('id'), $this->view->group->get('gidNumber'));
827827

828828
// Log the membership cancellation
829829
Log::log(array(

0 commit comments

Comments
 (0)