Report site health and pending translations through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1317,6 +1317,9 @@ POST /api/v1/{tipo}/{id}/return devolver con motivo {note}
|
|||||||
POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at}
|
POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at}
|
||||||
POST /api/v1/{tipo}/{id}/unpublish
|
POST /api/v1/{tipo}/{id}/unpublish
|
||||||
POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma
|
POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma
|
||||||
|
GET /api/v1/health la pantalla de salud del sitio
|
||||||
|
GET /api/v1/translations/pending lo que falta traducir (missing) o se quedó
|
||||||
|
atrás (stale); filtros: locale, type, published
|
||||||
```
|
```
|
||||||
|
|
||||||
Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es
|
Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es
|
||||||
|
|||||||
129
app/Http/Controllers/Api/V1/HealthController.php
Normal file
129
app/Http/Controllers/Api/V1/HealthController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Support\AdminContent;
|
||||||
|
use App\Support\ContentTypes;
|
||||||
|
use App\Support\Health\Finding;
|
||||||
|
use App\Support\Health\HealthReport;
|
||||||
|
use App\Support\TranslationFreshness;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is wrong with the site, and what is left to translate — the panel's
|
||||||
|
* health screen, for a client that cannot open it.
|
||||||
|
*/
|
||||||
|
class HealthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The kinds of content written in several languages and checked for it.
|
||||||
|
*
|
||||||
|
* @var array<int, class-string<Model>>
|
||||||
|
*/
|
||||||
|
protected const TRANSLATED = [Page::class, Project::class, Service::class, Call::class];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every check the caller may see, problems first. A check behind a
|
||||||
|
* permission they lack is left out, as on the panel.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, HealthReport $report): JsonResponse
|
||||||
|
{
|
||||||
|
// Who may open the panel's screen; each check guards itself further.
|
||||||
|
$this->authorize('panel.access');
|
||||||
|
|
||||||
|
$findings = $report->runFor($request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $findings->map(fn (Finding $finding) => [
|
||||||
|
'key' => $finding->key,
|
||||||
|
'severity' => $finding->severity->value,
|
||||||
|
'count' => $finding->count,
|
||||||
|
'title' => $finding->title(),
|
||||||
|
'description' => $finding->description(),
|
||||||
|
'samples' => $finding->samples,
|
||||||
|
'panel_url' => $finding->url,
|
||||||
|
])->values(),
|
||||||
|
'meta' => [
|
||||||
|
'problems' => $findings->filter(fn (Finding $f) => $f->severity->isProblem())->count(),
|
||||||
|
'checked_at' => now()->toIso8601String(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records with a language still to write (`missing`) or left behind by the
|
||||||
|
* one it was translated from (`stale`, with since when).
|
||||||
|
*
|
||||||
|
* Filters: locale (only what that language lacks), type, published (only
|
||||||
|
* what is on the site — open, for calls).
|
||||||
|
*/
|
||||||
|
public function translations(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locales = array_keys((array) config('locales.supported'));
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'locale' => ['sometimes', Rule::in($locales)],
|
||||||
|
'type' => ['sometimes', Rule::in(['pages', 'projects', 'services', 'calls'])],
|
||||||
|
'published' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wanted = $request->input('locale');
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
foreach (self::TRANSLATED as $model) {
|
||||||
|
$type = AdminContent::nameOf(new $model);
|
||||||
|
|
||||||
|
if ($request->filled('type') && $request->input('type') !== $type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->user()->cannot('viewAny', $model)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model::query()
|
||||||
|
->when($request->boolean('published'), fn ($q) => $model === Call::class ? $q->open() : $q->published())
|
||||||
|
->with('revisions')
|
||||||
|
->get()
|
||||||
|
->each(function (Model $record) use ($locales, $wanted, $type, $items): void {
|
||||||
|
$missing = array_values(array_filter(
|
||||||
|
$locales,
|
||||||
|
fn (string $locale) => blank($record->getTranslation('title', $locale, false)),
|
||||||
|
));
|
||||||
|
|
||||||
|
$stale = collect(TranslationFreshness::for($record))
|
||||||
|
->map(fn ($since) => $since->toIso8601String())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($wanted !== null) {
|
||||||
|
$missing = array_values(array_intersect($missing, [$wanted]));
|
||||||
|
$stale = array_intersect_key($stale, [$wanted => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing === [] && $stale === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items->push([
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'title' => ContentTypes::title($record),
|
||||||
|
'missing' => $missing,
|
||||||
|
'stale' => (object) $stale,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['data' => $items->values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,8 @@ tags:
|
|||||||
description: Who changed what and when, and undoing a change
|
description: Who changed what and when, and undoing a change
|
||||||
- name: Reviews
|
- name: Reviews
|
||||||
description: Review, publication and preview links
|
description: Review, publication and preview links
|
||||||
|
- name: Health
|
||||||
|
description: What is wrong with the site, and what is left to translate
|
||||||
- name: Users
|
- name: Users
|
||||||
description: Local user administration
|
description: Local user administration
|
||||||
- name: Media
|
- name: Media
|
||||||
@@ -3527,6 +3529,66 @@ paths:
|
|||||||
data: { $ref: "#/components/schemas/PreviewLinks" }
|
data: { $ref: "#/components/schemas/PreviewLinks" }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/health:
|
||||||
|
get:
|
||||||
|
tags: [Health]
|
||||||
|
summary: Site health
|
||||||
|
description: |
|
||||||
|
The panel's health screen: every check the caller may see, problems
|
||||||
|
first — broken images, missing slugs, stale translations, unanswered
|
||||||
|
submissions… A check behind a permission the caller lacks is left out.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The findings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/HealthFinding" }
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
problems: { type: integer }
|
||||||
|
checked_at: { type: string, format: date-time }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
|
/v1/translations/pending:
|
||||||
|
get:
|
||||||
|
tags: [Health]
|
||||||
|
summary: What is left to translate
|
||||||
|
description: |
|
||||||
|
Pages, projects, services and calls with a language still to write
|
||||||
|
(`missing`) or left behind by the one it was translated from (`stale`,
|
||||||
|
with since when). Unpaginated.
|
||||||
|
parameters:
|
||||||
|
- name: locale
|
||||||
|
in: query
|
||||||
|
description: "Only what this language lacks."
|
||||||
|
schema: { type: string }
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
description: "`pages`, `projects`, `services` or `calls`."
|
||||||
|
schema: { type: string }
|
||||||
|
- name: published
|
||||||
|
in: query
|
||||||
|
description: "Only what is on the site \u2014 open, for calls."
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The records
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/PendingTranslation" }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
/v1/media:
|
/v1/media:
|
||||||
get:
|
get:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
@@ -5277,6 +5339,26 @@ components:
|
|||||||
links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. }
|
links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. }
|
||||||
expires_at: { type: string, format: date-time }
|
expires_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
HealthFinding:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key: { type: string, example: missing-translations }
|
||||||
|
severity: { type: string, enum: [ok, warning, error] }
|
||||||
|
count: { type: integer }
|
||||||
|
title: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
samples: { type: array, items: { type: string }, description: Up to three examples. }
|
||||||
|
panel_url: { type: string, nullable: true, description: Where to fix it in the panel. }
|
||||||
|
|
||||||
|
PendingTranslation:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [pages, projects, services, calls] }
|
||||||
|
id: { type: integer }
|
||||||
|
title: { type: string }
|
||||||
|
missing: { type: array, items: { type: string }, description: Languages with nothing written. }
|
||||||
|
stale: { type: object, additionalProperties: { type: string, format: date-time }, description: Language → since when it has been behind. }
|
||||||
|
|
||||||
MediaFor:
|
MediaFor:
|
||||||
type: string
|
type: string
|
||||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationControl
|
|||||||
use App\Http\Controllers\Api\V1\CallController;
|
use App\Http\Controllers\Api\V1\CallController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryController;
|
use App\Http\Controllers\Api\V1\CategoryController;
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
use App\Http\Controllers\Api\V1\ClientController;
|
||||||
|
use App\Http\Controllers\Api\V1\HealthController;
|
||||||
use App\Http\Controllers\Api\V1\MaintenanceController;
|
use App\Http\Controllers\Api\V1\MaintenanceController;
|
||||||
use App\Http\Controllers\Api\V1\MeController;
|
use App\Http\Controllers\Api\V1\MeController;
|
||||||
use App\Http\Controllers\Api\V1\MediaController;
|
use App\Http\Controllers\Api\V1\MediaController;
|
||||||
@@ -243,6 +244,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
|
|
||||||
Route::get('reviews', [ReviewController::class, 'queue'])->name('reviews.queue');
|
Route::get('reviews', [ReviewController::class, 'queue'])->name('reviews.queue');
|
||||||
|
|
||||||
|
// The panel's health screen, and what is left to translate.
|
||||||
|
Route::get('health', [HealthController::class, 'index'])->name('health');
|
||||||
|
Route::get('translations/pending', [HealthController::class, 'translations'])->name('translations.pending');
|
||||||
|
|
||||||
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
|
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
|
||||||
$parameter = AdminContent::parameter($resource);
|
$parameter = AdminContent::parameter($resource);
|
||||||
|
|
||||||
|
|||||||
97
tests/Feature/Api/HealthApiTest.php
Normal file
97
tests/Feature/Api/HealthApiTest.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's health and what is left to translate, through the admin API.
|
||||||
|
*/
|
||||||
|
class HealthApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_health_report_lists_problems_first(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
// Published with no Catalan title: one missing translation.
|
||||||
|
Page::factory()->published()->create([
|
||||||
|
'title' => ['es' => 'Hola', 'en' => 'Hello'],
|
||||||
|
'slug' => ['es' => 'hola', 'en' => 'hello'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/health')->assertOk();
|
||||||
|
|
||||||
|
$finding = collect($response->json('data'))->firstWhere('key', 'missing-translations');
|
||||||
|
|
||||||
|
$this->assertSame('warning', $finding['severity']);
|
||||||
|
$this->assertSame(1, $finding['count']);
|
||||||
|
$this->assertNotSame('ok', $response->json('data.0.severity'));
|
||||||
|
$this->assertGreaterThanOrEqual(1, $response->json('meta.problems'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_check_behind_a_permission_is_left_out(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('viewer');
|
||||||
|
|
||||||
|
$keys = collect($this->getJson('/api/v1/health')->assertOk()->json('data'))->pluck('key');
|
||||||
|
|
||||||
|
// Strangers' submissions are not a viewer's to see, not even counted.
|
||||||
|
$this->assertNotContains('unanswered-submissions', $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function pending_translations_say_which_languages(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$page = Page::factory()->draft()->create([
|
||||||
|
'title' => ['es' => 'Solo en castellano'],
|
||||||
|
'slug' => ['es' => 'solo'],
|
||||||
|
]);
|
||||||
|
Page::factory()->draft()->create([
|
||||||
|
'title' => ['es' => 'Completa', 'en' => 'Complete', 'ca' => 'Completa'],
|
||||||
|
'slug' => ['es' => 'completa', 'en' => 'complete', 'ca' => 'completa'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/translations/pending?type=pages')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $page->id)
|
||||||
|
->assertJsonPath('data.0.missing', ['en', 'ca']);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/translations/pending?locale=ca')
|
||||||
|
->assertJsonPath('data.0.missing', ['ca']);
|
||||||
|
|
||||||
|
// Drafts only, so nothing is on the site yet.
|
||||||
|
$this->getJson('/api/v1/translations/pending?published=1')->assertJsonCount(0, 'data');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user