MaxSem has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/350500 )

Change subject: WIP: Add user right expiry notifications
......................................................................

WIP: Add user right expiry notifications

TODO: links
TODO: configurability

More fundamental problem is that without remembering which user rights have
already been echoed, you can't reliably rerun the script or miss a scheduled
run and hope to recover from it later. Users will just receive duplicate 
messages.

Bug: T153817
Change-Id: I697ba9524b9798ae35dcd6565089e0727f7c1b2f
---
M extension.json
M i18n/en.json
A includes/formatters/UserRightsExpiryPresentationModel.php
A includes/jobs/UserRightsExpiryNotificationJob.php
A maintenance/generateRightExpiryNotifications.php
5 files changed, 334 insertions(+), 1 deletion(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Echo 
refs/changes/00/350500/1

diff --git a/extension.json b/extension.json
index 2dec0f1..d4393eb 100644
--- a/extension.json
+++ b/extension.json
@@ -35,7 +35,8 @@
        ],
        "JobClasses": {
                "EchoNotificationJob": "EchoNotificationJob",
-               "EchoNotificationDeleteJob": "EchoNotificationDeleteJob"
+               "EchoNotificationDeleteJob": "EchoNotificationDeleteJob",
+               "EchoUserRightExpiryNotification": 
"EchoUserRightsExpiryNotificationJob"
        },
        "SpecialPages": {
                "Notifications": "SpecialNotifications",
@@ -143,6 +144,8 @@
                "EchoUserLocatorTest": "tests/phpunit/UserLocatorTest.php",
                "EchoUserNotificationGateway": 
"includes/gateway/UserNotificationGateway.php",
                "EchoUserNotificationGatewayTest": 
"tests/phpunit/gateway/UserNotificationGatewayTest.php",
+               "EchoUserRightsExpiryNotificationJob": 
"includes/jobs/UserRightsExpiryNotificationJob.php",
+               "EchoUserRightsExpiryPresentationModel": 
"includes/formatters/UserRightsExpiryPresentationModel.php",
                "EchoUserRightsPresentationModel": 
"includes/formatters/UserRightsPresentationModel.php",
                "EchoWelcomePresentationModel": 
"includes/formatters/WelcomePresentationModel.php",
                "FilteredSequentialIteratorTest": 
"tests/phpunit/iterator/FilteredSequentialIteratorTest.php",
@@ -928,6 +931,15 @@
                                        "section": "alert",
                                        "presentation-model": 
"EchoUserRightsPresentationModel"
                                },
+                               "user-rights-expiry": {
+                                       "user-locators": [
+                                               
"EchoUserLocator::locateEventAgent"
+                                       ],
+                                       "category": "user-rights",
+                                       "group": "neutral",
+                                       "section": "alert",
+                                       "presentation-model": 
"EchoUserRightsExpiryPresentationModel"
+                               },
                                "emailuser": {
                                        "presentation-model": 
"EchoEmailUserPresentationModel",
                                        "user-locators": [
diff --git a/i18n/en.json b/i18n/en.json
index 28d626f..0fe2861 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -167,6 +167,8 @@
        "notification-header-user-rights-remove-only": "{{GENDER:$4|Your}} user 
rights were {{GENDER:$1|changed}}. You are no longer a member of: $2.",
        "notification-header-user-rights-add-and-remove": "{{GENDER:$6|Your}} 
user rights were {{GENDER:$1|changed}}. You have been added to: $2. You are no 
longer a member of: $4.",
        "notification-header-user-rights-expiry-change": "The expiry of 
{{GENDER:$4|your}} membership in the following {{PLURAL:$3|group|groups}} has 
been {{GENDER:$1|changed}}: $2.",
+       "notification-header-user-rights-future-expiry": "{{GENDER:$3|Your}} 
membership in the following {{PLURAL:$2|group|groups}}: $1 expires on $4.",
+       "notification-header-user-rights-past-expiry": "{{GENDER:$3|Your}} 
membership in the following {{PLURAL:$2|group|groups}}: $1 expired.",
        "notification-body-user-rights": "$1",
        "notification-header-welcome": "{{GENDER:$2|Welcome}} to {{SITENAME}}, 
$1! We're glad {{GENDER:$2|you're}} here.",
        "notification-welcome-link": "",
diff --git a/includes/formatters/UserRightsExpiryPresentationModel.php 
b/includes/formatters/UserRightsExpiryPresentationModel.php
new file mode 100644
index 0000000..0b38a28
--- /dev/null
+++ b/includes/formatters/UserRightsExpiryPresentationModel.php
@@ -0,0 +1,76 @@
+<?php
+
+class EchoUserRightsExpiryPresentationModel extends EchoEventPresentationModel 
{
+       const TYPE_FUTURE = 'future';
+       const TYPE_PAST = 'past';
+
+       public function getIconType() {
+               return 'user-rights';
+       }
+
+       public function getHeaderMessage() {
+               $groups = $this->event->getExtraParam( 'groups' );
+               if ( !$groups ) {
+                       throw new Exception( __CLASS__ . ' received an invalid 
list of groups: '
+                               . json_encode( $groups )
+                       );
+               }
+               $groupNames = array_map(
+                       [ $this->language, 'embedBidi' ],
+                       $this->getLocalizedGroupNames( $groups )
+               );
+
+               $type = $this->event->getExtraParam( 'type' );
+               if ( !$type ) {
+                       throw new Exception( __CLASS__ . ' received no 
notification type' );
+               }
+
+               switch ( $type ) {
+                       case self::TYPE_FUTURE:
+                               $messageKey = 
'notification-header-user-rights-future-expiry';
+                               break;
+                       case self::TYPE_PAST:
+                               $messageKey = 
'notification-header-user-rights-past-expiry';
+                               break;
+                       default:
+                               throw new Exception( "Unrecognized notification 
type '{$type}'" );
+               }
+
+               $msg = $this->msg( $messageKey )
+                       ->params(
+                               $this->language->commaList( $groupNames ),
+                               count( $groupNames ),
+                               $this->getViewingUserForGender()
+                       );
+
+               if ( $type === self::TYPE_FUTURE ) {
+                       $expiry = $this->event->getExtraParam( 'expiry' );
+                       if ( !$expiry ) {
+                               throw new Exception( __CLASS__ . ' received an 
event without expiry time' );
+                       }
+
+                       $dateTime = new MWTimestamp( $expiry );
+                       $msg->params( $this->language->date( $dateTime ) );
+               }
+
+               return $msg;
+       }
+
+       private function getLocalizedGroupNames( $names ) {
+               return array_map( function( $name ) {
+                       $msg = $this->msg( 'group-' . $name );
+                       return $msg->isBlank() ? $name : $msg->text();
+               }, $names );
+       }
+
+       /**
+        * Array of primary link details, with possibly-relative URL & label.
+        *
+        * @return array|bool Array of link data, or false for no link:
+        *                    ['url' => (string) url, 'label' => (string) link 
text (non-escaped)]
+        */
+       public function getPrimaryLink() {
+               // @TODO:
+               return false;
+       }
+}
diff --git a/includes/jobs/UserRightsExpiryNotificationJob.php 
b/includes/jobs/UserRightsExpiryNotificationJob.php
new file mode 100644
index 0000000..162e0f3
--- /dev/null
+++ b/includes/jobs/UserRightsExpiryNotificationJob.php
@@ -0,0 +1,108 @@
+<?php
+
+use Wikimedia\Assert\Assert;
+
+/**
+ * Job that sends notifications about user rights expiry at the moment these 
happen
+ *
+ * These jobs are enqueued by maintenance/generateRightExpiryNotifications.php
+ */
+class EchoUserRightsExpiryNotificationJob extends Job {
+       /** @var User */
+       private $user;
+
+       /** @var string[] */
+       private $groups;
+
+       /** @var string */
+       private $expiry;
+
+       /**
+        * @param Title $title
+        * @param array|bool $params
+        */
+       public function __construct( Title $title, $params = false ) {
+               parent::__construct( 'EchoUserRightExpiryNotification', $title, 
$params );
+
+               Assert::parameter( isset( $params['user'] ),
+                       "params['user']",
+                       __CLASS__ . ' needs a user'
+               );
+               Assert::parameterType( 'User', $params['user'], 
"params['user']" );
+               $this->user = $params['user'];
+
+               Assert::parameter( isset( $params['groups'] ),
+                       "params['groups']",
+                       __CLASS__ . ' needs groups'
+               );
+               Assert::parameterElementType( 'string', $params['groups'], 
"params['groups']" );
+               $this->groups = $params['groups'];
+
+               Assert::parameter( isset( $params['expiry'] ),
+                       "params['expiry']",
+                       __CLASS__ . ' needs expiry time'
+               );
+               $this->expiry = $params['expiry'];
+       }
+
+       /**
+        * Run the job
+        *
+        * @return bool Success
+        */
+       public function run() {
+               $groups = $this->getStillExpiringGroups();
+               if ( !$groups ) {
+                       return true;
+               }
+
+               return (bool)EchoEvent::create( [
+                       'type' => 'user-rights-expiry',
+                       'agent' => $this->user,
+                       'extra' => [
+                               'type' => 
EchoUserRightsExpiryPresentationModel::TYPE_PAST,
+                               'groups' => $groups,
+                               'expiry' => $this->expiry,
+                               'notifyAgent' => true,
+                       ],
+               ] );
+       }
+
+       /**
+        * This job should be executed in the future, when user rights expire
+        *
+        * @return string
+        */
+       public function getReleaseTimestamp() {
+               return wfTimestamp( TS_UNIX, $this->expiry );
+       }
+
+       /**
+        * Returns which of the groups are still expiring at the expected time
+        *
+        * @return string[]
+        */
+       private function getStillExpiringGroups() {
+               $dbr = wfGetDB( DB_REPLICA );
+
+               $res = $dbr->select( 'user_groups',
+                       '*',
+                       [
+                               'ug_user' => $this->user->getId(),
+                               'ug_group' => $this->groups,
+                       ],
+                       __METHOD__
+               );
+
+               // If a group is still there but its expiry is different, don't 
notify about it
+               // If a group is missing from database, assume it had expired 
and was garbage collected
+               $groups = array_flip( $this->groups );
+               foreach ( $res as $row ) {
+                       if ( $row->ug_expiry !== $this->expiry ) {
+                               unset( $groups[$row->ug_group] );
+                       }
+               }
+
+               return array_keys( $groups );
+       }
+}
diff --git a/maintenance/generateRightExpiryNotifications.php 
b/maintenance/generateRightExpiryNotifications.php
new file mode 100644
index 0000000..046434d
--- /dev/null
+++ b/maintenance/generateRightExpiryNotifications.php
@@ -0,0 +1,135 @@
+<?php
+
+$IP = getenv( 'MW_INSTALL_PATH' );
+if ( $IP === false ) {
+       $IP = __DIR__ . '/../../..';
+}
+require_once ( "$IP/maintenance/Maintenance.php" );
+
+class GenerateRightExpiryNotifications extends Maintenance {
+       private $startTimestamp;
+
+       public function __construct() {
+               parent::__construct();
+
+               $this->requireExtension( 'Echo' );
+               $this->addDescription( 'Generates notifications for expiring 
user rights' );
+               $this->setBatchSize( 500 );
+       }
+
+       public function execute() {
+               $this->startTimestamp = wfTimestampNow();
+
+               $this->output( "Looking up rights expiring in the future...\n" 
);
+               $this->scanExpiringRights( '1 days', '14 days', function( 
$userId, array $groups, $expiry ) {
+                       $this->notifyAboutExpiry( 
EchoUserRightsExpiryPresentationModel::TYPE_FUTURE,
+                               $userId, $groups, $expiry
+                       );
+               } );
+
+               $this->output( "Looking up rights expiring soon...\n" );
+               $this->scanExpiringRights( false, '1 day', function( $userId, 
array $groups, $expiry ) {
+                       $user = User::newFromId( $userId );
+                       echo "Past: {$user} " . json_encode( $groups ) . " 
expires {$expiry}\n";
+                       $job = new EchoUserRightsExpiryNotificationJob( 
$user->getTalkPage(),
+                               [
+                                       'user' => $user,
+                                       'groups' => $groups,
+                                       'expiry' => $expiry,
+                               ]
+                       );
+
+                       JobQueueGroup::singleton()->push( $job );
+               } );
+       }
+
+       /**
+        * @param string|bool $offset
+        * @return mixed
+        */
+       private function timeFromNow( $offset = false ) {
+               $ts = new MWTimestamp( $this->startTimestamp );
+
+               if ( $offset !== false ) {
+                       $ts->timestamp->modify( $offset );
+               }
+
+               return $ts;
+       }
+
+       private function notifyAboutExpiry( $type, $userId, array $groups, 
$expiry ) {
+               $user = User::newFromId( $userId );
+               if ( !$user ) {
+                       $this->error( "User with id={$userId} does not exist\n" 
);
+               }
+               sort( $groups );
+
+               $event = EchoEvent::create( [
+                       'type' => 'user-rights-expiry',
+                       'agent' => $user,
+                       'extra' => [
+                               'type' => $type,
+                               'groups' => $groups,
+                               'expiry' => $expiry,
+                               'notifyAgent' => true,
+                       ],
+               ] );
+
+               if ( !$event ) {
+                       $this->error( "Error sending notification\n" );
+               }
+       }
+
+       private function scanExpiringRights( $from, $to, callable $callback ) {
+               $dbr = $this->getDB( DB_REPLICA );
+
+               $from = $dbr->timestamp( $this->timeFromNow( $from ) );
+               $to = $dbr->timestamp( $this->timeFromNow( $to ) );
+
+               $count = 0;
+               $lastUser = null;
+               do {
+                       $conds = [
+                               'ug_expiry BETWEEN ' . $dbr->addQuotes( $from ) 
. ' AND ' . $dbr->addQuotes( $to ),
+                       ];
+                       if ( $lastUser !== null ) {
+                               $conds[] = 'ug_user >= ' . $dbr->addQuotes( 
$lastUser );
+                       }
+
+                       $res = $dbr->select( 'user_groups', '*', $conds, 
__METHOD__,
+                               [ 'LIMIT' => $this->mBatchSize, 'ORDER BY' => [ 
'ug_expiry', 'ug_user' ] ]
+                       );
+
+                       // This omits the last user in the batch because we 
might miss some of their groups.
+                       // Start our next select with them.
+                       $user = null;
+                       $groups = [];
+                       $expiry = null;
+                       foreach ( $res as $row ) {
+                               if ( $user && $user != $row->ug_user && $expiry 
&& $expiry != $row->ug_expiry ) {
+                                       if ( !$groups ) {
+                                               throw new Exception( "Trying to 
notify user {$user} with an empty group list" );
+                                       }
+
+                                       $callback( $user, $groups, $expiry );
+                                       $groups = [];
+                               }
+                               $user = $row->ug_user;
+                               $expiry = $row->ug_expiry;
+                               $groups[] = $row->ug_group;
+                       }
+                       $lastUser = $user;
+
+                       $count += $res->numRows();
+                       $this->output( "  {$count}\n" );
+               } while ( $res->numRows() == $this->mBatchSize );
+
+               // Flush last user
+               if ( $user && $expiry && $groups ) {
+                       $callback( $user, $groups, $expiry );
+               }
+       }
+}
+
+$maintClass = 'GenerateRightExpiryNotifications';
+require_once ( DO_MAINTENANCE );

-- 
To view, visit https://gerrit.wikimedia.org/r/350500
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I697ba9524b9798ae35dcd6565089e0727f7c1b2f
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/Echo
Gerrit-Branch: master
Gerrit-Owner: MaxSem <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to