Mwalker has submitted this change and it was merged.
Change subject: UI for importing checks
......................................................................
UI for importing checks
Change-Id: I5fd28fa12c3ca9f51d71ced6a05417ca1ca2d875
---
M sites/all/modules/offline2civicrm/ChecksFile.php
A sites/all/modules/offline2civicrm/ChecksImportLog.php
M sites/all/modules/offline2civicrm/offline2civicrm.info
M sites/all/modules/offline2civicrm/offline2civicrm.install
M sites/all/modules/offline2civicrm/offline2civicrm.module
M sites/all/modules/wmf_common/errors.inc
6 files changed, 335 insertions(+), 159 deletions(-)
Approvals:
Mwalker: Verified; Looks good to me, approved
diff --git a/sites/all/modules/offline2civicrm/ChecksFile.php
b/sites/all/modules/offline2civicrm/ChecksFile.php
index bea3a97..538e73c 100644
--- a/sites/all/modules/offline2civicrm/ChecksFile.php
+++ b/sites/all/modules/offline2civicrm/ChecksFile.php
@@ -1,181 +1,202 @@
<?php
+/**
+ * CSV batch format for manually-keyed donation checks
+ */
class ChecksFile {
- function import( $filename ) {
- $required_fields = array(
- 'date',
- 'gross',
- 'gift_source',
- 'import_batch_number',
- 'check_number',
- 'restrictions',
- );
+ /**
+ * Read checks from a file and save to the database.
+ *
+ * @param string $filename path to the file
+ */
+ function import( $filename ) {
+ ChecksImportLog::record( "Beginning import of checks file
$filename..." );
+ //TODO: $db->begin();
- ini_set( 'auto_detect_line_endings', true );
- if( ( $file = fopen( $filename, 'r' )) === FALSE ){
- watchdog('offline2civicrm', 'Import checks: Could not
open file for reading: ' . $filename, array(), WATCHDOG_ERROR);
- return;
- }
+ $required_fields = array(
+ 'date',
+ 'gross',
+ 'gift_source',
+ 'import_batch_number',
+ 'check_number',
+ 'restrictions',
+ );
- $headers = _load_headers( fgetcsv( $file, 0, ',', '"', '\\') );
+ ini_set( 'auto_detect_line_endings', true );
+ if( ( $file = fopen( $filename, 'r' )) === FALSE ){
+ throw new WmfException( 'FILE_NOT_FOUND', 'Import checks: Could
not open file for reading: ' . $filename );
+ }
- $required_columns = array(
- 'Batch',
- 'Check Number',
- 'City',
- 'Contribution Type',
- 'Country',
- 'Direct Mail Appeal',
- 'Email',
- 'Gift Source',
- 'Payment Instrument',
- 'Postal Code',
- 'Received Date',
- 'Restrictions',
- 'Source',
- 'State',
- 'Street Address',
- 'Thank You Letter Date',
- 'Total Amount',
- );
+ $headers = _load_headers( fgetcsv( $file, 0, ',', '"', '\\') );
- $failed = array();
- foreach ( $required_columns as $name ) {
- if ( !array_key_exists( $name, $headers ) ) {
- $failed[] = $name;
- }
- }
- if ( $failed ) {
- throw new WmfException( 'INVALID_FILE_FORMAT', "This
file is missing headers: " . implode( ", ", $failed ) );
- }
+ $required_columns = array(
+ 'Batch',
+ 'Check Number',
+ 'City',
+ 'Contribution Type',
+ 'Country',
+ 'Direct Mail Appeal',
+ 'Email',
+ 'Gift Source',
+ 'Payment Instrument',
+ 'Postal Code',
+ 'Received Date',
+ 'Restrictions',
+ 'Source',
+ 'State',
+ 'Street Address',
+ 'Thank You Letter Date',
+ 'Total Amount',
+ );
- while( ( $row = fgetcsv( $file, 0, ',', '"', '\\')) !== FALSE) {
- list($currency, $source_amount) = explode( " ",
_get_value( "Source", $row, $headers ) );
- $total_amount = (float)_get_value( "Total Amount",
$row, $headers );
+ $failed = array();
+ foreach ( $required_columns as $name ) {
+ if ( !array_key_exists( $name, $headers ) ) {
+ $failed[] = $name;
+ }
+ }
+ if ( $failed ) {
+ throw new WmfException( 'INVALID_FILE_FORMAT', "This file is
missing column headers: " . implode( ", ", $failed ) );
+ }
- if ( abs( $source_amount - $total_amount ) > .01 ) {
- $pretty_msg = json_encode( array_combine(
array_keys( $headers ), $row ) );
- throw new WmfException( 'INVALID_MESSAGE',
$pretty_msg );
- }
+ $num_successful = 0;
+ $num_duplicates = 0;
- $msg = array(
- "optout" => "1",
- "anonymous" => "0",
- "letter_code" => _get_value( "Letter Code",
$row, $headers ),
- "contact_source" => "check",
- "language" => "en",
- "street_address" => _get_value( "Street
Address", $row, $headers ),
- "supplemental_address_1" => _get_value(
"Additional Address 1", $row, $headers ),
- "city" => _get_value( "City", $row, $headers ),
- "state_province" => _get_value( "State", $row,
$headers ),
- "postal_code" => _get_value( "Postal Code",
$row, $headers ),
- "payment_method" => _get_value( "Payment
Instrument", $row, $headers ),
- "payment_submethod" => "",
- "check_number" => _get_value( "Check Number",
$row, $headers ),
- "currency" => $currency,
- "original_currency" => $currency,
- "original_gross" => _get_value( "Total Amount",
$row, $headers ),
- "fee" => "0",
- "gross" => _get_value( "Total Amount", $row,
$headers ),
- "net" => _get_value( "Total Amount", $row,
$headers ),
- "date" => strtotime( _get_value( "Received
Date", $row, $headers ) ),
- "thankyou_date" => strtotime( _get_value(
"Thank You Letter Date", $row, $headers ) ),
- "restrictions" => _get_value( "Restrictions",
$row, $headers ),
- "gift_source" => _get_value( "Gift Source",
$row, $headers ),
- "direct_mail_appeal" => _get_value( "Direct
Mail Appeal", $row, $headers ),
- "import_batch_number" => _get_value( "Batch",
$row, $headers ),
- );
+ while( ( $row = fgetcsv( $file, 0, ',', '"', '\\')) !== FALSE) {
+ list($currency, $source_amount) = explode( " ", _get_value(
"Source", $row, $headers ) );
+ $total_amount = (float)_get_value( "Total Amount", $row, $headers
);
- $contype = _get_value( 'Contribution Type', $row,
$headers );
- switch ( $contype ) {
- case "Merkle":
- $msg['gateway'] = "merkle";
- break;
+ if ( abs( $source_amount - $total_amount ) > .01 ) {
+ $pretty_msg = json_encode( array_combine( array_keys( $headers
), $row ) );
+ throw new WmfException( 'INVALID_MESSAGE', "Amount mismatch: "
. $pretty_msg );
+ }
- case "Arizona Lockbox":
- $msg['gateway'] = "arizonalockbox";
- break;
+ $msg = array(
+ "optout" => "1",
+ "anonymous" => "0",
+ "letter_code" => _get_value( "Letter Code", $row, $headers ),
+ "contact_source" => "check",
+ "language" => "en",
+ "street_address" => _get_value( "Street Address", $row,
$headers ),
+ "supplemental_address_1" => _get_value( "Additional Address
1", $row, $headers ),
+ "city" => _get_value( "City", $row, $headers ),
+ "state_province" => _get_value( "State", $row, $headers ),
+ "postal_code" => _get_value( "Postal Code", $row, $headers ),
+ "payment_method" => _get_value( "Payment Instrument", $row,
$headers ),
+ "payment_submethod" => "",
+ "check_number" => _get_value( "Check Number", $row, $headers ),
+ "currency" => $currency,
+ "original_currency" => $currency,
+ "original_gross" => _get_value( "Total Amount", $row, $headers
),
+ "fee" => "0",
+ "gross" => _get_value( "Total Amount", $row, $headers ),
+ "net" => _get_value( "Total Amount", $row, $headers ),
+ "date" => strtotime( _get_value( "Received Date", $row,
$headers ) ),
+ "thankyou_date" => strtotime( _get_value( "Thank You Letter
Date", $row, $headers ) ),
+ "restrictions" => _get_value( "Restrictions", $row, $headers ),
+ "gift_source" => _get_value( "Gift Source", $row, $headers ),
+ "direct_mail_appeal" => _get_value( "Direct Mail Appeal",
$row, $headers ),
+ "import_batch_number" => _get_value( "Batch", $row, $headers ),
+ );
- default:
- throw new WmfException(
'CIVI_REQ_FIELD', "Contribution Type '$contype' is unknown whilst importing
checks!" );
- }
+ $contype = _get_value( 'Contribution Type', $row, $headers );
+ switch ( $contype ) {
+ case "Merkle":
+ $msg['gateway'] = "merkle";
+ break;
- // Attempt to get the organization name if it exists...
- // Merkle used the "Organization Name" column header
where AZL uses "Company"
- $orgname = _get_value( 'Organization Name', $row,
$headers, FALSE );
- if ( $orgname === FALSE ) {
- $orgname = _get_value( 'Company', $row,
$headers, FALSE );
- }
+ case "Arizona Lockbox":
+ $msg['gateway'] = "arizonalockbox";
+ break;
- if( $orgname === FALSE ) {
- // If it's still false let's just assume it's
an individual
- $msg['contact_type'] = "Individual";
- $msg["first_name"] = _get_value( "First Name",
$row, $headers );
- $msg["middle_name"] = _get_value( "Middle
Name", $row, $headers );
- $msg["last_name"] = _get_value( "Last Name",
$row, $headers );
- } else {
- $msg['contact_type'] = "Organization";
- $msg['organization_name'] = $orgname;
- }
+ default:
+ throw new WmfException( 'INVALID_MESSAGE', "Contribution
Type '$contype' is unknown whilst importing checks!" );
+ }
- // check for additional address information
- if( _get_value( 'Additional Address 2', $row, $headers
) != ''){
- $msg['supplemental_address_1'] .= ' ' .
_get_value( 'Additional Address 2', $row, $headers );
- }
+ // Attempt to get the organization name if it exists...
+ // Merkle used the "Organization Name" column header where AZL
uses "Company"
+ $orgname = _get_value( 'Organization Name', $row, $headers, FALSE
);
+ if ( $orgname === FALSE ) {
+ $orgname = _get_value( 'Company', $row, $headers, FALSE );
+ }
- // An email address is one of the crucial fields we need
- if( _get_value( 'Email', $row, $headers ) == ''){
- // set to the default, no TY will be sent
- $msg['email'] = "[email protected]";
- } else {
- $msg['email'] = _get_value( 'Email', $row,
$headers );
- }
+ if( $orgname === FALSE ) {
+ // If it's still false let's just assume it's an individual
+ $msg['contact_type'] = "Individual";
+ $msg["first_name"] = _get_value( "First Name", $row, $headers
);
+ $msg["middle_name"] = _get_value( "Middle Name", $row,
$headers );
+ $msg["last_name"] = _get_value( "Last Name", $row, $headers );
+ } else {
+ $msg['contact_type'] = "Organization";
+ $msg['organization_name'] = $orgname;
+ }
- // CiviCRM gets all weird when there is no country set
- // Making the assumption that none = US
- if( _get_value( 'Country', $row, $headers ) == ''){
- $msg['country'] = "US";
- } else {
- $msg['country'] = _get_value( 'Country', $row,
$headers );
- }
+ // check for additional address information
+ if( _get_value( 'Additional Address 2', $row, $headers ) != ''){
+ $msg['supplemental_address_2'] .= ' ' . _get_value(
'Additional Address 2', $row, $headers );
+ }
- if ( $msg['country'] === "US" ) {
- // left-pad the zipcode
- if ( preg_match( '/^(\d{1,4})(-\d+)?$/',
$msg['postal_code'], $matches ) ) {
- $msg['postal_code'] = str_pad(
$matches[1], 5, "0", STR_PAD_LEFT );
- if ( !empty( $matches[2] ) ) {
- $msg['postal_code'] .=
$matches[2];
- }
- }
- }
+ // An email address is one of the crucial fields we need
+ if( _get_value( 'Email', $row, $headers ) == ''){
+ // set to the default, no TY will be sent
+ $msg['email'] = "[email protected]";
+ } else {
+ $msg['email'] = _get_value( 'Email', $row, $headers );
+ }
- // Generating a transaction id so that we don't import
the same rows multiple times
- $name_salt = $msg['contact_type'] == "Individual" ?
$msg["first_name"] . $msg["last_name"] : $msg["organization_name"];
- $msg['gateway_txn_id'] = md5( $msg['check_number'] .
$name_salt );
+ // CiviCRM gets all weird when there is no country set
+ // Making the assumption that none = US
+ if( _get_value( 'Country', $row, $headers ) == ''){
+ $msg['country'] = "US";
+ } else {
+ $msg['country'] = _get_value( 'Country', $row, $headers );
+ }
- // check to see if we have already processed this check
- if ( $existing =
wmf_civicrm_get_contributions_from_gateway_id( $msg['gateway'],
$msg['gateway_txn_id'] ) ){
- // if so, move on
- watchdog('offline2civicrm', 'Contribution
matches existing contribution (id: ' . $existing[0]['id'] .
- ') Skipping', array(), WATCHDOG_INFO);
- continue;
- }
+ if ( $msg['country'] === "US" ) {
+ // left-pad the zipcode
+ if ( preg_match( '/^(\d{1,4})(-\d+)?$/', $msg['postal_code'],
$matches ) ) {
+ $msg['postal_code'] = str_pad( $matches[1], 5, "0",
STR_PAD_LEFT );
+ if ( !empty( $matches[2] ) ) {
+ $msg['postal_code'] .= $matches[2];
+ }
+ }
+ }
- $failed = array();
- foreach ( $required_fields as $key ) {
- if ( !array_key_exists( $key, $msg ) or empty(
$msg[$key] ) ) {
- $failed[] = $key;
- }
- }
- if ( $failed ) {
- throw new WmfException( 'CIVI_REQ_FIELD', t(
"Missing required fields :keys during check import", array( ":keys" => implode(
", ", $failed ) ) ) );
- }
+ // Generating a transaction id so that we don't import the same
rows multiple times
+ $name_salt = $msg['contact_type'] == "Individual" ?
$msg["first_name"] . $msg["last_name"] : $msg["organization_name"];
+ $msg['gateway_txn_id'] = md5( $msg['check_number'] . $name_salt );
- $contribution =
wmf_civicrm_contribution_message_import( $msg );
+ // check to see if we have already processed this check
+ if ( $existing = wmf_civicrm_get_contributions_from_gateway_id(
$msg['gateway'], $msg['gateway_txn_id'] ) ){
+ // if so, move on
+ watchdog( 'offline2civicrm', 'Contribution matches existing
contribution (id: @id), skipping it.', array( '@id' => $existing[0]['id'] ),
WATCHDOG_INFO );
+ $num_duplicates++;
+ continue;
+ }
- watchdog('offline2civicrm', 'Import checks:
Contribution imported successfully (!id): !msg', array('!id' =>
$contribution['id'], '!msg' => print_r( $msg, true )), WATCHDOG_INFO);
- }
+ $failed = array();
+ foreach ( $required_fields as $key ) {
+ if ( !array_key_exists( $key, $msg ) or empty( $msg[$key] ) ) {
+ $failed[] = $key;
+ }
+ }
+ if ( $failed ) {
+ throw new WmfException( 'CIVI_REQ_FIELD', t( "Missing required
fields @keys during check import", array( "@keys" => implode( ", ", $failed ) )
) );
+ }
- watchdog( 'offline2civicrm', 'Import checks: finished', null,
WATCHDOG_INFO );
- }
+ $contribution = wmf_civicrm_contribution_message_import( $msg );
+
+ watchdog( 'offline2civicrm',
+ 'Import checks: Contribution imported successfully (@id):
!msg', array(
+ '@id' => $contribution['id'],
+ '!msg' => print_r( $msg, true ),
+ ), WATCHDOG_INFO
+ );
+ $num_successful++;
+ }
+
+ $message = t( "Checks import complete. @successful imported, not
including @duplicates duplicates.", array( '@successful' => $num_successful,
'@duplicates' => $num_duplicates ) );
+ ChecksImportLog::record( $message );
+ watchdog( 'offline2civicrm', $message, array(), WATCHDOG_INFO );
+ }
}
diff --git a/sites/all/modules/offline2civicrm/ChecksImportLog.php
b/sites/all/modules/offline2civicrm/ChecksImportLog.php
new file mode 100644
index 0000000..7c7fcf5
--- /dev/null
+++ b/sites/all/modules/offline2civicrm/ChecksImportLog.php
@@ -0,0 +1,36 @@
+<?php
+
+class ChecksImportLog {
+ function recentEvents( $pageLength = 20 ) {
+ $result = db_select( 'offline2civicrm_log' )
+ ->fields( 'offline2civicrm_log' )
+ ->orderBy( 'id', 'DESC' )
+ ->extend( 'PagerDefault' )
+ ->limit( $pageLength )
+ ->execute();
+
+ $events = array();
+ while ( $row = $result->fetchAssoc() ) {
+ $events[] = CheckImportLogEvent::loadFromRow( $row );
+ }
+ return $events;
+ }
+
+ function record( $description ) {
+ global $user;
+ db_insert( 'offline2civicrm_log' )->fields( array(
+ 'who' => $user->name,
+ 'done' => $description,
+ ) )->execute();
+ }
+}
+
+class CheckImportLogEvent {
+ static function loadFromRow( $data ) {
+ $event = new CheckImportLogEvent();
+ $event->time = $data['time'];
+ $event->who = $data['who'];
+ $event->done = check_plain( $data['done'] );
+ return $event;
+ }
+}
diff --git a/sites/all/modules/offline2civicrm/offline2civicrm.info
b/sites/all/modules/offline2civicrm/offline2civicrm.info
index 91baa19..4f77dc4 100755
--- a/sites/all/modules/offline2civicrm/offline2civicrm.info
+++ b/sites/all/modules/offline2civicrm/offline2civicrm.info
@@ -3,6 +3,8 @@
core = 7.x
dependencies[] = queue2civicrm
dependencies[] = wmf_civicrm
+dependencies[] = wmf_communication
dependencies[] = civicrm
package = offline2civicrm
files[] = ChecksFile.php
+files[] = ChecksImportLog.php
diff --git a/sites/all/modules/offline2civicrm/offline2civicrm.install
b/sites/all/modules/offline2civicrm/offline2civicrm.install
index 38a370a..66d2ae3 100644
--- a/sites/all/modules/offline2civicrm/offline2civicrm.install
+++ b/sites/all/modules/offline2civicrm/offline2civicrm.install
@@ -8,6 +8,38 @@
offline2civicrm_update_7000();
}
+function offline2civicrm_schema() {
+ $schema['offline2civicrm_log'] = array(
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'time' => array(
+ // Too bad, this only has 1s resolution
+ 'mysql_type' => 'timestamp',
+ // FIXME: will drupal one day add a stupidly redundant and breaking
null check?
+ 'not null' => TRUE,
+ ),
+ 'who' => array(
+ 'type' => 'char',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'done' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array( 'id' ),
+ 'indexes' => array(
+ 'time' => array( 'time' ),
+ ),
+ );
+ return $schema;
+}
+
/**
* Create the Batch Number field
*/
diff --git a/sites/all/modules/offline2civicrm/offline2civicrm.module
b/sites/all/modules/offline2civicrm/offline2civicrm.module
index 390e670..7121570 100644
--- a/sites/all/modules/offline2civicrm/offline2civicrm.module
+++ b/sites/all/modules/offline2civicrm/offline2civicrm.module
@@ -2,6 +2,8 @@
require_once 'offline2civicrm.common.inc';
+use wmf_communication\Templating;
+
/**
* Implementation of hook_menu().
*/
@@ -16,11 +18,14 @@
'page arguments' => array('offline2civicrm_settings'),
);
- $items['admin/config/offline2civicrm/configure'] = array(
- 'title' => 'Configure',
+ $items['admin/import_checks'] = array(
+ 'title' => 'Import Checks',
'access arguments' => array('administer offline2civicrm'),
- 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'type' => MENU_CALLBACK,
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('offline2civicrm_import_checks_form'),
);
+
return $items;
}
@@ -40,10 +45,84 @@
return system_settings_form($form);
}
+function offline2civicrm_import_checks_form() {
+ $log_events = ChecksImportLog::recentEvents();
+ $headers = array( 'Time', 'Who', 'Done' );
+ $rows = array();
+ foreach ( $log_events as $event ) {
+ $rows[] = array(
+ $event->time,
+ $event->who,
+ $event->done,
+ );
+ }
+ $log_html = theme_table( array(
+ 'header' => $headers,
+ 'rows' => $rows,
+ 'empty' => "No events yet.",
+ 'attributes' => array(),
+ 'caption' => t( 'Latest import events' ),
+ 'colgroups' => array(),
+ 'sticky' => true,
+ ) ).theme('pager');
+
+ $form['import_upload_file'] = array(
+ '#title' => t( 'Upload checks file' ),
+ '#type' => 'file',
+ );
+ $form['import_upload_submit'] = array(
+ '#type' => 'submit',
+ '#value' => t( 'Upload' ),
+ );
+ $form['log'] = array(
+ '#markup' => $log_html,
+ );
+
+ $form['#attributes'] = array( 'enctype' => "multipart/form-data" );
+
+ return $form;
+}
+
+function offline2civicrm_import_checks_form_submit( $form, $form_state ) {
+ if ( !empty( $form_state['values']['import_upload_submit'] ) ) {
+ try {
+ $validators = array(
+ 'file_validate_extensions' => array('csv'),
+ );
+ $file = file_save_upload( 'import_upload_file', $validators );
+ if ( !$file ) {
+ throw new Exception( t( "Form upload failed!" ) );
+ }
+
+ // This workaround... does not always work. Will be deprecated in Civi
4.3
+ civicrm_initialize();
+ $smellyTmp = CRM_Core_TemporaryErrorScope::useException();
+
+ ChecksFile::import( $file->uri );
+ drupal_set_message( "Successful import!" );
+ }
+ catch ( WmfException $ex ) {
+ $message = t( "Import error: !err", array( '!err' => $ex->getMessage() )
);
+ form_set_error( 'import_upload_file', $message );
+ ChecksImportLog::record( $message );
+ }
+ catch ( Exception $ex ) {
+ $message = t( "Unknown import error: !err", array( '!err' =>
$ex->getMessage() ) );
+ form_set_error( 'import_upload_file', $message );
+ ChecksImportLog::record( $message );
+ }
+ if ( $file ) {
+ file_delete( $file, true );
+ }
+ }
+}
+
/**
- * This hook gets called "magically" after a contribution is added via the
queue consumer.
+ * This hook gets called after a contribution is added via the queue consumer.
* If the contribution was a check, it adds the check to a "Review" group for
manual review
* by the Development/Major Gifts team.
+ *
+ * Implementation of hook_queue2civicrm_import
*
* @param $contribution_info
*/
@@ -60,6 +139,7 @@
return;
}
+ //FIXME: we are not using any such gateway. Instead, look for non-null
custom 'check number' field. QA this code path before enabling.
if( strtoupper( $contribution_info['msg']['gateway'] ) == "CHECK" ){
// add the transactions to the check import group for review
watchdog( 'offline2civicrm', "Adding CHECK to group $group_name" );
diff --git a/sites/all/modules/wmf_common/errors.inc
b/sites/all/modules/wmf_common/errors.inc
index f7989a3..61a4f70 100644
--- a/sites/all/modules/wmf_common/errors.inc
+++ b/sites/all/modules/wmf_common/errors.inc
@@ -50,6 +50,11 @@
'UNSUBSCRIBE_WARN' => array(
'no-email' => TRUE,
),
+
+ // other errors
+ 'FILE_NOT_FOUND' => array(
+ 'fatal' => TRUE,
+ ),
'INVALID_FILE_FORMAT' => array(
'fatal' => TRUE,
),
--
To view, visit https://gerrit.wikimedia.org/r/79930
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I5fd28fa12c3ca9f51d71ced6a05417ca1ca2d875
Gerrit-PatchSet: 6
Gerrit-Project: wikimedia/fundraising/crm
Gerrit-Branch: master
Gerrit-Owner: Adamw <[email protected]>
Gerrit-Reviewer: Adamw <[email protected]>
Gerrit-Reviewer: Katie Horn <[email protected]>
Gerrit-Reviewer: Mwalker <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits