Skip to content
Open
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
89 changes: 41 additions & 48 deletions app/Models/Foundation/Summit/SummitRegistrationStats.php
Original file line number Diff line number Diff line change
Expand Up @@ -599,77 +599,70 @@ public function getVirtualNonCheckedInAttendeesCount(?DateTime $startDate = nul
return 0;
}
/**
* Counts tickets per badge feature type, including the features a badge inherits from its badge type
* (same semantics as SummitAttendeeBadge::getAllFeatures).
* @param bool $checked_in_only
* @param DateTime|null $startDate
* @param DateTime|null $endDate
* @return array
* @throws \Exception
*/
public function getActiveTicketsPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array
private function getTicketsPerBadgeFeatureType(bool $checked_in_only, ?DateTime $startDate = null, ?DateTime $endDate = null): array
{
try {
$checkin_join = $checked_in_only ? 'INNER JOIN SummitAttendee ON SummitAttendee.ID = SummitAttendeeTicket.OwnerID' : '';
$checkin_filter = $checked_in_only ? ' AND SummitAttendee.SummitHallCheckedIn = 1' : '';

// one branch per level: the date filter is string concatenation on the WHERE clause,
// so it has to be applied to each branch and not to the combined query
$branch = function (string $feature_join) use ($checkin_join, $checkin_filter, $startDate, $endDate): string {
$sql = <<<SQL
SELECT SummitBadgeFeatureType.Name as type, COUNT(DISTINCT(SummitAttendeeTicket.ID)) as qty FROM SummitAttendeeTicket
SELECT SummitBadgeFeatureType.Name AS type, SummitAttendeeTicket.ID AS ticket_id FROM SummitAttendeeTicket
INNER JOIN SummitOrder ON SummitOrder.ID = SummitAttendeeTicket.OrderID
INNER JOIN SummitAttendeeBadge ON SummitAttendeeBadge.TicketID = SummitAttendeeTicket.ID
INNER JOIN SummitBadgeType ON SummitBadgeType.ID = SummitAttendeeBadge.BadgeTypeID
LEFT JOIN SummitBadgeType_BadgeFeatures ON SummitBadgeType_BadgeFeatures.SummitBadgeTypeID = SummitBadgeType.ID
LEFT JOIN SummitAttendeeBadge_Features ON SummitAttendeeBadge_Features.SummitAttendeeBadgeID = SummitAttendeeBadge.ID
INNER JOIN SummitBadgeFeatureType ON SummitBadgeFeatureType.ID = SummitAttendeeBadge_Features.SummitBadgeFeatureTypeID
OR SummitBadgeFeatureType.ID = SummitAttendeeBadge_Features.SummitBadgeFeatureTypeID
{$feature_join}
{$checkin_join}
WHERE
SummitAttendeeTicket.IsActive = 1 AND
SummitAttendeeTicket.Status = 'Paid' AND
SummitOrder.SummitID = :summit_id
SummitOrder.SummitID = :summit_id{$checkin_filter}
SQL;
return self::addDatesFilteringWithTimeZone($sql, "SummitAttendeeTicket", "Created", $startDate, $endDate);
};

$sql = self::addDatesFilteringWithTimeZone($sql, "SummitAttendeeTicket", "Created", $startDate, $endDate);
$sql .= ' GROUP BY SummitBadgeFeatureType.Name';
$stmt = $this->prepareRawSQL($sql, ['summit_id' => $this->id]);
$res = $stmt->executeQuery();
$res = $res->fetchAllAssociative();
$res = count($res) > 0 ? $res : [];
return $res;
} catch (\Exception $ex) {
$badge_level = $branch(<<<SQL
INNER JOIN SummitAttendeeBadge_Features ON SummitAttendeeBadge_Features.SummitAttendeeBadgeID = SummitAttendeeBadge.ID
INNER JOIN SummitBadgeFeatureType ON SummitBadgeFeatureType.ID = SummitAttendeeBadge_Features.SummitBadgeFeatureTypeID
SQL);
$type_level = $branch(<<<SQL
INNER JOIN SummitBadgeType_BadgeFeatures ON SummitBadgeType_BadgeFeatures.SummitBadgeTypeID = SummitAttendeeBadge.BadgeTypeID
INNER JOIN SummitBadgeFeatureType ON SummitBadgeFeatureType.ID = SummitBadgeType_BadgeFeatures.SummitBadgeFeatureTypeID
SQL);

$sql = "SELECT features.type AS type, COUNT(DISTINCT features.ticket_id) AS qty FROM ({$badge_level} UNION ALL {$type_level}) AS features GROUP BY features.type";
$stmt = $this->prepareRawSQL($sql, ['summit_id' => $this->id]);
return $stmt->executeQuery()->fetchAllAssociative();
}

}
return [];
/**
* @param DateTime|null $startDate
* @param DateTime|null $endDate
* @return array
* @throws \Exception
*/
public function getActiveTicketsPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array
{
return $this->getTicketsPerBadgeFeatureType(false, $startDate, $endDate);
}

/**
* @param DateTime|null $startDate
* @param DateTime|null $endDate
* @return array
* @throws \Exception
*/
public function getAttendeesCheckinPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array
{
try {
$sql = <<<SQL
SELECT SummitBadgeFeatureType.Name as type, COUNT(DISTINCT(SummitAttendeeTicket.ID)) as qty FROM SummitAttendeeTicket
INNER JOIN SummitOrder ON SummitOrder.ID = SummitAttendeeTicket.OrderID
INNER JOIN SummitAttendeeBadge ON SummitAttendeeBadge.TicketID = SummitAttendeeTicket.ID
INNER JOIN SummitBadgeType ON SummitBadgeType.ID = SummitAttendeeBadge.BadgeTypeID
LEFT JOIN SummitBadgeType_BadgeFeatures ON SummitBadgeType_BadgeFeatures.SummitBadgeTypeID = SummitBadgeType.ID
LEFT JOIN SummitAttendeeBadge_Features ON SummitAttendeeBadge_Features.SummitAttendeeBadgeID = SummitAttendeeBadge.ID
INNER JOIN SummitBadgeFeatureType ON SummitBadgeFeatureType.ID = SummitAttendeeBadge_Features.SummitBadgeFeatureTypeID
OR SummitBadgeFeatureType.ID = SummitAttendeeBadge_Features.SummitBadgeFeatureTypeID
INNER JOIN SummitAttendee ON SummitAttendee.ID = SummitAttendeeTicket.OwnerID
WHERE
SummitAttendeeTicket.IsActive = 1 AND
SummitAttendeeTicket.Status = 'Paid' AND
SummitOrder.SummitID = :summit_id AND
SummitAttendee.SummitHallCheckedIn = 1
SQL;

$sql = self::addDatesFilteringWithTimeZone($sql, "SummitAttendeeTicket", "Created", $startDate, $endDate);
$sql .= ' GROUP BY SummitBadgeFeatureType.Name';
$stmt = $this->prepareRawSQL($sql, ['summit_id' => $this->id]);
$res = $stmt->executeQuery();
$res = $res->fetchAllAssociative();
$res = count($res) > 0 ? $res : [];
return $res;
} catch (\Exception $ex) {

}
return [];
return $this->getTicketsPerBadgeFeatureType(true, $startDate, $endDate);
}

/**
Expand Down
137 changes: 137 additions & 0 deletions tests/Repositories/RegistrationStatsBadgeFeatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php namespace Tests\Repositories;
/*
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Models\Foundation\Main\IGroup;
use DateTime;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
use models\summit\Summit;
use models\summit\SummitAttendee;
use models\summit\SummitAttendeeBadge;
use Tests\InsertOrdersTestData;
use Tests\InsertSummitTestData;
use Tests\ProtectedApiTestCase;

/**
* The tickets per badge feature type counts include the features a badge inherits from its badge type,
* the same semantics as SummitAttendeeBadge::getAllFeatures.
*
* Fixture (feature F1 is granted at badge level, F2 by the badge type, F3 at both levels):
* ticket 0: default badge type, F1 on the badge
* ticket 1: badge type 2 (F2, F3), nothing on the badge, owner checked in
* ticket 2: badge type 2 (F2, F3), F3 on the badge, owner checked in
* ticket 3: badge type 2 (F2, F3), F1 on the badge, owner NOT checked in
*
* @package Tests\Repositories
*/
final class RegistrationStatsBadgeFeatureTest extends ProtectedApiTestCase
{
use InsertSummitTestData;
use InsertOrdersTestData;

private Summit $stats_summit;

protected function setUp(): void
{
$this->current_group = IGroup::TrackChairs;
parent::setUp();
self::insertSummitTestData();
self::InsertOrdersTestData();

[$f1, $f2, $f3] = self::$badge_features;
self::$badge_type_2->addBadgeFeatureType($f2);
self::$badge_type_2->addBadgeFeatureType($f3);

$layout = [
[self::$default_badge_type, [$f1]],
[self::$badge_type_2, []],
[self::$badge_type_2, [$f3]],
[self::$badge_type_2, [$f1]],
];

$checked_in = [];
foreach ($layout as $i => [$type, $features]) {
$ticket = self::$summit_orders[$i]->getTickets()->first();
$badge = new SummitAttendeeBadge();
$badge->setType($type);
$badge->setTicket($ticket);
foreach ($features as $f) {
$badge->addFeature($f);
}
$ticket->setBadge($badge);

if ($i === 0) continue;
$attendee = new SummitAttendee();
$attendee->setEmail(sprintf("badge-feature+%s@test.com", $i));
$attendee->setFirstName("Attendee");
$attendee->setSurname(sprintf("%s", $i));
$attendee->addTicket($ticket);
self::$summit->addAttendee($attendee);
self::$em->persist($attendee);
self::$em->flush();
if ($i !== 3) $checked_in[] = $attendee->getId();
}
self::$em->flush();

DB::table('SummitAttendee')->whereIn('ID', $checked_in)->update(['SummitHallCheckedIn' => 1]);

$id = self::$summit->getId();
self::$em->clear();
$this->stats_summit = self::$em->find(Summit::class, $id);
}

protected function tearDown(): void
{
self::clearOrdersTestData();
self::clearSummitTestData();
parent::tearDown();
}

private static function byType(array $rows): array
{
$res = array_column($rows, 'qty', 'type');
$res = array_map('intval', $res);
ksort($res);
return $res;
}

public function testSoldQtyIncludesFeaturesInheritedFromTheBadgeType(): void
{
$this->assertSame(
['FEATURE 1' => 2, 'FEATURE 2' => 3, 'FEATURE 3' => 3],
self::byType($this->stats_summit->getActiveTicketsPerBadgeFeatureType())
);
}

public function testCheckinQtyIncludesFeaturesInheritedFromTheBadgeType(): void
{
$this->assertSame(
['FEATURE 2' => 2, 'FEATURE 3' => 2],
self::byType($this->stats_summit->getAttendeesCheckinPerBadgeFeatureType())
);
}

public function testDateFilterAppliesToBothBranches(): void
{
$future = new DateTime('+1 year', new DateTimeZone('UTC'));
$this->assertSame([], $this->stats_summit->getActiveTicketsPerBadgeFeatureType($future));
$this->assertSame([], $this->stats_summit->getAttendeesCheckinPerBadgeFeatureType($future));

$past = new DateTime('-1 year', new DateTimeZone('UTC'));
$this->assertSame(
['FEATURE 1' => 2, 'FEATURE 2' => 3, 'FEATURE 3' => 3],
self::byType($this->stats_summit->getActiveTicketsPerBadgeFeatureType($past))
);
}
}
Loading