Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add endpoint for Campaign Overview Statistics #7545

Merged
merged 7 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions src/Campaigns/CampaignDonationQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Give\Campaigns;

use DateTimeInterface;
use Give\Campaigns\Models\Campaign;
use Give\Donations\ValueObjects\DonationMetaKeys;
use Give\Framework\QueryBuilder\JoinQueryBuilder;
Expand Down Expand Up @@ -36,6 +37,21 @@ public function __construct(Campaign $campaign)
$this->where('campaign_forms.campaign_id', $campaign->id);
}

/**
* @unreleased
*/
public function between(DateTimeInterface $startDate, DateTimeInterface $endDate): self
{
$query = clone $this;
$query->joinDonationMeta('_give_completed_date', 'completed');
$query->whereBetween(
'completed.meta_value',
$startDate->format('Y-m-d H:i:s'),
$endDate->format('Y-m-d H:i:s')
);
return $query;
}

/**
* Returns a calculated sum of the intended amounts (without recovered fees) for the donations.
*
Expand All @@ -45,9 +61,10 @@ public function __construct(Campaign $campaign)
*/
public function sumIntendedAmount()
{
$this->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount');
$this->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount');
return $this->sum(
$query = clone $this;
$query->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount');
$query->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount');
return $query->sum(
/**
* The intended amount meta and the amount meta could either be 0 or NULL.
* So we need to use the NULLIF function to treat the 0 values as NULL.
Expand All @@ -63,16 +80,18 @@ public function sumIntendedAmount()
*/
public function countDonations(): int
{
return $this->count('donation.ID');
$query = clone $this;
return $query->count('donation.ID');
}

/**
* @unreleased
*/
public function countDonors(): int
{
$this->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorId');
return $this->count('DISTINCT donorId.meta_value');
$query = clone $this;
$query->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorId');
return $query->count('DISTINCT donorId.meta_value');
}

/**
Expand Down
97 changes: 97 additions & 0 deletions src/Campaigns/Routes/CampaignOverviewStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace Give\Campaigns\Routes;

use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use Exception;
use Give\API\RestRoute;
use Give\Campaigns\CampaignDonationQuery;
use Give\Campaigns\Models\Campaign;
use Give\Framework\Support\Facades\DateTime\Temporal;
use WP_REST_Response;
use WP_REST_Server;

/**
* @unreleased
*/
class CampaignOverviewStatistics implements RestRoute
{
/** @var string */
protected $endpoint = 'campaign-overview-statistics';

/**
* @unreleased
*/
public function registerRoute()
{
register_rest_route(
'give-api/v2',
$this->endpoint,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'handleRequest'],
'permission_callback' => function () {
return current_user_can('manage_options');
},
],
'args' => [
'campaignId' => [
'type' => 'integer',
'required' => true,
kjohnson marked this conversation as resolved.
Show resolved Hide resolved
'sanitize_callback' => 'absint',
'validate_callback' => 'is_numeric',
],
'rangeInDays' => [
'type' => 'integer',
'required' => false,
'sanitize_callback' => 'absint',
'default' => 0, // Zero to mean "all time".
'validate_callback' => 'is_numeric',
],
],
]
);
}

/**
* @unreleased
*
* @throws Exception
*/
public function handleRequest($request): WP_REST_Response
{
$campaign = Campaign::find($request->get_param('campaignId'));

$query = new CampaignDonationQuery($campaign);

if(!$request->get_param('rangeInDays')) {
return new WP_REST_Response([[
'amountRaised' => $query->sumIntendedAmount(),
'donationCount' => $query->countDonations(),
'donorCount' => $query->countDonors(),
]]);
}

$days = $request->get_param('rangeInDays');
$date = new DateTimeImmutable('now', wp_timezone());
$interval = DateInterval::createFromDateString("-$days days");
$period = new DatePeriod($date, $interval, 1);

return new WP_REST_Response(array_map(function($targetDate) use ($query, $interval) {

$query = $query->between(
Temporal::withStartOfDay($targetDate->add($interval)),
Temporal::withEndOfDay($targetDate)
);

return [
'amountRaised' => $query->sumIntendedAmount(),
'donationCount' => $query->countDonations(),
'donorCount' => $query->countDonors(),
];
}, iterator_to_array($period) ));
}
}
1 change: 1 addition & 0 deletions src/Campaigns/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ private function registerRoutes()
Hooks::addAction('rest_api_init', Routes\CreateCampaign::class, 'registerRoute');
Hooks::addAction('rest_api_init', Routes\GetCampaignsListTable::class, 'registerRoute');
Hooks::addAction('rest_api_init', Routes\DeleteCampaignListTable::class, 'registerRoute');
Hooks::addAction('rest_api_init', Routes\CampaignOverviewStatistics::class, 'registerRoute');
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/Framework/Support/Facades/DateTime/Temporal.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
* @method static string getFormattedDateTime(DateTimeInterface $dateTime)
* @method static string getCurrentFormattedDateForDatabase()
* @method static DateTimeInterface withoutMicroseconds(DateTimeInterface $dateTime)
* @method static DateTimeInterface withStartOfDay(DateTimeInterface $dateTime)
* @method static DateTimeInterface withEndOfDay(DateTimeInterface $dateTime)
* @method static DateTimeInterface immutableOrClone(DateTimeInterface $dateTime)
*/
class Temporal extends Facade
{
Expand Down
45 changes: 35 additions & 10 deletions src/Framework/Support/Facades/DateTime/TemporalFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,26 +54,51 @@ public function getCurrentFormattedDateForDatabase()
/**
* Immutably returns a new DateTime instance with the microseconds set to 0.
*
* @unreleased Extracted new immutableOrClone method.
* @since 2.20.0
*/
public function withoutMicroseconds(DateTimeInterface $dateTime)
{
if ($dateTime instanceof DateTimeImmutable) {
return $dateTime->setTime(
return $this
->immutableOrClone($dateTime)
->setTime(
$dateTime->format('H'),
$dateTime->format('i'),
$dateTime->format('s')
);
}
}

$newDateTime = clone $dateTime;
/**
* Immutably returns a new DateTime instance with the time set to the start of the day.
*
* @unreleased
*/
public function withStartOfDay(DateTimeInterface $dateTime): DateTimeInterface
{
return $this
->immutableOrClone($dateTime)
->setTime(0, 0, 0, 0);
}

$newDateTime->setTime(
$newDateTime->format('H'),
$newDateTime->format('i'),
$newDateTime->format('s')
);
/**
* Immutably returns a new DateTime instance with the time set to the end of the day.
*
* @unreleased
*/
public function withEndOfDay(DateTimeInterface $dateTime): DateTimeInterface
{
return $this
->immutableOrClone($dateTime)
->setTime(23, 59, 59, 999999);
}

return $newDateTime;
/**
* @unreleased
*/
public function immutableOrClone(DateTimeInterface $dateTime): DateTimeInterface
{
return $dateTime instanceof DateTimeImmutable
? $dateTime
: clone $dateTime;
}
}
120 changes: 120 additions & 0 deletions tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace Give\Tests\Unit\Campaigns\Routes;

use DateTime;
use Give\Campaigns\Models\Campaign;
use Give\Campaigns\Routes\CampaignOverviewStatistics;
use Give\DonationForms\Models\DonationForm;
use Give\Donations\Models\Donation;
use Give\Donations\ValueObjects\DonationStatus;
use Give\Framework\Database\DB;
use Give\Framework\Support\ValueObjects\Money;
use Give\Tests\TestCase;
use Give\Tests\TestTraits\RefreshDatabase;
use WP_REST_Request;

/**
* @unreleased
*/
final class CampaignOverviewStatisticsTest extends TestCase
{
use RefreshDatabase;

/**
* @unreleased
*/
public function testReturnsAllTimeDonationsStatistics()
{
$campaign = Campaign::factory()->create();
$form = DonationForm::factory()->create();

$db = DB::table('give_campaign_forms');
$db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);

$donation1 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('-35 days'),
]);
give_update_meta($donation1->id, '_give_completed_date', $donation1->createdAt->format('Y-m-d H:i:s'));

$donation2 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('-5 days'),
]);
give_update_meta($donation2->id, '_give_completed_date', $donation2->createdAt->format('Y-m-d H:i:s'));

$donation3 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('now'),
]);
give_update_meta($donation3->id, '_give_completed_date', $donation3->createdAt->format('Y-m-d H:i:s'));

$request = new WP_REST_Request('GET', '/give-api/v2/campaign-overview-statistics');
$request->set_param('campaignId', $campaign->id);

$route = new CampaignOverviewStatistics;
$response = $route->handleRequest($request);

$this->assertEquals(3, $response->data[0]['donorCount']);
$this->assertEquals(3, $response->data[0]['donationCount']);
$this->assertEquals(30, $response->data[0]['amountRaised']);
}

/**
* @unreleased
*/
public function testReturnsPeriodStatisticsWithPreviousPeriod()
{
$campaign = Campaign::factory()->create();
$form = DonationForm::factory()->create();

$db = DB::table('give_campaign_forms');
$db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]);

$donation1 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('-35 days'),
]);
give_update_meta($donation1->id, '_give_completed_date', $donation1->createdAt->format('Y-m-d H:i:s'));

$donation2 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('-5 days'),
]);
give_update_meta($donation2->id, '_give_completed_date', $donation2->createdAt->format('Y-m-d H:i:s'));

$donation3 = Donation::factory()->create([
'formId' => $form->id,
'status' => DonationStatus::COMPLETE(),
'amount' => new Money(1000, 'USD'),
'createdAt' => new DateTime('now'),
]);
give_update_meta($donation3->id, '_give_completed_date', $donation3->createdAt->format('Y-m-d H:i:s'));

$request = new WP_REST_Request('GET', '/give-api/v2/campaign-overview-statistics');
$request->set_param('campaignId', $campaign->id);
$request->set_param('rangeInDays', 30);

$route = new CampaignOverviewStatistics;
$response = $route->handleRequest($request);

$this->assertEquals(2, $response->data[0]['donorCount']);
$this->assertEquals(2, $response->data[0]['donationCount']);
$this->assertEquals(20, $response->data[0]['amountRaised']);

$this->assertEquals(1, $response->data[1]['donorCount']);
$this->assertEquals(1, $response->data[1]['donationCount']);
$this->assertEquals(10, $response->data[1]['amountRaised']);
}
}
Loading
Loading