User group memberships that expire
authorThis, that and the other <at.light@live.com.au>
Thu, 12 Jan 2017 06:07:56 +0000 (17:07 +1100)
committerTTO <at.light@live.com.au>
Fri, 27 Jan 2017 09:24:20 +0000 (09:24 +0000)
This patch adds an ug_expiry column to the user_groups table, a timestamp
giving a date when the user group expires. A new UserGroupMembership class,
based on the Block class, manages entries in this table.

When the expiry date passes, the row in user_groups is ignored, and will
eventually be purged from the DB when UserGroupMembership::insert is next
called. Old, expired user group memberships are not kept; instead, the log
entries are available to find the history of these memberships, similar
to the way it has always worked for blocks and protections.

Anyone getting user group info through the User object will get correct
information. However, code that reads the user_groups table directly will
now need to skip over rows with ug_expiry < wfTimestampNow(). See
UsersPager for an example of how to do this.

NULL is used to represent infinite (no) expiry, rather than a string
'infinity' or similar (except in the API). This allows existing user group
assignments and log entries, which are all infinite in duration, to be
treated the same as new, infinite-length memberships, without special
casing everything.

The whole thing is behind the temporary feature flag
$wgDisableUserGroupExpiry, in accordance with the WMF schema change policy.

The opportunity has been taken to refactor some static user-group-related
functions out of User into UserGroupMembership, and also to add a primary
key (ug_user, ug_group) to the user_groups table.

There are a few breaking changes:
- UserRightsProxy-like objects are now required to have a
  getGroupMemberships() function.
- $user->mGroups (on a User object) is no longer present.
- Some protected functions in UsersPager are altered or removed.
- The UsersPagerDoBatchLookups hook (unused in any Wikimedia Git-hosted
  extension) has a change of parameter.

Bug: T12493
Depends-On: Ia9616e1e35184fed9058d2d39afbe1038f56d7fa
Depends-On: I86eb1d5619347ce54a5f33a591417742ebe5d6f8
Change-Id: I93c955dc7a970f78e32aa503c01c67da30971d1a

43 files changed:
RELEASE-NOTES-1.29
autoload.php
docs/hooks.txt
includes/DefaultSettings.php
includes/Preferences.php
includes/Title.php
includes/api/ApiQueryUserInfo.php
includes/api/ApiQueryUsers.php
includes/api/ApiUserrights.php
includes/api/i18n/en.json
includes/api/i18n/qqq.json
includes/exception/PermissionsError.php
includes/installer/MysqlUpdater.php
includes/installer/OracleUpdater.php
includes/installer/PostgresUpdater.php
includes/installer/SqliteUpdater.php
includes/logging/RightsLogFormatter.php
includes/specials/SpecialActiveusers.php
includes/specials/SpecialListgrouprights.php
includes/specials/SpecialUserrights.php
includes/specials/pagers/ActiveUsersPager.php
includes/specials/pagers/UsersPager.php
includes/user/User.php
includes/user/UserGroupMembership.php [new file with mode: 0644]
includes/user/UserRightsProxy.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/archives/patch-user_groups-ug_expiry.sql [new file with mode: 0644]
maintenance/mssql/archives/patch-user_groups-ug_expiry.sql [new file with mode: 0644]
maintenance/mssql/tables.sql
maintenance/oracle/archives/patch-user_groups-ug_expiry.sql [new file with mode: 0644]
maintenance/oracle/tables.sql
maintenance/postgres/tables.sql
maintenance/sqlite/archives/patch-user_groups-ug_expiry.sql [new file with mode: 0644]
maintenance/tables.sql
resources/Resources.php
resources/src/mediawiki.special/mediawiki.special.userrights.css [new file with mode: 0644]
resources/src/mediawiki.special/mediawiki.special.userrights.js
tests/phpunit/includes/logging/RightsLogFormatterTest.php
tests/phpunit/includes/page/WikiPageTest.php
tests/phpunit/includes/password/UserPasswordPolicyTest.php
tests/phpunit/includes/user/UserGroupMembershipTest.php [new file with mode: 0644]
tests/phpunit/includes/user/UserTest.php

index 3bf50ac..0874513 100644 (file)
@@ -28,6 +28,9 @@ production.
   $wgNamespacesWithSubpages[NS_TEMPLATE] to false to keep the old behavior.
 * $wgRunJobsAsync is now false by default (T142751). This change only affects
   wikis with $wgJobRunRate > 0.
+* A temporary feature flag, $wgDisableUserGroupExpiry, is provided to disable
+  new features that rely on the schema changes to the user_groups table. This
+  feature flag will likely be removed before 1.29 is released.
 
 === New features in 1.29 ===
 * (T5233) A cookie can now be set when a user is autoblocked, to track that user
@@ -48,6 +51,7 @@ production.
   browsers had poor support for them, but modern browsers handle them fine.
   This might affect some forms that used them and only worked because the
   attributes were not actually being set.
+* Expiry times can now be specified when users are added to user groups.
 
 === External library changes in 1.29 ===
 
@@ -208,6 +212,20 @@ changes to languages because of Phabricator reports.
 * ContentHandler::runLegacyHooks() was removed.
 * refreshLinks.php now can be limited to a particular category with --category=...
   or a tracking category with --tracking-category=...
+* User-like objects that are passed to SpecialUserRights and its subclasses are
+  now required to have a getGroupMemberships() method. See UserRightsProxy for
+  an example.
+* User::$mGroups (instance variable) was marked private. Use User::getGroups()
+  instead.
+* User::getGroupName(), User::getGroupMember(), User:getGroupPage(),
+  User::makeGroupLinkHTML(), and User::makeGroupLinkWiki() were deprecated.
+  Use equivalent methods on the UserGroupMembership class.
+* Maintenance scripts and tests that call User::addGroup() must now ensure that
+  User objects have been added to the database prior to calling addGroup().
+* Protected function UsersPager::getGroups() was removed, and protected function
+  UsersPager::buildGroupLink() was changed from a static to an instance method.
+* The third parameter ($cache) to the UsersPagerDoBatchLookups hook was changed;
+  see docs/hooks.txt.
 
 == Compatibility ==
 
index 329cdb3..2bc110c 100644 (file)
@@ -1524,6 +1524,7 @@ $wgAutoloadLocalClasses = [
        'UserBlockedError' => __DIR__ . '/includes/exception/UserBlockedError.php',
        'UserCache' => __DIR__ . '/includes/cache/UserCache.php',
        'UserDupes' => __DIR__ . '/maintenance/userDupes.inc',
+       'UserGroupMembership' => __DIR__ . '/includes/user/UserGroupMembership.php',
        'UserMailer' => __DIR__ . '/includes/mail/UserMailer.php',
        'UserNamePrefixSearch' => __DIR__ . '/includes/user/UserNamePrefixSearch.php',
        'UserNotLoggedIn' => __DIR__ . '/includes/exception/UserNotLoggedIn.php',
index 803c20d..ca751ab 100644 (file)
@@ -3452,10 +3452,12 @@ temporary password
 &$ip: IP of the user who sent the message out
 &$u: the account whose new password will be set
 
-'UserAddGroup': Called when adding a group; return false to override
-stock group addition.
+'UserAddGroup': Called when adding a group or changing a group's expiry; return
+false to override stock group addition.
 $user: the user object that is to have a group added
-&$group: the group to add, can be modified
+&$group: the group to add; can be modified
+&$expiry: the expiry time in TS_MW format, or null if the group is not to
+expire; can be modified
 
 'UserArrayFromResult': Called when creating an UserArray object from a database
 result.
@@ -3698,7 +3700,8 @@ their data into the cache array so that things like global user groups are
 displayed correctly in Special:ListUsers.
 $dbr: Read-only database handle
 $userIds: Array of user IDs whose groups we should look up
-&$cache: Array of user ID -> internal user group name (e.g. 'sysop') mappings
+&$cache: Array of user ID -> (array of internal group name (e.g. 'sysop') ->
+UserGroupMembership object)
 &$groups: Array of group name -> bool true mappings for members of a given user
 group
 
index 58ddb69..a7cbd96 100644 (file)
@@ -5883,6 +5883,15 @@ $wgBotPasswordsCluster = false;
  */
 $wgBotPasswordsDatabase = false;
 
+/**
+ * Whether to disable user group expiry. This is a transitional feature flag
+ * in accordance with WMF schema change policy, and will be removed later
+ * (hopefully before MW 1.29 release).
+ *
+ * @since 1.29
+ */
+$wgDisableUserGroupExpiry = false;
+
 /** @} */ # end of user rights settings
 
 /************************************************************************//**
index a5e9d77..6d15c1e 100644 (file)
@@ -222,24 +222,48 @@ class Preferences {
                        'section' => 'personal/info',
                ];
 
+               $lang = $context->getLanguage();
+
                # Get groups to which the user belongs
                $userEffectiveGroups = $user->getEffectiveGroups();
-               $userGroups = $userMembers = [];
+               $userGroupMemberships = $user->getGroupMemberships();
+               $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
                foreach ( $userEffectiveGroups as $ueg ) {
                        if ( $ueg == '*' ) {
                                // Skip the default * group, seems useless here
                                continue;
                        }
-                       $groupName = User::getGroupName( $ueg );
-                       $userGroups[] = User::makeGroupLinkHTML( $ueg, $groupName );
 
-                       $memberName = User::getGroupMember( $ueg, $userName );
-                       $userMembers[] = User::makeGroupLinkHTML( $ueg, $memberName );
-               }
-               asort( $userGroups );
-               asort( $userMembers );
+                       if ( isset( $userGroupMemberships[$ueg] ) ) {
+                               $groupStringOrObject = $userGroupMemberships[$ueg];
+                       } else {
+                               $groupStringOrObject = $ueg;
+                       }
 
-               $lang = $context->getLanguage();
+                       $userG = UserGroupMembership::getLink( $groupStringOrObject, $context, 'html' );
+                       $userM = UserGroupMembership::getLink( $groupStringOrObject, $context, 'html',
+                               $userName );
+
+                       // Store expiring groups separately, so we can place them before non-expiring
+                       // groups in the list. This is to avoid the ambiguity of something like
+                       // "administrator, bureaucrat (until X date)" -- users might wonder whether the
+                       // expiry date applies to both groups, or just the last one
+                       if ( $groupStringOrObject instanceof UserGroupMembership &&
+                               $groupStringOrObject->getExpiry()
+                       ) {
+                               $userTempGroups[] = $userG;
+                               $userTempMembers[] = $userM;
+                       } else {
+                               $userGroups[] = $userG;
+                               $userMembers[] = $userM;
+                       }
+               }
+               sort( $userGroups );
+               sort( $userMembers );
+               sort( $userTempGroups );
+               sort( $userTempMembers );
+               $userGroups = array_merge( $userTempGroups, $userGroups );
+               $userMembers = array_merge( $userTempMembers, $userMembers );
 
                $defaultPreferences['usergroups'] = [
                        'type' => 'info',
index 5cf911f..3ce775b 100644 (file)
@@ -2417,7 +2417,7 @@ class Title implements LinkTarget {
         *
         * @param string $action The action to check
         * @param bool $short Short circuit on first error
-        * @return array List of errors
+        * @return array Array containing an error message key and any parameters
         */
        private function missingPermissionError( $action, $short ) {
                // We avoid expensive display logic for quickUserCan's and such
@@ -2425,19 +2425,7 @@ class Title implements LinkTarget {
                        return [ 'badaccess-group0' ];
                }
 
-               $groups = array_map( [ 'User', 'makeGroupLinkWiki' ],
-                       User::getGroupsWithPermission( $action ) );
-
-               if ( count( $groups ) ) {
-                       global $wgLang;
-                       return [
-                               'badaccess-groups',
-                               $wgLang->commaList( $groups ),
-                               count( $groups )
-                       ];
-               } else {
-                       return [ 'badaccess-group0' ];
-               }
+               return User::newFatalPermissionDeniedStatus( $action )->getErrorsArray()[0];
        }
 
        /**
index 7bc00cb..04b0fac 100644 (file)
@@ -143,6 +143,19 @@ class ApiQueryUserInfo extends ApiQueryBase {
                        ApiResult::setIndexedTagName( $vals['groups'], 'g' ); // even if empty
                }
 
+               if ( isset( $this->prop['groupmemberships'] ) ) {
+                       $ugms = $user->getGroupMemberships();
+                       $vals['groupmemberships'] = [];
+                       foreach ( $ugms as $group => $ugm ) {
+                               $vals['groupmemberships'][] = [
+                                       'group' => $group,
+                                       'expiry' => ApiResult::formatExpiry( $ugm->getExpiry() ),
+                               ];
+                       }
+                       ApiResult::setArrayType( $vals['groupmemberships'], 'array' ); // even if empty
+                       ApiResult::setIndexedTagName( $vals['groupmemberships'], 'groupmembership' ); // even if empty
+               }
+
                if ( isset( $this->prop['implicitgroups'] ) ) {
                        $vals['implicitgroups'] = $user->getAutomaticGroups();
                        ApiResult::setArrayType( $vals['implicitgroups'], 'array' ); // even if empty
@@ -302,6 +315,7 @@ class ApiQueryUserInfo extends ApiQueryBase {
                                        'blockinfo',
                                        'hasmsg',
                                        'groups',
+                                       'groupmemberships',
                                        'implicitgroups',
                                        'rights',
                                        'changeablegroups',
index 2d620a4..609f90d 100644 (file)
@@ -42,6 +42,7 @@ class ApiQueryUsers extends ApiQueryBase {
                // everything except 'blockinfo' which might show hidden records if the user
                // making the request has the appropriate permissions
                'groups',
+               'groupmemberships',
                'implicitgroups',
                'rights',
                'editcount',
@@ -207,6 +208,15 @@ class ApiQueryUsers extends ApiQueryBase {
                                        $data[$key]['groups'] = $user->getEffectiveGroups();
                                }
 
+                               if ( isset( $this->prop['groupmemberships'] ) ) {
+                                       $data[$key]['groupmemberships'] = array_map( function( $ugm ) {
+                                               return [
+                                                       'group' => $ugm->getGroup(),
+                                                       'expiry' => ApiResult::formatExpiry( $ugm->getExpiry() ),
+                                               ];
+                                       }, $user->getGroupMemberships() );
+                               }
+
                                if ( isset( $this->prop['implicitgroups'] ) ) {
                                        $data[$key]['implicitgroups'] = $user->getAutomaticGroups();
                                }
@@ -303,6 +313,10 @@ class ApiQueryUsers extends ApiQueryBase {
                                        ApiResult::setArrayType( $data[$u]['groups'], 'array' );
                                        ApiResult::setIndexedTagName( $data[$u]['groups'], 'g' );
                                }
+                               if ( isset( $this->prop['groupmemberships'] ) && isset( $data[$u]['groupmemberships'] ) ) {
+                                       ApiResult::setArrayType( $data[$u]['groupmemberships'], 'array' );
+                                       ApiResult::setIndexedTagName( $data[$u]['groupmemberships'], 'groupmembership' );
+                               }
                                if ( isset( $this->prop['implicitgroups'] ) && isset( $data[$u]['implicitgroups'] ) ) {
                                        ApiResult::setArrayType( $data[$u]['implicitgroups'], 'array' );
                                        ApiResult::setIndexedTagName( $data[$u]['implicitgroups'], 'g' );
@@ -347,6 +361,7 @@ class ApiQueryUsers extends ApiQueryBase {
                                ApiBase::PARAM_TYPE => [
                                        'blockinfo',
                                        'groups',
+                                       'groupmemberships',
                                        'implicitgroups',
                                        'rights',
                                        'editcount',
index 4ef974c..262f072 100644 (file)
@@ -1,9 +1,7 @@
 <?php
 
 /**
- *
- *
- * Created on Mar 24, 2009
+ * API userrights module
  *
  * Copyright Â© 2009 Roan Kattouw "<Firstname>.<Lastname>@gmail.com"
  *
@@ -59,6 +57,41 @@ class ApiUserrights extends ApiBase {
 
                $params = $this->extractRequestParams();
 
+               // Figure out expiry times from the input
+               // @todo Remove this isset check when removing $wgDisableUserGroupExpiry
+               if ( isset( $params['expiry'] ) ) {
+                       $expiry = (array)$params['expiry'];
+               } else {
+                       $expiry = [ 'infinity' ];
+               }
+               if ( count( $expiry ) !== count( $params['add'] ) ) {
+                       if ( count( $expiry ) === 1 ) {
+                               $expiry = array_fill( 0, count( $params['add'] ), $expiry[0] );
+                       } else {
+                               $this->dieWithError( [
+                                       'apierror-toofewexpiries',
+                                       count( $expiry ),
+                                       count( $params['add'] )
+                               ] );
+                       }
+               }
+
+               // Validate the expiries
+               $groupExpiries = [];
+               foreach ( $expiry as $index => $expiryValue ) {
+                       $group = $params['add'][$index];
+                       $groupExpiries[$group] = UserrightsPage::expiryToTimestamp( $expiryValue );
+
+                       if ( $groupExpiries[$group] === false ) {
+                               $this->dieWithError( [ 'apierror-invalidexpiry', wfEscapeWikiText( $expiryValue ) ] );
+                       }
+
+                       // not allowed to have things expiring in the past
+                       if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
+                               $this->dieWithError( [ 'apierror-pastexpiry', wfEscapeWikiText( $expiryValue ) ] );
+                       }
+               }
+
                $user = $this->getUrUser( $params );
 
                $tags = $params['tags'];
@@ -76,8 +109,8 @@ class ApiUserrights extends ApiBase {
                $r['user'] = $user->getName();
                $r['userid'] = $user->getId();
                list( $r['added'], $r['removed'] ) = $form->doSaveUserGroups(
-                       $user, (array)$params['add'],
-                       (array)$params['remove'], $params['reason'], $tags
+                       $user, (array)$params['add'], (array)$params['remove'],
+                       $params['reason'], $tags, $groupExpiries
                );
 
                $result = $this->getResult();
@@ -120,7 +153,7 @@ class ApiUserrights extends ApiBase {
        }
 
        public function getAllowedParams() {
-               return [
+               $a = [
                        'user' => [
                                ApiBase::PARAM_TYPE => 'user',
                        ],
@@ -131,6 +164,11 @@ class ApiUserrights extends ApiBase {
                                ApiBase::PARAM_TYPE => $this->getAllGroups(),
                                ApiBase::PARAM_ISMULTI => true
                        ],
+                       'expiry' => [
+                               ApiBase::PARAM_ISMULTI => true,
+                               ApiBase::PARAM_ALLOW_DUPLICATES => true,
+                               ApiBase::PARAM_DFLT => 'infinite',
+                       ],
                        'remove' => [
                                ApiBase::PARAM_TYPE => $this->getAllGroups(),
                                ApiBase::PARAM_ISMULTI => true
@@ -147,6 +185,10 @@ class ApiUserrights extends ApiBase {
                                ApiBase::PARAM_ISMULTI => true
                        ],
                ];
+               if ( !$this->getUserRightsPage()->canProcessExpiries() ) {
+                       unset( $a['expiry'] );
+               }
+               return $a;
        }
 
        public function needsToken() {
@@ -158,12 +200,17 @@ class ApiUserrights extends ApiBase {
        }
 
        protected function getExamplesMessages() {
-               return [
+               $a = [
                        'action=userrights&user=FooBot&add=bot&remove=sysop|bureaucrat&token=123ABC'
                                => 'apihelp-userrights-example-user',
                        'action=userrights&userid=123&add=bot&remove=sysop|bureaucrat&token=123ABC'
                                => 'apihelp-userrights-example-userid',
                ];
+               if ( $this->getUserRightsPage()->canProcessExpiries() ) {
+                       $a['action=userrights&user=SometimeSysop&add=sysop&expiry=1%20month&token=123ABC']
+                               = 'apihelp-userrights-example-expiry';
+               }
+               return $a;
        }
 
        public function getHelpUrls() {
index c7fea8b..f04befa 100644 (file)
        "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Tags if the current user is blocked, by whom, and for what reason.",
        "apihelp-query+userinfo-paramvalue-prop-hasmsg": "Adds a tag <samp>messages</samp> if the current user has pending messages.",
        "apihelp-query+userinfo-paramvalue-prop-groups": "Lists all the groups the current user belongs to.",
+       "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "Lists groups that the current user has been explicitly assigned to, including the expiry date of each group membership.",
        "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "Lists all the groups the current user is automatically a member of.",
        "apihelp-query+userinfo-paramvalue-prop-rights": "Lists all the rights the current user has.",
        "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "Lists the groups the current user can add to and remove from.",
        "apihelp-query+users-param-prop": "Which pieces of information to include:",
        "apihelp-query+users-paramvalue-prop-blockinfo": "Tags if the user is blocked, by whom, and for what reason.",
        "apihelp-query+users-paramvalue-prop-groups": "Lists all the groups each user belongs to.",
+       "apihelp-query+users-paramvalue-prop-groupmemberships": "Lists groups that each user has been explicitly assigned to, including the expiry date of each group membership.",
        "apihelp-query+users-paramvalue-prop-implicitgroups": "Lists all the groups a user is automatically a member of.",
        "apihelp-query+users-paramvalue-prop-rights": "Lists all the rights each user has.",
        "apihelp-query+users-paramvalue-prop-editcount": "Adds the user's edit count.",
        "apihelp-userrights-description": "Change a user's group membership.",
        "apihelp-userrights-param-user": "User name.",
        "apihelp-userrights-param-userid": "User ID.",
-       "apihelp-userrights-param-add": "Add the user to these groups.",
+       "apihelp-userrights-param-add": "Add the user to these groups, or if they are already a member, update the expiry of their membership in that group.",
+       "apihelp-userrights-param-expiry": "Expiry timestamps. May be relative (e.g. <kbd>5 months</kbd> or <kbd>2 weeks</kbd>) or absolute (e.g. <kbd>2014-09-18T12:34:56Z</kbd>). If only one timestamp is set, it will be used for all groups passed to the <var>$1add</var> parameter. Use <kbd>infinite</kbd>, <kbd>indefinite</kbd>, <kbd>infinity</kbd>, or <kbd>never</kbd> for a never-expiring user group.",
        "apihelp-userrights-param-remove": "Remove the user from these groups.",
        "apihelp-userrights-param-reason": "Reason for the change.",
        "apihelp-userrights-param-tags": "Change tags to apply to the entry in the user rights log.",
        "apihelp-userrights-example-user": "Add user <kbd>FooBot</kbd> to group <kbd>bot</kbd>, and remove from groups <kbd>sysop</kbd> and <kbd>bureaucrat</kbd>.",
        "apihelp-userrights-example-userid": "Add the user with ID <kbd>123</kbd> to group <kbd>bot</kbd>, and remove from groups <kbd>sysop</kbd> and <kbd>bureaucrat</kbd>.",
+       "apihelp-userrights-example-expiry": "Add user <kbd>SometimeSysop</kbd> to group <kbd>sysop</kbd> for 1 month.",
 
        "apihelp-validatepassword-description": "Validate a password against the wiki's password policies.\n\nValidity is reported as <samp>Good</samp> if the password is acceptable, <samp>Change</samp> if the password may be used for login but must be changed, or <samp>Invalid</samp> if the password is not usable.",
        "apihelp-validatepassword-param-password": "Password to validate.",
index e6ee8d4..86a5c91 100644 (file)
        "apihelp-query+userinfo-paramvalue-prop-blockinfo": "{{doc-apihelp-paramvalue|query+userinfo|prop|blockinfo}}",
        "apihelp-query+userinfo-paramvalue-prop-hasmsg": "{{doc-apihelp-paramvalue|query+userinfo|prop|hasmsg}}",
        "apihelp-query+userinfo-paramvalue-prop-groups": "{{doc-apihelp-paramvalue|query+userinfo|prop|groups}}",
+       "apihelp-query+userinfo-paramvalue-prop-groupmemberships": "{{doc-apihelp-paramvalue|query+userinfo|prop|groupmemberships}}",
        "apihelp-query+userinfo-paramvalue-prop-implicitgroups": "{{doc-apihelp-paramvalue|query+userinfo|prop|implicitgroups}}",
        "apihelp-query+userinfo-paramvalue-prop-rights": "{{doc-apihelp-paramvalue|query+userinfo|prop|rights}}",
        "apihelp-query+userinfo-paramvalue-prop-changeablegroups": "{{doc-apihelp-paramvalue|query+userinfo|prop|changeablegroups}}",
        "apihelp-query+users-param-prop": "{{doc-apihelp-param|query+users|prop|paramvalues=1}}",
        "apihelp-query+users-paramvalue-prop-blockinfo": "{{doc-apihelp-paramvalue|query+users|prop|blockinfo}}",
        "apihelp-query+users-paramvalue-prop-groups": "{{doc-apihelp-paramvalue|query+users|prop|groups}}",
+       "apihelp-query+users-paramvalue-prop-groupmemberships": "{{doc-apihelp-paramvalue|query+allusers|prop|groupmemberships}}",
        "apihelp-query+users-paramvalue-prop-implicitgroups": "{{doc-apihelp-paramvalue|query+users|prop|implicitgroups}}",
        "apihelp-query+users-paramvalue-prop-rights": "{{doc-apihelp-paramvalue|query+users|prop|rights}}",
        "apihelp-query+users-paramvalue-prop-editcount": "{{doc-apihelp-paramvalue|query+users|prop|editcount}}",
        "apihelp-userrights-param-user": "{{doc-apihelp-param|userrights|user}}\n{{Identical|Username}}",
        "apihelp-userrights-param-userid": "{{doc-apihelp-param|userrights|userid}}\n{{Identical|User ID}}",
        "apihelp-userrights-param-add": "{{doc-apihelp-param|userrights|add}}",
+       "apihelp-userrights-param-expiry": "{{doc-apihelp-param|userrights|expiry}}",
        "apihelp-userrights-param-remove": "{{doc-apihelp-param|userrights|remove}}",
        "apihelp-userrights-param-reason": "{{doc-apihelp-param|userrights|reason}}",
        "apihelp-userrights-param-tags": "{{doc-apihelp-param|userrights|tags}}",
        "apihelp-userrights-example-user": "{{doc-apihelp-example|userrights}}",
        "apihelp-userrights-example-userid": "{{doc-apihelp-example|userrights}}",
+       "apihelp-userrights-example-expiry": "{{doc-apihelp-example|userrights}}",
        "apihelp-validatepassword-description": "{{doc-apihelp-description|validatepassword}}",
        "apihelp-validatepassword-param-password": "{{doc-apihelp-param|validatepassword|password}}",
        "apihelp-validatepassword-param-user": "{{doc-apihelp-param|validatepassword|user}}",
index 5ecd237..cc69a76 100644 (file)
@@ -45,10 +45,10 @@ class PermissionsError extends ErrorPageError {
                $this->permission = $permission;
 
                if ( !count( $errors ) ) {
-                       $groups = array_map(
-                               [ 'User', 'makeGroupLinkWiki' ],
-                               User::getGroupsWithPermission( $this->permission )
-                       );
+                       $groups = [];
+                       foreach ( User::getGroupsWithPermission( $this->permission ) as $group ) {
+                               $groups[] = UserGroupMembership::getLink( $group, RequestContext::getMain(), 'wiki' );
+                       }
 
                        if ( $groups ) {
                                $errors[] = [ 'badaccess-groups', $wgLang->commaList( $groups ), count( $groups ) ];
index d95222c..7a27f5f 100644 (file)
@@ -294,6 +294,7 @@ class MysqlUpdater extends DatabaseUpdater {
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+                       [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
                ];
        }
 
index 1f0e411..79ae175 100644 (file)
@@ -121,6 +121,7 @@ class OracleUpdater extends DatabaseUpdater {
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+                       [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
 
                        // KEEP THIS AT THE BOTTOM!!
                        [ 'doRebuildDuplicateFunction' ],
index 1eb3f41..e041fdd 100644 (file)
@@ -448,6 +448,8 @@ class PostgresUpdater extends DatabaseUpdater {
                        [ 'addPgField', 'externallinks', 'el_index_60', "BYTEA NOT NULL DEFAULT ''" ],
                        [ 'addPgIndex', 'externallinks', 'el_index_60', '( el_index_60, el_id )' ],
                        [ 'addPgIndex', 'externallinks', 'el_from_index_60', '( el_from, el_index_60, el_id )' ],
+                       [ 'addPgField', 'user_groups', 'ug_expiry', "TIMESTAMPTZ NULL" ],
+                       [ 'addPgIndex', 'user_groups', 'user_groups_expiry', '( ug_expiry )' ],
                ];
        }
 
index 32068e6..cdbbfd0 100644 (file)
@@ -161,6 +161,7 @@ class SqliteUpdater extends DatabaseUpdater {
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+                       [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
                ];
        }
 
index be73c86..791330c 100644 (file)
@@ -70,7 +70,7 @@ class RightsLogFormatter extends LogFormatter {
        protected function getMessageParameters() {
                $params = parent::getMessageParameters();
 
-               // Really old entries
+               // Really old entries that lack old/new groups
                if ( !isset( $params[3] ) && !isset( $params[4] ) ) {
                        return $params;
                }
@@ -81,25 +81,29 @@ class RightsLogFormatter extends LogFormatter {
                $userName = $this->entry->getTarget()->getText();
                if ( !$this->plaintext && count( $oldGroups ) ) {
                        foreach ( $oldGroups as &$group ) {
-                               $group = User::getGroupMember( $group, $userName );
+                               $group = UserGroupMembership::getGroupMemberName( $group, $userName );
                        }
                }
                if ( !$this->plaintext && count( $newGroups ) ) {
                        foreach ( $newGroups as &$group ) {
-                               $group = User::getGroupMember( $group, $userName );
+                               $group = UserGroupMembership::getGroupMemberName( $group, $userName );
                        }
                }
 
-               $lang = $this->context->getLanguage();
+               // fetch the metadata about each group membership
+               $allParams = $this->entry->getParameters();
+
                if ( count( $oldGroups ) ) {
-                       $params[3] = $lang->listToText( $oldGroups );
+                       $params[3] = [ 'raw' => $this->formatRightsList( $oldGroups,
+                               isset( $allParams['oldmetadata'] ) ? $allParams['oldmetadata'] : [] ) ];
                } else {
                        $params[3] = $this->msg( 'rightsnone' )->text();
                }
                if ( count( $newGroups ) ) {
                        // Array_values is used here because of T44211
                        // see use of array_unique in UserrightsPage::doSaveUserGroups on $newGroups.
-                       $params[4] = $lang->listToText( array_values( $newGroups ) );
+                       $params[4] = [ 'raw' => $this->formatRightsList( array_values( $newGroups ),
+                               isset( $allParams['newmetadata'] ) ? $allParams['newmetadata'] : [] ) ];
                } else {
                        $params[4] = $this->msg( 'rightsnone' )->text();
                }
@@ -109,6 +113,42 @@ class RightsLogFormatter extends LogFormatter {
                return $params;
        }
 
+       protected function formatRightsList( $groups, $serializedUGMs = [] ) {
+               $uiLanguage = $this->context->getLanguage();
+               $uiUser = $this->context->getUser();
+               // separate arrays of temporary and permanent memberships
+               $tempList = $permList = [];
+
+               reset( $groups );
+               reset( $serializedUGMs );
+               while ( current( $groups ) ) {
+                       $group = current( $groups );
+
+                       if ( current( $serializedUGMs ) &&
+                               isset( current( $serializedUGMs )['expiry'] ) &&
+                               current( $serializedUGMs )['expiry']
+                       ) {
+                               // there is an expiry date; format the group and expiry into a friendly string
+                               $expiry = current( $serializedUGMs )['expiry'];
+                               $expiryFormatted = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
+                               $expiryFormattedD = $uiLanguage->userDate( $expiry, $uiUser );
+                               $expiryFormattedT = $uiLanguage->userTime( $expiry, $uiUser );
+                               $tempList[] = $this->msg( 'rightslogentry-temporary-group' )->params( $group,
+                                       $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->parse();
+                       } else {
+                               // the right does not expire; just insert the group name
+                               $permList[] = $group;
+                       }
+
+                       next( $groups );
+                       next( $serializedUGMs );
+               }
+
+               // place all temporary memberships first, to avoid the ambiguity of
+               // "adinistrator, bureaucrat and importer (temporary, until X time)"
+               return $uiLanguage->listToText( array_merge( $tempList, $permList ) );
+       }
+
        protected function getParametersForApi() {
                $entry = $this->entry;
                $params = $entry->getParameters();
@@ -126,12 +166,44 @@ class RightsLogFormatter extends LogFormatter {
                        }
                }
 
-               // Really old entries does not have log params
+               // Really old entries do not have log params, so form them from whatever info
+               // we have.
+               // Also walk through the parallel arrays of groups and metadata, combining each
+               // metadata array with the name of the group it pertains to
                if ( isset( $params['4:array:oldgroups'] ) ) {
                        $params['4:array:oldgroups'] = $this->makeGroupArray( $params['4:array:oldgroups'] );
+
+                       $oldmetadata =& $params['oldmetadata'];
+                       // unset old metadata entry to ensure metadata goes at the end of the params array
+                       unset( $params['oldmetadata'] );
+                       $params['oldmetadata'] = array_map( function( $index ) use ( $params, $oldmetadata ) {
+                               $result = [ 'group' => $params['4:array:oldgroups'][$index] ];
+                               if ( isset( $oldmetadata[$index] ) ) {
+                                       $result += $oldmetadata[$index];
+                               }
+                               $result['expiry'] = ApiResult::formatExpiry( isset( $result['expiry'] ) ?
+                                       $result['expiry'] : null );
+
+                               return $result;
+                       }, array_keys( $params['4:array:oldgroups'] ) );
                }
+
                if ( isset( $params['5:array:newgroups'] ) ) {
                        $params['5:array:newgroups'] = $this->makeGroupArray( $params['5:array:newgroups'] );
+
+                       $newmetadata =& $params['newmetadata'];
+                       // unset old metadata entry to ensure metadata goes at the end of the params array
+                       unset( $params['newmetadata'] );
+                       $params['newmetadata'] = array_map( function( $index ) use ( $params, $newmetadata ) {
+                               $result = [ 'group' => $params['5:array:newgroups'][$index] ];
+                               if ( isset( $newmetadata[$index] ) ) {
+                                       $result += $newmetadata[$index];
+                               }
+                               $result['expiry'] = ApiResult::formatExpiry( isset( $result['expiry'] ) ?
+                                       $result['expiry'] : null );
+
+                               return $result;
+                       }, array_keys( $params['5:array:newgroups'] ) );
                }
 
                return $params;
@@ -145,6 +217,14 @@ class RightsLogFormatter extends LogFormatter {
                if ( isset( $ret['newgroups'] ) ) {
                        ApiResult::setIndexedTagName( $ret['newgroups'], 'g' );
                }
+               if ( isset( $ret['oldmetadata'] ) ) {
+                       ApiResult::setArrayType( $ret['oldmetadata'], 'array' );
+                       ApiResult::setIndexedTagName( $ret['oldmetadata'], 'g' );
+               }
+               if ( isset( $ret['newmetadata'] ) ) {
+                       ApiResult::setArrayType( $ret['newmetadata'], 'array' );
+                       ApiResult::setIndexedTagName( $ret['newmetadata'], 'g' );
+               }
                return $ret;
        }
 
index a01e9b2..e7030c5 100644 (file)
@@ -86,7 +86,7 @@ class SpecialActiveUsers extends SpecialPage {
                $groups = User::getAllGroups();
 
                foreach ( $groups as $group ) {
-                       $msg = htmlspecialchars( User::getGroupName( $group ) );
+                       $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) );
                        $options[$msg] = $group;
                }
 
index f3d3a77..7a25e55 100644 (file)
@@ -273,12 +273,14 @@ class SpecialListGroupRights extends SpecialPage {
                        } elseif ( is_array( $changeGroup ) ) {
                                $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups );
                                if ( count( $changeGroup ) ) {
+                                       $groupLinks = [];
+                                       foreach ( $changeGroup as $group ) {
+                                               $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' );
+                                       }
                                        // For grep: listgrouprights-addgroup, listgrouprights-removegroup,
                                        // listgrouprights-addgroup-self, listgrouprights-removegroup-self
                                        $r[] = $this->msg( 'listgrouprights-' . $messageKey,
-                                               $lang->listToText( array_map( [ 'User', 'makeGroupLinkWiki' ], $changeGroup ) ),
-                                               count( $changeGroup )
-                                       )->parse();
+                                               $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse();
                                }
                        }
                }
index b1f8a17..b0808f6 100644 (file)
@@ -85,6 +85,8 @@ class UserrightsPage extends SpecialPage {
                $session = $request->getSession();
                $out = $this->getOutput();
 
+               $out->addModules( [ 'mediawiki.special.userrights' ] );
+
                if ( $par !== null ) {
                        $this->mTarget = $par;
                } else {
@@ -117,7 +119,6 @@ class UserrightsPage extends SpecialPage {
                        // Remove session data for the success message
                        $session->remove( 'specialUserrightsSaveSuccess' );
 
-                       $out->addModules( [ 'mediawiki.special.userrights' ] );
                        $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' );
                        $out->addHTML(
                                Html::rawElement(
@@ -178,18 +179,22 @@ class UserrightsPage extends SpecialPage {
                        ) {
                                $out->addWikiMsg( 'userrights-conflict' );
                        } else {
-                               $this->saveUserGroups(
+                               $status = $this->saveUserGroups(
                                        $this->mTarget,
                                        $request->getVal( 'user-reason' ),
                                        $targetUser
                                );
 
-                               // Set session data for the success message
-                               $session->set( 'specialUserrightsSaveSuccess', 1 );
-
-                               $out->redirect( $this->getSuccessURL() );
+                               if ( $status->isOK() ) {
+                                       // Set session data for the success message
+                                       $session->set( 'specialUserrightsSaveSuccess', 1 );
 
-                               return;
+                                       $out->redirect( $this->getSuccessURL() );
+                                       return;
+                               } else {
+                                       // Print an error message and redisplay the form
+                                       $out->addWikiText( '<div class="error">' . $status->getWikiText() . '</div>' );
+                               }
                        }
                }
 
@@ -203,6 +208,41 @@ class UserrightsPage extends SpecialPage {
                return $this->getPageTitle( $this->mTarget )->getFullURL();
        }
 
+       /**
+        * Returns true if this user rights form can set and change user group expiries.
+        * Subclasses may wish to override this to return false.
+        *
+        * @return bool
+        */
+       public function canProcessExpiries() {
+               return !$this->getConfig()->get( 'DisableUserGroupExpiry' );
+       }
+
+       /**
+        * Converts a user group membership expiry string into a timestamp. Words like
+        * 'existing' or 'other' should have been filtered out before calling this
+        * function.
+        *
+        * @param string $expiry
+        * @return string|null|false A string containing a valid timestamp, or null
+        *   if the expiry is infinite, or false if the timestamp is not valid
+        */
+       public static function expiryToTimestamp( $expiry ) {
+               if ( wfIsInfinity( $expiry ) ) {
+                       return null;
+               }
+
+               $unix = strtotime( $expiry );
+
+               if ( !$unix || $unix === -1 ) {
+                       return false;
+               }
+
+               // @todo FIXME: Non-qualified absolute times are not in users specified timezone
+               // and there isn't notice about it in the ui (see ProtectionForm::getExpiry)
+               return wfTimestamp( TS_MW, $unix );
+       }
+
        /**
         * Save user groups changes in the database.
         * Data comes from the editUserGroupsForm() form function
@@ -210,11 +250,12 @@ class UserrightsPage extends SpecialPage {
         * @param string $username Username to apply changes to.
         * @param string $reason Reason for group change
         * @param User|UserRightsProxy $user Target user object.
-        * @return null
+        * @return Status
         */
-       function saveUserGroups( $username, $reason, $user ) {
+       protected function saveUserGroups( $username, $reason, $user ) {
                $allgroups = $this->getAllGroups();
                $addgroup = [];
+               $groupExpiries = []; // associative array of (group name => expiry)
                $removegroup = [];
 
                // This could possibly create a highly unlikely race condition if permissions are changed between
@@ -224,12 +265,38 @@ class UserrightsPage extends SpecialPage {
                        // Later on, this gets filtered for what can actually be removed
                        if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) {
                                $addgroup[] = $group;
+
+                               if ( $this->canProcessExpiries() ) {
+                                       // read the expiry information from the request
+                                       $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
+                                       if ( $expiryDropdown === 'other' ) {
+                                               $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
+                                       } elseif ( $expiryDropdown !== 'existing' ) {
+                                               $expiryValue = $expiryDropdown;
+                                       } else {
+                                               continue;
+                                       }
+
+                                       // validate the expiry
+                                       $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue );
+
+                                       if ( $groupExpiries[$group] === false ) {
+                                               return Status::newFatal( 'userrights-invalid-expiry', $group );
+                                       }
+
+                                       // not allowed to have things expiring in the past
+                                       if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
+                                               return Status::newFatal( 'userrights-expiry-in-past', $group );
+                                       }
+                               }
                        } else {
                                $removegroup[] = $group;
                        }
                }
 
-               $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason );
+               $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries );
+
+               return Status::newGood();
        }
 
        /**
@@ -240,9 +307,13 @@ class UserrightsPage extends SpecialPage {
         * @param array $remove Array of groups to remove
         * @param string $reason Reason for group change
         * @param array $tags Array of change tags to add to the log entry
+        * @param array $groupExpiries Associative array of (group name => expiry),
+        *   containing only those groups that are to have new expiry values set
         * @return array Tuple of added, then removed groups
         */
-       function doSaveUserGroups( $user, $add, $remove, $reason = '', $tags = [] ) {
+       function doSaveUserGroups( $user, $add, $remove, $reason = '', $tags = [],
+               $groupExpiries = [] ) {
+
                // Validate input set...
                $isself = $user->getName() == $this->getUser()->getName();
                $groups = $user->getGroups();
@@ -252,17 +323,21 @@ class UserrightsPage extends SpecialPage {
 
                $remove = array_unique(
                        array_intersect( (array)$remove, $removable, $groups ) );
-               $add = array_unique( array_diff(
-                       array_intersect( (array)$add, $addable ),
-                       $groups )
-               );
+               $add = array_intersect( (array)$add, $addable );
+
+               // add only groups that are not already present or that need their expiry updated
+               $add = array_filter( $add,
+                       function( $group ) use ( $groups, $groupExpiries ) {
+                               return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
+                       } );
 
                Hooks::run( 'ChangeUserGroups', [ $this->getUser(), $user, &$add, &$remove ] );
 
-               $oldGroups = $user->getGroups();
+               $oldGroups = $groups;
+               $oldUGMs = $user->getGroupMemberships();
                $newGroups = $oldGroups;
 
-               // Remove then add groups
+               // Remove groups, then add new ones/update expiries of existing ones
                if ( $remove ) {
                        foreach ( $remove as $index => $group ) {
                                if ( !$user->removeGroup( $group ) ) {
@@ -273,13 +348,15 @@ class UserrightsPage extends SpecialPage {
                }
                if ( $add ) {
                        foreach ( $add as $index => $group ) {
-                               if ( !$user->addGroup( $group ) ) {
+                               $expiry = isset( $groupExpiries[$group] ) ? $groupExpiries[$group] : null;
+                               if ( !$user->addGroup( $group, $expiry ) ) {
                                        unset( $add[$index] );
                                }
                        }
                        $newGroups = array_merge( $newGroups, $add );
                }
                $newGroups = array_unique( $newGroups );
+               $newUGMs = $user->getGroupMemberships();
 
                // Ensure that caches are cleared
                $user->invalidateCache();
@@ -292,25 +369,59 @@ class UserrightsPage extends SpecialPage {
 
                wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" );
                wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" );
+               wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" );
+               wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" );
                // Deprecated in favor of UserGroupsChanged hook
                Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' );
 
-               if ( $newGroups != $oldGroups ) {
-                       $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags );
+               // Only add a log entry if something actually changed
+               if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) {
+                       $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs );
                }
 
                return [ $add, $remove ];
        }
 
+       /**
+        * Serialise a UserGroupMembership object for storage in the log_params section
+        * of the logging table. Only keeps essential data, removing redundant fields.
+        *
+        * @param UserGroupMembership|null $ugm May be null if things get borked
+        * @return array
+        */
+       protected static function serialiseUgmForLog( $ugm ) {
+               if ( !$ugm instanceof UserGroupMembership ) {
+                       return null;
+               }
+               return [ 'expiry' => $ugm->getExpiry() ];
+       }
+
        /**
         * Add a rights log entry for an action.
-        * @param User $user
+        * @param User|UserRightsProxy $user
         * @param array $oldGroups
         * @param array $newGroups
         * @param array $reason
-        * @param array $tags
+        * @param array $tags Change tags for the log entry
+        * @param array $oldUGMs Associative array of (group name => UserGroupMembership)
+        * @param array $newUGMs Associative array of (group name => UserGroupMembership)
         */
-       function addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags ) {
+       protected function addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags,
+               $oldUGMs, $newUGMs ) {
+
+               // make sure $oldUGMs and $newUGMs are in the same order, and serialise
+               // each UGM object to a simplified array
+               $oldUGMs = array_map( function( $group ) use ( $oldUGMs ) {
+                       return isset( $oldUGMs[$group] ) ?
+                               self::serialiseUgmForLog( $oldUGMs[$group] ) :
+                               null;
+               }, $oldGroups );
+               $newUGMs = array_map( function( $group ) use ( $newUGMs ) {
+                       return isset( $newUGMs[$group] ) ?
+                               self::serialiseUgmForLog( $newUGMs[$group] ) :
+                               null;
+               }, $newGroups );
+
                $logEntry = new ManualLogEntry( 'rights', 'rights' );
                $logEntry->setPerformer( $this->getUser() );
                $logEntry->setTarget( $user->getUserPage() );
@@ -318,6 +429,8 @@ class UserrightsPage extends SpecialPage {
                $logEntry->setParameters( [
                        '4::oldgroups' => $oldGroups,
                        '5::newgroups' => $newGroups,
+                       'oldmetadata' => $oldUGMs,
+                       'newmetadata' => $newUGMs,
                ] );
                $logid = $logEntry->insert();
                if ( count( $tags ) ) {
@@ -341,8 +454,8 @@ class UserrightsPage extends SpecialPage {
                }
 
                $groups = $user->getGroups();
-
-               $this->showEditUserGroupsForm( $user, $groups );
+               $groupMemberships = $user->getGroupMemberships();
+               $this->showEditUserGroupsForm( $user, $groups, $groupMemberships );
 
                // This isn't really ideal logging behavior, but let's not hide the
                // interwiki logs if we're using them as is.
@@ -475,35 +588,47 @@ class UserrightsPage extends SpecialPage {
         * Show the form to edit group memberships.
         *
         * @param User|UserRightsProxy $user User or UserRightsProxy you're editing
-        * @param array $groups Array of groups the user is in
+        * @param array $groups Array of groups the user is in. Not used by this implementation
+        *   anymore, but kept for backward compatibility with subclasses
+        * @param array $groupMemberships Associative array of (group name => UserGroupMembership
+        *   object) containing the groups the user is in
         */
-       protected function showEditUserGroupsForm( $user, $groups ) {
-               $list = [];
-               $membersList = [];
-               foreach ( $groups as $group ) {
-                       $list[] = self::buildGroupLink( $group );
-                       $membersList[] = self::buildGroupMemberLink( $group );
+       protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) {
+               $list = $membersList = $tempList = $tempMembersList = [];
+               foreach ( $groupMemberships as $ugm ) {
+                       $linkG = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html' );
+                       $linkM = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html',
+                               $user->getName() );
+                       if ( $ugm->getExpiry() ) {
+                               $tempList[] = $linkG;
+                               $tempMembersList[] = $linkM;
+                       } else {
+                               $list[] = $linkG;
+                               $membersList[] = $linkM;
+
+                       }
                }
 
                $autoList = [];
                $autoMembersList = [];
                if ( $user instanceof User ) {
                        foreach ( Autopromote::getAutopromoteGroups( $user ) as $group ) {
-                               $autoList[] = self::buildGroupLink( $group );
-                               $autoMembersList[] = self::buildGroupMemberLink( $group );
+                               $autoList[] = UserGroupMembership::getLink( $group, $this->getContext(), 'html' );
+                               $autoMembersList[] = UserGroupMembership::getLink( $group, $this->getContext(),
+                                       'html', $user->getName() );
                        }
                }
 
                $language = $this->getLanguage();
                $displayedList = $this->msg( 'userrights-groupsmember-type' )
                        ->rawParams(
-                               $language->listToText( $list ),
-                               $language->listToText( $membersList )
+                               $language->commaList( array_merge( $tempList, $list ) ),
+                               $language->commaList( array_merge( $tempMembersList, $membersList ) )
                        )->escaped();
                $displayedAutolist = $this->msg( 'userrights-groupsmember-type' )
                        ->rawParams(
-                               $language->listToText( $autoList ),
-                               $language->listToText( $autoMembersList )
+                               $language->commaList( $autoList ),
+                               $language->commaList( $autoMembersList )
                        )->escaped();
 
                $grouplist = '';
@@ -532,7 +657,8 @@ class UserrightsPage extends SpecialPage {
                        Linker::TOOL_LINKS_EMAIL /* Add "send e-mail" link */
                );
 
-               list( $groupCheckboxes, $canChangeAny ) = $this->groupCheckboxes( $groups, $user );
+               list( $groupCheckboxes, $canChangeAny ) =
+                       $this->groupCheckboxes( $groupMemberships, $user );
                $this->getOutput()->addHTML(
                        Xml::openElement(
                                'form',
@@ -598,26 +724,6 @@ class UserrightsPage extends SpecialPage {
                );
        }
 
-       /**
-        * Format a link to a group description page
-        *
-        * @param string $group
-        * @return string
-        */
-       private static function buildGroupLink( $group ) {
-               return User::makeGroupLinkHTML( $group, User::getGroupName( $group ) );
-       }
-
-       /**
-        * Format a link to a group member description page
-        *
-        * @param string $group
-        * @return string
-        */
-       private static function buildGroupMemberLink( $group ) {
-               return User::makeGroupLinkHTML( $group, User::getGroupMember( $group ) );
-       }
-
        /**
         * Returns an array of all groups that may be edited
         * @return array Array of groups that may be edited.
@@ -629,8 +735,8 @@ class UserrightsPage extends SpecialPage {
        /**
         * Adds a table with checkboxes where you can select what groups to add/remove
         *
-        * @todo Just pass the username string?
-        * @param array $usergroups Groups the user belongs to
+        * @param array $usergroups Associative array of (group name as string =>
+        *   UserGroupMembership object) for groups the user belongs to
         * @param User $user
         * @return Array with 2 elements: the XHTML table element with checkxboes, and
         * whether any groups are changeable
@@ -639,12 +745,18 @@ class UserrightsPage extends SpecialPage {
                $allgroups = $this->getAllGroups();
                $ret = '';
 
+               // Get the list of preset expiry times from the system message
+               $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage();
+               $expiryOptions = $expiryOptionsMsg->isDisabled() ?
+                       [] :
+                       explode( ',', $expiryOptionsMsg->text() );
+
                // Put all column info into an associative array so that extensions can
                // more easily manage it.
                $columns = [ 'unchangeable' => [], 'changeable' => [] ];
 
                foreach ( $allgroups as $group ) {
-                       $set = in_array( $group, $usergroups );
+                       $set = isset( $usergroups[$group] );
                        // Should the checkbox be disabled?
                        $disabled = !(
                                ( $set && $this->canRemove( $group ) ) ||
@@ -691,7 +803,7 @@ class UserrightsPage extends SpecialPage {
                        foreach ( $column as $group => $checkbox ) {
                                $attr = $checkbox['disabled'] ? [ 'disabled' => 'disabled' ] : [];
 
-                               $member = User::getGroupMember( $group, $user->getName() );
+                               $member = UserGroupMembership::getGroupMemberName( $group, $user->getName() );
                                if ( $checkbox['irreversible'] ) {
                                        $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
                                } else {
@@ -700,9 +812,91 @@ class UserrightsPage extends SpecialPage {
                                $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
                                        "wpGroup-" . $group, $checkbox['set'], $attr );
                                $ret .= "\t\t" . ( $checkbox['disabled']
-                                       ? Xml::tags( 'span', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
-                                       : $checkboxHtml
-                               ) . "<br />\n";
+                                       ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
+                                       : Xml::tags( 'div', [], $checkboxHtml )
+                               ) . "\n";
+
+                               if ( $this->canProcessExpiries() ) {
+                                       $uiUser = $this->getUser();
+                                       $uiLanguage = $this->getLanguage();
+
+                                       $currentExpiry = isset( $usergroups[$group] ) ?
+                                               $usergroups[$group]->getExpiry() :
+                                               null;
+
+                                       // If the user can't uncheck this checkbox, print the current expiry below
+                                       // it in plain text. Otherwise provide UI to set/change the expiry
+                                       if ( $checkbox['set'] && ( $checkbox['irreversible'] || $checkbox['disabled'] ) ) {
+                                               if ( $currentExpiry ) {
+                                                       $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+                                                       $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
+                                                       $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser );
+                                                       $expiryHtml = $this->msg( 'userrights-expiry-current' )->params(
+                                                               $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text();
+                                               } else {
+                                                       $expiryHtml = $this->msg( 'userrights-expiry-none' )->text();
+                                               }
+                                               $expiryHtml .= "<br />\n";
+                                       } else {
+                                               $expiryHtml = Xml::element( 'span', null,
+                                                       $this->msg( 'userrights-expiry' )->text() );
+                                               $expiryHtml .= Xml::openElement( 'span' );
+
+                                               // add a form element to set the expiry date
+                                               $expiryFormOptions = new XmlSelect(
+                                                       "wpExpiry-$group",
+                                                       "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
+                                                       $currentExpiry ? 'existing' : 'infinite'
+                                               );
+                                               if ( $checkbox['disabled'] ) {
+                                                       $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
+                                               }
+
+                                               if ( $currentExpiry ) {
+                                                       $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
+                                                       $d = $uiLanguage->userDate( $currentExpiry, $uiUser );
+                                                       $t = $uiLanguage->userTime( $currentExpiry, $uiUser );
+                                                       $existingExpiryMessage = $this->msg( 'userrights-expiry-existing',
+                                                               $timestamp, $d, $t );
+                                                       $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' );
+                                               }
+
+                                               $expiryFormOptions->addOption(
+                                                       $this->msg( 'userrights-expiry-none' )->text(),
+                                                       'infinite'
+                                               );
+                                               $expiryFormOptions->addOption(
+                                                       $this->msg( 'userrights-expiry-othertime' )->text(),
+                                                       'other'
+                                               );
+                                               foreach ( $expiryOptions as $option ) {
+                                                       if ( strpos( $option, ":" ) === false ) {
+                                                               $displayText = $value = $option;
+                                                       } else {
+                                                               list( $displayText, $value ) = explode( ":", $option );
+                                                       }
+                                                       $expiryFormOptions->addOption( $displayText, htmlspecialchars( $value ) );
+                                               }
+
+                                               // Add expiry dropdown
+                                               $expiryHtml .= $expiryFormOptions->getHTML() . '<br />';
+
+                                               // Add custom expiry field
+                                               $attribs = [ 'id' => "mw-input-wpExpiry-$group-other" ];
+                                               if ( $checkbox['disabled'] ) {
+                                                       $attribs['disabled'] = 'disabled';
+                                               }
+                                               $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
+
+                                               $expiryHtml .= Xml::closeElement( 'span' );
+                                       }
+
+                                       $divAttribs = [
+                                               'id' => "mw-userrights-nested-wpGroup-$group",
+                                               'class' => 'mw-userrights-nested',
+                                       ];
+                                       $ret .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n";
+                               }
                        }
                        $ret .= "\t</td>\n";
                }
index 645a115..a748272 100644 (file)
@@ -165,9 +165,9 @@ class ActiveUsersPager extends UsersPager {
                $list = [];
                $user = User::newFromId( $row->user_id );
 
-               $groups_list = self::getGroups( intval( $row->user_id ), $this->userGroupCache );
-               foreach ( $groups_list as $group ) {
-                       $list[] = self::buildGroupLink( $group, $userName );
+               $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache );
+               foreach ( $ugms as $ugm ) {
+                       $list[] = $this->buildGroupLink( $ugm, $userName );
                }
 
                $groups = $lang->commaList( $list );
index 901be38..48ce23a 100644 (file)
@@ -177,12 +177,12 @@ class UsersPager extends AlphabeticPager {
                $lang = $this->getLanguage();
 
                $groups = '';
-               $groups_list = self::getGroups( intval( $row->user_id ), $this->userGroupCache );
+               $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache );
 
-               if ( !$this->including && count( $groups_list ) > 0 ) {
+               if ( !$this->including && count( $ugms ) > 0 ) {
                        $list = [];
-                       foreach ( $groups_list as $group ) {
-                               $list[] = self::buildGroupLink( $group, $userName );
+                       foreach ( $ugms as $ugm ) {
+                               $list[] = $this->buildGroupLink( $ugm, $userName );
                        }
                        $groups = $lang->commaList( $list );
                }
@@ -231,15 +231,18 @@ class UsersPager extends AlphabeticPager {
                $dbr = wfGetDB( DB_REPLICA );
                $groupRes = $dbr->select(
                        'user_groups',
-                       [ 'ug_user', 'ug_group' ],
+                       UserGroupMembership::selectFields(),
                        [ 'ug_user' => $userIds ],
                        __METHOD__
                );
                $cache = [];
                $groups = [];
                foreach ( $groupRes as $row ) {
-                       $cache[intval( $row->ug_user )][] = $row->ug_group;
-                       $groups[$row->ug_group] = true;
+                       $ugm = UserGroupMembership::newFromRow( $row );
+                       if ( !$ugm->isExpired() ) {
+                               $cache[$row->ug_user][$row->ug_group] = $ugm;
+                               $groups[$row->ug_group] = true;
+                       }
                }
 
                // Give extensions a chance to add things like global user group data
@@ -250,7 +253,7 @@ class UsersPager extends AlphabeticPager {
 
                // Add page of groups to link batch
                foreach ( $groups as $group => $unused ) {
-                       $groupPage = User::getGroupPage( $group );
+                       $groupPage = UserGroupMembership::getGroupPage( $group );
                        if ( $groupPage ) {
                                $batch->addObj( $groupPage );
                        }
@@ -340,7 +343,7 @@ class UsersPager extends AlphabeticPager {
        function getAllGroups() {
                $result = [];
                foreach ( User::getAllGroups() as $group ) {
-                       $result[$group] = User::getGroupName( $group );
+                       $result[$group] = UserGroupMembership::getGroupName( $group );
                }
                asort( $result );
 
@@ -365,36 +368,30 @@ class UsersPager extends AlphabeticPager {
        }
 
        /**
-        * Get a list of groups the specified user belongs to
+        * Get an associative array containing groups the specified user belongs to,
+        * and the relevant UserGroupMembership objects
         *
         * @param int $uid User id
         * @param array|null $cache
-        * @return array
+        * @return array (group name => UserGroupMembership object)
         */
-       protected static function getGroups( $uid, $cache = null ) {
+       protected static function getGroupMemberships( $uid, $cache = null ) {
                if ( $cache === null ) {
                        $user = User::newFromId( $uid );
-                       $effectiveGroups = $user->getEffectiveGroups();
+                       return $user->getGroupMemberships();
                } else {
-                       $effectiveGroups = isset( $cache[$uid] ) ? $cache[$uid] : [];
+                       return isset( $cache[$uid] ) ? $cache[$uid] : [];
                }
-               $groups = array_diff( $effectiveGroups, User::getImplicitGroups() );
-
-               return $groups;
        }
 
        /**
         * Format a link to a group description page
         *
-        * @param string $group Group name
+        * @param string|UserGroupMembership $group Group name or UserGroupMembership object
         * @param string $username Username
         * @return string
         */
-       protected static function buildGroupLink( $group, $username ) {
-               return User::makeGroupLinkHTML(
-                       $group,
-                       User::getGroupMember( $group, $username )
-               );
+       protected function buildGroupLink( $group, $username ) {
+               return UserGroupMembership::getLink( $group, $this->getContext(), 'html', $username );
        }
-
 }
index 6763ec1..cccca38 100644 (file)
@@ -66,7 +66,7 @@ class User implements IDBAccessObject {
        /**
         * @const int Serialized record version.
         */
-       const VERSION = 10;
+       const VERSION = 11;
 
        /**
         * Exclude user options that are set to their default value.
@@ -104,7 +104,7 @@ class User implements IDBAccessObject {
                'mRegistration',
                'mEditCount',
                // user_groups table
-               'mGroups',
+               'mGroupMemberships',
                // user_properties table
                'mOptionOverrides',
        ];
@@ -225,8 +225,13 @@ class User implements IDBAccessObject {
        protected $mRegistration;
        /** @var int */
        protected $mEditCount;
-       /** @var array */
-       public $mGroups;
+       /**
+        * @var array No longer used since 1.29; use User::getGroups() instead
+        * @deprecated since 1.29
+        */
+       private $mGroups;
+       /** @var array Associative array of (group name => UserGroupMembership object) */
+       protected $mGroupMemberships;
        /** @var array */
        protected $mOptionOverrides;
        // @}
@@ -283,9 +288,7 @@ class User implements IDBAccessObject {
        /** @var array */
        public $mOptions;
 
-       /**
-        * @var WebRequest
-        */
+       /** @var WebRequest */
        private $mRequest;
 
        /** @var Block */
@@ -1149,7 +1152,7 @@ class User implements IDBAccessObject {
                $this->mEmailToken = '';
                $this->mEmailTokenExpires = null;
                $this->mRegistration = wfTimestamp( TS_MW );
-               $this->mGroups = [];
+               $this->mGroupMemberships = [];
 
                Hooks::run( 'UserLoadDefaults', [ $this, $name ] );
        }
@@ -1261,7 +1264,7 @@ class User implements IDBAccessObject {
                if ( $s !== false ) {
                        // Initialise user table data
                        $this->loadFromRow( $s );
-                       $this->mGroups = null; // deferred
+                       $this->mGroupMemberships = null; // deferred
                        $this->getEditCount(); // revalidation for nulls
                        return true;
                } else {
@@ -1278,13 +1281,16 @@ class User implements IDBAccessObject {
         * @param stdClass $row Row from the user table to load.
         * @param array $data Further user data to load into the object
         *
-        *      user_groups             Array with groups out of the user_groups table
-        *      user_properties         Array with properties out of the user_properties table
+        *  user_groups   Array of arrays or stdClass result rows out of the user_groups
+        *                table. Previously you were supposed to pass an array of strings
+        *                here, but we also need expiry info nowadays, so an array of
+        *                strings is ignored.
+        *  user_properties   Array with properties out of the user_properties table
         */
        protected function loadFromRow( $row, $data = null ) {
                $all = true;
 
-               $this->mGroups = null; // deferred
+               $this->mGroupMemberships = null; // deferred
 
                if ( isset( $row->user_name ) ) {
                        $this->mName = $row->user_name;
@@ -1353,7 +1359,18 @@ class User implements IDBAccessObject {
 
                if ( is_array( $data ) ) {
                        if ( isset( $data['user_groups'] ) && is_array( $data['user_groups'] ) ) {
-                               $this->mGroups = $data['user_groups'];
+                               if ( !count( $data['user_groups'] ) ) {
+                                       $this->mGroupMemberships = [];
+                               } else {
+                                       $firstGroup = reset( $data['user_groups'] );
+                                       if ( is_array( $firstGroup ) || is_object( $firstGroup ) ) {
+                                               $this->mGroupMemberships = [];
+                                               foreach ( $data['user_groups'] as $row ) {
+                                                       $ugm = UserGroupMembership::newFromRow( (object)$row );
+                                                       $this->mGroupMemberships[$ugm->getGroup()] = $ugm;
+                                               }
+                                       }
+                               }
                        }
                        if ( isset( $data['user_properties'] ) && is_array( $data['user_properties'] ) ) {
                                $this->loadOptions( $data['user_properties'] );
@@ -1377,18 +1394,12 @@ class User implements IDBAccessObject {
         * Load the groups from the database if they aren't already loaded.
         */
        private function loadGroups() {
-               if ( is_null( $this->mGroups ) ) {
+               if ( is_null( $this->mGroupMemberships ) ) {
                        $db = ( $this->queryFlagsUsed & self::READ_LATEST )
                                ? wfGetDB( DB_MASTER )
                                : wfGetDB( DB_REPLICA );
-                       $res = $db->select( 'user_groups',
-                               [ 'ug_group' ],
-                               [ 'ug_user' => $this->mId ],
-                               __METHOD__ );
-                       $this->mGroups = [];
-                       foreach ( $res as $row ) {
-                               $this->mGroups[] = $row->ug_group;
-                       }
+                       $this->mGroupMemberships = UserGroupMembership::getMembershipsForUser(
+                               $this->mId, $db );
                }
        }
 
@@ -1520,7 +1531,7 @@ class User implements IDBAccessObject {
                $this->mRights = null;
                $this->mEffectiveGroups = null;
                $this->mImplicitGroups = null;
-               $this->mGroups = null;
+               $this->mGroupMemberships = null;
                $this->mOptions = null;
                $this->mOptionsLoaded = false;
                $this->mEditCount = null;
@@ -3233,7 +3244,20 @@ class User implements IDBAccessObject {
        public function getGroups() {
                $this->load();
                $this->loadGroups();
-               return $this->mGroups;
+               return array_keys( $this->mGroupMemberships );
+       }
+
+       /**
+        * Get the list of explicit group memberships this user has, stored as
+        * UserGroupMembership objects. Implicit groups are not included.
+        *
+        * @return array Associative array of (group name as string => UserGroupMembership object)
+        * @since 1.29
+        */
+       public function getGroupMemberships() {
+               $this->load();
+               $this->loadGroups();
+               return $this->mGroupMemberships;
        }
 
        /**
@@ -3344,34 +3368,35 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Add the user to the given group.
-        * This takes immediate effect.
+        * Add the user to the given group. This takes immediate effect.
+        * If the user is already in the group, the expiry time will be updated to the new
+        * expiry time. (If $expiry is omitted or null, the membership will be altered to
+        * never expire.)
+        *
         * @param string $group Name of the group to add
+        * @param string $expiry Optional expiry timestamp in any format acceptable to
+        *   wfTimestamp(), or null if the group assignment should not expire
         * @return bool
         */
-       public function addGroup( $group ) {
+       public function addGroup( $group, $expiry = null ) {
                $this->load();
+               $this->loadGroups();
 
-               if ( !Hooks::run( 'UserAddGroup', [ $this, &$group ] ) ) {
+               if ( $expiry ) {
+                       $expiry = wfTimestamp( TS_MW, $expiry );
+               }
+
+               if ( !Hooks::run( 'UserAddGroup', [ $this, &$group, &$expiry ] ) ) {
                        return false;
                }
 
-               $dbw = wfGetDB( DB_MASTER );
-               if ( $this->getId() ) {
-                       $dbw->insert( 'user_groups',
-                               [
-                                       'ug_user' => $this->getId(),
-                                       'ug_group' => $group,
-                               ],
-                               __METHOD__,
-                               [ 'IGNORE' ] );
+               // create the new UserGroupMembership and put it in the DB
+               $ugm = new UserGroupMembership( $this->mId, $group, $expiry );
+               if ( !$ugm->insert( true ) ) {
+                       return false;
                }
 
-               $this->loadGroups();
-               $this->mGroups[] = $group;
-               // In case loadGroups was not called before, we now have the right twice.
-               // Get rid of the duplicate.
-               $this->mGroups = array_unique( $this->mGroups );
+               $this->mGroupMemberships[$group] = $ugm;
 
                // Refresh the groups caches, and clear the rights cache so it will be
                // refreshed on the next call to $this->getRights().
@@ -3391,29 +3416,19 @@ class User implements IDBAccessObject {
         */
        public function removeGroup( $group ) {
                $this->load();
+
                if ( !Hooks::run( 'UserRemoveGroup', [ $this, &$group ] ) ) {
                        return false;
                }
 
-               $dbw = wfGetDB( DB_MASTER );
-               $dbw->delete( 'user_groups',
-                       [
-                               'ug_user' => $this->getId(),
-                               'ug_group' => $group,
-                       ], __METHOD__
-               );
-               // Remember that the user was in this group
-               $dbw->insert( 'user_former_groups',
-                       [
-                               'ufg_user' => $this->getId(),
-                               'ufg_group' => $group,
-                       ],
-                       __METHOD__,
-                       [ 'IGNORE' ]
-               );
+               $ugm = UserGroupMembership::getMembership( $this->mId, $group );
+               // delete the membership entry
+               if ( !$ugm || !$ugm->delete() ) {
+                       return false;
+               }
 
                $this->loadGroups();
-               $this->mGroups = array_diff( $this->mGroups, [ $group ] );
+               unset( $this->mGroupMemberships[$group] );
 
                // Refresh the groups caches, and clear the rights cache so it will be
                // refreshed on the next call to $this->getRights().
@@ -4747,25 +4762,27 @@ class User implements IDBAccessObject {
 
        /**
         * Get the localized descriptive name for a group, if it exists
+        * @deprecated since 1.29 Use UserGroupMembership::getGroupName instead
         *
         * @param string $group Internal group name
         * @return string Localized descriptive group name
         */
        public static function getGroupName( $group ) {
-               $msg = wfMessage( "group-$group" );
-               return $msg->isBlank() ? $group : $msg->text();
+               wfDeprecated( __METHOD__, '1.29' );
+               return UserGroupMembership::getGroupName( $group );
        }
 
        /**
         * Get the localized descriptive name for a member of a group, if it exists
+        * @deprecated since 1.29 Use UserGroupMembership::getGroupMemberName instead
         *
         * @param string $group Internal group name
         * @param string $username Username for gender (since 1.19)
         * @return string Localized name for group member
         */
        public static function getGroupMember( $group, $username = '#' ) {
-               $msg = wfMessage( "group-$group-member", $username );
-               return $msg->isBlank() ? $group : $msg->text();
+               wfDeprecated( __METHOD__, '1.29' );
+               return UserGroupMembership::getGroupMemberName( $group, $username );
        }
 
        /**
@@ -4815,34 +4832,33 @@ class User implements IDBAccessObject {
 
        /**
         * Get the title of a page describing a particular group
+        * @deprecated since 1.29 Use UserGroupMembership::getGroupPage instead
         *
         * @param string $group Internal group name
         * @return Title|bool Title of the page if it exists, false otherwise
         */
        public static function getGroupPage( $group ) {
-               $msg = wfMessage( 'grouppage-' . $group )->inContentLanguage();
-               if ( $msg->exists() ) {
-                       $title = Title::newFromText( $msg->text() );
-                       if ( is_object( $title ) ) {
-                               return $title;
-                       }
-               }
-               return false;
+               wfDeprecated( __METHOD__, '1.29' );
+               return UserGroupMembership::getGroupPage( $group );
        }
 
        /**
         * Create a link to the group in HTML, if available;
         * else return the group name.
+        * @deprecated since 1.29 Use UserGroupMembership::getLink instead, or
+        * make the link yourself if you need custom text
         *
         * @param string $group Internal name of the group
         * @param string $text The text of the link
         * @return string HTML link to the group
         */
        public static function makeGroupLinkHTML( $group, $text = '' ) {
+               wfDeprecated( __METHOD__, '1.29' );
+
                if ( $text == '' ) {
-                       $text = self::getGroupName( $group );
+                       $text = UserGroupMembership::getGroupName( $group );
                }
-               $title = self::getGroupPage( $group );
+               $title = UserGroupMembership::getGroupPage( $group );
                if ( $title ) {
                        return Linker::link( $title, htmlspecialchars( $text ) );
                } else {
@@ -4853,16 +4869,20 @@ class User implements IDBAccessObject {
        /**
         * Create a link to the group in Wikitext, if available;
         * else return the group name.
+        * @deprecated since 1.29 Use UserGroupMembership::getLink instead, or
+        * make the link yourself if you need custom text
         *
         * @param string $group Internal name of the group
         * @param string $text The text of the link
         * @return string Wikilink to the group
         */
        public static function makeGroupLinkWiki( $group, $text = '' ) {
+               wfDeprecated( __METHOD__, '1.29' );
+
                if ( $text == '' ) {
-                       $text = self::getGroupName( $group );
+                       $text = UserGroupMembership::getGroupName( $group );
                }
-               $title = self::getGroupPage( $group );
+               $title = UserGroupMembership::getGroupPage( $group );
                if ( $title ) {
                        $page = $title->getFullText();
                        return "[[$page|$text]]";
@@ -5422,10 +5442,10 @@ class User implements IDBAccessObject {
        static function newFatalPermissionDeniedStatus( $permission ) {
                global $wgLang;
 
-               $groups = array_map(
-                       [ 'User', 'makeGroupLinkWiki' ],
-                       User::getGroupsWithPermission( $permission )
-               );
+               $groups = [];
+               foreach ( User::getGroupsWithPermission( $permission ) as $group ) {
+                       $groups[] = UserGroupMembership::getLink( $group, RequestContext::getMain(), 'wiki' );
+               }
 
                if ( $groups ) {
                        return Status::newFatal( 'badaccess-groups', $wgLang->commaList( $groups ), count( $groups ) );
diff --git a/includes/user/UserGroupMembership.php b/includes/user/UserGroupMembership.php
new file mode 100644 (file)
index 0000000..bb0b7d2
--- /dev/null
@@ -0,0 +1,475 @@
+<?php
+/**
+ * Represents the membership of a user to a user group.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents a "user group membership" -- a specific instance of a user belonging
+ * to a group. For example, the fact that user Mary belongs to the sysop group is a
+ * user group membership.
+ *
+ * The class encapsulates rows in the user_groups table. The logic is low-level and
+ * doesn't run any hooks. Often, you will want to call User::addGroup() or
+ * User::removeGroup() instead.
+ *
+ * @since 1.29
+ */
+class UserGroupMembership {
+       /** @var int The ID of the user who belongs to the group */
+       private $userId;
+
+       /** @var string */
+       private $group;
+
+       /** @var string|null Timestamp of expiry in TS_MW format, or null if no expiry */
+       private $expiry;
+
+       /**
+        * @param int $userId The ID of the user who belongs to the group
+        * @param string $group The internal group name
+        * @param string|null $expiry Timestamp of expiry in TS_MW format, or null if no expiry
+        */
+       public function __construct( $userId = 0, $group = null, $expiry = null ) {
+               global $wgDisableUserGroupExpiry;
+               if ( $wgDisableUserGroupExpiry ) {
+                       $expiry = null;
+               }
+
+               $this->userId = (int)$userId;
+               $this->group = $group; // TODO throw on invalid group?
+               $this->expiry = $expiry ?: null;
+       }
+
+       /**
+        * @return int
+        */
+       public function getUserId() {
+               return $this->userId;
+       }
+
+       /**
+        * @return string
+        */
+       public function getGroup() {
+               return $this->group;
+       }
+
+       /**
+        * @return string|null Timestamp of expiry in TS_MW format, or null if no expiry
+        */
+       public function getExpiry() {
+               global $wgDisableUserGroupExpiry;
+               if ( $wgDisableUserGroupExpiry ) {
+                       return null;
+               }
+
+               return $this->expiry;
+       }
+
+       protected function initFromRow( $row ) {
+               global $wgDisableUserGroupExpiry;
+
+               $this->userId = (int)$row->ug_user;
+               $this->group = $row->ug_group;
+               if ( $wgDisableUserGroupExpiry ) {
+                       $this->expiry = null;
+               } else {
+                       $this->expiry = $row->ug_expiry === null ?
+                               null :
+                               wfTimestamp( TS_MW, $row->ug_expiry );
+               }
+       }
+
+       /**
+        * Creates a new UserGroupMembership object from a database row.
+        *
+        * @param stdClass $row The row from the user_groups table
+        * @return UserGroupMembership
+        */
+       public static function newFromRow( $row ) {
+               $ugm = new self;
+               $ugm->initFromRow( $row );
+               return $ugm;
+       }
+
+       /**
+        * Returns the list of user_groups fields that should be selected to create
+        * a new user group membership.
+        * @return array
+        */
+       public static function selectFields() {
+               global $wgDisableUserGroupExpiry;
+               if ( $wgDisableUserGroupExpiry ) {
+                       return [
+                               'ug_user',
+                               'ug_group',
+                       ];
+               } else {
+                       return [
+                               'ug_user',
+                               'ug_group',
+                               'ug_expiry',
+                       ];
+               }
+       }
+
+       /**
+        * Delete the row from the user_groups table.
+        *
+        * @throws MWException
+        * @param IDatabase|null $dbw Optional master database connection to use
+        * @return bool Whether or not anything was deleted
+        */
+       public function delete( IDatabase $dbw = null ) {
+               global $wgDisableUserGroupExpiry;
+               if ( wfReadOnly() ) {
+                       return false;
+               }
+
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               if ( $wgDisableUserGroupExpiry ) {
+                       $dbw->delete( 'user_groups', $this->getDatabaseArray( $dbw ), __METHOD__ );
+               } else {
+                       $dbw->delete(
+                               'user_groups',
+                               [ 'ug_user' => $this->userId, 'ug_group' => $this->group ],
+                               __METHOD__ );
+               }
+               if ( !$dbw->affectedRows() ) {
+                       return false;
+               }
+
+               // Remember that the user was in this group
+               $dbw->insert(
+                       'user_former_groups',
+                       [ 'ufg_user' => $this->userId, 'ufg_group' => $this->group ],
+                       __METHOD__,
+                       [ 'IGNORE' ] );
+
+               return true;
+       }
+
+       /**
+        * Insert a user right membership into the database. When $allowUpdate is false,
+        * the function fails if there is a conflicting membership entry (same user and
+        * group) already in the table.
+        *
+        * @throws MWException
+        * @param bool $allowUpdate Whether to perform "upsert" instead of INSERT
+        * @param IDatabase|null $dbw If you have one available
+        * @return bool Whether or not anything was inserted
+        */
+       public function insert( $allowUpdate = false, IDatabase $dbw = null ) {
+               global $wgDisableUserGroupExpiry;
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               // Purge old, expired memberships from the DB
+               self::purgeExpired( $dbw );
+
+               // Check that the values make sense
+               if ( $this->group === null ) {
+                       throw new UnexpectedValueException(
+                               'Don\'t try inserting an uninitialized UserGroupMembership object' );
+               } elseif ( $this->userId <= 0 ) {
+                       throw new UnexpectedValueException(
+                               'UserGroupMembership::insert() needs a positive user ID. ' .
+                               'Did you forget to add your User object to the database before calling addGroup()?' );
+               }
+
+               $row = $this->getDatabaseArray( $dbw );
+               $dbw->insert( 'user_groups', $row, __METHOD__, [ 'IGNORE' ] );
+               $affected = $dbw->affectedRows();
+
+               // Don't collide with expired user group memberships
+               // Do this after trying to insert, in order to avoid locking
+               if ( !$wgDisableUserGroupExpiry && !$affected ) {
+                       $conds = [
+                               'ug_user' => $row['ug_user'],
+                               'ug_group' => $row['ug_group'],
+                       ];
+                       // if we're unconditionally updating, check that the expiry is not already the
+                       // same as what we are trying to update it to; otherwise, only update if
+                       // the expiry date is in the past
+                       if ( $allowUpdate ) {
+                               if ( $this->expiry ) {
+                                       $conds[] = 'ug_expiry IS NULL OR ug_expiry != ' .
+                                               $dbw->addQuotes( $dbw->timestamp( $this->expiry ) );
+                               } else {
+                                       $conds[] = 'ug_expiry IS NOT NULL';
+                               }
+                       } else {
+                               $conds[] = 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() );
+                       }
+
+                       $row = $dbw->selectRow( 'user_groups', $this::selectFields(), $conds, __METHOD__ );
+                       if ( $row ) {
+                               $dbw->update(
+                                       'user_groups',
+                                       [ 'ug_expiry' => $this->expiry ? $dbw->timestamp( $this->expiry ) : null ],
+                                       [ 'ug_user' => $row->ug_user, 'ug_group' => $row->ug_group ],
+                                       __METHOD__ );
+                               $affected = $dbw->affectedRows();
+                       }
+               }
+
+               return $affected > 0;
+       }
+
+       /**
+        * Get an array suitable for passing to $dbw->insert() or $dbw->update()
+        * @param IDatabase $db
+        * @return array
+        */
+       protected function getDatabaseArray( IDatabase $db ) {
+               global $wgDisableUserGroupExpiry;
+
+               $a = [
+                       'ug_user' => $this->userId,
+                       'ug_group' => $this->group,
+               ];
+               if ( !$wgDisableUserGroupExpiry ) {
+                       $a['ug_expiry'] = $this->expiry ? $db->timestamp( $this->expiry ) : null;
+               }
+               return $a;
+       }
+
+       /**
+        * Has the membership expired?
+        * @return bool
+        */
+       public function isExpired() {
+               global $wgDisableUserGroupExpiry;
+               if ( $wgDisableUserGroupExpiry || !$this->expiry ) {
+                       return false;
+               } else {
+                       return wfTimestampNow() > $this->expiry;
+               }
+       }
+
+       /**
+        * Purge expired memberships from the user_groups table
+        *
+        * @param IDatabase|null $dbw
+        */
+       public static function purgeExpired( IDatabase $dbw = null ) {
+               global $wgDisableUserGroupExpiry;
+               if ( $wgDisableUserGroupExpiry || wfReadOnly() ) {
+                       return;
+               }
+
+               if ( $dbw === null ) {
+                       $dbw = wfGetDB( DB_MASTER );
+               }
+
+               DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+                       $dbw,
+                       __METHOD__,
+                       function ( IDatabase $dbw, $fname ) {
+                               $expiryCond = [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ];
+                               $res = $dbw->select( 'user_groups', self::selectFields(), $expiryCond, $fname );
+
+                               // save an array of users/groups to insert to user_former_groups
+                               $usersAndGroups = [];
+                               foreach ( $res as $row ) {
+                                       $usersAndGroups[] = [ 'ufg_user' => $row->ug_user, 'ufg_group' => $row->ug_group ];
+                               }
+
+                               // delete 'em all
+                               $dbw->delete( 'user_groups', $expiryCond, $fname );
+
+                               // and push the groups to user_former_groups
+                               $dbw->insert( 'user_former_groups', $usersAndGroups, __METHOD__, [ 'IGNORE' ] );
+                       }
+               ) );
+       }
+
+       /**
+        * Returns UserGroupMembership objects for all the groups a user currently
+        * belongs to.
+        *
+        * @param int $userId ID of the user to search for
+        * @param IDatabase|null $db Optional database connection
+        * @return array Associative array of (group name => UserGroupMembership object)
+        */
+       public static function getMembershipsForUser( $userId, $db = null ) {
+               if ( !$db ) {
+                       $db = wfGetDB( DB_REPLICA );
+               }
+
+               $res = $db->select( 'user_groups',
+                       self::selectFields(),
+                       [ 'ug_user' => $userId ],
+                       __METHOD__ );
+
+               $ugms = [];
+               foreach ( $res as $row ) {
+                       $ugm = self::newFromRow( $row );
+                       if ( !$ugm->isExpired() ) {
+                               $ugms[$ugm->group] = $ugm;
+                       }
+               }
+
+               return $ugms;
+       }
+
+       /**
+        * Returns a UserGroupMembership object that pertains to the given user and group,
+        * or false if the user does not belong to that group (or the assignment has
+        * expired).
+        *
+        * @param int $userId ID of the user to search for
+        * @param string $group User group name
+        * @param IDatabase|null $db Optional database connection
+        * @return UserGroupMembership|false
+        */
+       public static function getMembership( $userId, $group, IDatabase $db = null ) {
+               if ( !$db ) {
+                       $db = wfGetDB( DB_REPLICA );
+               }
+
+               $row = $db->selectRow( 'user_groups',
+                       self::selectFields(),
+                       [ 'ug_user' => $userId, 'ug_group' => $group ],
+                       __METHOD__ );
+               if ( !$row ) {
+                       return false;
+               }
+
+               $ugm = self::newFromRow( $row );
+               if ( !$ugm->isExpired() ) {
+                       return $ugm;
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        * Gets a link for a user group, possibly including the expiry date if relevant.
+        *
+        * @param string|UserGroupMembership $ugm Either a group name as a string, or
+        *   a UserGroupMembership object
+        * @param IContextSource $context
+        * @param string $format Either 'wiki' or 'html'
+        * @param string|null $userName If you want to use the group member message
+        *   ("administrator"), pass the name of the user who belongs to the group; it
+        *   is used for GENDER of the group member message. If you instead want the
+        *   group name message ("Administrators"), omit this parameter.
+        * @return string
+        */
+       public static function getLink( $ugm, IContextSource $context, $format,
+               $userName = null ) {
+
+               if ( $format !== 'wiki' && $format !== 'html' ) {
+                       throw new MWException( 'UserGroupMembership::getLink() $format parameter should be ' .
+                               "'wiki' or 'html'" );
+               }
+
+               if ( $ugm instanceof UserGroupMembership ) {
+                       $expiry = $ugm->getExpiry();
+                       $group = $ugm->getGroup();
+               } else {
+                       $expiry = null;
+                       $group = $ugm;
+               }
+
+               if ( $userName !== null ) {
+                       $groupName = self::getGroupMemberName( $group, $userName );
+               } else {
+                       $groupName = self::getGroupName( $group );
+               }
+
+               // link to the group description page, if it exists
+               $linkTitle = self::getGroupPage( $group );
+               if ( $linkTitle ) {
+                       if ( $format === 'wiki' ) {
+                               $linkPage = $linkTitle->getFullText();
+                               $groupLink = "[[$linkPage|$groupName]]";
+                       } else {
+                               $groupLink = Linker::link( $linkTitle, htmlspecialchars( $groupName ) );
+                       }
+               } else {
+                       $groupLink = htmlspecialchars( $groupName );
+               }
+
+               if ( $expiry ) {
+                       // format the expiry to a nice string
+                       $uiLanguage = $context->getLanguage();
+                       $uiUser = $context->getUser();
+                       $expiryDT = $uiLanguage->userTimeAndDate( $expiry, $uiUser );
+                       $expiryD = $uiLanguage->userDate( $expiry, $uiUser );
+                       $expiryT = $uiLanguage->userTime( $expiry, $uiUser );
+                       if ( $format === 'html' ) {
+                               $groupLink = Message::rawParam( $groupLink );
+                       }
+                       return $context->msg( 'group-membership-link-with-expiry' )
+                               ->params( $groupLink, $expiryDT, $expiryD, $expiryT )->text();
+               } else {
+                       return $groupLink;
+               }
+       }
+
+       /**
+        * Gets the localized friendly name for a group, if it exists. For example,
+        * "Administrators" or "Bureaucrats"
+        *
+        * @param string $group Internal group name
+        * @return string Localized friendly group name
+        */
+       public static function getGroupName( $group ) {
+               $msg = wfMessage( "group-$group" );
+               return $msg->isBlank() ? $group : $msg->text();
+       }
+
+       /**
+        * Gets the localized name for a member of a group, if it exists. For example,
+        * "administrator" or "bureaucrat"
+        *
+        * @param string $group Internal group name
+        * @param string $username Username for gender
+        * @return string Localized name for group member
+        */
+       public static function getGroupMemberName( $group, $username ) {
+               $msg = wfMessage( "group-$group-member", $username );
+               return $msg->isBlank() ? $group : $msg->text();
+       }
+
+       /**
+        * Gets the title of a page describing a particular user group. When the name
+        * of the group appears in the UI, it can link to this page.
+        *
+        * @param string $group Internal group name
+        * @return Title|bool Title of the page if it exists, false otherwise
+        */
+       public static function getGroupPage( $group ) {
+               $msg = wfMessage( "grouppage-$group" )->inContentLanguage();
+               if ( $msg->exists() ) {
+                       $title = Title::newFromText( $msg->text() );
+                       if ( is_object( $title ) ) {
+                               return $title;
+                       }
+               }
+               return false;
+       }
+}
index 69bc503..23c2177 100644 (file)
@@ -210,38 +210,51 @@ class UserRightsProxy {
        }
 
        /**
-        * Replaces User::addUserGroup()
-        * @param string $group
+        * Replaces User::getGroupMemberships()
+        *
+        * @return array
+        * @since 1.29
+        */
+       function getGroupMemberships() {
+               $res = $this->db->select( 'user_groups',
+                       UserGroupMembership::selectFields(),
+                       [ 'ug_user' => $this->id ],
+                       __METHOD__ );
+               $ugms = [];
+               foreach ( $res as $row ) {
+                       $ugms[$row->ug_group] = UserGroupMembership::newFromRow( $row );
+               }
+               return $ugms;
+       }
+
+       /**
+        * Replaces User::addGroup()
         *
+        * @param string $group
+        * @param string|null $expiry
         * @return bool
         */
-       function addGroup( $group ) {
-               $this->db->insert( 'user_groups',
-                       [
-                               'ug_user' => $this->id,
-                               'ug_group' => $group,
-                       ],
-                       __METHOD__,
-                       [ 'IGNORE' ] );
+       function addGroup( $group, $expiry = null ) {
+               if ( $expiry ) {
+                       $expiry = wfTimestamp( TS_MW, $expiry );
+               }
 
-               return true;
+               $ugm = new UserGroupMembership( $this->id, $group, $expiry );
+               return $ugm->insert( true, $this->db );
        }
 
        /**
-        * Replaces User::removeUserGroup()
-        * @param string $group
+        * Replaces User::removeGroup()
         *
+        * @param string $group
         * @return bool
         */
        function removeGroup( $group ) {
-               $this->db->delete( 'user_groups',
-                       [
-                               'ug_user' => $this->id,
-                               'ug_group' => $group,
-                       ],
-                       __METHOD__ );
-
-               return true;
+               $ugm = UserGroupMembership::getMembership( $this->id, $group, $this->db );
+               if ( !$ugm ) {
+                       return false;
+               }
+               return $ugm->delete( $this->db );
        }
 
        /**
index 7370cea..81d9af1 100644 (file)
        "username": "{{GENDER:$1|Username}}:",
        "prefs-memberingroups": "{{GENDER:$2|Member}} of {{PLURAL:$1|group|groups}}:",
        "prefs-memberingroups-type": "$1",
+       "group-membership-link-with-expiry": "$1 (until $2)",
        "prefs-registration": "Registration time:",
        "prefs-registration-date-time": "$1",
        "yourrealname": "Real name:",
        "userrights-changeable-col": "Groups you can change",
        "userrights-unchangeable-col": "Groups you cannot change",
        "userrights-irreversible-marker": "$1*",
+       "userrights-expiry-current": "Expires $1",
+       "userrights-expiry-none": "Does not expire",
+       "userrights-expiry": "Expires:",
+       "userrights-expiry-existing": "Existing expiration time: $3, $2",
+       "userrights-expiry-othertime": "Other time:",
+       "userrights-expiry-options": "1 day:1 day,1 week:1 week,1 month:1 month,3 months:3 months,6 months:6 months,1 year:1 year",
+       "userrights-invalid-expiry": "The expiry time for group \"$1\" is invalid.",
+       "userrights-expiry-in-past": "The expiry time for group \"$1\" is in the past.",
        "userrights-conflict": "Conflict of user rights changes! Please review and confirm your changes.",
        "group": "Group:",
        "group-user": "Users",
        "newuserlog-autocreate-entry": "Account created automatically",
        "rightslogentry": "changed group membership for $1 from $2 to $3",
        "rightslogentry-autopromote": "was automatically promoted from $2 to $3",
+       "rightslogentry-temporary-group": "$1 (temporary, until $2)",
        "feedback-adding": "Adding feedback to page...",
        "feedback-back": "Back",
        "feedback-bugcheck": "Great! Just check that it is not already one of the [$1 known bugs].",
index 2f5b925..a484ad9 100644 (file)
        "username": "Username field in [[Special:Preferences]]. $1 is the current user name for GENDER distinction (depends on sex setting).\n\n{{Identical|Username}}",
        "prefs-memberingroups": "This message is shown on [[Special:Preferences]], first tab.\n\nParameters:\n* $1 - number of user groups\n* $2 - the username for GENDER\nSee also:\n* {{msg-mw|Prefs-memberingroups-type}}",
        "prefs-memberingroups-type": "{{optional}}\nParameters:\n* $1 - list of group names\n* $2 - list of group member names. Label for these is {{msg-mw|Prefs-memberingroups}}",
+       "group-membership-link-with-expiry": "Used as part of a list of user groups, to show the time and date when a user's membership of a group expires. That is, they are a member of that group \"until\" the specified date and time.\n\nParameters:\n* $1 - group name\n* $2 - time and date of expiry\n* $3 - date of expiry\n* $4 - time of expiry",
        "prefs-registration": "Used in [[Special:Preferences]].",
        "prefs-registration-date-time": "{{optional}}\nUsed in [[Special:Preferences]]. Parameters are:\n* $1 date and time of registration\n* $2 date of registration\n* $3 time of registration",
        "yourrealname": "Used in [[Special:Preferences]], first tab.\n{{Identical|Real name}}",
        "userrights-changeable-col": "Used when editing user groups in [[Special:Userrights]].\n\nThe message is the head of a column of group assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of items in the column following the message. Avoid PLURAL, if your language can do without.",
        "userrights-unchangeable-col": "Used when editing user groups in [[Special:Userrights]]. The message is the head of a column of group assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of items in the column following the message. Avoid PLURAL, if your language allows that.",
        "userrights-irreversible-marker": "{{optional}}\nParameters:\n* $1 - group member",
+       "userrights-expiry-current": "Indicates when a user's membership of a user group expires.\n\nParameters:\n* $1 - time and date of expiry\n* $2 - date of expiry\n* $3 - time of expiry",
+       "userrights-expiry-none": "Indicates that a user's membership of a user group lasts indefinitely, and does not expire.",
+       "userrights-expiry": "Used as a label for a form element which can be used to select an expiry date/time.",
+       "userrights-expiry-existing": "Shows the existing expiry time in the drop down menu underneath the individual user right on Special:UserRights.\n\nParameters:\n* $1 - Date and time of the existing expiry\n* $2 - date of the existing expiry\n* $3 - time of the existing expiry\n\nSee also:\n* {{msg-mw|protect-existing-expiry}}",
+       "userrights-expiry-othertime": "{{Identical|Other time}}",
+       "userrights-expiry-options": "{{doc-important|Be careful: '''1 translation:1 english''', so the first part is the translation and the second part should stay in English.}}\nOptions for the duration of the user group membership. Example: See e.g. [[MediaWiki:Userrights-expiry-options/nl]] if you still don't know how to do it.\n\nSee also {{msg-mw|protect-expiry-options}}.",
+       "userrights-invalid-expiry": "Error message on [[Special:UserRights]].\n\nParameters:\n* $1 - group name",
+       "userrights-expiry-in-past": "Error message on [[Special:UserRights]] when the user types an expiry date that has already passed.\n\nParameters:\n* $1 - group name",
        "userrights-conflict": "Shown on [[Special:UserRights]] if the target's rights have been changed since the form was loaded.",
        "group": "{{Identical|Group}}",
        "group-user": "{{doc-group|user}}\n{{Identical|User}}",
        "newuserlog-autocreate-entry": "This message is used in the [[:mw:Extension:Newuserlog|new user log]] to mark an account that was created by MediaWiki as part of a [[:mw:Extension:CentralAuth|CentralAuth]] global account.",
        "rightslogentry": "This message is displayed in the [[Special:Log/rights|User Rights Log]] when a bureaucrat changes the user groups for a user.\n\nParameters:\n* $1 - the username\n* $2 - list of user groups or {{msg-mw|Rightsnone}}\n* $3 - list of user groups or {{msg-mw|Rightsnone}}\n\nThe name of the bureaucrat who did this task appears before this message.\n\nSimilar to {{msg-mw|Gur-rightslog-entry}}",
        "rightslogentry-autopromote": "This message is displayed in the [[Special:Log/rights|User Rights Log]] when a user is automatically promoted to a user group.\n\nParameters:\n* $1 - (Unused)\n* $2 - a comma separated list of old user groups or {{msg-mw|Rightsnone}}\n* $3 - a comma separated list of new user groups",
+       "rightslogentry-temporary-group": "This message is displayed in the [[Special:Log/rights|User Rights Log]] to show that a user group has been allocated temporarily.\n\nParameters:\n* $1 - group name\n* $2 - date and time of expiry\n* $3 - date of expiry\n* $4 - time of expiry",
        "feedback-adding": "Progress notice",
        "feedback-back": "Button to go back to the previous action in the feedback dialog.\n{{Identical|Back}}",
        "feedback-bugcheck": "Message that appears before the user submits a bug, reminding them to check for known bugs.\n\nParameters:\n* $1 - bug list page URL",
diff --git a/maintenance/archives/patch-user_groups-ug_expiry.sql b/maintenance/archives/patch-user_groups-ug_expiry.sql
new file mode 100644 (file)
index 0000000..2ce2c9e
--- /dev/null
@@ -0,0 +1,7 @@
+-- Primary key and expiry column in user_groups table
+
+ALTER TABLE /*$wgDBprefix*/user_groups
+  DROP INDEX ug_user_group,
+  ADD PRIMARY KEY (ug_user, ug_group),
+  ADD COLUMN ug_expiry varbinary(14) NULL default NULL,
+  ADD INDEX ug_expiry (ug_expiry);
diff --git a/maintenance/mssql/archives/patch-user_groups-ug_expiry.sql b/maintenance/mssql/archives/patch-user_groups-ug_expiry.sql
new file mode 100644 (file)
index 0000000..371d80b
--- /dev/null
@@ -0,0 +1,6 @@
+-- Primary key and expiry column in user_groups table
+
+DROP INDEX IF EXISTS /*i*/ug_user_group ON /*_*/user_groups;
+ALTER TABLE /*_*/tag_summary ADD CONSTRAINT pk_user_groups PRIMARY KEY(ug_user, ug_group);
+ALTER TABLE /*_*/tag_summary ADD ug_expiry varchar(14) DEFAULT NULL;
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups(ug_expiry);
index ba1f752..1c633be 100644 (file)
@@ -64,9 +64,11 @@ INSERT INTO /*_*/mwuser (user_name) VALUES ('##Anonymous##');
 CREATE TABLE /*_*/user_groups (
    ug_user  INT     NOT NULL REFERENCES /*_*/mwuser(user_id) ON DELETE CASCADE,
    ug_group NVARCHAR(255) NOT NULL DEFAULT '',
+   ug_expiry varchar(14) DEFAULT NULL,
+   PRIMARY KEY(ug_user, ug_group)
 );
-CREATE UNIQUE clustered INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user, ug_group);
 CREATE INDEX /*i*/ug_group ON /*_*/user_groups(ug_group);
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups(ug_expiry);
 
 -- Stores the groups the user has once belonged to.
 -- The user may still belong to these groups (check user_groups).
diff --git a/maintenance/oracle/archives/patch-user_groups-ug_expiry.sql b/maintenance/oracle/archives/patch-user_groups-ug_expiry.sql
new file mode 100644 (file)
index 0000000..d5376a3
--- /dev/null
@@ -0,0 +1,8 @@
+define mw_prefix='{$wgDBprefix}';
+
+ALTER TABLE &mw_prefix.user_groups ADD (
+ug_expiry TIMESTAMP(6) WITH TIME ZONE  NULL
+);
+DROP INDEX IF EXISTS &mw_prefix.user_groups_u01;
+ALTER TABLE &mw_prefix.user_groups ADD CONSTRAINT &mw_prefix.user_groups_pk PRIMARY KEY (ug_user,ug_group);
+CREATE INDEX &mw_prefix.user_groups_i02 ON &mw_prefix.user_groups (ug_expiry);
index edb3398..fc3c696 100644 (file)
@@ -33,11 +33,13 @@ INSERT INTO &mw_prefix.mwuser
 
 CREATE TABLE &mw_prefix.user_groups (
   ug_user   NUMBER      DEFAULT 0 NOT NULL,
-  ug_group  VARCHAR2(255)     NOT NULL
+  ug_group  VARCHAR2(255)     NOT NULL,
+  ug_expiry TIMESTAMP(6) WITH TIME ZONE NULL
 );
+ALTER TABLE &mw_prefix.user_groups ADD CONSTRAINT &mw_prefix.user_groups_pk PRIMARY KEY (ug_user,ug_group);
 ALTER TABLE &mw_prefix.user_groups ADD CONSTRAINT &mw_prefix.user_groups_fk1 FOREIGN KEY (ug_user) REFERENCES &mw_prefix.mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-CREATE UNIQUE INDEX &mw_prefix.user_groups_u01 ON &mw_prefix.user_groups (ug_user,ug_group);
 CREATE INDEX &mw_prefix.user_groups_i01 ON &mw_prefix.user_groups (ug_group);
+CREATE INDEX &mw_prefix.user_groups_i02 ON &mw_prefix.user_groups (ug_expiry);
 
 CREATE TABLE &mw_prefix.user_former_groups (
   ufg_user   NUMBER      DEFAULT 0 NOT NULL,
index 61ad075..e19c447 100644 (file)
@@ -58,10 +58,13 @@ INSERT INTO mwuser
   VALUES (DEFAULT,'Anonymous','',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,now(),now());
 
 CREATE TABLE user_groups (
-  ug_user   INTEGER      NULL  REFERENCES mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
-  ug_group  TEXT     NOT NULL
+  ug_user    INTEGER          NULL  REFERENCES mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+  ug_group   TEXT         NOT NULL,
+  ug_expiry  TIMESTAMPTZ  NULL,
+  PRIMARY KEY(ug_user, ug_group)
 );
-CREATE UNIQUE INDEX user_groups_unique ON user_groups (ug_user, ug_group);
+CREATE INDEX user_groups_group  ON user_groups (ug_group);
+CREATE INDEX user_groups_expiry ON user_groups (ug_expiry);
 
 CREATE TABLE user_former_groups (
   ufg_user   INTEGER      NULL  REFERENCES mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
diff --git a/maintenance/sqlite/archives/patch-user_groups-ug_expiry.sql b/maintenance/sqlite/archives/patch-user_groups-ug_expiry.sql
new file mode 100644 (file)
index 0000000..7fc8941
--- /dev/null
@@ -0,0 +1,21 @@
+DROP TABLE IF EXISTS /*_*/user_groups_tmp;
+
+CREATE TABLE /*$wgDBprefix*/user_groups_tmp (
+  ug_user int unsigned NOT NULL default 0,
+  ug_group varbinary(255) NOT NULL default '',
+  ug_expiry varbinary(14) NULL default NULL,
+  PRIMARY KEY (ug_user, ug_group)
+);
+
+INSERT OR IGNORE INTO /*_*/user_groups_tmp (
+    ug_user, ug_group )
+    SELECT
+    ug_user, ug_group
+    FROM /*_*/user_groups;
+
+DROP TABLE /*_*/user_groups;
+
+ALTER TABLE /*_*/user_groups_tmp RENAME TO /*_*/user_groups;
+
+CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups (ug_expiry);
index 2b6ea03..892f799 100644 (file)
@@ -160,11 +160,17 @@ CREATE TABLE /*_*/user_groups (
   -- with particular permissions. A user will have the combined
   -- permissions of any group they're explicitly in, plus
   -- the implicit '*' and 'user' groups.
-  ug_group varbinary(255) NOT NULL default ''
+  ug_group varbinary(255) NOT NULL default '',
+
+  -- Time at which the user group membership will expire. Set to
+  -- NULL for a non-expiring (infinite) membership.
+  ug_expiry varbinary(14) NULL default NULL,
+
+  PRIMARY KEY (ug_user, ug_group)
 ) /*$wgDBTableOptions*/;
 
-CREATE UNIQUE INDEX /*i*/ug_user_group ON /*_*/user_groups (ug_user,ug_group);
 CREATE INDEX /*i*/ug_group ON /*_*/user_groups (ug_group);
+CREATE INDEX /*i*/ug_expiry ON /*_*/user_groups (ug_expiry);
 
 -- Stores the groups the user has once belonged to.
 -- The user may still belong to these groups (check user_groups).
index 8283792..f14787f 100644 (file)
@@ -1956,6 +1956,7 @@ return [
                ],
        ],
        'mediawiki.special.userrights' => [
+               'styles' => 'resources/src/mediawiki.special/mediawiki.special.userrights.css',
                'scripts' => 'resources/src/mediawiki.special/mediawiki.special.userrights.js',
                'dependencies' => [
                        'mediawiki.notification.convertmessagebox',
diff --git a/resources/src/mediawiki.special/mediawiki.special.userrights.css b/resources/src/mediawiki.special/mediawiki.special.userrights.css
new file mode 100644 (file)
index 0000000..a4b4087
--- /dev/null
@@ -0,0 +1,12 @@
+/*!
+ * Styling for Special:UserRights
+ */
+.mw-userrights-nested {
+       margin-left: 1.2em;
+}
+
+.mw-userrights-nested span {
+       margin-left: 0.3em;
+       display: inline-block;
+       vertical-align: middle;
+}
index 0643988..3f864dd 100644 (file)
@@ -1,8 +1,18 @@
 /*!
  * JavaScript for Special:UserRights
  */
-( function () {
+( function ( $ ) {
        var convertmessagebox = require( 'mediawiki.notification.convertmessagebox' );
        // Replace successbox with notifications
        convertmessagebox();
-}() );
+
+       // Dynamically show/hide the expiry selection underneath each checkbox
+       $( '#mw-userrights-form2 input[type=checkbox]' ).on( 'change', function ( e ) {
+               $( '#mw-userrights-nested-' + e.target.id ).toggle( e.target.checked );
+       } ).trigger( 'change' );
+
+       // Also dynamically show/hide the "other time" input under each dropdown
+       $( '.mw-userrights-nested select' ).on( 'change', function ( e ) {
+               $( e.target.parentNode ).find( 'input' ).toggle( $( e.target ).val() === 'other' );
+       } ).trigger( 'change' );
+}( jQuery ) );
index a81e7ec..f48507d 100644 (file)
@@ -10,6 +10,41 @@ class RightsLogFormatterTest extends LogFormatterTestCase {
        public static function provideRightsLogDatabaseRows() {
                return [
                        // Current format
+                       [
+                               [
+                                       'type' => 'rights',
+                                       'action' => 'rights',
+                                       'comment' => 'rights comment',
+                                       'user' => 0,
+                                       'user_text' => 'Sysop',
+                                       'namespace' => NS_USER,
+                                       'title' => 'User',
+                                       'params' => [
+                                               '4::oldgroups' => [],
+                                               '5::newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [],
+                                               'newmetadata' => [
+                                                       [ 'expiry' => null ],
+                                                       [ 'expiry' => '20160101123456' ]
+                                               ],
+                                       ],
+                               ],
+                               [
+                                       'text' => 'Sysop changed group membership for User from (none) to '
+                                               . 'bureaucrat (temporary, until 12:34, 1 January 2016) and administrator',
+                                       'api' => [
+                                               'oldgroups' => [],
+                                               'newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [],
+                                               'newmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                                       [ 'group' => 'bureaucrat', 'expiry' => '2016-01-01T12:34:56Z' ],
+                                               ],
+                                       ],
+                               ],
+                       ],
+
+                       // Previous format (oldgroups and newgroups as arrays, no metadata)
                        [
                                [
                                        'type' => 'rights',
@@ -30,11 +65,16 @@ class RightsLogFormatterTest extends LogFormatterTestCase {
                                        'api' => [
                                                'oldgroups' => [],
                                                'newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [],
+                                               'newmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                                       [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+                                               ],
                                        ],
                                ],
                        ],
 
-                       // Legacy format
+                       // Legacy format (oldgroups and newgroups as numeric-keyed strings)
                        [
                                [
                                        'type' => 'rights',
@@ -56,6 +96,11 @@ class RightsLogFormatterTest extends LogFormatterTestCase {
                                        'api' => [
                                                'oldgroups' => [],
                                                'newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [],
+                                               'newmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                                       [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+                                               ],
                                        ],
                                ],
                        ],
@@ -116,6 +161,13 @@ class RightsLogFormatterTest extends LogFormatterTestCase {
                                        'api' => [
                                                'oldgroups' => [ 'sysop' ],
                                                'newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                               ],
+                                               'newmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                                       [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+                                               ],
                                        ],
                                ],
                        ],
@@ -142,6 +194,13 @@ class RightsLogFormatterTest extends LogFormatterTestCase {
                                        'api' => [
                                                'oldgroups' => [ 'sysop' ],
                                                'newgroups' => [ 'sysop', 'bureaucrat' ],
+                                               'oldmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                               ],
+                                               'newmetadata' => [
+                                                       [ 'group' => 'sysop', 'expiry' => 'infinity' ],
+                                                       [ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
+                                               ],
                                        ],
                                ],
                        ],
index e778270..d32915b 100644 (file)
@@ -796,6 +796,7 @@ more stuff
                $this->assertEquals( 'Admin', $rev1->getUserText() );
 
                # now, try the actual rollback
+               $admin->addToDatabase();
                $admin->addGroup( "sysop" ); # XXX: make the test user a sysop...
                $token = $admin->getEditToken(
                        [ $page->getTitle()->getPrefixedText(), $user2->getName() ],
@@ -828,6 +829,7 @@ more stuff
        public function testDoRollback() {
                $admin = new User();
                $admin->setName( "Admin" );
+               $admin->addToDatabase();
 
                $text = "one";
                $page = $this->newPage( "WikiPageTest_testDoRollback" );
@@ -854,10 +856,7 @@ more stuff
 
                # now, try the rollback
                $admin->addGroup( "sysop" ); # XXX: make the test user a sysop...
-               $token = $admin->getEditToken(
-                       [ $page->getTitle()->getPrefixedText(), $user1->getName() ],
-                       null
-               );
+               $token = $admin->getEditToken( 'rollback' );
                $errors = $page->doRollback(
                        $user1->getName(),
                        "testing revert",
@@ -884,6 +883,7 @@ more stuff
        public function testDoRollbackFailureSameContent() {
                $admin = new User();
                $admin->setName( "Admin" );
+               $admin->addToDatabase();
                $admin->addGroup( "sysop" ); # XXX: make the test user a sysop...
 
                $text = "one";
@@ -899,6 +899,7 @@ more stuff
 
                $user1 = new User();
                $user1->setName( "127.0.1.11" );
+               $user1->addToDatabase();
                $user1->addGroup( "sysop" ); # XXX: make the test user a sysop...
                $text .= "\n\ntwo";
                $page = new WikiPage( $page->getTitle() );
@@ -912,10 +913,7 @@ more stuff
 
                # now, do a the rollback from the same user was doing the edit before
                $resultDetails = [];
-               $token = $user1->getEditToken(
-                       [ $page->getTitle()->getPrefixedText(), $user1->getName() ],
-                       null
-               );
+               $token = $user1->getEditToken( 'rollback' );
                $errors = $page->doRollback(
                        $user1->getName(),
                        "testing revert same user",
@@ -929,10 +927,7 @@ more stuff
 
                # now, try the rollback
                $resultDetails = [];
-               $token = $admin->getEditToken(
-                       [ $page->getTitle()->getPrefixedText(), $user1->getName() ],
-                       null
-               );
+               $token = $admin->getEditToken( 'rollback' );
                $errors = $page->doRollback(
                        $user1->getName(),
                        "testing revert",
index d16200b..5ea7b1d 100644 (file)
@@ -61,6 +61,7 @@ class UserPasswordPolicyTest extends MediaWikiTestCase {
                $upp = $this->getUserPasswordPolicy();
 
                $user = User::newFromName( 'TestUserPolicy' );
+               $user->addToDatabase();
                $user->addGroup( 'sysop' );
 
                $this->assertArrayEquals(
@@ -106,6 +107,7 @@ class UserPasswordPolicyTest extends MediaWikiTestCase {
                $upp = $this->getUserPasswordPolicy();
 
                $user = User::newFromName( $username );
+               $user->addToDatabase();
                foreach ( $groups as $group ) {
                        $user->addGroup( $group );
                }
diff --git a/tests/phpunit/includes/user/UserGroupMembershipTest.php b/tests/phpunit/includes/user/UserGroupMembershipTest.php
new file mode 100644 (file)
index 0000000..a297f29
--- /dev/null
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @group Database
+ */
+class UserGroupMembershipTest extends MediaWikiTestCase {
+       /**
+        * @var User Belongs to no groups
+        */
+       protected $userNoGroups;
+       /**
+        * @var User Belongs to the 'unittesters' group indefinitely, and the
+        * 'testwriters' group with expiry
+        */
+       protected $userTester;
+       /**
+        * @var string The timestamp, in TS_MW format, of the expiry of $userTester's
+        * membership in the 'testwriters' group
+        */
+       protected $expiryTime;
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( [
+                       'wgGroupPermissions' => [
+                               'unittesters' => [
+                                       'runtest' => true,
+                               ],
+                               'testwriters' => [
+                                       'writetest' => true,
+                               ]
+                       ]
+               ] );
+
+               $this->userNoGroups = new User;
+               $this->userNoGroups->setName( 'NoGroups' );
+               $this->userNoGroups->addToDatabase();
+
+               $this->userTester = new User;
+               $this->userTester->setName( 'Tester' );
+               $this->userTester->addToDatabase();
+               $this->userTester->addGroup( 'unittesters' );
+               $this->expiryTime = wfTimestamp( TS_MW, time() + 100500 );
+               $this->userTester->addGroup( 'testwriters', $this->expiryTime );
+       }
+
+       /**
+        * @covers UserGroupMembership::insert
+        * @covers UserGroupMembership::delete
+        */
+       public function testAddAndRemoveGroups() {
+               $user = new User;
+               $user->addToDatabase();
+
+               // basic tests
+               $ugm = new UserGroupMembership( $user->getId(), 'unittesters' );
+               $this->assertTrue( $ugm->insert() );
+               $user->clearInstanceCache();
+               $this->assertContains( 'unittesters', $user->getGroups() );
+               $this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
+               $this->assertTrue( $user->isAllowed( 'runtest' ) );
+
+               // try updating without allowUpdate. Should fail
+               $ugm = new UserGroupMembership( $user->getId(), 'unittesters', $this->expiryTime );
+               $this->assertFalse( $ugm->insert() );
+
+               // now try updating with allowUpdate
+               $this->assertTrue( $ugm->insert( 2 ) );
+               $user->clearInstanceCache();
+               $this->assertContains( 'unittesters', $user->getGroups() );
+               $this->assertArrayHasKey( 'unittesters', $user->getGroupMemberships() );
+               $this->assertTrue( $user->isAllowed( 'runtest' ) );
+
+               // try removing the group
+               $ugm->delete();
+               $user->clearInstanceCache();
+               $this->assertThat( $user->getGroups(),
+                       $this->logicalNot( $this->contains( 'unittesters' ) ) );
+               $this->assertThat( $user->getGroupMemberships(),
+                       $this->logicalNot( $this->arrayHasKey( 'unittesters' ) ) );
+               $this->assertFalse( $user->isAllowed( 'runtest' ) );
+
+               // check that the user group is now in user_former_groups
+               $this->assertContains( 'unittesters', $user->getFormerGroups() );
+       }
+
+       private function addUserTesterToExpiredGroup() {
+               // put $userTester in a group with expiry in the past
+               $ugm = new UserGroupMembership( $this->userTester->getId(), 'sysop', '20010102030405' );
+               $ugm->insert();
+       }
+
+       /**
+        * @covers UserGroupMembership::getMembershipsForUser
+        */
+       public function testGetMembershipsForUser() {
+               $this->addUserTesterToExpiredGroup();
+
+               // check that the user in no groups has no group memberships
+               $ugms = UserGroupMembership::getMembershipsForUser( $this->userNoGroups->getId() );
+               $this->assertEmpty( $ugms );
+
+               // check that the user in 2 groups has 2 group memberships
+               $testerUserId = $this->userTester->getId();
+               $ugms = UserGroupMembership::getMembershipsForUser( $testerUserId );
+               $this->assertCount( 2, $ugms );
+
+               // check that the required group memberships are present on $userTester,
+               // with the correct user IDs and expiries
+               $expectedGroups = [ 'unittesters', 'testwriters' ];
+
+               foreach ( $expectedGroups as $group ) {
+                       $this->assertArrayHasKey( $group, $ugms );
+                       $this->assertEquals( $ugms[$group]->getUserId(), $testerUserId );
+                       $this->assertEquals( $ugms[$group]->getGroup(), $group );
+
+                       if ( $group === 'unittesters' ) {
+                               $this->assertNull( $ugms[$group]->getExpiry() );
+                       } elseif ( $group === 'testwriters' ) {
+                               $this->assertEquals( $ugms[$group]->getExpiry(), $this->expiryTime );
+                       }
+               }
+       }
+
+       /**
+        * @covers UserGroupMembership::getMembership
+        */
+       public function testGetMembership() {
+               $this->addUserTesterToExpiredGroup();
+
+               // groups that the user doesn't belong to shouldn't be returned
+               $ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'sysop' );
+               $this->assertFalse( $ugm );
+
+               // implicit groups shouldn't be returned
+               $ugm = UserGroupMembership::getMembership( $this->userNoGroups->getId(), 'user' );
+               $this->assertFalse( $ugm );
+
+               // expired groups shouldn't be returned
+               $ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'sysop' );
+               $this->assertFalse( $ugm );
+
+               // groups that the user does belong to should be returned with correct properties
+               $ugm = UserGroupMembership::getMembership( $this->userTester->getId(), 'unittesters' );
+               $this->assertInstanceOf( UserGroupMembership::class, $ugm );
+               $this->assertEquals( $ugm->getUserId(), $this->userTester->getId() );
+               $this->assertEquals( $ugm->getGroup(), 'unittesters' );
+               $this->assertNull( $ugm->getExpiry() );
+       }
+}
index fe56d27..26e5d89 100644 (file)
@@ -25,6 +25,7 @@ class UserTest extends MediaWikiTestCase {
                $this->setUpPermissionGlobals();
 
                $this->user = new User;
+               $this->user->addToDatabase();
                $this->user->addGroup( 'unittesters' );
        }
 
@@ -99,6 +100,7 @@ class UserTest extends MediaWikiTestCase {
         */
        public function testUserGetRightsHooks() {
                $user = new User;
+               $user->addToDatabase();
                $user->addGroup( 'unittesters' );
                $user->addGroup( 'testwriters' );
                $userWrapper = TestingAccessWrapper::newFromObject( $user );