diff --git a/app/Group.php b/app/Group.php index 5ceb2a260d..555352dd14 100644 --- a/app/Group.php +++ b/app/Group.php @@ -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()) { @@ -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 diff --git a/app/Helpers/StatsPeriod.php b/app/Helpers/StatsPeriod.php new file mode 100644 index 0000000000..0a9b2a819b --- /dev/null +++ b/app/Helpers/StatsPeriod.php @@ -0,0 +1,39 @@ +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()]; + } +} diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php index f6d0f37482..46899042d9 100644 --- a/app/Http/Controllers/API/NetworkController.php +++ b/app/Http/Controllers/API/NetworkController.php @@ -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; @@ -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(); @@ -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(); @@ -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), @@ -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); } /** diff --git a/app/Http/Controllers/CalendarEventsController.php b/app/Http/Controllers/CalendarEventsController.php index 1c08fdfd96..066983b8e3 100644 --- a/app/Http/Controllers/CalendarEventsController.php +++ b/app/Http/Controllers/CalendarEventsController.php @@ -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') diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 49bc75e2e7..09cae52886 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -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); diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php index cc2e2224a9..ca68792d54 100644 --- a/app/Http/Controllers/NetworkController.php +++ b/app/Http/Controllers/NetworkController.php @@ -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. */ @@ -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 = []; @@ -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; @@ -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, diff --git a/app/Network.php b/app/Network.php index c0ea3840a1..47e7fb0f9e 100644 --- a/app/Network.php +++ b/app/Network.php @@ -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(); @@ -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(); diff --git a/app/Policies/NetworkPolicy.php b/app/Policies/NetworkPolicy.php index 4aee713fb0..5eb05d5b60 100644 --- a/app/Policies/NetworkPolicy.php +++ b/app/Policies/NetworkPolicy.php @@ -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. */ diff --git a/docs/specs/manifest.json b/docs/specs/manifest.json index 92eb69f26a..48aeda957d 100644 --- a/docs/specs/manifest.json +++ b/docs/specs/manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-14T05:47:25Z", + "generatedAt": "2026-10-23T17:10:43Z", "features": { "Administration": { "description": "Platform administration and configuration", @@ -2415,7 +2415,12 @@ "method": "NetworkController::getNetworkStatsv2", "class": "App\\Http\\Controllers\\API\\NetworkController", "file": "app/Http/Controllers/API/NetworkController.php", - "tests": [] + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] }, { "story": "As a Guest, I can list a network's group tags via the API", @@ -2458,6 +2463,20 @@ } ] }, + { + "story": "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", + "method": "NetworkController::getNetworkStatsv2", + "class": "App\\Http\\Controllers\\API\\NetworkController", + "file": "app/Http/Controllers/API/NetworkController.php", + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] + }, { "story": "As a NetworkCoordinator, I can add groups to my network", "persona": "NetworkCoordinator", @@ -2616,7 +2635,12 @@ "method": "NetworkController::getNetworkStatsv2", "class": "App\\Http\\Controllers\\API\\NetworkController", "file": "app/Http/Controllers/API/NetworkController.php", - "tests": [] + "tests": [ + { + "file": "tests/Feature/Networks/APIv2NetworkTest.php", + "test": "testNetworkStatsForLastTwelveCompletedMonths" + } + ] }, { "story": "As a ThirdParty, I can retrieve a network's group tags to display on my platform", @@ -2660,10 +2684,11 @@ ] } ], - "storyCount": 25, + "storyCount": 26, "personas": [ "Admin", "Guest", + "Host", "NetworkCoordinator", "ThirdParty" ] @@ -3594,9 +3619,10 @@ "Dashboard", "Devices", "Events", - "Groups" + "Groups", + "Networks" ], - "storyCount": 22 + "storyCount": 23 }, "NetworkCoordinator": { "features": [ @@ -3635,7 +3661,7 @@ } }, "coverage": { - "annotatedStories": 188, - "storiesWithTests": 168 + "annotatedStories": 189, + "storiesWithTests": 171 } } diff --git a/lang/en/networks.php b/lang/en/networks.php index 94bb23484a..2e7ca7afb7 100644 --- a/lang/en/networks.php +++ b/lang/en/networks.php @@ -16,6 +16,10 @@ 'impact' => 'Impact' ], 'stats' => [ + 'all_time' => 'All time', + 'last_12_months' => 'Last 12 months', + 'period_toggle' => 'Period', + 'period_range' => ':start to :end', 'groups' => '{1} Group|[0,*] Groups', 'events' => '{1} Event|[0,*] Events', 'waste_diverted' => 'Waste Diverted', @@ -65,4 +69,9 @@ 'new_field_name' => 'New field name', 'add_field' => 'Add field', ], + 'nearby' => [ + 'title' => 'Groups near you', + 'intro' => 'The nearest groups in this network to :group.', + 'distance' => ':distance km', + ], ]; diff --git a/lang/fr-BE/networks.php b/lang/fr-BE/networks.php index 87e7db3ad8..3c9ac5a75a 100644 --- a/lang/fr-BE/networks.php +++ b/lang/fr-BE/networks.php @@ -23,6 +23,10 @@ 'impact' => 'Impact', ], 'stats' => [ + 'all_time' => 'Depuis le début', + 'last_12_months' => '12 derniers mois', + 'period_toggle' => 'Période', + 'period_range' => 'De :start à :end', 'groups' => '{1} Repair Café|[0,*] Repair Cafés', 'events' => '{1} Événement|[0,*] Événements', 'waste_diverted' => 'Déchets évités', @@ -65,4 +69,9 @@ 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', ], + 'nearby' => [ + 'title' => 'Repair cafés près de chez vous', + 'intro' => 'Les repair cafés de ce réseau les plus proches de :group.', + 'distance' => ':distance km', + ], ]; diff --git a/lang/fr/networks.php b/lang/fr/networks.php index 87e7db3ad8..3c9ac5a75a 100644 --- a/lang/fr/networks.php +++ b/lang/fr/networks.php @@ -23,6 +23,10 @@ 'impact' => 'Impact', ], 'stats' => [ + 'all_time' => 'Depuis le début', + 'last_12_months' => '12 derniers mois', + 'period_toggle' => 'Période', + 'period_range' => 'De :start à :end', 'groups' => '{1} Repair Café|[0,*] Repair Cafés', 'events' => '{1} Événement|[0,*] Événements', 'waste_diverted' => 'Déchets évités', @@ -65,4 +69,9 @@ 'add_groups_warning_none_selected' => 'Pas de repair café(s) sélectionné(s)', 'none' => 'Aucun', ], + 'nearby' => [ + 'title' => 'Repair cafés près de chez vous', + 'intro' => 'Les repair cafés de ce réseau les plus proches de :group.', + 'distance' => ':distance km', + ], ]; diff --git a/resources/js/components/NetworkPage.test.js b/resources/js/components/NetworkPage.test.js index add3a466b6..64ce99b18e 100644 --- a/resources/js/components/NetworkPage.test.js +++ b/resources/js/components/NetworkPage.test.js @@ -6,6 +6,9 @@ import { mount, createLocalVue } from '@vue/test-utils' import Vuex from 'vuex' import LangMixin from 'resources/js/mixins/lang.js' import NetworkPage from './NetworkPage.vue' +import axios from 'axios' + +jest.mock('axios') const localVue = createLocalVue() localVue.use(Vuex) @@ -105,6 +108,7 @@ test('does not offer the tag filter to users who cannot see tags', () => { test('links to the network reporting dashboard from the actions menu', () => { const wrapper = makeWrapper({ + canModerate: true, network: { id: 5, name: 'Test Network', coordinators: [], reporting_url: 'https://reports.example.org/dashboard/7' }, }) @@ -142,3 +146,54 @@ test('shows a page of groups and a button for the rest, not an infinite scroll', expect(wrapper.findComponent(groupMapStub).props('showAllButton')).toBe(true) }) + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)) + +test('the period toggle fetches the last 12 completed months and says which months they are', async () => { + axios.get.mockResolvedValue({ + data: { groups: 2, parties: 7, waste_total: 10, co2_total: 20, period: { start: '2025-10-01', end: '2026-09-30' } } + }) + const wrapper = makeWrapper() + + expect(wrapper.find('.period-all').classes()).toContain('active') + await wrapper.find('.period-last12').trigger('click') + await flush() + + expect(axios.get).toHaveBeenCalledWith('/api/v2/networks/5/stats', { params: { period: 'last_12_months' } }) + expect(wrapper.find('.period-last12').classes()).toContain('active') + expect(wrapper.find('.stat-events .stat-value').text()).toBe('7') + // Jest has no translations loaded, so check the label is there and built from the period's months. + expect(wrapper.find('.stats-period').exists()).toBe(true) + expect(wrapper.vm.periodLabel).toBe('networks.stats.period_range') + expect(wrapper.vm.stats.period).toEqual({ start: '2025-10-01', end: '2026-09-30' }) + + // Back to all time uses the stats already on the page. + axios.get.mockClear() + await wrapper.find('.period-all').trigger('click') + await flush() + expect(axios.get).not.toHaveBeenCalled() + expect(wrapper.find('.stats-period').exists()).toBe(false) +}) + +test('lists the nearest groups in the network with their distance', () => { + const wrapper = makeWrapper({ + nearbyGroups: { + from: 'Hackney Fixers', + groups: [ + { id: 7, name: 'Islington Repair Cafe', location: 'Islington', distance: 5.6 }, + { id: 8, name: 'Camden Fixers', location: 'Camden', distance: 8.1 } + ] + } + }) + + const rows = wrapper.findAll('.nearby-group') + expect(rows).toHaveLength(2) + expect(rows.at(0).find('a').attributes('href')).toBe('/group/view/7') + expect(rows.at(0).text()).toContain('Islington Repair Cafe') + expect(rows.at(1).text()).toContain('Camden') +}) + +test('has no nearby groups section without a located group of your own', () => { + expect(makeWrapper().find('.nearby-groups').exists()).toBe(false) + expect(makeWrapper({ nearbyGroups: { from: 'X', groups: [] } }).find('.nearby-groups').exists()).toBe(false) +}) diff --git a/resources/js/components/NetworkPage.vue b/resources/js/components/NetworkPage.vue index c922b3e88a..a8d61b0c33 100644 --- a/resources/js/components/NetworkPage.vue +++ b/resources/js/components/NetworkPage.vue @@ -11,23 +11,27 @@
- + {{ __('networks.show.add_groups_menuitem') }} {{ __('networks.general.view_reports') }} - {{ __('groups.export_event_list') }} + {{ __('groups.export_event_list') }}
-

{{ __('networks.general.impact') }}

+
+

{{ __('networks.general.impact') }}

+ +
+

{{ periodLabel }}

{{ statsLoaded ? (stats.groups || 0) : statsPlaceholder }}
{{ __('networks.stats.groups', { count: stats.groups || 0 }) }}
-
+
{{ statsLoaded ? (stats.parties || 0) : statsPlaceholder }}
{{ __('networks.stats.events', { count: stats.parties || 0 }) }}
@@ -67,15 +71,30 @@
+ +
+

{{ __('networks.nearby.title') }}

+

{{ __('networks.nearby.intro', { group: nearbyGroups.from }) }}

+
    +
  • + + {{ g.name }} + {{ g.location }} + + {{ __('networks.nearby.distance', { distance: g.distance }) }} +
  • +
+
+ -
+

{{ __('groups.groups_title_admin') }}

{{ __('networks.show.none') }}
-
+

{{ __('events.events_title_admin') }}

{{ __('networks.show.none') }}
@@ -179,13 +198,15 @@ + + diff --git a/resources/views/networks/show.blade.php b/resources/views/networks/show.blade.php index 76a7111960..41d733a639 100644 --- a/resources/views/networks/show.blade.php +++ b/resources/views/networks/show.blade.php @@ -28,6 +28,8 @@ :initial-stats="{{ json_encode($stats) }}" :initial-tags="{{ json_encode($tags) }}" :can-manage-tags="{{ json_encode($canManageTags) }}" + :can-moderate="{{ json_encode($canModerate) }}" + :nearby-groups="{{ json_encode($nearbyGroups) }}" :can-associate-groups="{{ json_encode($canAssociateGroups) }}" :is-logged-in="{{ json_encode(Auth::check()) }}" api-token="{{ $apiToken }}" diff --git a/tests/Feature/Calendar/CalendarTest.php b/tests/Feature/Calendar/CalendarTest.php index 80e85b48d3..77c5c05fff 100644 --- a/tests/Feature/Calendar/CalendarTest.php +++ b/tests/Feature/Calendar/CalendarTest.php @@ -60,6 +60,7 @@ protected function setUp(): void 'longitude' => 5.575, 'event_start_utc' => $this->start, 'event_end_utc' => $this->end, + 'approved' => true, ]); $event->approve(); @@ -137,6 +138,25 @@ public function testByNetwork(): void { $response = $this->get('/calendar/network/' . $network->id); } + public function testByNetworkExcludesUnapproved(): void { + $network = Network::factory()->create(); + + // An approved group with only an unapproved event, and an unapproved group with an approved event. + $group = Group::factory()->create(['approved' => true]); + $network->addGroup($group); + Party::factory()->create(['group' => $group->idgroups, 'approved' => false, + 'event_start_utc' => $this->start, 'event_end_utc' => $this->end]); + + $group2 = Group::factory()->create(['approved' => false]); + $network->addGroup($group2); + $event = Party::factory()->create(['group' => $group2->idgroups, 'approved' => true, + 'event_start_utc' => $this->start, 'event_end_utc' => $this->end]); + $event->approve(); + + $this->expectException(NotFoundHttpException::class); + $this->get('/calendar/network/' . $network->id); + } + /** @story:CalendarEventsController::allEventsByArea */ public function testByArea(): void { // One event. diff --git a/tests/Feature/Events/ExportTest.php b/tests/Feature/Events/ExportTest.php index 66a548516f..47e9337cf1 100644 --- a/tests/Feature/Events/ExportTest.php +++ b/tests/Feature/Events/ExportTest.php @@ -150,9 +150,8 @@ public function testExport($role): void $row2 = fgetcsv($fh); self::assertEquals($event2->getEventName(), $row2[1]); self::assertEquals($group2->name, $row2[10]); - $row2 = fgetcsv($fh); - self::assertEquals($event3->getEventName(), $row2[1]); - self::assertEquals($group3->name, $row2[10]); + // The unapproved group3's event must not be in the network export. + self::assertFalse(fgetcsv($fh)); // Export devices. $response = $this->get("/export/devices"); diff --git a/tests/Feature/Networks/APIv2NetworkTest.php b/tests/Feature/Networks/APIv2NetworkTest.php index 214ca15cf5..cc2bcae5b3 100644 --- a/tests/Feature/Networks/APIv2NetworkTest.php +++ b/tests/Feature/Networks/APIv2NetworkTest.php @@ -906,4 +906,40 @@ public function testUnauthenticatedGroupTagsReturnsEmpty(): void { // Should return empty array $this->assertEmpty($json); } -} \ No newline at end of file + + /** + * The headline stats can be for the last 12 completed months instead of all time. + * + * @story:NetworkController::getNetworkStatsv2 + */ + public function testNetworkStatsForLastTwelveCompletedMonths(): void { + Carbon::setTestNow('2026-10-23 12:00:00'); + + $network = Network::factory()->create(); + $group = Group::factory()->create(['approved' => true]); + $network->addGroup($group); + + // The window is 1 Oct 2025 to 30 Sep 2026: the first and last events fall outside it. + foreach (['2025-09-15', '2025-10-01', '2026-09-30', '2026-10-05'] as $day) { + Party::factory()->moderated()->create([ + 'group' => $group->idgroups, + 'event_start_utc' => $day . 'T10:00:00Z', + 'event_end_utc' => $day . 'T12:00:00Z', + ]); + } + + $all = $this->get("/api/v2/networks/{$network->id}/stats")->assertSuccessful()->json(); + $this->assertEquals(4, $all['parties']); + + $recent = $this->get("/api/v2/networks/{$network->id}/stats?period=last_12_months")->assertSuccessful()->json(); + $this->assertEquals(2, $recent['parties']); + $this->assertEquals(1, $recent['groups'], 'The group tally is not limited to the period'); + $this->assertEquals('2025-10-01', $recent['period']['start']); + $this->assertEquals('2026-09-30', $recent['period']['end']); + + Carbon::setTestNow(); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + $this->getJson("/api/v2/networks/{$network->id}/stats?period=nonsense"); + } +} diff --git a/tests/Feature/Networks/NetworkPageVisibilityTest.php b/tests/Feature/Networks/NetworkPageVisibilityTest.php new file mode 100644 index 0000000000..9401e85941 --- /dev/null +++ b/tests/Feature/Networks/NetworkPageVisibilityTest.php @@ -0,0 +1,202 @@ +vueProp($response, ':network'); + } + + private function vueProp($response, string $name) + { + foreach ($this->getVueProperties($response) as $prop) { + if (array_key_exists($name, $prop)) { + return json_decode($prop[$name], true); + } + } + + $this->fail("No $name property on the page"); + } + + private function networkWithHost(): array + { + $network = Network::factory()->create(['reporting_url' => 'https://reports.example.org/dashboard/7']); + $group = Group::factory()->create(['approved' => true]); + $network->addGroup($group); + + $host = User::factory()->host()->create(); + $group->addVolunteer($host); + $group->makeMemberAHost($host); + + return [$network, $group, $host]; + } + + public function testHostOfGroupInNetworkSeesPageWithoutCoordinatorOnlyData(): void + { + [$network, , $host] = $this->networkWithHost(); + + $coordinator = User::factory()->networkCoordinator()->create(['name' => 'Cordelia Coordinator']); + $network->addCoordinator($coordinator); + + $this->actingAs($host); + $response = $this->get('/networks/' . $network->id); + $response->assertStatus(200); + + $data = $this->networkData($response); + $this->assertNull($data['reporting_url']); + + // Organisers can see who their coordinators are. + $this->assertCount(1, $data['coordinators']); + $this->assertEquals('Cordelia Coordinator', $data['coordinators'][0]['name']); + $this->assertArrayHasKey('picture', $data['coordinators'][0]); + $this->assertFalse($this->vueProp($response, ':can-moderate')); + $this->assertFalse($this->vueProp($response, ':can-manage-tags')); + $this->assertFalse($this->vueProp($response, ':can-associate-groups')); + $this->assertEmpty($this->vueProp($response, ':initial-tags')); + + $response->assertDontSee('reports.example.org'); + $response->assertDontSee('network-add-group'); + } + + public function testHostOfUnrelatedNetworkGroupIsForbidden(): void + { + [$network] = $this->networkWithHost(); + [, , $otherHost] = $this->networkWithHost(); + + $this->actingAs($otherHost); + $this->expectException(\Illuminate\Auth\Access\AuthorizationException::class); + $this->get('/networks/' . $network->id); + } + + public function testVolunteerInGroupSeesPageWithoutCoordinatorOnlyData(): void + { + [$network, $group] = $this->networkWithHost(); + $network->addCoordinator(User::factory()->networkCoordinator()->create(['name' => 'Cordelia Coordinator'])); + + $member = User::factory()->restarter()->create(); + $group->addVolunteer($member); + + $this->actingAs($member); + $response = $this->get('/networks/' . $network->id); + $response->assertStatus(200); + $this->assertNull($this->networkData($response)['reporting_url']); + $this->assertFalse($this->vueProp($response, ':can-moderate')); + $response->assertDontSee('reports.example.org'); + } + + public function testUserWithNoGroupInNetworkIsForbidden(): void + { + [$network] = $this->networkWithHost(); + + $this->actingAs(User::factory()->restarter()->create()); + $this->expectException(\Illuminate\Auth\Access\AuthorizationException::class); + $this->get('/networks/' . $network->id); + } + + public function testCoordinatorStillGetsFullPage(): void + { + [$network] = $this->networkWithHost(); + + $coordinator = User::factory()->networkCoordinator()->create(['name' => 'Cordelia Coordinator']); + $network->addCoordinator($coordinator); + + $this->actingAs($coordinator); + $response = $this->get('/networks/' . $network->id); + $response->assertStatus(200); + + $data = $this->networkData($response); + $this->assertEquals('https://reports.example.org/dashboard/7', $data['reporting_url']); + $this->assertCount(1, $data['coordinators']); + $this->assertTrue($this->vueProp($response, ':can-moderate')); + $this->assertTrue($this->vueProp($response, ':can-manage-tags')); + } + + public function testAdministratorStillGetsFullPage(): void + { + [$network] = $this->networkWithHost(); + + $this->actingAs(User::factory()->administrator()->create()); + $response = $this->get('/networks/' . $network->id); + $response->assertStatus(200); + $this->assertTrue($this->vueProp($response, ':can-moderate')); + } + + private function groupAt(Network $network, string $name, float $lat, float $lng, array $extra = []): Group + { + $group = Group::factory()->create(array_merge(['name' => $name, 'approved' => true, 'latitude' => $lat, 'longitude' => $lng], $extra)); + $network->addGroup($group); + + return $group; + } + + public function testHostSeesTheFiveNearestGroupsInTheNetwork(): void + { + [$network, $group, $host] = $this->networkWithHost(); + $group->update(['latitude' => 51.50, 'longitude' => -0.10]); + + // Six candidates at increasing distances, plus groups that must not be listed. + foreach ([1, 2, 3, 4, 5, 6] as $i) { + $this->groupAt($network, "Near $i", 51.50 + $i * 0.05, -0.10); + } + $this->groupAt($network, 'Unapproved', 51.501, -0.10, ['approved' => false]); + $this->groupAt($network, 'Archived', 51.501, -0.10, ['archived_at' => '2020-01-01']); + Group::factory()->create(['name' => 'Other network', 'approved' => true, 'latitude' => 51.501, 'longitude' => -0.10]); + + $this->actingAs($host); + $response = $this->get('/networks/' . $network->id); + $response->assertStatus(200); + + $nearby = $this->vueProp($response, ':nearby-groups'); + $this->assertEquals($group->name, $nearby['from']); + $this->assertEquals(['Near 1', 'Near 2', 'Near 3', 'Near 4', 'Near 5'], array_column($nearby['groups'], 'name')); + $this->assertEqualsWithDelta(5.6, $nearby['groups'][0]['distance'], 0.2); + } + + public function testUserInSeveralGroupsSeesNearestToAnyOfThemOnce(): void + { + [$network, $group, $host] = $this->networkWithHost(); + $group->update(['latitude' => 51.50, 'longitude' => -0.10]); + + $second = $this->groupAt($network, 'Second home', 53.00, -0.10); + $second->addVolunteer($host); + + // One near each of the user's groups, and one far from both. + $this->groupAt($network, 'By first', 51.55, -0.10); + $this->groupAt($network, 'By second', 53.03, -0.10); + $this->groupAt($network, 'Far away', 56.00, -0.10); + + $this->actingAs($host); + $nearby = $this->vueProp($this->get('/networks/' . $network->id), ':nearby-groups'); + + $names = array_column($nearby['groups'], 'name'); + $this->assertEquals(['By second', 'By first', 'Far away'], $names); + $this->assertCount(count(array_unique($names)), $names); + $this->assertNotContains('Second home', $names); + $this->assertNotContains($group->name, $names); + $this->assertEqualsWithDelta(3.3, $nearby['groups'][0]['distance'], 0.2); + $this->assertEqualsWithDelta(5.6, $nearby['groups'][1]['distance'], 0.2); + } + + public function testNoNearbyGroupsWithoutAGroupLocation(): void + { + [$network, $group, $host] = $this->networkWithHost(); + $group->update(['latitude' => null, 'longitude' => null]); + $this->groupAt($network, 'Somewhere', 51.5, -0.1); + + $this->actingAs($host); + $this->assertNull($this->vueProp($this->get('/networks/' . $network->id), ':nearby-groups')); + + // A coordinator with no group of their own gets no nearby section either. + $coordinator = User::factory()->networkCoordinator()->create(); + $network->addCoordinator($coordinator); + $this->actingAs($coordinator); + $this->assertNull($this->vueProp($this->get('/networks/' . $network->id), ':nearby-groups')); + } +} diff --git a/tests/Unit/StatsPeriodTest.php b/tests/Unit/StatsPeriodTest.php new file mode 100644 index 0000000000..55c2d59e02 --- /dev/null +++ b/tests/Unit/StatsPeriodTest.php @@ -0,0 +1,27 @@ +assertSame([null, null], StatsPeriod::window('all')); + $this->assertSame([null, null], StatsPeriod::window(null)); + } + + public function testLastTwelveMonthsAreCompletedCalendarMonths(): void + { + foreach (['2026-10-01 00:00:00', '2026-10-24 12:00:00', '2026-10-31 23:59:59'] as $now) { + [$from, $to] = StatsPeriod::window('last_12_months', Carbon::parse($now, 'UTC')); + $this->assertEquals(['start' => '2025-10-01', 'end' => '2026-09-30'], StatsPeriod::describe($from, $to)); + } + + [$from, $to] = StatsPeriod::window('last_12_months', Carbon::parse('2026-01-15', 'UTC')); + $this->assertEquals(['start' => '2025-01-01', 'end' => '2025-12-31'], StatsPeriod::describe($from, $to)); + } +}