images: press-release-images:regenerate-variants --only=og für den Bestand, Testhelfer eindeutig benannt (Bildkennzeichnung H.1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:13:49 +00:00
parent 62723b551a
commit 3b613fadf4
3 changed files with 135 additions and 5 deletions

View file

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use App\Support\ImageCredit;
use Illuminate\Console\Command;
/**
* Bildkennzeichnung H.1, Bestand: OG-Variante mit eingebranntem Nachweis
* für Bilder nachziehen, die noch keine haben (vor Formular v2 hochgeladen
* oder importiert). Quelle ist die größte vorhandene Variante; der Nachweis
* kommt aus der gemeinsamen Auflösung (Foto: … / Quelle: [Firma]).
*/
class RegeneratePressReleaseImageVariants extends Command
{
protected $signature = 'press-release-images:regenerate-variants
{--only=og : Nur diese Variante (derzeit: og)}
{--published : Nur Bilder veröffentlichter Meldungen}
{--force : Auch vorhandene Varianten neu erzeugen}
{--limit=0 : Höchstens so viele Bilder (0 = alle)}
{--execute : Wirklich schreiben (Standard ist ein Trockenlauf)}';
protected $description = 'Erzeugt fehlende OG-Varianten mit eingebranntem Bildnachweis für den Bestand';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
if ($this->option('only') !== 'og') {
$this->error('Derzeit wird nur --only=og unterstützt.');
return self::FAILURE;
}
$query = PressReleaseImage::query()
->with(['pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->with('company')])
->orderBy('id');
if ($this->option('published')) {
$query->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published'));
}
if (! $this->option('force')) {
$query->where(function ($q): void {
$q->whereNull('variants')->orWhereNull('variants->og');
});
}
$total = $query->count();
$limit = (int) $this->option('limit');
$execute = (bool) $this->option('execute');
$this->info(($execute ? 'Erzeuge' : 'Trockenlauf:').' OG-Varianten für '.($limit > 0 ? min($limit, $total) : $total).' von '.$total.' Bildern.');
if (! $execute) {
return self::SUCCESS;
}
$done = 0;
$skipped = 0;
$query->chunkById(200, function ($images) use (&$done, &$skipped, $limit): bool {
foreach ($images as $image) {
if ($limit > 0 && $done >= $limit) {
return false;
}
$caption = $image->pressRelease ? ImageCredit::for($image, $image->pressRelease->company)->line() : null;
$path = $this->imageService->regenerateOgVariant(
$image->disk,
$image->variants,
$image->path,
$image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null,
(bool) $image->no_crop,
$caption,
);
if ($path === null) {
$skipped++;
continue;
}
$image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->saveQuietly();
$done++;
}
return true;
});
$this->info("OG-Varianten erzeugt: {$done}, übersprungen (keine Quelle): {$skipped}.");
return self::SUCCESS;
}
}

View file

@ -137,3 +137,32 @@ test('teasers carry the credit in alt and title and the hero shows a caption', f
->assertSee('alt="Teaser-Meldung · Foto: Anna Beispiel · KI-generiert"', false)
->assertSee('data-image-credit-teaser', false);
});
test('the regenerate command builds missing og variants for the existing stock with the credit line', function () {
/** @var TestCase $this */
$company = Company::factory()->presseecho()->create(['name' => 'Bestand GmbH', 'is_active' => true]);
$pr = PressRelease::factory()->published()->for($company)->create(['published_ever_at' => now()->subDay()]);
$upload = UploadedFile::fake()->image('alt.jpg', 1600, 900)->size(100);
$stored = (new ImageService)->storePressReleaseImage($upload, $pr->id, 'local');
$variants = $stored['variants'];
Storage::disk('local')->delete($variants['og']);
unset($variants['og']);
$image = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => $stored['path'], 'variants' => $variants]);
$this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true])
->expectsOutputToContain('Trockenlauf: OG-Varianten für 1 von 1 Bildern.')
->assertSuccessful();
expect($image->fresh()->variants)->not->toHaveKey('og');
$this->artisan('press-release-images:regenerate-variants', ['--only' => 'og', '--published' => true, '--execute' => true])
->expectsOutputToContain('OG-Varianten erzeugt: 1')
->assertSuccessful();
$og = $image->fresh()->variants['og'] ?? null;
expect($og)->not->toBeNull()
->and(Storage::disk('local')->exists($og))->toBeTrue()
->and(getimagesize(Storage::disk('local')->path($og))[0])->toBe(1200);
});

View file

@ -29,7 +29,7 @@ beforeEach(function (): void {
/**
* @return array{owner: User, pr: PressRelease}
*/
function gateDraft(): array
function legalGateDraft(): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
@ -49,7 +49,7 @@ function gateDraft(): array
test('with the gate off nothing is required and submitting works as before', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = gateDraft();
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty();
@ -68,7 +68,7 @@ test('with the gate off nothing is required and submitting works as before', fun
test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = gateDraft();
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']);
@ -99,7 +99,7 @@ test('with the gate on the submit requires an active acceptance of agb and guide
test('acceptances are insert-only', function () {
config()->set('legal.acceptance_gate', true);
['owner' => $owner] = gateDraft();
['owner' => $owner] = legalGateDraft();
$gate = app(LegalAcceptanceGate::class);
$rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest');
@ -112,7 +112,7 @@ test('acceptances are insert-only', function () {
test('the api answers 428 with the required documents until they are accepted', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = gateDraft();
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])