diff --git a/.gitignore b/.gitignore index 2e7256eadb..20c803a683 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /public/storage /public/uploads /public/devices.csv +/public/exports /public/report.html npm-debug.log yarn-error.log diff --git a/app/Console/Commands/ExportRepairData.php b/app/Console/Commands/ExportRepairData.php new file mode 100644 index 0000000000..c4a9f60051 --- /dev/null +++ b/app/Console/Commands/ExportRepairData.php @@ -0,0 +1,88 @@ +count(), + DB::table('devices')->max('updated_at'), + DB::table('events')->max('updated_at'), + DB::table('groups')->max('updated_at'), + ]); + } + + public function handle(RepairDataExport $export): int + { + $path = self::path(); + $fingerprint = $this->fingerprint(); + + if (! $this->option('force') && file_exists($path)) { + if (time() - filemtime($path) < self::MAX_AGE_SECONDS) { + $this->info('Built recently; not rebuilding.'); + + return self::SUCCESS; + } + + if (@file_get_contents($this->fingerprintPath()) === $fingerprint) { + // Mark it as checked, so we don't check again until tomorrow. + touch($path); + $this->info('Data unchanged; not rebuilding.'); + + return self::SUCCESS; + } + } + + foreach ([dirname($path), dirname($this->fingerprintPath())] as $dir) { + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + } + + // Build alongside and then swap in, so nobody downloads a half-written file. + $tmp = $path.'.tmp'; + $file = fopen($tmp, 'w'); + $export->write($export->query(), $file); + fclose($file); + rename($tmp, $path); + + file_put_contents($this->fingerprintPath(), $fingerprint); + $this->info('Built '.$path); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index a33477ed8d..2fe0c8593a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -44,6 +44,9 @@ protected function schedule(Schedule $schedule): void $schedule->command('event:timezones')->hourly(); $schedule->command('wordpress:event:create_failed')->daily(); + + // Rebuilds at most daily; hourly so that a new machine gets the file soon after it starts. + $schedule->command('export:repair-data')->hourly()->withoutOverlapping(); } /** diff --git a/app/Device.php b/app/Device.php index 627cf20463..200cd7510c 100644 --- a/app/Device.php +++ b/app/Device.php @@ -207,6 +207,9 @@ public function countByClustersYearStatus($group) return DB::select($sql, ['group' => $group]); } + /** + * The most common categories, powered and unpowered, leaving out the "None of the above" ones. + */ public function findMostSeen($status = null, $cluster = null, $group = null) { $sql = 'SELECT COUNT(`d`.`category`) AS `counter`, `c`.`name` FROM `'.$this->table.'` AS `d` @@ -214,7 +217,7 @@ public function findMostSeen($status = null, $cluster = null, $group = null) ON `d`.`event` = `e`.`idevents` INNER JOIN `categories` AS `c` ON `d`.`category` = `c`.`idcategories` - WHERE 1=1 and `c`.`powered` = 1 AND `c`.`idcategories` <> '.env('MISC_CATEGORY_ID_POWERED'); + WHERE `c`.`idcategories` NOT IN ('.(int) env('MISC_CATEGORY_ID_POWERED').', '.(int) env('MISC_CATEGORY_ID_UNPOWERED').')'; if (! is_null($status) && is_numeric($status)) { $sql .= ' AND `d`.`repair_status` = :status '; diff --git a/app/Group.php b/app/Group.php index 097de77cf5..34fd9cd7f7 100644 --- a/app/Group.php +++ b/app/Group.php @@ -497,6 +497,35 @@ public function isMemberOf($network) return $this->networks->contains($network); } + /** + * The group reporting dashboard, filtered to this group, or null if reporting isn't set up. + * + * The dashboard is in the language of the group's network, falling back to English when there's no + * dashboard in that language or its base language. + */ + public function reportingUrl(): ?string + { + $templates = config('restarters.reporting.group_urls', []); + $network = $this->networks->first(); + $language = $network ? $network->default_language : null; + + // A regional language (e.g. Repair Together's fr-BE) uses the dashboard for its base language. + $base = $language ? explode('-', $language)[0] : null; + $template = ($language ? ($templates[$language] ?? null) : null) + ?: ($base ? ($templates[$base] ?? null) : null) + ?: ($templates['en'] ?? null); + + if (! $template) { + return null; + } + + return str_replace( + ['{group}', '{group_name}'], + [$this->idgroups, rawurlencode($this->name)], + $template + ); + } + // If just one of the networks that the group is a member of should push to Wordpress, then we should push. // If an group is not approved, then we should not push the events to Wordpress. public function eventsShouldPushToWordpress() diff --git a/app/Http/Controllers/ApiController.php b/app/Http/Controllers/ApiController.php index 7e6f2e05da..878694a4a9 100644 --- a/app/Http/Controllers/ApiController.php +++ b/app/Http/Controllers/ApiController.php @@ -7,6 +7,7 @@ use App\Group; use App\Party; use App\User; +use Carbon\Carbon; use Auth; use DB; use Illuminate\Http\Request; @@ -51,24 +52,27 @@ public static function homepage_data(): JsonResponse { $result = []; - $lock = \Cache::lock('homepage_data_lock', 60); + $lock = \Cache::lock('homepage_data_v3_lock', 60); - if (\Cache::has('homepage_data')) { - $result = \Cache::get('homepage_data'); + if (\Cache::has('homepage_data_v3')) { + $result = \Cache::get('homepage_data_v3'); } elseif ($lock->get()) { try { $Device = new Device; // Aggregate participants and hours in SQL — avoids loading 18k+ event rows into PHP. // hoursVolunteered() formula: cancelled→3, volunteers>0→9+volunteers*ceil(minutes/60), else→21 + // Events held leaves out cancelled events and those of groups that aren't approved. $eventStats = DB::table('events') - ->whereNull('deleted_at') - ->where('event_end_utc', '<', now()) + ->leftJoin('groups', 'groups.idgroups', '=', 'events.group') + ->whereNull('events.deleted_at') + ->where('events.event_end_utc', '<', now()) ->selectRaw(" - SUM(pax) as participants, + SUM(CASE WHEN events.cancelled = 0 AND groups.approved = 1 THEN 1 ELSE 0 END) as events, + SUM(events.pax) as participants, SUM(CASE - WHEN cancelled = 1 THEN 3 - WHEN volunteers > 0 THEN 9 + volunteers * CEIL(TIMESTAMPDIFF(MINUTE, event_start_utc, event_end_utc) / 60) + WHEN events.cancelled = 1 THEN 3 + WHEN events.volunteers > 0 THEN 9 + events.volunteers * CEIL(TIMESTAMPDIFF(MINUTE, events.event_start_utc, events.event_end_utc) / 60) ELSE 21 END) as hours_volunteered ") @@ -76,6 +80,7 @@ public static function homepage_data(): JsonResponse $result['participants'] = (int) ($eventStats->participants ?? 0); $result['hours_volunteered'] = (int) ($eventStats->hours_volunteered ?? 0); + $result['events'] = (int) ($eventStats->events ?? 0); $fixed = $Device->statusCount(); $result['items_fixed'] = count($fixed) ? $fixed[0]->counter : 0; @@ -93,6 +98,7 @@ public static function homepage_data(): JsonResponse $result['fixed_unpowered'] = $devices->fixedUnpoweredCount(); $result['total_powered'] = $devices->poweredCount(); $result['total_unpowered'] = $devices->unpoweredCount(); + $result['total_items'] = $result['total_powered'] + $result['total_unpowered']; // for backward compatibility (don't break therestartproject.org) $result['weights'] = round($result['waste_total']); @@ -100,13 +106,13 @@ public static function homepage_data(): JsonResponse $result['unpowered_waste'] = round($result['waste_unpowered']); $result['emissions'] = round($result['co2_total']); - \Cache::put('homepage_data', $result, 43200); + \Cache::put('homepage_data_v3', $result, 43200); } finally { $lock->release(); } } else { // Another worker is rebuilding — return stale or empty rather than pile on - $result = \Cache::get('homepage_data', []); + $result = \Cache::get('homepage_data_v3', []); } return response() @@ -200,75 +206,107 @@ public static function getUserList() } /** - * List/search devices. + * The where clauses for the device list's search filters. */ - public static function getDevices(Request $request, $page, $size): JsonResponse + private static function deviceFilters(Request $request): array { - $powered = $request->input('powered'); - $sortBy = $request->input('sortBy'); - $sortDesc = $request->input('sortDesc'); - $category = $request->input('category'); - $brand = $request->input('brand'); - $model = $request->input('model'); - $item_type = $request->input('item_type'); - $status = $request->input('status'); - $comments = $request->input('comments'); - $wiki = filter_var($request->input('wiki', false), FILTER_VALIDATE_BOOLEAN); - $group = $request->input('group'); - $from_date = $request->input('from_date'); - $to_date = $request->input('to_date'); - - $wheres = [ - ['categories.powered', '=', $powered == 'true' ? 1 : 0], - ]; + $wheres = []; - if ($category) { - $wheres[] = ['idcategories', '=', $category]; - } + // No powered filter means both powered and unpowered items. + $powered = $request->input('powered'); - if ($brand) { - $wheres[] = ['devices.brand', 'LIKE', '%'.$brand.'%']; + if ($powered === 'true' || $powered === 'false') { + $wheres[] = ['categories.powered', '=', $powered === 'true' ? 1 : 0]; } - if ($model) { - $wheres[] = ['devices.model', 'LIKE', '%'.$model.'%']; + if ($request->input('category')) { + $wheres[] = ['idcategories', '=', $request->input('category')]; } - if ($item_type) { - $wheres[] = ['devices.item_type', 'LIKE', '%'.$item_type.'%']; - } + // Free text searches. + $likes = [ + 'brand' => 'devices.brand', + 'model' => 'devices.model', + 'item_type' => 'devices.item_type', + 'comments' => 'devices.problem', + 'group' => 'groups.name', + ]; - if ($comments) { - $wheres[] = ['devices.problem', 'LIKE', '%'.$comments.'%']; + foreach ($likes as $param => $column) { + if ($request->input($param)) { + $wheres[] = [$column, 'LIKE', '%'.$request->input($param).'%']; + } } - if ($wiki) { + if (filter_var($request->input('wiki', false), FILTER_VALIDATE_BOOLEAN)) { $wheres[] = ['devices.wiki', '=', 1]; } + $status = $request->input('status'); + if ($status) { - $wheres[] = ['repair_status', '=', $status]; - } + // The client uses the status strings from the rest of the API; accept the underlying numbers too. + $statuses = [ + Device::REPAIR_STATUS_FIXED_STR => Device::REPAIR_STATUS_FIXED, + Device::REPAIR_STATUS_REPAIRABLE_STR => Device::REPAIR_STATUS_REPAIRABLE, + Device::REPAIR_STATUS_ENDOFLIFE_STR => Device::REPAIR_STATUS_ENDOFLIFE, + ]; - if ($group) { - $wheres[] = ['groups.name', 'LIKE', '%'.$group.'%']; + $wheres[] = ['repair_status', '=', $statuses[$status] ?? intval($status)]; } - if ($from_date) { - $wheres[] = ['events.event_start_utc', '>=', $from_date]; + if ($request->input('from_date')) { + $wheres[] = ['events.event_start_utc', '>=', Carbon::parse($request->input('from_date'))->startOfDay()]; } - if ($to_date) { - $wheres[] = ['events.event_end_utc', '<=', $to_date]; + if ($request->input('to_date')) { + // The date is inclusive - events on that day count. + $wheres[] = ['events.event_start_utc', '<', Carbon::parse($request->input('to_date'))->startOfDay()->addDay()]; } - // Get the items we want for this page. + return $wheres; + } + + /** + * List/search devices. + */ + public static function getDevices(Request $request, $page, $size): JsonResponse + { + $request->validate([ + 'from_date' => 'nullable|date', + 'to_date' => 'nullable|date', + ]); + + // The client asks to sort by one of its table columns; map those onto database columns. Anything else + // gets the default of most recent event first. + $sortColumns = [ + 'item_type' => 'devices.item_type', + 'category' => 'categories.name', + 'device_category.name' => 'categories.name', + 'brand' => 'devices.brand', + 'groupname' => 'groups.name', + 'repair_status' => 'devices.repair_status', + 'event_date' => 'events.event_start_utc', + 'created_at' => 'devices.created_at', + 'iddevices' => 'devices.iddevices', + ]; + + $sortBy = $sortColumns[$request->input('sortBy')] ?? 'events.event_start_utc'; + $sortDesc = strtolower($request->input('sortDesc', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $wheres = self::deviceFilters($request); + + // Get the items we want for this page. Select only device columns - the joined tables share column names + // such as created_at, which would otherwise overwrite the device's own. $query = Device::with(['deviceEvent.theGroup', 'deviceCategory', 'barriers']) + ->select('devices.*') ->join('events', 'events.idevents', '=', 'devices.event') ->join('groups', 'events.group', '=', 'groups.idgroups') ->join('categories', 'devices.category', '=', 'categories.idcategories') + ->whereNull('events.deleted_at') ->where($wheres) - ->orderBy($sortBy, $sortDesc); + ->orderBy($sortBy, $sortDesc) + ->orderBy('devices.iddevices', $sortDesc); // Get total info across all pages. $count = $query->count(); diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php index f68d119897..494906b8ec 100644 --- a/app/Http/Controllers/DeviceController.php +++ b/app/Http/Controllers/DeviceController.php @@ -62,6 +62,8 @@ public function index($search = null): \Illuminate\View\View 'clusters' => $clusters, 'barriers' => \App\Helpers\Fixometer::allBarriers(), 'brands' => $brands, + // The nightly file if it's there; a new machine won't have it until the hourly build has run. + 'download_url' => file_exists(\App\Console\Commands\ExportRepairData::path()) ? '/exports/repair-data.csv' : '/export/devices', ]); } diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 2a48b2dbfd..0116a82734 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -37,23 +37,10 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) // To not display column if the referring URL is therestartproject.org $host = parse_url(\Request::server('HTTP_REFERER'), PHP_URL_HOST); - $all_devices = Device::with([ - 'deviceCategory', - 'deviceEvent', - ]) - ->join('events', 'events.idevents', '=', 'devices.event') - ->join('groups', 'groups.idgroups', '=', 'events.group') - ->when($idevents != NULL, function($query) use ($idevents) { - return $query->where('events.idevents', $idevents); - }) - ->when($idgroups != NULL, function($query) use ($idgroups) { - return $query->where('events.group', $idgroups); - }) - ->select('devices.*', 'groups.name AS group_name')->get(); + $me = auth()->user(); - $displacementFactor = \App\Device::getDisplacementFactor(); - $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); - $uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered(); + $export = app(\App\Services\RepairDataExport::class); + $all_devices = $export->query($me, $idevents, $idgroups); // Create CSV $filename = 'repair-data'; @@ -75,70 +62,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) $fullpath = $this->exportPath($filename); $file = fopen($fullpath, 'w+'); - $me = auth()->user(); - - // We can't put accented characters into a CSV file, so flatten them. - // Use //TRANSLIT//IGNORE to handle characters that can't be transliterated on - // servers with older glibc (e.g. 2.27) and POSIX locale, which lack transliteration - // tables for certain Unicode characters like emdash (—). Without //IGNORE, iconv - // throws "Detected an illegal character in input string" on such systems. - $columns = [ - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.item_type_short')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.category')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.brand')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.model')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.title_assessment')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.repair_status')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.spare_parts')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.event')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('groups.group')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.event_date')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.stat-7')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.stat-6')), - iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', ucfirst(__('devices.title_powered'))) - ]; - - fputcsv($file, $columns); - $party = null; - - foreach ($all_devices as $device) { - set_time_limit(60); - $party = !$party || $party->idevents != $device->event ? Party::findOrFail($device->event) : $party; - - if (User::userCanSeeEvent($me, $party)) { - $wasteImpact = 0; - $co2Diverted = 0; - - if ($device->isFixed()) - { - if ($device->deviceCategory->powered) - { - $wasteImpact = $device->eWasteDiverted(); - $co2Diverted = $device->eCo2Diverted($eEmissionRatio, $displacementFactor); - } else - { - $wasteImpact = $device->uWasteDiverted(); - $co2Diverted = $device->uCo2Diverted($uEmissionratio, $displacementFactor); - } - } - - fputcsv($file, $this->csvSafeRow([ - $device->item_type, - $device->deviceCategory->name, - $device->brand, - $device->model, - $device->problem, - $device->getRepairStatus(), - $device->getSpareParts(), - $device->deviceEvent->getEventName(), - $device->deviceEvent->theGroup->name, - $device->deviceEvent->getFormattedLocalStart('Y-m-d'), - $wasteImpact, - $co2Diverted, - $device->deviceCategory->powered ? 'Powered' : 'Unpowered' - ])); - } - } + $export->write($all_devices, $file); fclose($file); @@ -226,7 +150,7 @@ private function exportEvents($parties) { fputcsv($file, $headers); foreach ($PartyArray as $d) { - fputcsv($file, $this->csvSafeRow($d)); + fputcsv($file, \App\Services\RepairDataExport::csvSafeRow($d)); } fclose($file); @@ -252,19 +176,4 @@ private function exportPath($filename) return $dir . DIRECTORY_SEPARATOR . $filename; } - - /** - * Spreadsheets treat a cell starting with =, +, - or @ as a formula, so prefix those - * with an apostrophe. Device fields are free text entered at events. - */ - private function csvSafeRow(array $row) - { - return array_map(function ($value) { - if (is_string($value) && $value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { - return "'" . $value; - } - - return $value; - }, $row); - } } diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php index a28eaabcba..e95347c92b 100644 --- a/app/Http/Controllers/GroupController.php +++ b/app/Http/Controllers/GroupController.php @@ -192,66 +192,6 @@ public function view($groupid) $Device->ofThisGroup($group->idgroups); - $counts = $Device->countByClustersYearStatus($group->idgroups); - $template = [ - 0 => [ - 'counter' => 0, - 'repair_status' => 1, - ], - 1 => [ - 'counter' => 0, - 'repair_status' => 2, - ], - 2 => [ - 'counter' => 0, - 'repair_status' => 3, - ], - 'total' => 0 - ]; - - $clusters = [ - 'all' => [ - 1 => $template, - 2 => $template, - 3 => $template, - 4 => $template, - ] - ]; - - foreach ($counts as $count) { - $year = $count->year; - $cluster = $count->cluster; - $repair_status = $count->repair_status; - $counter = $count->counter; - - if ($repair_status && $cluster) { - if (array_key_exists($cluster, $clusters['all'])) { - $clusters['all'][$cluster][$repair_status - 1]['counter'] += $counter; - $clusters['all'][$cluster]['total'] += $counter; - - if (!array_key_exists($year, $clusters)) { - $clusters[$year] = [ - 1 => $template, - 2 => $template, - 3 => $template, - 4 => $template, - ]; - } - - $clusters[$year][$cluster][$repair_status - 1]['counter'] += $counter; - $clusters[$year][$cluster]['total'] += $counter; - } - } - } - - // most/least stats for clusters - $mostleast = []; - for ($i = 1; $i <= 4; $i++) { - $mostleast[$i]['most_seen'] = $Device->findMostSeen(null, $i, $group->idgroups); - $mostleast[$i]['most_repaired'] = $Device->findMostSeen(1, $i, $group->idgroups); - $mostleast[$i]['least_repaired'] = $Device->findMostSeen(3, $i, $group->idgroups); - } - if (! isset($response)) { $response = null; } @@ -319,8 +259,6 @@ public function view($groupid) 'group_device_count_status' => $Device->statusCount($group->idgroups), 'group_stats' => $groupStats, 'expanded_events' => $expanded_events, - 'clusters' => $clusters, - 'mostleast' => $mostleast, 'top' => $Device->findMostSeen(1, null, $group->idgroups), 'user' => $user, 'upcoming_events' => $upcoming_events, diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php index ef269b8022..018fc4f87d 100644 --- a/app/Http/Controllers/NetworkController.php +++ b/app/Http/Controllers/NetworkController.php @@ -87,6 +87,7 @@ public function show(Network $network): View 'name' => $network->name, 'description' => $network->description, 'website' => $network->website, + 'reporting_url' => $network->reporting_url, 'logo' => $network->sizedLogo('_x100'), 'coordinators' => $network->coordinators->map(function ($c) { $profile = $c->getProfile($c->id); @@ -120,6 +121,7 @@ public function edit(Network $network): View return view('networks.edit', [ 'network' => $network, + 'canEditReportingUrl' => Fixometer::hasRole(Auth::user(), 'Administrator'), ]); } @@ -130,6 +132,16 @@ public function update(Request $request, Network $network): RedirectResponse { $this->authorize('update', $network); + // The reporting dashboard address is set up by administrators alongside the dashboard itself. + if (Fixometer::hasRole(Auth::user(), 'Administrator') && $request->has('reporting_url')) { + $request->validate([ + 'reporting_url' => 'nullable|url:https,http|max:2048', + ]); + + $network->reporting_url = $request->input('reporting_url') ?: null; + $network->save(); + } + if ($request->hasFile('network_logo')) { if (! config('restarters.features.image_upload')) { return redirect()->route('networks.edit', [$network]) diff --git a/app/Http/Resources/Device.php b/app/Http/Resources/Device.php index f2ed2806e3..9a313f7e92 100644 --- a/app/Http/Resources/Device.php +++ b/app/Http/Resources/Device.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use App\Party; +use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -34,6 +35,13 @@ * example="Europe/London" * ), * @OA\Property( + * property="event_date", + * title="event_date", + * description="When the event started. Provided for convenience to avoid extra API calls.", + * format="date-time", + * example="2024-03-10T13:00:00+00:00" + * ), + * @OA\Property( * property="groupid", * title="groupid", * description="The group to which this device belongs.", @@ -195,6 +203,7 @@ public function toArray(Request $request): array 'id' => intval($this->iddevices), 'eventid' => intval($this->event), 'eventtitle' => $event ? $event->title : NULL, + 'event_date' => $event ? Carbon::parse($event->event_start_utc)->toIso8601String() : null, 'groupid' => $event ? intval($event->group) : NULL, 'groupname' => $group ? $group->name : NULL, 'category' => intval($this->category), diff --git a/app/Services/RepairDataExport.php b/app/Services/RepairDataExport.php new file mode 100644 index 0000000000..2b1d37f911 --- /dev/null +++ b/app/Services/RepairDataExport.php @@ -0,0 +1,158 @@ +join('events', 'events.idevents', '=', 'devices.event') + ->join('groups', 'groups.idgroups', '=', 'events.group') + ->whereNull('events.deleted_at') + ->when($idevents != null, function ($query) use ($idevents) { + return $query->where('events.idevents', $idevents); + }) + ->when($idgroups != null, function ($query) use ($idgroups) { + return $query->where('events.group', $idgroups); + }) + ->when(! $viewer || ! $viewer->hasRole('Administrator'), function ($query) use ($viewer) { + $extraGroups = $this->groupsWithUnapprovedEventsVisibleTo($viewer); + + return $query->where(function ($query) use ($extraGroups) { + $query->where(function ($query) { + $query->where('events.approved', true) + ->where('groups.approved', true); + })->orWhereIn('events.group', $extraGroups); + }); + }) + ->select('devices.*', 'groups.name AS group_name'); + } + + /** + * Write the devices a query finds to an open file. + */ + public function write($devices, $file): void + { + $displacementFactor = Device::getDisplacementFactor(); + $eEmissionRatio = \App\Helpers\LcaStats::getEmissionRatioPowered(); + $uEmissionratio = \App\Helpers\LcaStats::getEmissionRatioUnpowered(); + + // We can't put accented characters into a CSV file, so flatten them. + // Use //TRANSLIT//IGNORE to handle characters that can't be transliterated on + // servers with older glibc (e.g. 2.27) and POSIX locale, which lack transliteration + // tables for certain Unicode characters like emdash (—). Without //IGNORE, iconv + // throws "Detected an illegal character in input string" on such systems. + $columns = [ + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.item_type_short')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.category')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.brand')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.model')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.title_assessment')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.repair_status')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('devices.spare_parts')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.event')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('groups.group')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.event_date')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.stat-7')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', __('events.stat-6')), + iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', ucfirst(__('devices.title_powered'))), + ]; + + fputcsv($file, $columns); + + // Work through the devices in chunks so that the whole dataset isn't held in memory at once. + foreach ($devices->lazyById(1000, 'devices.iddevices', 'iddevices') as $device) { + set_time_limit(60); + + $wasteImpact = 0; + $co2Diverted = 0; + + if ($device->isFixed()) { + if ($device->deviceCategory->powered) { + $wasteImpact = $device->eWasteDiverted(); + $co2Diverted = $device->eCo2Diverted($eEmissionRatio, $displacementFactor); + } else { + $wasteImpact = $device->uWasteDiverted(); + $co2Diverted = $device->uCo2Diverted($uEmissionratio, $displacementFactor); + } + } + + fputcsv($file, self::csvSafeRow([ + $device->item_type, + $device->deviceCategory->name, + $device->brand, + $device->model, + $device->problem, + $device->getRepairStatus(), + $device->getSpareParts(), + $device->deviceEvent->getEventName(), + $device->deviceEvent->theGroup->name, + $device->deviceEvent->getFormattedLocalStart('Y-m-d'), + $wasteImpact, + $co2Diverted, + $device->deviceCategory->powered ? 'Powered' : 'Unpowered', + ])); + } + } + + /** + * Groups whose events a user can see even when the event or group isn't approved: those they host, and those in + * networks they coordinate. + */ + private function groupsWithUnapprovedEventsVisibleTo($user): array + { + if (! $user) { + return []; + } + + $groups = []; + + if ($user->hasRole('Host')) { + $groups = UserGroups::where('user', $user->id) + ->where('role', \App\Role::HOST) + ->pluck('group') + ->all(); + } + + $networks = $user->networks->pluck('id'); + + if ($networks->count()) { + $groups = array_merge($groups, DB::table('group_network') + ->whereIn('network_id', $networks) + ->pluck('group_id') + ->all()); + } + + return array_values(array_unique($groups)); + } + + /** + * Spreadsheets treat a cell starting with =, +, - or @ as a formula, so prefix those + * with an apostrophe. Device fields are free text entered at events. + */ + public static function csvSafeRow(array $row) + { + return array_map(function ($value) { + if (is_string($value) && $value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { + return "'".$value; + } + + return $value; + }, $row); + } +} diff --git a/config/restarters.php b/config/restarters.php index f04f2779d0..5afe4394f5 100644 --- a/config/restarters.php +++ b/config/restarters.php @@ -26,6 +26,20 @@ 'api_key' => env('CARTO_API_KEY'), ], + 'reporting' => [ + // The group reporting dashboard, filtered to one group, one per language - Metabase can only show a + // dashboard in another language as a separate dashboard. {group} is replaced by the group id and + // {group_name} by its name, e.g. + // https://metabase.example.org/public/dashboard/abc?group_id={group}&group_name={group_name}#hide_parameters=group_id%2Cgroup_name + // A group gets the dashboard for its network's language, or English if there isn't one. + 'group_urls' => [ + 'en' => env('GROUP_REPORTING_URL_EN'), + 'fr' => env('GROUP_REPORTING_URL_FR'), + ], + // Further reports on all the repair data, linked from the Fixometer page. No link if unset. + 'fixometer_url' => env('FIXOMETER_REPORTING_URL'), + ], + 'xref_types' => [ 'networks' => 7, ], diff --git a/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php b/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php new file mode 100644 index 0000000000..d9de460b32 --- /dev/null +++ b/database/migrations/2026_09_30_000000_add_reporting_url_to_networks_table.php @@ -0,0 +1,23 @@ +string('reporting_url', 2048)->nullable(); + }); + } + + public function down(): void + { + Schema::table('networks', function (Blueprint $table) { + $table->dropColumn('reporting_url'); + }); + } +}; diff --git a/docker/nginx-fly.conf b/docker/nginx-fly.conf index 7600041e74..70a11dfe3b 100644 --- a/docker/nginx-fly.conf +++ b/docker/nginx-fly.conf @@ -42,7 +42,7 @@ http { gzip_min_length 1024; gzip_proxied any; gzip_vary on; - gzip_types text/plain text/css text/javascript application/javascript application/json application/xml text/xml image/svg+xml; + gzip_types text/plain text/csv text/css text/javascript application/javascript application/json application/xml text/xml image/svg+xml; server { listen 80 default_server; @@ -162,6 +162,13 @@ http { location /build/ { auth_request off; expires 1y; add_header Cache-Control "public, immutable"; } location /images/ { auth_request off; expires 7d; } + # The repair data download, rebuilt overnight by export:repair-data. Served as CSV so that gzip applies. + location /exports/ { + types { text/csv csv; } + default_type text/csv; + add_header Content-Disposition "attachment"; + } + location = /fpm-status { auth_request off; allow 127.0.0.1; diff --git a/lang/de/devices.php b/lang/de/devices.php index 39040847e0..c3b4aaed8e 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Ersatzteile', 'latest_data' => 'Neueste Daten', - 'table_intro' => 'Klicken Sie auf die „i"-Symbole für Details. Klicken Sie auf einen Spaltenkopf, um nach dieser Spalte zu sortieren – erneut klicken, um die Sortierreihenfolge umzukehren.', 'assessment' => 'Beurteilung', 'item_and_repair_info' => 'Artikel- und Reparaturinfo', 'search_assessment_comments' => 'Beurteilung', diff --git a/lang/de/groups.php b/lang/de/groups.php index 4fb009110e..618539a0f4 100644 --- a/lang/de/groups.php +++ b/lang/de/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Zur Gruppe einladen', 'participants' => 'Teilnehmende', 'hours_volunteered' => 'geleistete Freiwilligenstunden', - 'years_volunteered' => 'geleistete Freiwilligenjahre', 'waste_prevented' => 'Vermiedener Abfall', 'upcoming_active' => 'Bevorstehend & Aktiv', 'past' => 'Vergangen', 'no_upcoming_events' => 'Derzeit gibt es keine bevorstehenden Veranstaltungen.', 'no_past_events' => 'Für diese Gruppe gibt es derzeit keine vergangenen Veranstaltungen', - 'device_breakdown' => 'Geräteaufschlüsselung', 'total_devices' => 'Insgesamt bearbeitete Artikel', 'most_repaired_devices' => 'Am häufigsten reparierte Geräte', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Reparierte Artikel', 'repairable_items' => 'Reparierbare Artikel', 'end_of_life_items' => 'Artikel am Ende ihres Lebens', - 'no_unpowered_stats' => 'Momentan werden diese Statistiken nur für elektrische Geräte angezeigt. Wir hoffen, bald auch nicht-elektrische Gegenstände einzubeziehen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Neu hinzufügen', 'groups_title1_mobile' => 'Meine', diff --git a/lang/en/devices.php b/lang/en/devices.php index 647f9da1c6..d35e119f4f 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Spare parts', 'latest_data' => 'Latest Data', - 'table_intro' => 'Press the ‘i’ icons for details. Click a column head to sort by that column - click again to reverse sort order.', 'assessment' => 'Assessment', 'item_and_repair_info' => 'Item & Repair Info', 'search_assessment_comments' => 'Assessment', @@ -78,4 +77,15 @@ 'image_delete_success' => 'Thank you, the image has been deleted', 'image_delete_error' => 'Sorry, but the image can\'t be deleted', 'image_upload_error' => 'fail - image could not be uploaded', + 'powered_items_fixed' => 'powered items fixed', + 'unpowered_items_fixed' => 'unpowered items fixed', + 'events_held' => 'events held', + 'items_seen' => 'items seen', + 'powered_or_unpowered' => 'Powered or unpowered', + 'both' => 'Both', + 'view_event_for_repair' => 'View the event for this repair', + 'powered' => 'Powered', + 'unpowered' => 'Unpowered', + 'view_record' => 'See the details of this repair', + 'edit_record' => 'See and edit the details of this repair', ]; diff --git a/lang/en/groups.php b/lang/en/groups.php index 7a11f8d64f..7b6b17c185 100644 --- a/lang/en/groups.php +++ b/lang/en/groups.php @@ -73,15 +73,13 @@ 'participants' => 'participants', 'parties_thrown' => 'parties thrown', 'hours_volunteered' => 'hours volunteered', - 'years_volunteered' => 'years volunteered', 'waste_prevented' => 'Waste prevented', 'upcoming_active' => 'Upcoming & Active', 'past' => 'Past', 'no_upcoming_events' => 'There are currently no upcoming events.', 'no_past_events' => 'There are currently no past events for this group', - 'device_breakdown' => 'Device breakdown', 'total_devices' => 'Total items worked on', - 'most_repaired_devices' => 'Most repaired devices', + 'most_repaired_devices' => 'Most repaired items', 'website' => 'Website', 'about_none' => 'There is no description for this group yet.', 'read_less' => ' READ LESS', @@ -102,7 +100,6 @@ 'fixed_items' => 'Fixed items', 'repairable_items' => 'Repairable items', 'end_of_life_items' => 'End-of-life items', - 'no_unpowered_stats' => 'At the moment, these stats are only displayed for powered items. We hope to include unpowered items soon.', 'create_groups_mobile2' => 'Add new', 'groups_title1_mobile' => 'Yours', 'groups_title2_mobile' => 'Find', @@ -181,4 +178,7 @@ 'search_nothing_found' => 'Nothing found.', 'goto_group' => 'Go to group', 'next_event' => 'Next event', + 'years_of_time_volunteered' => 'years of time volunteered', + 'see_group_reports' => 'See more in your group\'s reports', + 'view_reports' => 'View reports', ]; diff --git a/lang/en/networks.php b/lang/en/networks.php index 61505b2603..94bb23484a 100644 --- a/lang/en/networks.php +++ b/lang/en/networks.php @@ -4,6 +4,8 @@ 'networks' => 'Networks', 'network' => 'Network', 'general' => [ + 'view_reports' => 'View reports', + 'see_network_reports' => 'See more in your network\'s reports', 'networks' => 'Networks', 'network' => 'Network', 'particular_network' => ':networkName network', @@ -55,6 +57,8 @@ 'none' => 'None', ], 'edit' => [ + 'label_reporting_url' => 'Reporting dashboard address', + 'help_reporting_url' => 'Where this network\'s reports are published. Leave blank if the network has no reports.', 'label_logo' => 'Network logo', 'button_save' => 'Save changes', 'add_new_field' => 'Add new field', diff --git a/lang/en/partials.php b/lang/en/partials.php index 95cec7a55c..83fef3cf03 100644 --- a/lang/en/partials.php +++ b/lang/en/partials.php @@ -96,5 +96,6 @@ 'share_modal_seedlings' => "tree seedling for 10 years.*|tree seedlings for 10 years.*", 'share_modal_planting_around' => "planting around", 'share_modal_hectares' => "hectare of trees.*|hectares of trees.*", - 'impact_estimates' => 'Impact figures are estimates based on data entered by groups into Restarters.net.' + 'impact_estimates' => 'Impact figures are estimates based on data entered by groups into Restarters.net.', + 'see_further_reports' => 'See more analysis in our further reports.' ]; diff --git a/lang/es/devices.php b/lang/es/devices.php index f96332604a..86b7b18f99 100644 --- a/lang/es/devices.php +++ b/lang/es/devices.php @@ -62,7 +62,6 @@ 'status' => 'Estado', 'spare_parts' => 'Piezas de repuesto', 'latest_data' => 'Últimos datos', - 'table_intro' => 'Pulsa los iconos «i» para ver los detalles. Haz clic en el encabezado de una columna para ordenar por esa columna; vuelve a hacer clic para invertir el orden.', 'assessment' => 'Evaluación', 'item_and_repair_info' => 'Info del artículo y la reparación', 'search_assessment_comments' => 'Evaluación', diff --git a/lang/es/groups.php b/lang/es/groups.php index fff0be774e..fa3e30721d 100644 --- a/lang/es/groups.php +++ b/lang/es/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Invitar al grupo', 'participants' => 'participantes', 'hours_volunteered' => 'horas de voluntariado', - 'years_volunteered' => 'años de voluntariado', 'waste_prevented' => 'Residuos evitados', 'upcoming_active' => 'Próximos y activos', 'past' => 'Pasados', 'no_upcoming_events' => 'Actualmente no hay próximos eventos.', 'no_past_events' => 'Actualmente no hay eventos pasados para este grupo', - 'device_breakdown' => 'Desglose de dispositivos', 'total_devices' => 'Total de artículos trabajados', 'most_repaired_devices' => 'Dispositivos más reparados', 'website' => 'Sitio web', @@ -103,7 +101,6 @@ 'fixed_items' => 'Artículos reparados', 'repairable_items' => 'Artículos reparables', 'end_of_life_items' => 'Artículos al final de su vida útil', - 'no_unpowered_stats' => 'En este momento, estas estadísticas solo se muestran para artículos con alimentación eléctrica. Esperamos incluir artículos sin alimentación próximamente.', 'all_groups_mobile' => 'Todos', 'create_groups_mobile2' => 'Añadir nuevo', 'groups_title1_mobile' => 'Los tuyos', diff --git a/lang/fr-BE/devices.php b/lang/fr-BE/devices.php index e64556c516..7fe2b458ff 100644 --- a/lang/fr-BE/devices.php +++ b/lang/fr-BE/devices.php @@ -65,7 +65,6 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur la ligne pour plus de détails. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre de tri.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -78,4 +77,15 @@ 'image_delete_success' => 'Merci, l\'image a été supprimée', 'image_delete_error' => 'Désolé, mais l\'image ne peut pas être supprimée.', 'image_upload_error' => 'fail - l\'image n\'a pas pu être téléchargée', + 'powered_items_fixed' => 'appareils électriques réparés', + 'unpowered_items_fixed' => 'objets non électriques réparés', + 'events_held' => 'événements organisés', + 'items_seen' => 'objets vus', + 'powered_or_unpowered' => 'Électrique ou non électrique', + 'both' => 'Les deux', + 'view_event_for_repair' => 'Voir l\'événement de cette réparation', + 'powered' => 'Électrique', + 'unpowered' => 'Non électrique', + 'view_record' => 'Voir les détails de cette réparation', + 'edit_record' => 'Voir et modifier les détails de cette réparation', ]; diff --git a/lang/fr-BE/groups.php b/lang/fr-BE/groups.php index cfdeae755a..0a31421a7d 100644 --- a/lang/fr-BE/groups.php +++ b/lang/fr-BE/groups.php @@ -65,12 +65,11 @@ 'create_groups_mobile' => 'créer', 'about' => 'A propos', 'add_event' => 'Ajouter événement', - 'device_breakdown' => 'Panne de l\'appareil', 'group_actions' => 'Gestion du Repair Café', 'host' => 'Organisateur', 'invite_to_group' => 'Inviter au Repair Café', 'invite_volunteers' => 'Inviter des bénévoles', - 'most_repaired_devices' => 'Les appareils les plus réparés', + 'most_repaired_devices' => 'Les objets les plus réparés', 'no_past_events' => 'Il n\'y a actuellement aucun événement passé pour ce Repair Café', 'no_upcoming_events' => 'Il n\'y a actuellement aucun événément à venir pour ce Repair Café', 'past' => 'Passé', @@ -80,7 +79,6 @@ 'participants' => 'participants', 'parties_thrown' => 'événements organisés', 'hours_volunteered' => 'heures de bénévolat', - 'years_volunteered' => 'années de bénévolat', 'waste_prevented' => 'Déchets évités', 'upcoming_active' => 'A venir et actif', 'volunteers' => 'Bénévoles', @@ -118,7 +116,6 @@ 'no_groups_mine' => 'Si vous ne pouvez encore en voir aucun ici, pourquoi ne pas trouver un Repair Café près de chez vous?', 'no_groups_nearest_no_location' => '

Vous n\'avez pas défini de village/ville. Vous pouvez en ajouter un.e dans votre profil.

Vous pouvez aussi voir tous les Repair Cafés.

', 'no_groups_nearest_with_location' => '

Il n\'y a apparemment pas encore de Repair Cafés listé proche de chez vous.

Voulez-vous créer ou ajouter un Repair Café? Regardez comment faire dans nos ressources.

', - 'no_unpowered_stats' => 'Pour l\'instant, ces statistiques sont seulement affichées pour les appareils électriques. Nous espérons pouvoir inclure les appareils non-électriques sous peu.', 'search_country_placeholder' => 'Filtrer par pays', 'search_location_placeholder' => 'Rechercher par localisation', 'search_name_placeholder' => 'Rechercher par nom', @@ -184,4 +181,7 @@ 'search_nothing_found' => 'Aucun résultat.', 'goto_group' => 'Aller au Repair Café', 'next_event' => 'Prochain événement', + 'years_of_time_volunteered' => 'années de temps de bénévolat', + 'see_group_reports' => 'En savoir plus dans les rapports de votre groupe', + 'view_reports' => 'Voir les rapports', ]; diff --git a/lang/fr-BE/networks.php b/lang/fr-BE/networks.php index 84bf262c9b..87e7db3ad8 100644 --- a/lang/fr-BE/networks.php +++ b/lang/fr-BE/networks.php @@ -2,6 +2,8 @@ return [ 'edit' => [ + 'label_reporting_url' => 'Adresse du tableau de bord des rapports', + 'help_reporting_url' => 'L\'adresse où sont publiés les rapports de ce réseau. Laissez vide si le réseau n\'a pas de rapports.', 'button_save' => 'Sauver les changements', 'label_logo' => 'Logo du réseau', 'add_new_field' => 'Ajouter un nouveau champ', @@ -9,6 +11,8 @@ 'add_field' => 'Ajouter le champ', ], 'general' => [ + 'view_reports' => 'Voir les rapports', + 'see_network_reports' => 'En savoir plus dans les rapports de votre réseau', 'network' => 'Réseau', 'networks' => 'Réseaux', 'particular_network' => 'Réseau :networkName', diff --git a/lang/fr-BE/partials.php b/lang/fr-BE/partials.php index ab28284b87..b9df4ef8fa 100644 --- a/lang/fr-BE/partials.php +++ b/lang/fr-BE/partials.php @@ -100,4 +100,5 @@ 'share_modal_planting_around' => "planter environ", 'share_modal_hectares' => "hectare d’arbres.*|hectares d’arbres.*", 'impact_estimates' => 'Les chiffres relatifs à l\'impact sont des estimations basées sur les données saisies par les groupes dans Restarters.net.', + 'see_further_reports' => 'Voir plus d\'analyses dans nos rapports détaillés.', ]; diff --git a/lang/fr/devices.php b/lang/fr/devices.php index e64556c516..7fe2b458ff 100644 --- a/lang/fr/devices.php +++ b/lang/fr/devices.php @@ -65,7 +65,6 @@ 'assessment' => 'Evaluation', 'item_and_repair_info' => 'Appareils et informations de réparation', 'search_assessment_comments' => 'Evaluation', - 'table_intro' => 'Cliquez sur la ligne pour plus de détails. Cliquez sur l\'en-tête d\'une colonne pour trier par cette colonne - cliquez à nouveau pour inverser l\'ordre de tri.', 'tooltip_type' => 'Ajoutez autant d\'informations que possible sur le type d\'objet (ex: "Jeans Denim", ou "divan")', 'add_data_event' => 'Veuillez sélectionner un événement', 'add_data_group' => 'Veuillez sélectionner un Repair Café', @@ -78,4 +77,15 @@ 'image_delete_success' => 'Merci, l\'image a été supprimée', 'image_delete_error' => 'Désolé, mais l\'image ne peut pas être supprimée.', 'image_upload_error' => 'fail - l\'image n\'a pas pu être téléchargée', + 'powered_items_fixed' => 'appareils électriques réparés', + 'unpowered_items_fixed' => 'objets non électriques réparés', + 'events_held' => 'événements organisés', + 'items_seen' => 'objets vus', + 'powered_or_unpowered' => 'Électrique ou non électrique', + 'both' => 'Les deux', + 'view_event_for_repair' => 'Voir l\'événement de cette réparation', + 'powered' => 'Électrique', + 'unpowered' => 'Non électrique', + 'view_record' => 'Voir les détails de cette réparation', + 'edit_record' => 'Voir et modifier les détails de cette réparation', ]; diff --git a/lang/fr/groups.php b/lang/fr/groups.php index 24beb108dc..9d064a3bc9 100644 --- a/lang/fr/groups.php +++ b/lang/fr/groups.php @@ -65,12 +65,11 @@ 'create_groups_mobile' => 'créer', 'about' => 'A propos', 'add_event' => 'Ajouter événement', - 'device_breakdown' => 'Panne de l\'appareil', 'group_actions' => 'Gestion du Repair Café', 'host' => 'Organisateur', 'invite_to_group' => 'Inviter au Repair Café', 'invite_volunteers' => 'Inviter des bénévoles', - 'most_repaired_devices' => 'Les appareils les plus réparés', + 'most_repaired_devices' => 'Les objets les plus réparés', 'no_past_events' => 'Il n\'y a actuellement aucun événement passé pour ce Repair Café', 'no_upcoming_events' => 'Il n\'y a actuellement aucun événément à venir pour ce Repair Café', 'past' => 'Passé', @@ -80,7 +79,6 @@ 'participants' => 'participants', 'parties_thrown' => 'événements organisés', 'hours_volunteered' => 'heures de bénévolat', - 'years_volunteered' => 'années de bénévolat', 'waste_prevented' => 'Déchets évités', 'upcoming_active' => 'A venir et actif', 'volunteers' => 'Bénévoles', @@ -118,7 +116,6 @@ 'no_groups_mine' => 'Si vous ne pouvez encore en voir aucun ici, pourquoi ne pas trouver un Repair Café près de chez vous?', 'no_groups_nearest_no_location' => '

Vous n\'avez pas défini de village/ville. Vous pouvez en ajouter un.e dans votre profil.

Vous pouvez aussi voir tous les Repair Cafés.

', 'no_groups_nearest_with_location' => '

Il n\'y a apparemment pas encore de Repair Cafés listé proche de chez vous.

Voulez-vous créer ou ajouter un Repair Café? Regardez comment faire dans nos ressources.

', - 'no_unpowered_stats' => 'Pour l\'instant, ces statistiques sont seulement affichées pour les appareils électriques. Nous espérons pouvoir inclure les appareils non-électriques sous peu.', 'search_country_placeholder' => 'Filtrer par pays', 'search_location_placeholder' => 'Rechercher par localisation', 'search_name_placeholder' => 'Rechercher par nom', @@ -184,4 +181,7 @@ 'search_nothing_found' => 'Aucun résultat.', 'goto_group' => 'Aller au Repair Café', 'next_event' => 'Prochain événement', + 'years_of_time_volunteered' => 'années de temps de bénévolat', + 'see_group_reports' => 'En savoir plus dans les rapports de votre groupe', + 'view_reports' => 'Voir les rapports', ]; diff --git a/lang/fr/networks.php b/lang/fr/networks.php index 84bf262c9b..87e7db3ad8 100644 --- a/lang/fr/networks.php +++ b/lang/fr/networks.php @@ -2,6 +2,8 @@ return [ 'edit' => [ + 'label_reporting_url' => 'Adresse du tableau de bord des rapports', + 'help_reporting_url' => 'L\'adresse où sont publiés les rapports de ce réseau. Laissez vide si le réseau n\'a pas de rapports.', 'button_save' => 'Sauver les changements', 'label_logo' => 'Logo du réseau', 'add_new_field' => 'Ajouter un nouveau champ', @@ -9,6 +11,8 @@ 'add_field' => 'Ajouter le champ', ], 'general' => [ + 'view_reports' => 'Voir les rapports', + 'see_network_reports' => 'En savoir plus dans les rapports de votre réseau', 'network' => 'Réseau', 'networks' => 'Réseaux', 'particular_network' => 'Réseau :networkName', diff --git a/lang/fr/partials.php b/lang/fr/partials.php index ab28284b87..b9df4ef8fa 100644 --- a/lang/fr/partials.php +++ b/lang/fr/partials.php @@ -100,4 +100,5 @@ 'share_modal_planting_around' => "planter environ", 'share_modal_hectares' => "hectare d’arbres.*|hectares d’arbres.*", 'impact_estimates' => 'Les chiffres relatifs à l\'impact sont des estimations basées sur les données saisies par les groupes dans Restarters.net.', + 'see_further_reports' => 'Voir plus d\'analyses dans nos rapports détaillés.', ]; diff --git a/lang/it/devices.php b/lang/it/devices.php index 62cbf71331..d8c8990ee6 100644 --- a/lang/it/devices.php +++ b/lang/it/devices.php @@ -62,7 +62,6 @@ 'status' => 'Stato', 'spare_parts' => 'Parti di ricambio', 'latest_data' => 'Dati più recenti', - 'table_intro' => 'Premi le icone \'i\' per i dettagli. Clicca l\'intestazione di una colonna per ordinare per quella colonna - clicca di nuovo per invertire l\'ordine.', 'assessment' => 'Valutazione', 'item_and_repair_info' => 'Informazioni oggetto e riparazione', 'search_assessment_comments' => 'Valutazione', diff --git a/lang/it/groups.php b/lang/it/groups.php index 59e0d3411c..6cac8af26a 100644 --- a/lang/it/groups.php +++ b/lang/it/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Invita nel gruppo', 'participants' => 'partecipanti', 'hours_volunteered' => 'ore di volontariato', - 'years_volunteered' => 'anni di volontariato', 'waste_prevented' => 'Rifiuti prevenuti', 'upcoming_active' => 'Prossimi e attivi', 'past' => 'Passati', 'no_upcoming_events' => 'Al momento non ci sono prossimi eventi.', 'no_past_events' => 'Al momento non ci sono eventi passati per questo gruppo', - 'device_breakdown' => 'Ripartizione dispositivi', 'total_devices' => 'Totale oggetti trattati', 'most_repaired_devices' => 'Dispositivi più riparati', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Oggetti riparati', 'repairable_items' => 'Oggetti riparabili', 'end_of_life_items' => 'Oggetti a fine vita', - 'no_unpowered_stats' => 'Al momento queste statistiche sono visualizzate solo per gli oggetti alimentati. Speriamo di includere presto gli oggetti non alimentati.', 'all_groups_mobile' => 'Tutti', 'create_groups_mobile2' => 'Aggiungi nuovo', 'groups_title1_mobile' => 'Tuoi', diff --git a/lang/ne/devices.php b/lang/ne/devices.php index 50e2aa2e10..b91591253d 100644 --- a/lang/ne/devices.php +++ b/lang/ne/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Klik op de \'i\'-pictogrammen voor details. Klik op een kolomkop om op die kolom te sorteren - klik nogmaals om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en reparatie-informatie', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/ne/groups.php b/lang/ne/groups.php index 93fdae5cc0..683d881841 100644 --- a/lang/ne/groups.php +++ b/lang/ne/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'vrijwilligersuren', - 'years_volunteered' => 'vrijwilligersjaren', 'waste_prevented' => 'Afval voorkomen', 'upcoming_active' => 'Aankomend & Actief', 'past' => 'Afgelopen', 'no_upcoming_events' => 'Er zijn momenteel geen aankomende evenementen.', 'no_past_events' => 'Er zijn momenteel geen afgelopen evenementen voor deze groep', - 'device_breakdown' => 'Apparatenanalyse', 'total_devices' => 'Totaal aantal items bewerkt', 'most_repaired_devices' => 'Meest gerepareerde apparaten', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Gerepareerde items', 'repairable_items' => 'Repareerbare items', 'end_of_life_items' => 'Items aan einde levensduur', - 'no_unpowered_stats' => 'Op dit moment worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Van mij', diff --git a/lang/nl-BE/devices.php b/lang/nl-BE/devices.php index 0b9038d21d..b4052cd216 100644 --- a/lang/nl-BE/devices.php +++ b/lang/nl-BE/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Druk op de \'i\'-pictogrammen voor details. Klik op een kolomhoofd om op die kolom te sorteren - klik opnieuw om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en herstelinfo', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/nl-BE/groups.php b/lang/nl-BE/groups.php index 53a0671f4f..475ad6097e 100644 --- a/lang/nl-BE/groups.php +++ b/lang/nl-BE/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'uren vrijwilligerswerk', - 'years_volunteered' => 'jaren vrijwilligerswerk', 'waste_prevented' => 'Afval vermeden', 'upcoming_active' => 'Komend & Actief', 'past' => 'Voorbij', 'no_upcoming_events' => 'Er zijn momenteel geen komende activiteiten.', 'no_past_events' => 'Er zijn momenteel geen voorbije activiteiten voor deze groep', - 'device_breakdown' => 'Overzicht van toestellen', 'total_devices' => 'Totaal aantal behandelde items', 'most_repaired_devices' => 'Meest herstelde toestellen', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Herstelde items', 'repairable_items' => 'Herstelbare items', 'end_of_life_items' => 'Onherstelbare items', - 'no_unpowered_stats' => 'Momenteel worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Jouw', diff --git a/lang/nl/devices.php b/lang/nl/devices.php index 50e2aa2e10..b91591253d 100644 --- a/lang/nl/devices.php +++ b/lang/nl/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reserveonderdelen', 'latest_data' => 'Meest recente gegevens', - 'table_intro' => 'Klik op de \'i\'-pictogrammen voor details. Klik op een kolomkop om op die kolom te sorteren - klik nogmaals om de sorteervolgorde om te keren.', 'assessment' => 'Beoordeling', 'item_and_repair_info' => 'Item- en reparatie-informatie', 'search_assessment_comments' => 'Beoordeling', diff --git a/lang/nl/groups.php b/lang/nl/groups.php index 93fdae5cc0..683d881841 100644 --- a/lang/nl/groups.php +++ b/lang/nl/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Uitnodigen voor groep', 'participants' => 'deelnemers', 'hours_volunteered' => 'vrijwilligersuren', - 'years_volunteered' => 'vrijwilligersjaren', 'waste_prevented' => 'Afval voorkomen', 'upcoming_active' => 'Aankomend & Actief', 'past' => 'Afgelopen', 'no_upcoming_events' => 'Er zijn momenteel geen aankomende evenementen.', 'no_past_events' => 'Er zijn momenteel geen afgelopen evenementen voor deze groep', - 'device_breakdown' => 'Apparatenanalyse', 'total_devices' => 'Totaal aantal items bewerkt', 'most_repaired_devices' => 'Meest gerepareerde apparaten', 'website' => 'Website', @@ -103,7 +101,6 @@ 'fixed_items' => 'Gerepareerde items', 'repairable_items' => 'Repareerbare items', 'end_of_life_items' => 'Items aan einde levensduur', - 'no_unpowered_stats' => 'Op dit moment worden deze statistieken alleen weergegeven voor elektrische items. We hopen binnenkort ook niet-elektrische items op te nemen.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Nieuw toevoegen', 'groups_title1_mobile' => 'Van mij', diff --git a/lang/no/devices.php b/lang/no/devices.php index be135143ce..d05631d935 100644 --- a/lang/no/devices.php +++ b/lang/no/devices.php @@ -62,7 +62,6 @@ 'status' => 'Status', 'spare_parts' => 'Reservedeler', 'latest_data' => 'Siste data', - 'table_intro' => 'Trykk på «i»-ikonene for detaljer. Klikk på en kolonneoverskrift for å sortere etter den kolonnen – klikk igjen for omvendt sorteringsrekkefølge.', 'assessment' => 'Vurdering', 'item_and_repair_info' => 'Gjenstand og reparasjonsinfo', 'search_assessment_comments' => 'Vurdering', diff --git a/lang/no/groups.php b/lang/no/groups.php index e52f37225e..0d0578c229 100644 --- a/lang/no/groups.php +++ b/lang/no/groups.php @@ -74,13 +74,11 @@ 'invite_to_group' => 'Inviter til gruppe', 'participants' => 'deltakere', 'hours_volunteered' => 'timer frivillig arbeid', - 'years_volunteered' => 'år frivillig arbeid', 'waste_prevented' => 'Forhindret avfall', 'upcoming_active' => 'Kommende og aktive', 'past' => 'Tidligere', 'no_upcoming_events' => 'Det er for øyeblikket ingen kommende arrangementer.', 'no_past_events' => 'Det er for øyeblikket ingen tidligere arrangementer for denne gruppen', - 'device_breakdown' => 'Enhetsfordeling', 'total_devices' => 'Totalt antall gjenstander arbeidet med', 'most_repaired_devices' => 'Mest reparerte enheter', 'website' => 'Nettsted', @@ -103,7 +101,6 @@ 'fixed_items' => 'Reparerte gjenstander', 'repairable_items' => 'Reparerbare gjenstander', 'end_of_life_items' => 'Gjenstander ved slutten av levetid', - 'no_unpowered_stats' => 'For øyeblikket vises disse statistikkene kun for elektriske gjenstander. Vi håper å inkludere ikke-elektriske gjenstander snart.', 'all_groups_mobile' => 'Alle', 'create_groups_mobile2' => 'Legg til ny', 'groups_title1_mobile' => 'Dine', diff --git a/resources/js/components/DeviceCategorySelect.vue b/resources/js/components/DeviceCategorySelect.vue index 44064e5c73..99de39ef9a 100644 --- a/resources/js/components/DeviceCategorySelect.vue +++ b/resources/js/components/DeviceCategorySelect.vue @@ -21,7 +21,7 @@ @open="onOpen" > -
+
@@ -45,9 +45,11 @@ export default { required: true }, powered: { - // The server might return a number rather than a boolean. + // The server might return a number rather than a boolean. Null offers both powered and unpowered categories, + // for searching. type: [ Boolean, Number ], - required: true + required: false, + default: null }, iconVariant: { type: String, @@ -88,6 +90,9 @@ export default { miscCat() { return this.powered ? CATEGORY_MISC_POWERED : CATEGORY_MISC_UNPOWERED }, + anyPower() { + return this.powered === null + }, categoryOptions() { let ret = [] @@ -97,7 +102,9 @@ export default { cluster.categories.forEach((c) => { // Don't include the misc category - it gets added at the end, which encourages people to read the // whole list before selecting it. - if (((c.idcategories !== this.miscCat) && + if (this.anyPower ? + (c.idcategories !== CATEGORY_MISC_POWERED && c.idcategories !== CATEGORY_MISC_UNPOWERED) : + ((c.idcategories !== this.miscCat) && ((this.powered && c.powered) || (!this.powered && !c.powered)))) { categories.push({ name: this.__(c.name), @@ -116,7 +123,16 @@ export default { ret.push({ cluster: '---', - categories: [ + categories: this.anyPower ? [ + { + name: this.__('partials.category_none') + ' (' + this.__('devices.powered') + ')', + value: CATEGORY_MISC_POWERED, + }, + { + name: this.__('partials.category_none') + ' (' + this.__('devices.unpowered') + ')', + value: CATEGORY_MISC_UNPOWERED, + } + ] : [ { name: this.__('partials.category_none'), value: this.miscCat, diff --git a/resources/js/components/DeviceModel.vue b/resources/js/components/DeviceModel.vue index cac5d5e75c..9a1f40c0b8 100644 --- a/resources/js/components/DeviceModel.vue +++ b/resources/js/components/DeviceModel.vue @@ -1,7 +1,7 @@