Skip to content
Closed
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
6 changes: 5 additions & 1 deletion app/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,10 @@ public function getGroupStats($eEmissionRatio = null, $uEmissionratio = null)
/**
* Compute stats for multiple groups in O(1) queries instead of O(N).
* Returns a map of [groupId => stats array].
*
* $from and $to (UTC, $to exclusive) limit the stats to events starting in that window.
*/
public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio = null, ?float $uEmissionratio = null): array
public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio = null, ?float $uEmissionratio = null, ?\Carbon\Carbon $from = null, ?\Carbon\Carbon $to = null): array
{
$groups = collect($groups);
if ($groups->isEmpty()) {
Expand Down Expand Up @@ -351,6 +353,8 @@ public static function bulkGroupStats(iterable $groups, ?float $eEmissionRatio =
->with('allDevices')
->withCount('allInvited')
->whereIn('events.group', $groupIds)
->when($from, fn ($q) => $q->where('event_start_utc', '>=', $from->toIso8601String()))
->when($to, fn ($q) => $q->where('event_start_utc', '<', $to->toIso8601String()))
// lazyById pages on the primary key (WHERE idevents > last) rather than lazy()'s growing
// OFFSET, which made MySQL rescan every earlier row for each chunk on large networks. past()
// orders by date, and keyset paging needs the id to be the only sort key or events are skipped
Expand Down
39 changes: 39 additions & 0 deletions app/Helpers/StatsPeriod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Helpers;

use Carbon\Carbon;

/**
* Date windows for headline stats toggles ("All time" / "Last 12 months"), shared so the Fixometer and group pages
* can offer the same toggle later.
*/
class StatsPeriod
{
public const ALL = 'all';
public const LAST_12_MONTHS = 'last_12_months';

/**
* The window for a period name as [from, to] (UTC, to exclusive), or [null, null] for all time. Last 12 months
* is the last 12 completed calendar months, so the figure doesn't change day by day: on 2026-10-24 it is
* 2025-10-01 to 2026-09-30.
*/
public static function window(?string $period, ?Carbon $now = null): array
{
if ($period !== self::LAST_12_MONTHS) {
return [null, null];
}

$to = ($now ?? Carbon::now('UTC'))->copy()->setTimezone('UTC')->startOfMonth();

return [$to->copy()->subMonths(12), $to];
}

/**
* The first and last days covered by a window, for display.
*/
public static function describe(Carbon $from, Carbon $to): array
{
return ['start' => $from->toDateString(), 'end' => $to->copy()->subDay()->toDateString()];
}
}
35 changes: 29 additions & 6 deletions app/Http/Controllers/API/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Network;
use App\Party;
use Auth;
use App\Helpers\StatsPeriod;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -40,7 +41,7 @@ public function stats(Request $request, Network $network): JsonResponse
/**
* Get stats for groups in a network that have a specific tag.
*/
private function statsForTag(Network $network, int $tagId): array
private function statsForTag(Network $network, int $tagId, ?Carbon $from = null, ?Carbon $to = null): array
{
$eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered();
$uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered();
Expand All @@ -51,7 +52,7 @@ private function statsForTag(Network $network, int $tagId): array
->where('grouptags_groups.group_tag', $tagId)
->get();

$allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio);
$allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio, $from, $to);

$stats = \App\Group::getGroupStatsArrayKeys();

Expand Down Expand Up @@ -521,10 +522,24 @@ public function getNetworkTagsv2(Request $request, $id)
* type="integer"
* )
* ),
* @OA\Parameter(
* name="period",
* description="all (default) for all time, or last_12_months for events in the last 12 completed calendar months. The group count is always the current one.",
* required=false,
* in="query",
* @OA\Schema(
* type="string",
* enum={"all", "last_12_months"}
* )
* ),
* @OA\Response(
* response=200,
* description="Successful operation",
* @OA\JsonContent(
* @OA\Property(property="period", type="object", description="Only for last_12_months: the first and last days covered.",
* @OA\Property(property="start", type="string", example="2025-10-01"),
* @OA\Property(property="end", type="string", example="2026-09-30")
* ),
* @OA\Property(property="co2_total", type="number", example=17631.6),
* @OA\Property(property="co2_powered", type="number", example=17475.97),
* @OA\Property(property="co2_unpowered", type="number", example=155.64),
Expand All @@ -550,16 +565,24 @@ public function getNetworkTagsv2(Request $request, $id)
*/
#[UserStory('As a Guest, I can view a network\'s impact statistics via the API, optionally for one group tag', persona: 'Guest', theme: 'Network stats')]
#[UserStory('As a ThirdParty, I can retrieve a network\'s impact statistics to display on my platform', persona: 'ThirdParty', theme: 'Network stats')]
#[UserStory('As a Host, I can see my network\'s impact over the last 12 completed months as well as all time', persona: 'Host', theme: 'Network stats')]
public function getNetworkStatsv2(Request $request, $id): JsonResponse
{
$network = Network::findOrFail($id);

if ($request->has('group_tag')) {
$tagId = $request->get('group_tag');
return response()->json($this->statsForTag($network, $tagId));
$request->validate(['period' => 'nullable|in:' . StatsPeriod::ALL . ',' . StatsPeriod::LAST_12_MONTHS]);

[$from, $to] = StatsPeriod::window($request->get('period'));

$stats = $request->has('group_tag')
? $this->statsForTag($network, $request->get('group_tag'), $from, $to)
: $network->stats($from, $to);

if ($from) {
$stats['period'] = StatsPeriod::describe($from, $to);
}

return response()->json($network->stats());
return response()->json($stats);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/CalendarEventsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ public function allEventsByNetwork(Request $request, Network $network)
$query->where('group_network.network_id', $network->id)
->whereNull('events.deleted_at');
})
->where('events.approved', true)
->where('groups.approved', true)
->select('events.*', 'groups.name')
->groupBy('events.idevents')
->orderBy('events.event_start_utc', 'ASC')
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ public function networkEvents(Request $request, $id)
$network = Network::findOrFail($id);
$parties = collect([]);

foreach ($network->groups as $group) {
$parties = $parties->merge($group->parties()->undeleted()->get());
foreach ($network->groups()->where('groups.approved', true)->get() as $group) {
$parties = $parties->merge($group->parties()->undeleted()->where('events.approved', true)->get());
}

return $this->exportEvents($parties);
Expand Down
60 changes: 58 additions & 2 deletions app/Http/Controllers/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,55 @@ public function index(): View
]);
}

/**
* The groups in the network nearest to any of the user's own located groups in it, nearest first, with each
* group listed once at its smallest distance. The user's own groups are left out. Null if they have no
* located group in the network.
*/
private function nearbyGroups(Network $network, $user): ?array
{
$mine = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups')
->join('group_network', 'group_network.group_id', '=', 'groups.idgroups')
->where('group_network.network_id', $network->id)
->where('users_groups.user', $user->id)
->whereNull('users_groups.deleted_at')
->whereNotNull('groups.latitude')
->whereNotNull('groups.longitude')
->orderBy('groups.name')
->select('groups.*')
->get();

if ($mine->isEmpty()) {
return null;
}

$mineIds = $mine->pluck('idgroups')->all();
$nearest = [];

foreach ($mine as $group) {
// Ask for extra so that skipping the user's other groups still leaves enough.
foreach ($network->nearestGroups($group, 5 + count($mineIds)) as $g) {
if (in_array($g->idgroups, $mineIds)) {
continue;
}

if (! isset($nearest[$g->idgroups]) || $g->dist < $nearest[$g->idgroups]->dist) {
$nearest[$g->idgroups] = $g;
}
}
}

return [
'from' => $mine->pluck('name')->implode(', '),
'groups' => collect($nearest)->sortBy('dist')->take(5)->map(fn ($g) => [
'id' => $g->idgroups,
'name' => $g->name,
'location' => $g->location,
'distance' => round($g->dist, 1),
])->values()->all(),
];
}

/**
* Display the specified network.
*/
Expand All @@ -56,7 +105,11 @@ public function show(Network $network): View
{
$user = Auth::user();

$this->authorize('view', $network);
// Coordinators and admins get the full page; volunteers and hosts of groups in the network get it without the
// coordinator-only parts.
$this->authorize('viewPublic', $network);

$canModerate = $user->can('view', $network);

$groupsForAssociating = [];

Expand Down Expand Up @@ -93,8 +146,9 @@ public function show(Network $network): View
'name' => $network->name,
'description' => $network->description,
'website' => $network->website,
'reporting_url' => $network->reporting_url,
'reporting_url' => $canModerate ? $network->reporting_url : null,
'logo' => $network->sizedLogo('_x100'),
// Organisers can see who their coordinators are, so they know who to contact.
'coordinators' => $network->coordinators->map(function ($c) {
$profile = $c->getProfile($c->id);
$path = $profile ? $profile->path : null;
Expand All @@ -107,6 +161,8 @@ public function show(Network $network): View
];

return view('networks.show', [
'canModerate' => $canModerate,
'nearbyGroups' => $this->nearbyGroups($network, $user),
'network' => $network,
'networkData' => $networkData,
'groupsForAssociating' => $groupsForAssociating,
Expand Down
31 changes: 29 additions & 2 deletions app/Network.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,34 @@ public function groupsNotIn()
return $query->get();
}

public function stats()
/**
* The approved, active groups in this network nearest to $from (not including it), with their distance in km.
*/
public function nearestGroups(Group $from, int $count = 5)
{
if (is_null($from->latitude) || is_null($from->longitude)) {
return collect();
}

return $this->groups()
->select('groups.idgroups', 'groups.name', 'groups.location', 'groups.latitude', 'groups.longitude')
->selectRaw(
'( 6371 * acos( cos( radians(?) ) * cos( radians( latitude ) ) * cos( radians( longitude ) - radians(?) ) + sin( radians(?) ) * sin( radians( latitude ) ) ) ) AS dist',
[$from->latitude, $from->longitude, $from->latitude]
)
->where('groups.idgroups', '!=', $from->idgroups)
->where('groups.approved', true)
->whereNull('groups.archived_at')
->whereNotNull('groups.latitude')
->whereNotNull('groups.longitude')
->orderBy('dist')
->take($count)
->get();
}

// $from and $to (UTC, $to exclusive) limit the impact figures to events in that window. The group tally is
// always the current one.
public function stats(?\Carbon\Carbon $from = null, ?\Carbon\Carbon $to = null)
{
$eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered();
$uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered();
Expand All @@ -110,7 +137,7 @@ public function stats()
$groups = $this->groups()->withoutGlobalScopes()->without(['networks', 'group_tags'])
->get(['groups.idgroups', 'groups.archived_at']);

$allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio);
$allStats = \App\Group::bulkGroupStats($groups, $eEmissionRatio, $uEmissionratio, $from, $to);

$stats = \App\Group::getGroupStatsArrayKeys();

Expand Down
16 changes: 16 additions & 0 deletions app/Policies/NetworkPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ public function view(User $user, Network $network): bool
return false;
}

/**
* Determine whether the user can view the network page with its coordinator-only parts hidden. That is
* anyone who can fully view it, plus the volunteers and hosts of a group in the network.
*/
public function viewPublic(User $user, Network $network): bool
{
if ($this->view($user, $network)) {
return true;
}

return \App\UserGroups::where('user', $user->id)
->where(fn ($q) => $q->where('status', 1)->orWhereNull('status'))
->whereIn('group', \DB::table('group_network')->where('network_id', $network->id)->select('group_id'))
->exists();
}

/**
* Determine whether the user can create networks.
*/
Expand Down
Loading
Loading