From ad07aa3703340782f8d3b8bf1587d5f33ca16e2d Mon Sep 17 00:00:00 2001 From: paranarimasu <33796518+paranarimasu@users.noreply.github.com> Date: Thu, 23 Jun 2022 12:44:45 -0500 Subject: [PATCH] #395 support images for studios (#403) * feat: support images for studios * style: fix StyleCI findings * fix(admin): throw exception for image request * style: fix StyleCI findings * style: remove extraneous constant * fix(admin): resources mapping may not exist when sending pipe notification * style: fix StyleCI findings --- .../Commands/Wiki/WikiDatabaseDumpCommand.php | 2 + .../Pivot/StudioImage/StudioImageCreated.php | 41 +++++ .../Pivot/StudioImage/StudioImageDeleted.php | 41 +++++ app/Http/Api/Schema/Wiki/ImageSchema.php | 1 + app/Http/Api/Schema/Wiki/StudioSchema.php | 1 + .../Resources/Wiki/Resource/ImageResource.php | 2 + .../Wiki/Resource/StudioResource.php | 2 + app/Models/Wiki/Image.php | 15 ++ app/Models/Wiki/Studio.php | 15 ++ .../Wiki/Anime/BackfillAnimeAction.php | 6 +- .../Wiki/Studio/BackfillStudioAction.php | 147 +++++++++++++++++ app/Nova/Lenses/Image/ImageUnlinkedLens.php | 3 +- .../Lenses/Studio/StudioCoverLargeLens.php | 107 ++++++++++++ app/Nova/Resources/Admin/Announcement.php | 4 +- app/Nova/Resources/Auth/Invitation.php | 4 +- app/Nova/Resources/Auth/Permission.php | 4 +- app/Nova/Resources/Auth/Role.php | 4 +- app/Nova/Resources/Auth/User.php | 4 +- .../{Resource.php => BaseResource.php} | 2 +- app/Nova/Resources/Billing/Balance.php | 4 +- app/Nova/Resources/Billing/Transaction.php | 4 +- app/Nova/Resources/Document/Page.php | 4 +- app/Nova/Resources/Wiki/Anime.php | 4 +- app/Nova/Resources/Wiki/Anime/Synonym.php | 4 +- app/Nova/Resources/Wiki/Anime/Theme.php | 4 +- app/Nova/Resources/Wiki/Anime/Theme/Entry.php | 4 +- app/Nova/Resources/Wiki/Artist.php | 4 +- app/Nova/Resources/Wiki/ExternalResource.php | 4 +- app/Nova/Resources/Wiki/Image.php | 17 +- app/Nova/Resources/Wiki/Series.php | 4 +- app/Nova/Resources/Wiki/Song.php | 4 +- app/Nova/Resources/Wiki/Studio.php | 48 +++++- app/Nova/Resources/Wiki/Video.php | 4 +- app/Pipes/BasePipe.php | 83 ++++++++++ app/Pipes/Wiki/Anime/BackfillAnimeImage.php | 117 +++++-------- app/Pipes/Wiki/Anime/BackfillAnimePipe.php | 46 ------ .../Wiki/Anime/BackfillAnimeResource.php | 95 +++++------ .../Anime/Image/BackfillLargeCoverImage.php | 9 +- .../Anime/Image/BackfillSmallCoverImage.php | 9 +- .../Anime/Resource/BackfillAnidbResource.php | 6 +- .../Resource/BackfillAnilistResource.php | 6 +- .../Anime/Resource/BackfillAnnResource.php | 2 +- .../Anime/Resource/BackfillKitsuResource.php | 8 +- .../Anime/Resource/BackfillMalResource.php | 6 +- .../Anime/Studio/BackfillAnimeStudios.php | 154 ++++++------------ app/Pipes/Wiki/BackfillImage.php | 125 ++++++++++++++ app/Pipes/Wiki/BackfillResource.php | 88 ++++++++++ app/Pipes/Wiki/BackfillStudios.php | 135 +++++++++++++++ app/Pipes/Wiki/Studio/BackfillStudioImage.php | 73 +++++++++ .../Studio/Image/BackfillLargeCoverImage.php | 58 +++++++ app/Pivots/StudioImage.php | 67 ++++++++ app/Policies/Wiki/ImagePolicy.php | 42 +++++ app/Policies/Wiki/StudioPolicy.php | 42 +++++ .../Api/Schema/Wiki/StudioSchema.php | 2 + .../factories/Pivots/StudioImageFactory.php | 38 +++++ .../2022_06_17_163940_create_studio_image.php | 42 +++++ resources/lang/en/nova.php | 5 +- .../Feature/Events/Pivot/StudioImageTest.php | 53 ++++++ .../Http/Api/Wiki/Studio/StudioIndexTest.php | 88 ++++++++++ .../Http/Api/Wiki/Studio/StudioShowTest.php | 84 ++++++++++ tests/Feature/Jobs/Pivot/StudioImageTest.php | 57 +++++++ tests/Unit/Models/Wiki/ImageTest.php | 21 +++ tests/Unit/Models/Wiki/StudioTest.php | 21 +++ tests/Unit/Pivots/StudioImageTest.php | 52 ++++++ 64 files changed, 1799 insertions(+), 353 deletions(-) create mode 100644 app/Events/Pivot/StudioImage/StudioImageCreated.php create mode 100644 app/Events/Pivot/StudioImage/StudioImageDeleted.php create mode 100644 app/Nova/Actions/Wiki/Studio/BackfillStudioAction.php create mode 100644 app/Nova/Lenses/Studio/StudioCoverLargeLens.php rename app/Nova/Resources/{Resource.php => BaseResource.php} (97%) create mode 100644 app/Pipes/BasePipe.php delete mode 100644 app/Pipes/Wiki/Anime/BackfillAnimePipe.php create mode 100644 app/Pipes/Wiki/BackfillImage.php create mode 100644 app/Pipes/Wiki/BackfillResource.php create mode 100644 app/Pipes/Wiki/BackfillStudios.php create mode 100644 app/Pipes/Wiki/Studio/BackfillStudioImage.php create mode 100644 app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php create mode 100644 app/Pivots/StudioImage.php create mode 100644 database/factories/Pivots/StudioImageFactory.php create mode 100644 database/migrations/2022_06_17_163940_create_studio_image.php create mode 100644 tests/Feature/Events/Pivot/StudioImageTest.php create mode 100644 tests/Feature/Jobs/Pivot/StudioImageTest.php create mode 100644 tests/Unit/Pivots/StudioImageTest.php diff --git a/app/Console/Commands/Wiki/WikiDatabaseDumpCommand.php b/app/Console/Commands/Wiki/WikiDatabaseDumpCommand.php index 878b96917..78a2ffce5 100644 --- a/app/Console/Commands/Wiki/WikiDatabaseDumpCommand.php +++ b/app/Console/Commands/Wiki/WikiDatabaseDumpCommand.php @@ -25,6 +25,7 @@ use App\Pivots\ArtistImage; use App\Pivots\ArtistMember; use App\Pivots\ArtistResource; use App\Pivots\ArtistSong; +use App\Pivots\StudioImage; use App\Pivots\StudioResource; /** @@ -89,6 +90,7 @@ class WikiDatabaseDumpCommand extends DatabaseDumpCommand Series::TABLE, Song::TABLE, Studio::TABLE, + StudioImage::TABLE, StudioResource::TABLE, Video::TABLE, ]; diff --git a/app/Events/Pivot/StudioImage/StudioImageCreated.php b/app/Events/Pivot/StudioImage/StudioImageCreated.php new file mode 100644 index 000000000..f669323ce --- /dev/null +++ b/app/Events/Pivot/StudioImage/StudioImageCreated.php @@ -0,0 +1,41 @@ + + */ +class StudioImageCreated extends PivotCreatedEvent +{ + /** + * Create a new event instance. + * + * @param StudioImage $studioImage + */ + public function __construct(StudioImage $studioImage) + { + parent::__construct($studioImage->studio, $studioImage->image); + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + $foreign = $this->getForeign(); + $related = $this->getRelated(); + + return "Image '**{$foreign->getName()}**' has been attached to Studio '**{$related->getName()}**'."; + } +} diff --git a/app/Events/Pivot/StudioImage/StudioImageDeleted.php b/app/Events/Pivot/StudioImage/StudioImageDeleted.php new file mode 100644 index 000000000..a57503678 --- /dev/null +++ b/app/Events/Pivot/StudioImage/StudioImageDeleted.php @@ -0,0 +1,41 @@ + + */ +class StudioImageDeleted extends PivotDeletedEvent +{ + /** + * Create a new event instance. + * + * @param StudioImage $studioImage + */ + public function __construct(StudioImage $studioImage) + { + parent::__construct($studioImage->studio, $studioImage->image); + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + $foreign = $this->getForeign(); + $related = $this->getRelated(); + + return "Image '**{$foreign->getName()}**' has been detached from Studio '**{$related->getName()}**'."; + } +} diff --git a/app/Http/Api/Schema/Wiki/ImageSchema.php b/app/Http/Api/Schema/Wiki/ImageSchema.php index 5b28f9ede..7da08c2fb 100644 --- a/app/Http/Api/Schema/Wiki/ImageSchema.php +++ b/app/Http/Api/Schema/Wiki/ImageSchema.php @@ -50,6 +50,7 @@ class ImageSchema extends EloquentSchema return [ new AllowedInclude(new AnimeSchema(), Image::RELATION_ANIME), new AllowedInclude(new ArtistSchema(), Image::RELATION_ARTISTS), + new AllowedInclude(new StudioSchema(), Image::RELATION_STUDIOS), ]; } diff --git a/app/Http/Api/Schema/Wiki/StudioSchema.php b/app/Http/Api/Schema/Wiki/StudioSchema.php index 3a6088a9d..8f5d648d3 100644 --- a/app/Http/Api/Schema/Wiki/StudioSchema.php +++ b/app/Http/Api/Schema/Wiki/StudioSchema.php @@ -49,6 +49,7 @@ class StudioSchema extends EloquentSchema return [ new AllowedInclude(new AnimeSchema(), Studio::RELATION_ANIME), new AllowedInclude(new ExternalResourceSchema(), Studio::RELATION_RESOURCES), + new AllowedInclude(new ImageSchema(), Studio::RELATION_IMAGES), // Undocumented paths needed for client builds new AllowedInclude(new ImageSchema(), 'anime.images'), diff --git a/app/Http/Resources/Wiki/Resource/ImageResource.php b/app/Http/Resources/Wiki/Resource/ImageResource.php index 1315cf984..b2ffb43ad 100644 --- a/app/Http/Resources/Wiki/Resource/ImageResource.php +++ b/app/Http/Resources/Wiki/Resource/ImageResource.php @@ -8,6 +8,7 @@ use App\Http\Api\Query\ReadQuery; use App\Http\Resources\BaseResource; use App\Http\Resources\Wiki\Collection\AnimeCollection; use App\Http\Resources\Wiki\Collection\ArtistCollection; +use App\Http\Resources\Wiki\Collection\StudioCollection; use App\Models\BaseModel; use App\Models\Wiki\Image; use Illuminate\Http\Request; @@ -84,6 +85,7 @@ class ImageResource extends BaseResource $result[Image::RELATION_ARTISTS] = new ArtistCollection($this->whenLoaded(Image::RELATION_ARTISTS), $this->query); $result[Image::RELATION_ANIME] = new AnimeCollection($this->whenLoaded(Image::RELATION_ANIME), $this->query); + $result[Image::RELATION_STUDIOS] = new StudioCollection($this->whenLoaded(Image::RELATION_STUDIOS), $this->query); return $result; } diff --git a/app/Http/Resources/Wiki/Resource/StudioResource.php b/app/Http/Resources/Wiki/Resource/StudioResource.php index b1ce3ff82..48668feb9 100644 --- a/app/Http/Resources/Wiki/Resource/StudioResource.php +++ b/app/Http/Resources/Wiki/Resource/StudioResource.php @@ -8,6 +8,7 @@ use App\Http\Api\Query\ReadQuery; use App\Http\Resources\BaseResource; use App\Http\Resources\Wiki\Collection\AnimeCollection; use App\Http\Resources\Wiki\Collection\ExternalResourceCollection; +use App\Http\Resources\Wiki\Collection\ImageCollection; use App\Models\BaseModel; use App\Models\Wiki\Studio; use App\Pivots\StudioResource as StudioResourcePivot; @@ -78,6 +79,7 @@ class StudioResource extends BaseResource $result[Studio::RELATION_ANIME] = new AnimeCollection($this->whenLoaded(Studio::RELATION_ANIME), $this->query); $result[Studio::RELATION_RESOURCES] = new ExternalResourceCollection($this->whenLoaded(Studio::RELATION_RESOURCES), $this->query); + $result[Studio::RELATION_IMAGES] = new ImageCollection($this->whenLoaded(Studio::RELATION_IMAGES), $this->query); if ($this->isAllowedField(StudioResourcePivot::ATTRIBUTE_AS)) { $result[StudioResourcePivot::ATTRIBUTE_AS] = $this->whenPivotLoaded(StudioResourcePivot::TABLE, fn () => $this->pivot->getAttribute(StudioResourcePivot::ATTRIBUTE_AS)); diff --git a/app/Models/Wiki/Image.php b/app/Models/Wiki/Image.php index d063ef587..e98693d27 100644 --- a/app/Models/Wiki/Image.php +++ b/app/Models/Wiki/Image.php @@ -13,6 +13,7 @@ use App\Events\Wiki\Image\ImageUpdated; use App\Models\BaseModel; use App\Pivots\AnimeImage; use App\Pivots\ArtistImage; +use App\Pivots\StudioImage; use BenSampo\Enum\Enum; use Database\Factories\Wiki\ImageFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -29,6 +30,7 @@ use Laravel\Nova\Actions\Actionable; * @property string $mimetype * @property string $path * @property int $size + * @property Collection $studios * * @method static ImageFactory factory(...$parameters) */ @@ -44,6 +46,7 @@ class Image extends BaseModel final public const RELATION_ANIME = 'anime'; final public const RELATION_ARTISTS = 'artists'; + final public const RELATION_STUDIOS = 'studios'; /** * The attributes that are mass assignable. @@ -126,4 +129,16 @@ class Image extends BaseModel ->using(ArtistImage::class) ->withTimestamps(); } + + /** + * Get the studios that use this image. + * + * @return BelongsToMany + */ + public function studios(): BelongsToMany + { + return $this->belongsToMany(Studio::class, StudioImage::TABLE, Image::ATTRIBUTE_ID, Studio::ATTRIBUTE_ID) + ->using(StudioImage::class) + ->withTimestamps(); + } } diff --git a/app/Models/Wiki/Studio.php b/app/Models/Wiki/Studio.php index bf43ebd2a..33788ed07 100644 --- a/app/Models/Wiki/Studio.php +++ b/app/Models/Wiki/Studio.php @@ -11,6 +11,7 @@ use App\Events\Wiki\Studio\StudioUpdated; use App\Models\BaseModel; use App\Pivots\AnimeStudio; use App\Pivots\BasePivot; +use App\Pivots\StudioImage; use App\Pivots\StudioResource; use Database\Factories\Wiki\StudioFactory; use ElasticScoutDriverPlus\Searchable; @@ -22,6 +23,7 @@ use Laravel\Nova\Actions\Actionable; * Class Studio. * * @property Collection $anime + * @property Collection $images * @property string $name * @property string $slug * @property int $studio_id @@ -41,6 +43,7 @@ class Studio extends BaseModel final public const ATTRIBUTE_SLUG = 'slug'; final public const RELATION_ANIME = 'anime'; + final public const RELATION_IMAGES = 'images'; final public const RELATION_RESOURCES = 'resources'; /** @@ -127,4 +130,16 @@ class Studio extends BaseModel ->withPivot(StudioResource::ATTRIBUTE_AS) ->withTimestamps(); } + + /** + * Get the images for the studio. + * + * @return BelongsToMany + */ + public function images(): BelongsToMany + { + return $this->belongsToMany(Image::class, StudioImage::TABLE, Studio::ATTRIBUTE_ID, Image::ATTRIBUTE_ID) + ->using(StudioImage::class) + ->withTimestamps(); + } } diff --git a/app/Nova/Actions/Wiki/Anime/BackfillAnimeAction.php b/app/Nova/Actions/Wiki/Anime/BackfillAnimeAction.php index 59844f04e..c8cfdb26e 100644 --- a/app/Nova/Actions/Wiki/Anime/BackfillAnimeAction.php +++ b/app/Nova/Actions/Wiki/Anime/BackfillAnimeAction.php @@ -10,7 +10,7 @@ use App\Models\Auth\User; use App\Models\Wiki\Anime; use App\Models\Wiki\ExternalResource; use App\Models\Wiki\Image; -use App\Pipes\Wiki\Anime\BackfillAnimePipe; +use App\Pipes\BasePipe; use App\Pipes\Wiki\Anime\Image\BackfillLargeCoverImage; use App\Pipes\Wiki\Anime\Image\BackfillSmallCoverImage; use App\Pipes\Wiki\Anime\Resource\BackfillAnidbResource; @@ -165,7 +165,7 @@ class BackfillAnimeAction extends Action implements ShouldQueue * * @param ActionFields $fields * @param Anime $anime - * @return BackfillAnimePipe[] + * @return BasePipe[] */ protected function getPipes(ActionFields $fields, Anime $anime): array { @@ -184,7 +184,7 @@ class BackfillAnimeAction extends Action implements ShouldQueue * Get the mapping of anime pipes to their form fields. * * @param Anime $anime - * @return array + * @return array */ protected function getPipeMapping(Anime $anime): array { diff --git a/app/Nova/Actions/Wiki/Studio/BackfillStudioAction.php b/app/Nova/Actions/Wiki/Studio/BackfillStudioAction.php new file mode 100644 index 000000000..6a9cf8890 --- /dev/null +++ b/app/Nova/Actions/Wiki/Studio/BackfillStudioAction.php @@ -0,0 +1,147 @@ + $models + * @return mixed + */ + public function handle(ActionFields $fields, Collection $models): mixed + { + foreach ($models as $studio) { + if ($studio->resources()->doesntExist()) { + $this->markAsFailed($studio, 'At least one Resource is required to backfill Studio'); + continue; + } + + $pipes = $this->getPipes($fields, $studio); + + $pipeline = new Pipeline(Container::getInstance()); + + try { + $pipeline->send($this->user) + ->through($pipes) + ->then(fn () => $this->markAsFinished($studio)); + } catch (Exception $e) { + $this->markAsFailed($studio, $e); + } finally { + // Try not to upset third-party APIs + sleep(rand(3, 5)); + } + } + + return $models; + } + + /** + * Get the fields available on the action. + * + * @param NovaRequest $request + * @return array + * + * @noinspection PhpMissingParentCallCommonInspection + */ + public function fields(NovaRequest $request): array + { + $studio = $request->resourceId !== null + ? $request->findModel() + : null; + + return [ + Heading::make(__('nova.backfill_images')), + + Boolean::make(__('nova.backfill_large_cover'), self::BACKFILL_LARGE_COVER) + ->help(__('nova.backfill_studio_large_cover_help')) + ->default(fn () => $studio instanceof Studio && $studio->images()->where(Image::ATTRIBUTE_FACET, ImageFacet::COVER_LARGE)->doesntExist()), + ]; + } + + /** + * Get the selected pipes for backfilling studios. + * + * @param ActionFields $fields + * @param Studio $studio + * @return BasePipe[] + */ + protected function getPipes(ActionFields $fields, Studio $studio): array + { + $pipes = []; + + foreach ($this->getPipeMapping($studio) as $field => $pipe) { + if (Arr::get($fields, $field) === true) { + $pipes[] = $pipe; + } + } + + return $pipes; + } + + /** + * Get the mapping of studio pipes to their form fields. + * + * @param Studio $studio + * @return array + */ + protected function getPipeMapping(Studio $studio): array + { + return [ + self::BACKFILL_LARGE_COVER => new BackfillLargeCoverImage($studio), + ]; + } +} diff --git a/app/Nova/Lenses/Image/ImageUnlinkedLens.php b/app/Nova/Lenses/Image/ImageUnlinkedLens.php index 6978bd9ea..3d0815056 100644 --- a/app/Nova/Lenses/Image/ImageUnlinkedLens.php +++ b/app/Nova/Lenses/Image/ImageUnlinkedLens.php @@ -56,7 +56,8 @@ class ImageUnlinkedLens extends BaseLens { return $query->whereNot(Image::ATTRIBUTE_FACET, ImageFacet::GRILL) ->whereDoesntHave(Image::RELATION_ANIME) - ->whereDoesntHave(Image::RELATION_ARTISTS); + ->whereDoesntHave(Image::RELATION_ARTISTS) + ->whereDoesntHave(Image::RELATION_STUDIOS); } /** diff --git a/app/Nova/Lenses/Studio/StudioCoverLargeLens.php b/app/Nova/Lenses/Studio/StudioCoverLargeLens.php new file mode 100644 index 000000000..41ef8dd0b --- /dev/null +++ b/app/Nova/Lenses/Studio/StudioCoverLargeLens.php @@ -0,0 +1,107 @@ + ImageFacet::getDescription(ImageFacet::COVER_LARGE)]); + } + + /** + * Get the query builder / paginator for the lens. + * + * @param LensRequest $request + * @param Builder $query + * @return Builder + */ + public static function query(LensRequest $request, $query): Builder + { + return $request->withOrdering($request->withFilters( + static::criteria($query) + )); + } + + /** + * The criteria used to refine the models for the lens. + * + * @param Builder $query + * @return Builder + */ + public static function criteria(Builder $query): Builder + { + return $query->whereDoesntHave(Studio::RELATION_IMAGES, function (Builder $imageQuery) { + $imageQuery->where(Image::ATTRIBUTE_FACET, ImageFacet::COVER_LARGE); + }); + } + + /** + * Get the fields available to the lens. + * + * @param NovaRequest $request + * @return array + */ + public function fields(NovaRequest $request): array + { + return [ + ID::make(__('nova.id'), Studio::ATTRIBUTE_ID) + ->sortable(), + + Text::make(__('nova.name'), Studio::ATTRIBUTE_NAME) + ->sortable() + ->filterable(), + + Text::make(__('nova.slug'), Studio::ATTRIBUTE_SLUG) + ->sortable() + ->filterable(), + ]; + } + + /** + * Get the actions available on the lens. + * + * @param NovaRequest $request + * @return array + * + * @noinspection PhpMissingParentCallCommonInspection + */ + public function actions(NovaRequest $request): array + { + return []; + } + + /** + * Get the URI key for the lens. + * + * @return string + * + * @noinspection PhpMissingParentCallCommonInspection + */ + public function uriKey(): string + { + return 'studio-cover-large-lens'; + } +} diff --git a/app/Nova/Resources/Admin/Announcement.php b/app/Nova/Resources/Admin/Announcement.php index 11526eb1a..5b5ef85b5 100644 --- a/app/Nova/Resources/Admin/Announcement.php +++ b/app/Nova/Resources/Admin/Announcement.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Nova\Resources\Admin; use App\Models\Admin\Announcement as AnnouncementModel; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use Laravel\Nova\Fields\Code; use Laravel\Nova\Fields\ID; use Laravel\Nova\Fields\Text; @@ -16,7 +16,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Announcement. */ -class Announcement extends Resource +class Announcement extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Auth/Invitation.php b/app/Nova/Resources/Auth/Invitation.php index d50b0e46a..8250c811d 100644 --- a/app/Nova/Resources/Auth/Invitation.php +++ b/app/Nova/Resources/Auth/Invitation.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Auth; use App\Enums\Models\Auth\InvitationStatus; use App\Models\Auth\Invitation as InvitationModel; use App\Nova\Actions\Auth\Invitation\ResendInvitationAction; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use BenSampo\Enum\Enum; use BenSampo\Enum\Rules\EnumValue; use Exception; @@ -23,7 +23,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Invitation. */ -class Invitation extends Resource +class Invitation extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Auth/Permission.php b/app/Nova/Resources/Auth/Permission.php index 48bef08ee..99e2e0310 100644 --- a/app/Nova/Resources/Auth/Permission.php +++ b/app/Nova/Resources/Auth/Permission.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Auth; use App\Models\Auth\Permission as PermissionModel; use App\Nova\Actions\Auth\Permission\GiveRoleAction; use App\Nova\Actions\Auth\Permission\RevokeRoleAction; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use Exception; use Laravel\Nova\Fields\BelongsToMany; use Laravel\Nova\Fields\ID; @@ -18,7 +18,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Permission. */ -class Permission extends Resource +class Permission extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Auth/Role.php b/app/Nova/Resources/Auth/Role.php index 4677adb26..81adb8107 100644 --- a/app/Nova/Resources/Auth/Role.php +++ b/app/Nova/Resources/Auth/Role.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Auth; use App\Models\Auth\Role as RoleModel; use App\Nova\Actions\Auth\Role\GivePermissionAction; use App\Nova\Actions\Auth\Role\RevokePermissionAction; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use Exception; use Laravel\Nova\Fields\BelongsToMany; use Laravel\Nova\Fields\ID; @@ -18,7 +18,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Role. */ -class Role extends Resource +class Role extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Auth/User.php b/app/Nova/Resources/Auth/User.php index 8cd137846..4c92b275f 100644 --- a/app/Nova/Resources/Auth/User.php +++ b/app/Nova/Resources/Auth/User.php @@ -9,7 +9,7 @@ use App\Nova\Actions\Auth\User\GivePermissionAction; use App\Nova\Actions\Auth\User\GiveRoleAction; use App\Nova\Actions\Auth\User\RevokePermissionAction; use App\Nova\Actions\Auth\User\RevokeRoleAction; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use Exception; use Illuminate\Validation\Rule; use Laravel\Nova\Fields\BelongsToMany; @@ -24,7 +24,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class User. */ -class User extends Resource +class User extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Resource.php b/app/Nova/Resources/BaseResource.php similarity index 97% rename from app/Nova/Resources/Resource.php rename to app/Nova/Resources/BaseResource.php index 230b3f57e..ee3aaa581 100644 --- a/app/Nova/Resources/Resource.php +++ b/app/Nova/Resources/BaseResource.php @@ -11,7 +11,7 @@ use Laravel\Nova\Resource as NovaResource; /** * Class Resource. */ -abstract class Resource extends NovaResource +abstract class BaseResource extends NovaResource { /** * The number of results to display when searching relatable resource without Scout. diff --git a/app/Nova/Resources/Billing/Balance.php b/app/Nova/Resources/Billing/Balance.php index 81decf6e9..7a54e52a3 100644 --- a/app/Nova/Resources/Billing/Balance.php +++ b/app/Nova/Resources/Billing/Balance.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Billing; use App\Enums\Models\Billing\BalanceFrequency; use App\Enums\Models\Billing\Service; use App\Models\Billing\Balance as BalanceModel; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use BenSampo\Enum\Enum; use BenSampo\Enum\Rules\EnumValue; use Laravel\Nova\Fields\Currency; @@ -21,7 +21,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Balance. */ -class Balance extends Resource +class Balance extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Billing/Transaction.php b/app/Nova/Resources/Billing/Transaction.php index 2a3cc556a..39a7a426f 100644 --- a/app/Nova/Resources/Billing/Transaction.php +++ b/app/Nova/Resources/Billing/Transaction.php @@ -6,7 +6,7 @@ namespace App\Nova\Resources\Billing; use App\Enums\Models\Billing\Service; use App\Models\Billing\Transaction as TransactionModel; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use BenSampo\Enum\Enum; use BenSampo\Enum\Rules\EnumValue; use Exception; @@ -22,7 +22,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Transaction. */ -class Transaction extends Resource +class Transaction extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Document/Page.php b/app/Nova/Resources/Document/Page.php index 71cfa13f7..0f86aff28 100644 --- a/app/Nova/Resources/Document/Page.php +++ b/app/Nova/Resources/Document/Page.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Nova\Resources\Document; use App\Models\Document\Page as PageModel; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use Exception; use Illuminate\Validation\Rule; use Laravel\Nova\Fields\ID; @@ -19,7 +19,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Page. */ -class Page extends Resource +class Page extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Anime.php b/app/Nova/Resources/Wiki/Anime.php index 52360c425..74374c150 100644 --- a/app/Nova/Resources/Wiki/Anime.php +++ b/app/Nova/Resources/Wiki/Anime.php @@ -19,7 +19,7 @@ use App\Nova\Lenses\Anime\AnimePlanetResourceLens; use App\Nova\Lenses\Anime\AnimeStudioLens; use App\Nova\Metrics\Anime\AnimePerDay; use App\Nova\Metrics\Anime\NewAnime; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime\Synonym; use App\Nova\Resources\Wiki\Anime\Theme; use App\Pivots\AnimeResource; @@ -46,7 +46,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Anime. */ -class Anime extends Resource +class Anime extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Anime/Synonym.php b/app/Nova/Resources/Wiki/Anime/Synonym.php index ded33e185..efbd83ac6 100644 --- a/app/Nova/Resources/Wiki/Anime/Synonym.php +++ b/app/Nova/Resources/Wiki/Anime/Synonym.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Nova\Resources\Wiki\Anime; use App\Models\Wiki\Anime\AnimeSynonym; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime; use Illuminate\Database\Eloquent\Builder; use Laravel\Nova\Fields\BelongsTo; @@ -18,7 +18,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Synonym. */ -class Synonym extends Resource +class Synonym extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Anime/Theme.php b/app/Nova/Resources/Wiki/Anime/Theme.php index 95b3f4c79..1a1506189 100644 --- a/app/Nova/Resources/Wiki/Anime/Theme.php +++ b/app/Nova/Resources/Wiki/Anime/Theme.php @@ -6,7 +6,7 @@ namespace App\Nova\Resources\Wiki\Anime; use App\Enums\Models\Wiki\ThemeType; use App\Models\Wiki\Anime\AnimeTheme; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime; use App\Nova\Resources\Wiki\Anime\Theme\Entry; use App\Nova\Resources\Wiki\Song; @@ -28,7 +28,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Theme. */ -class Theme extends Resource +class Theme extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Anime/Theme/Entry.php b/app/Nova/Resources/Wiki/Anime/Theme/Entry.php index b104ea9f5..d11d888f3 100644 --- a/app/Nova/Resources/Wiki/Anime/Theme/Entry.php +++ b/app/Nova/Resources/Wiki/Anime/Theme/Entry.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Nova\Resources\Wiki\Anime\Theme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime; use App\Nova\Resources\Wiki\Anime\Theme; use App\Nova\Resources\Wiki\Video; @@ -25,7 +25,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Entry. */ -class Entry extends Resource +class Entry extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Artist.php b/app/Nova/Resources/Wiki/Artist.php index b3f826f0c..15602c3b4 100644 --- a/app/Nova/Resources/Wiki/Artist.php +++ b/app/Nova/Resources/Wiki/Artist.php @@ -14,7 +14,7 @@ use App\Nova\Lenses\Artist\ArtistMalResourceLens; use App\Nova\Lenses\Artist\ArtistSongLens; use App\Nova\Metrics\Artist\ArtistsPerDay; use App\Nova\Metrics\Artist\NewArtists; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Pivots\ArtistMember; use App\Pivots\ArtistResource; use App\Pivots\ArtistSong; @@ -34,7 +34,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Artist. */ -class Artist extends Resource +class Artist extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/ExternalResource.php b/app/Nova/Resources/Wiki/ExternalResource.php index c33e8f2ad..a1a348633 100644 --- a/app/Nova/Resources/Wiki/ExternalResource.php +++ b/app/Nova/Resources/Wiki/ExternalResource.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Wiki; use App\Enums\Models\Wiki\ResourceSite; use App\Models\Wiki\ExternalResource as ExternalResourceModel; use App\Nova\Lenses\ExternalResource\ExternalResourceUnlinkedLens; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Pivots\AnimeResource; use App\Pivots\ArtistResource; use App\Pivots\BasePivot; @@ -31,7 +31,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class ExternalResource. */ -class ExternalResource extends Resource +class ExternalResource extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Image.php b/app/Nova/Resources/Wiki/Image.php index 025295272..ff1e8e748 100644 --- a/app/Nova/Resources/Wiki/Image.php +++ b/app/Nova/Resources/Wiki/Image.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Wiki; use App\Enums\Models\Wiki\ImageFacet; use App\Models\Wiki\Image as ImageModel; use App\Nova\Lenses\Image\ImageUnlinkedLens; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Pivots\BasePivot; use BenSampo\Enum\Enum; use BenSampo\Enum\Rules\EnumValue; @@ -25,7 +25,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Image. */ -class Image extends Resource +class Image extends BaseResource { /** * The model the resource corresponds to. @@ -170,6 +170,19 @@ class Image extends Resource ->hideWhenCreating(), ]), + BelongsToMany::make(__('nova.studios'), ImageModel::RELATION_STUDIOS, Studio::class) + ->searchable() + ->filterable() + ->withSubtitles() + ->showCreateRelationButton() + ->fields(fn () => [ + DateTime::make(__('nova.created_at'), BasePivot::ATTRIBUTE_CREATED_AT) + ->hideWhenCreating(), + + DateTime::make(__('nova.updated_at'), BasePivot::ATTRIBUTE_UPDATED_AT) + ->hideWhenCreating(), + ]), + Panel::make(__('nova.file_properties'), $this->fileProperties()) ->collapsable(), diff --git a/app/Nova/Resources/Wiki/Series.php b/app/Nova/Resources/Wiki/Series.php index d04158ca4..08aea89d5 100644 --- a/app/Nova/Resources/Wiki/Series.php +++ b/app/Nova/Resources/Wiki/Series.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Wiki; use App\Models\Wiki\Series as SeriesModel; use App\Nova\Metrics\Series\NewSeries; use App\Nova\Metrics\Series\SeriesPerDay; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Pivots\BasePivot; use Exception; use Illuminate\Validation\Rule; @@ -24,7 +24,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Series. */ -class Series extends Resource +class Series extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Song.php b/app/Nova/Resources/Wiki/Song.php index 13005b580..17becaf9b 100644 --- a/app/Nova/Resources/Wiki/Song.php +++ b/app/Nova/Resources/Wiki/Song.php @@ -7,7 +7,7 @@ namespace App\Nova\Resources\Wiki; use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Song as SongModel; use App\Nova\Lenses\Song\SongArtistLens; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime\Theme; use App\Pivots\ArtistSong; use App\Pivots\BasePivot; @@ -25,7 +25,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Song. */ -class Song extends Resource +class Song extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Nova/Resources/Wiki/Studio.php b/app/Nova/Resources/Wiki/Studio.php index f1eeea5a2..c95cba97e 100644 --- a/app/Nova/Resources/Wiki/Studio.php +++ b/app/Nova/Resources/Wiki/Studio.php @@ -4,17 +4,21 @@ declare(strict_types=1); namespace App\Nova\Resources\Wiki; +use App\Models\Auth\User; use App\Models\Wiki\Studio as StudioModel; +use App\Nova\Actions\Wiki\Studio\BackfillStudioAction; use App\Nova\Lenses\Studio\StudioAniDbResourceLens; use App\Nova\Lenses\Studio\StudioAnilistResourceLens; use App\Nova\Lenses\Studio\StudioAnimePlanetResourceLens; use App\Nova\Lenses\Studio\StudioAnnResourceLens; +use App\Nova\Lenses\Studio\StudioCoverLargeLens; use App\Nova\Lenses\Studio\StudioMalResourceLens; use App\Nova\Lenses\Studio\StudioUnlinkedLens; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Pivots\BasePivot; use App\Pivots\StudioResource; use Exception; +use Illuminate\Http\Request; use Illuminate\Validation\Rule; use Laravel\Nova\Fields\BelongsToMany; use Laravel\Nova\Fields\DateTime; @@ -28,7 +32,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Studio. */ -class Studio extends Resource +class Studio extends BaseResource { /** * The model the resource corresponds to. @@ -176,11 +180,50 @@ class Studio extends Resource ->hideWhenUpdating(), ]), + BelongsToMany::make(__('nova.images'), StudioModel::RELATION_IMAGES, Image::class) + ->searchable() + ->filterable() + ->withSubtitles() + ->showCreateRelationButton() + ->fields(fn () => [ + DateTime::make(__('nova.created_at'), BasePivot::ATTRIBUTE_CREATED_AT) + ->hideWhenCreating(), + + DateTime::make(__('nova.updated_at'), BasePivot::ATTRIBUTE_UPDATED_AT) + ->hideWhenCreating(), + ]), + Panel::make(__('nova.timestamps'), $this->timestamps()) ->collapsable(), ]; } + /** + * Get the actions available for the resource. + * + * @param NovaRequest $request + * @return array + */ + public function actions(NovaRequest $request): array + { + return array_merge( + parent::actions($request), + [ + (new BackfillStudioAction($request->user())) + ->confirmButtonText(__('nova.backfill')) + ->cancelButtonText(__('nova.cancel')) + ->showOnIndex() + ->showOnDetail() + ->showInline() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('update studio'); + }), + ] + ); + } + /** * Get the lenses available for the resource. * @@ -194,6 +237,7 @@ class Studio extends Resource [ new StudioAniDbResourceLens(), new StudioAnilistResourceLens(), + new StudioCoverLargeLens(), new StudioAnimePlanetResourceLens(), new StudioAnnResourceLens(), new StudioMalResourceLens(), diff --git a/app/Nova/Resources/Wiki/Video.php b/app/Nova/Resources/Wiki/Video.php index 5b4164f7c..d00917793 100644 --- a/app/Nova/Resources/Wiki/Video.php +++ b/app/Nova/Resources/Wiki/Video.php @@ -12,7 +12,7 @@ use App\Nova\Lenses\Video\VideoSourceLens; use App\Nova\Lenses\Video\VideoUnlinkedLens; use App\Nova\Metrics\Video\NewVideos; use App\Nova\Metrics\Video\VideosPerDay; -use App\Nova\Resources\Resource; +use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime\Theme\Entry; use App\Pivots\BasePivot; use BenSampo\Enum\Enum; @@ -33,7 +33,7 @@ use Laravel\Nova\Query\Search\Column; /** * Class Video. */ -class Video extends Resource +class Video extends BaseResource { /** * The model the resource corresponds to. diff --git a/app/Pipes/BasePipe.php b/app/Pipes/BasePipe.php new file mode 100644 index 000000000..fa5972ff1 --- /dev/null +++ b/app/Pipes/BasePipe.php @@ -0,0 +1,83 @@ +getModel())); + } + + /** + * Send notification for user to review failure. + * + * @param User $user + * @param string $message + * @return void + */ + protected function sendNotification(User $user, string $message): void + { + $resource = $this->resource(); + + $uriKey = $resource::uriKey(); + + $user->notify( + NovaNotification::make() + ->icon('flag') + ->message($message) + ->type(NovaNotification::WARNING_TYPE) + ->url("/resources/$uriKey/{$this->getModel()->getKey()}") + ); + } + + /** + * Get the relation to resources. + * + * @return Relation + */ + abstract protected function relation(): Relation; + + /** + * Get the nova resource. + * + * @return class-string + */ + abstract protected function resource(): string; +} diff --git a/app/Pipes/Wiki/Anime/BackfillAnimeImage.php b/app/Pipes/Wiki/Anime/BackfillAnimeImage.php index e9e6e6991..df0b212e0 100644 --- a/app/Pipes/Wiki/Anime/BackfillAnimeImage.php +++ b/app/Pipes/Wiki/Anime/BackfillAnimeImage.php @@ -4,80 +4,59 @@ declare(strict_types=1); namespace App\Pipes\Wiki\Anime; -use App\Enums\Models\Wiki\ImageFacet; -use App\Models\Auth\User; +use App\Models\Wiki\Anime; use App\Models\Wiki\Image; -use App\Pivots\AnimeImage; -use Closure; -use Illuminate\Http\Client\RequestException; -use Illuminate\Http\Testing\File; -use Illuminate\Support\Facades\Http; +use App\Nova\Resources\BaseResource; +use App\Nova\Resources\Wiki\Anime as AnimeResource; +use App\Pipes\Wiki\BackfillImage; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; /** * Class BackfillAnimeImage. + * + * @extends BackfillImage */ -abstract class BackfillAnimeImage extends BackfillAnimePipe +abstract class BackfillAnimeImage extends BackfillImage { /** - * Handle an incoming request. + * Create a new pipe instance. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed - * - * @throws RequestException + * @param Anime $anime */ - public function handle(User $user, Closure $next): mixed + public function __construct(Anime $anime) { - if ($this->anime->images()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->exists()) { - Log::info("Anime '{$this->anime->getName()}' already has Image of Facet '{$this->getFacet()->value}'."); - - return $next($user); - } - - $image = $this->getImage(); - - if ($image !== null) { - $this->attachImageToAnime($image); - } - - if ($this->anime->images()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->doesntExist()) { - $this->sendNotification( - $user, - "Anime '{$this->anime->getName()}' has no {$this->getFacet()->description} Image after backfilling. Please review." - ); - } - - return $next($user); + parent::__construct($anime); } /** - * Create Image from response. + * Get the model passed into the pipeline. * - * @param string $url - * @return Image + * @return Anime */ - protected function createImage(string $url): Image + public function getModel(): Anime { - $imageResponse = Http::get($url); + return $this->model; + } - $image = $imageResponse->body(); + /** + * Get the relation to images. + * + * @return BelongsToMany + */ + protected function relation(): BelongsToMany + { + return $this->getModel()->images(); + } - $file = File::createWithContent(basename($url), $image); - - $fs = Storage::disk('images'); - - $fsFile = $fs->putFile('', $file); - - /** @var Image $image */ - $image = Image::query()->create([ - Image::ATTRIBUTE_FACET => $this->getFacet()->value, - Image::ATTRIBUTE_PATH => $fsFile, - ]); - - return $image; + /** + * Get the nova resource. + * + * @return class-string + */ + protected function resource(): string + { + return AnimeResource::class; } /** @@ -86,31 +65,9 @@ abstract class BackfillAnimeImage extends BackfillAnimePipe * @param Image $image * @return void */ - protected function attachImageToAnime(Image $image): void + protected function attachImage(Image $image): void { - if (AnimeImage::query() - ->where($this->anime->getKeyName(), $this->anime->getKey()) - ->where($image->getKeyName(), $image->getKey()) - ->doesntExist() - ) { - Log::info("Attaching image '{$image->getName()}' to anime '{$this->anime->getName()}'"); - $image->anime()->attach($this->anime); - } + Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'"); + $this->relation()->attach($image); } - - /** - * Get the facet to backfill. - * - * @return ImageFacet - */ - abstract protected function getFacet(): ImageFacet; - - /** - * Query third-party APIs to find Image. - * - * @return Image|null - * - * @throws RequestException - */ - abstract protected function getImage(): ?Image; } diff --git a/app/Pipes/Wiki/Anime/BackfillAnimePipe.php b/app/Pipes/Wiki/Anime/BackfillAnimePipe.php deleted file mode 100644 index 56ebbb407..000000000 --- a/app/Pipes/Wiki/Anime/BackfillAnimePipe.php +++ /dev/null @@ -1,46 +0,0 @@ -notify( - NovaNotification::make() - ->icon('flag') - ->message($message) - ->type(NovaNotification::WARNING_TYPE) - ->url("/resources/$uriKey/{$this->anime->getKey()}") - ); - } -} diff --git a/app/Pipes/Wiki/Anime/BackfillAnimeResource.php b/app/Pipes/Wiki/Anime/BackfillAnimeResource.php index 952c6f9af..119335e59 100644 --- a/app/Pipes/Wiki/Anime/BackfillAnimeResource.php +++ b/app/Pipes/Wiki/Anime/BackfillAnimeResource.php @@ -5,50 +5,61 @@ declare(strict_types=1); namespace App\Pipes\Wiki\Anime; use App\Enums\Models\Wiki\ResourceSite; -use App\Models\Auth\User; +use App\Models\Wiki\Anime; use App\Models\Wiki\ExternalResource; +use App\Nova\Resources\BaseResource; +use App\Nova\Resources\Wiki\Anime as AnimeNovaResource; +use App\Pipes\Wiki\BackfillResource; use App\Pivots\AnimeResource; -use Closure; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\Client\RequestException; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Facades\Log; /** * Class BackfillAnimeResource. + * + * @extends BackfillResource */ -abstract class BackfillAnimeResource extends BackfillAnimePipe +abstract class BackfillAnimeResource extends BackfillResource { /** - * Handle an incoming request. + * Create a new pipe instance. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed - * - * @throws RequestException + * @param Anime $anime */ - public function handle(User $user, Closure $next): mixed + public function __construct(Anime $anime) { - if ($this->anime->resources()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->exists()) { - Log::info("Anime '{$this->anime->getName()}' already has Resource of Site '{$this->getSite()->value}'."); + parent::__construct($anime); + } - return $next($user); - } + /** + * Get the model passed into the pipeline. + * + * @return Anime + */ + public function getModel(): Anime + { + return $this->model; + } - $resource = $this->getResource(); + /** + * Get the relation to images. + * + * @return BelongsToMany + */ + protected function relation(): BelongsToMany + { + return $this->getModel()->resources(); + } - if ($resource !== null) { - $this->attachResourceToAnime($resource); - } - - if ($this->anime->resources()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->doesntExist()) { - $this->sendNotification( - $user, - "Anime '{$this->anime->getName()}' has no {$this->getSite()->description} Resource after backfilling. Please review." - ); - } - - return $next($user); + /** + * Get the nova resource. + * + * @return class-string + */ + protected function resource(): string + { + return AnimeNovaResource::class; } /** @@ -63,11 +74,11 @@ abstract class BackfillAnimeResource extends BackfillAnimePipe $resource = ExternalResource::query() ->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value) ->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id) - ->whereHas(ExternalResource::RELATION_ANIME, fn (Builder $animeQuery) => $animeQuery->whereKey($this->anime)) + ->whereHas(ExternalResource::RELATION_ANIME, fn (Builder $animeQuery) => $animeQuery->whereKey($this->getModel())) ->first(); if ($resource === null) { - Log::info("Creating {$this->getSite()->description} resource '$id'"); + Log::info("Creating {$this->getSite()->description} Resource '$id'"); $resource = ExternalResource::query()->create([ ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id, @@ -85,31 +96,15 @@ abstract class BackfillAnimeResource extends BackfillAnimePipe * @param ExternalResource $resource * @return void */ - protected function attachResourceToAnime(ExternalResource $resource): void + protected function attachResource(ExternalResource $resource): void { if (AnimeResource::query() - ->where($this->anime->getKeyName(), $this->anime->getKey()) + ->where($this->getModel()->getKeyName(), $this->getModel()->getKey()) ->where($resource->getKeyName(), $resource->getKey()) ->doesntExist() ) { - Log::info("Attaching resource '{$resource->getName()}' to anime '{$this->anime->getName()}'"); - $resource->anime()->attach($this->anime); + Log::info("Attaching Resource '{$resource->getName()}' to {$this->label()} '{$this->getModel()->getName()}'"); + $this->relation()->attach($resource); } } - - /** - * Get the site to backfill. - * - * @return ResourceSite - */ - abstract protected function getSite(): ResourceSite; - - /** - * Query third-party APIs to find Resource mapping. - * - * @return ExternalResource|null - * - * @throws RequestException - */ - abstract protected function getResource(): ?ExternalResource; } diff --git a/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php b/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php index 7ae94a3d3..e4b05e323 100644 --- a/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php +++ b/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php @@ -37,12 +37,9 @@ class BackfillLargeCoverImage extends BackfillAnimeImage */ protected function getImage(): ?Image { - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { - $anidbResource = $this->getAnidbImage($anilistResource); - if ($anidbResource !== null) { - return $anidbResource; - } + return $this->getAnilistImage($anilistResource); } return null; @@ -56,7 +53,7 @@ class BackfillLargeCoverImage extends BackfillAnimeImage * * @throws RequestException */ - protected function getAnidbImage(ExternalResource $anilistResource): ?Image + protected function getAnilistImage(ExternalResource $anilistResource): ?Image { $query = ' query ($id: Int) { diff --git a/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php b/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php index 390967257..5859d947a 100644 --- a/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php +++ b/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php @@ -37,12 +37,9 @@ class BackfillSmallCoverImage extends BackfillAnimeImage */ protected function getImage(): ?Image { - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { - $anidbResource = $this->getAnidbImage($anilistResource); - if ($anidbResource !== null) { - return $anidbResource; - } + return $this->getAnilistImage($anilistResource); } return null; @@ -56,7 +53,7 @@ class BackfillSmallCoverImage extends BackfillAnimeImage * * @throws RequestException */ - protected function getAnidbImage(ExternalResource $anilistResource): ?Image + protected function getAnilistImage(ExternalResource $anilistResource): ?Image { $query = ' query ($id: Int) { diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php b/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php index 424da1356..6e7048226 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php +++ b/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php @@ -37,7 +37,7 @@ class BackfillAnidbResource extends BackfillAnimeResource { // Allow fall-throughs in case AniDB Resource is not mapped to every external site. - $malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); + $malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); if ($malResource instanceof ExternalResource) { $anidbResource = $this->getAnidbMapping($malResource, 'myanimelist'); if ($anidbResource !== null) { @@ -48,7 +48,7 @@ class BackfillAnidbResource extends BackfillAnimeResource sleep(rand(1, 3)); } - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { $anidbResource = $this->getAnidbMapping($anilistResource, 'anilist'); if ($anidbResource !== null) { @@ -59,7 +59,7 @@ class BackfillAnidbResource extends BackfillAnimeResource sleep(rand(1, 3)); } - $kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); + $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { return $this->getAnidbMapping($kitsuResource, 'kitsu'); } diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php b/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php index 8c179e6e8..d4b20b6e2 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php +++ b/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php @@ -38,7 +38,7 @@ class BackfillAnilistResource extends BackfillAnimeResource { // Allow fall-throughs in case Anilist Resource is not mapped to every external site. - $malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); + $malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); if ($malResource instanceof ExternalResource) { $anilistResource = $this->getMalAnilistMapping($malResource); if ($anilistResource !== null) { @@ -46,7 +46,7 @@ class BackfillAnilistResource extends BackfillAnimeResource } } - $kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); + $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { $anilistResource = $this->getKitsuAnilistMapping($kitsuResource); if ($anilistResource !== null) { @@ -54,7 +54,7 @@ class BackfillAnilistResource extends BackfillAnimeResource } } - $anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); + $anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); if ($anidbResource instanceof ExternalResource) { return $this->getAnidbAnilistMapping($anidbResource); } diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php b/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php index be022fede..607677e01 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php +++ b/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php @@ -36,7 +36,7 @@ class BackfillAnnResource extends BackfillAnimeResource */ protected function getResource(): ?ExternalResource { - $kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); + $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { return $this->getKitsuAnnMapping($kitsuResource); } diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php b/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php index 6f125fa62..93af92e5c 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php +++ b/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php @@ -38,7 +38,7 @@ class BackfillKitsuResource extends BackfillAnimeResource { // Allow fall-throughs in case Kitsu Resource is not mapped to every external site. - $malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); + $malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); if ($malResource instanceof ExternalResource) { $kitsuResource = $this->getKitsuMapping($malResource, 'MYANIMELIST_ANIME'); if ($kitsuResource !== null) { @@ -49,7 +49,7 @@ class BackfillKitsuResource extends BackfillAnimeResource sleep(rand(1, 3)); } - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { $kitsuResource = $this->getKitsuMapping($anilistResource, 'ANILIST_ANIME'); if ($kitsuResource !== null) { @@ -60,7 +60,7 @@ class BackfillKitsuResource extends BackfillAnimeResource sleep(rand(1, 3)); } - $anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); + $anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); if ($anidbResource instanceof ExternalResource) { $kitsuResource = $this->getKitsuMapping($anidbResource, 'ANIDB'); if ($kitsuResource !== null) { @@ -71,7 +71,7 @@ class BackfillKitsuResource extends BackfillAnimeResource sleep(rand(1, 3)); } - $annResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN); + $annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN); if ($annResource instanceof ExternalResource) { return $this->getKitsuMapping($annResource, 'ANIMENEWSNETWORK'); } diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php b/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php index 9b63597aa..3d273bb9d 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php +++ b/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php @@ -36,7 +36,7 @@ class BackfillMalResource extends BackfillAnimeResource */ protected function getResource(): ?ExternalResource { - $kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); + $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { $malResource = $this->getKitsuMalMapping($kitsuResource); if ($malResource !== null) { @@ -44,7 +44,7 @@ class BackfillMalResource extends BackfillAnimeResource } } - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { $malResource = $this->getAnilistMalMapping($anilistResource); if ($malResource !== null) { @@ -52,7 +52,7 @@ class BackfillMalResource extends BackfillAnimeResource } } - $anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); + $anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB); if ($anidbResource instanceof ExternalResource) { return $this->getAnidbMalMapping($anidbResource); } diff --git a/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php b/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php index ed3d54929..2cbb9d28e 100644 --- a/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php +++ b/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php @@ -5,56 +5,66 @@ declare(strict_types=1); namespace App\Pipes\Wiki\Anime\Studio; use App\Enums\Models\Wiki\ResourceSite; -use App\Models\Auth\User; +use App\Models\Wiki\Anime; use App\Models\Wiki\ExternalResource; use App\Models\Wiki\Studio; -use App\Pipes\Wiki\Anime\BackfillAnimePipe; -use App\Pivots\StudioResource; -use Closure; -use Illuminate\Database\Eloquent\Builder; +use App\Nova\Resources\BaseResource; +use App\Nova\Resources\Wiki\Anime as AnimeResource; +use App\Pipes\Wiki\BackfillStudios; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; use Orchestra\Parser\Xml\Facade as XmlParser; use RuntimeException; /** * Class BackfillAnimeStudios. + * + * @extends BackfillStudios */ -class BackfillAnimeStudios extends BackfillAnimePipe +class BackfillAnimeStudios extends BackfillStudios { /** - * Handle an incoming request. + * Create a new pipe instance. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed - * - * @throws RequestException + * @param Anime $anime */ - public function handle(User $user, Closure $next): mixed + public function __construct(Anime $anime) { - if ($this->anime->studios()->exists()) { - Log::info("Anime '{$this->anime->getName()}' already has Studios."); + parent::__construct($anime); + } - return $next($user); - } + /** + * Get the model passed into the pipeline. + * + * @return Anime + */ + public function getModel(): Anime + { + return $this->model; + } - $studios = $this->getStudios(); + /** + * Get the relation to studios. + * + * @return BelongsToMany + */ + protected function relation(): BelongsToMany + { + return $this->getModel()->studios(); + } - if (! empty($studios)) { - $this->attachStudiosToAnime($studios); - } - - if ($this->anime->studios()->doesntExist()) { - $this->sendNotification($user, "Anime '{$this->anime->getName()}' has no Studios after backfilling. Please review."); - } - - return $next($user); + /** + * Get the nova resource. + * + * @return class-string + */ + protected function resource(): string + { + return AnimeResource::class; } /** @@ -66,7 +76,7 @@ class BackfillAnimeStudios extends BackfillAnimePipe */ protected function getStudios(): array { - $malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); + $malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); if ($malResource instanceof ExternalResource) { $studios = $this->getMalAnimeStudios($malResource); if (! empty($studios)) { @@ -74,7 +84,7 @@ class BackfillAnimeStudios extends BackfillAnimePipe } } - $anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); + $anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST); if ($anilistResource instanceof ExternalResource) { $studios = $this->getAnilistAnimeStudios($anilistResource); if (! empty($studios)) { @@ -82,7 +92,7 @@ class BackfillAnimeStudios extends BackfillAnimePipe } } - $kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); + $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { $studios = $this->getKitsuAnimeStudios($kitsuResource); if (! empty($studios)) { @@ -90,7 +100,7 @@ class BackfillAnimeStudios extends BackfillAnimePipe } } - $annResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN); + $annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN); if ($annResource instanceof ExternalResource) { return $this->getAnnAnimeStudios($annResource); } @@ -299,84 +309,14 @@ class BackfillAnimeStudios extends BackfillAnimePipe } /** - * Get or create Studio from name (case-insensitive). - * - * @param string $name - * @return Studio - */ - protected function getOrCreateStudio(string $name): Studio - { - $column = Studio::ATTRIBUTE_NAME; - $studio = Studio::query() - ->where(DB::raw("lower($column)"), Str::lower($name)) - ->first(); - - if (! $studio instanceof Studio) { - Log::info("Creating studio '$name'"); - - $studio = Studio::query()->create([ - Studio::ATTRIBUTE_NAME => $name, - Studio::ATTRIBUTE_SLUG => Str::slug($name, '_'), - ]); - } - - return $studio; - } - - /** - * Ensure Studio has Resource. - * - * @param Studio $studio - * @param ResourceSite $site - * @param int $id - * @return void - */ - protected function ensureStudioHasResource(Studio $studio, ResourceSite $site, int $id): void - { - $studioResource = ExternalResource::query() - ->where(ExternalResource::ATTRIBUTE_SITE, $site->value) - ->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id) - ->whereHas(ExternalResource::RELATION_STUDIOS, fn (Builder $studioQuery) => $studioQuery->whereKey($studio)) - ->first(); - - if (! $studioResource instanceof ExternalResource) { - Log::info("Creating studio resource with site '$site->value' and id '$id'"); - - $studioResource = ExternalResource::query()->create([ - ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id, - ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatStudioResourceLink($site, $id), - ExternalResource::ATTRIBUTE_SITE => $site->value, - ]); - } - - if (StudioResource::query() - ->where($studio->getKeyName(), $studio->getKey()) - ->where($studioResource->getKeyName(), $studioResource->getKey()) - ->doesntExist() - ) { - Log::info("Attaching resource '$studioResource->link' to studio '{$studio->getName()}'"); - $studioResource->studios()->attach($studio); - } - } - - /** - * Attach Studios to Anime. + * Attach Studios. * * @param Studio[] $studios * @return void */ - protected function attachStudiosToAnime(array $studios): void + protected function attachStudios(array $studios): void { - $results = $this->anime->studios()->sync(Arr::pluck($studios, Studio::ATTRIBUTE_ID)); - - if (count($results['attached'])) { - Log::info("Attached Studios to anime '{$this->anime->getName()}'", $results['attached']); - } - if (count($results['updated'])) { - Log::info("Updated Studios for anime '{$this->anime->getName()}'", $results['updated']); - } - if (count($results['detached'])) { - Log::info("Detached Studios from anime '{$this->anime->getName()}'", $results['detached']); - } + Log::info("Attaching studios to {$this->label()} '{$this->getModel()->getName()}'"); + $this->relation()->attach(Arr::pluck($studios, Studio::ATTRIBUTE_ID)); } } diff --git a/app/Pipes/Wiki/BackfillImage.php b/app/Pipes/Wiki/BackfillImage.php new file mode 100644 index 000000000..2faf97cbb --- /dev/null +++ b/app/Pipes/Wiki/BackfillImage.php @@ -0,0 +1,125 @@ + + */ +abstract class BackfillImage extends BasePipe +{ + /** + * Handle an incoming request. + * + * @param User $user + * @param Closure(User): mixed $next + * @return mixed + * + * @throws RequestException + */ + public function handle(User $user, Closure $next): mixed + { + if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->exists()) { + Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Image of Facet '{$this->getFacet()->value}'."); + + return $next($user); + } + + $image = $this->getImage(); + + if ($image !== null) { + $this->attachImage($image); + } + + if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->doesntExist()) { + $this->sendNotification( + $user, + "{$this->label()} '{$this->getModel()->getName()}' has no {$this->getFacet()->description} Image after backfilling. Please review." + ); + } + + return $next($user); + } + + /** + * Create Image from response. + * + * @param string $url + * @return Image + * + * @throws RequestException + */ + protected function createImage(string $url): Image + { + $imageResponse = Http::get($url)->throw(); + + $image = $imageResponse->body(); + + $file = File::createWithContent(basename($url), $image); + + $fs = Storage::disk('images'); + + $fsFile = $fs->putFile($this->path(), $file); + + /** @var Image $image */ + $image = Image::query()->create([ + Image::ATTRIBUTE_FACET => $this->getFacet()->value, + Image::ATTRIBUTE_PATH => $fsFile, + ]); + + return $image; + } + + /** + * Path to storage image in filesystem. + * + * @return string + */ + protected function path(): string + { + return Str::of(Str::kebab(class_basename($this->getModel()))) + ->append(DIRECTORY_SEPARATOR) + ->append(Str::kebab($this->getFacet()->description)) + ->__toString(); + } + + /** + * Attach Image to model. + * + * @param Image $image + * @return void + */ + abstract protected function attachImage(Image $image): void; + + /** + * Get the facet to backfill. + * + * @return ImageFacet + */ + abstract protected function getFacet(): ImageFacet; + + /** + * Query third-party APIs to find Image. + * + * @return Image|null + * + * @throws RequestException + */ + abstract protected function getImage(): ?Image; +} diff --git a/app/Pipes/Wiki/BackfillResource.php b/app/Pipes/Wiki/BackfillResource.php new file mode 100644 index 000000000..b49b59af9 --- /dev/null +++ b/app/Pipes/Wiki/BackfillResource.php @@ -0,0 +1,88 @@ + + */ +abstract class BackfillResource extends BasePipe +{ + /** + * Handle an incoming request. + * + * @param User $user + * @param Closure(User): mixed $next + * @return mixed + * + * @throws RequestException + */ + public function handle(User $user, Closure $next): mixed + { + if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->exists()) { + Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Resource of Site '{$this->getSite()->value}'."); + + return $next($user); + } + + $resource = $this->getResource(); + + if ($resource !== null) { + $this->attachResource($resource); + } + + if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->doesntExist()) { + $this->sendNotification( + $user, + "{$this->label()} '{$this->getModel()->getName()}' has no {$this->getSite()->description} Resource after backfilling. Please review." + ); + } + + return $next($user); + } + + /** + * Get or Create Resource from response. + * + * @param int $id + * @param string|null $slug + * @return ExternalResource + */ + abstract protected function getOrCreateResource(int $id, string $slug = null): ExternalResource; + + /** + * Attach External Resource to model. + * + * @param ExternalResource $resource + * @return void + */ + abstract protected function attachResource(ExternalResource $resource): void; + + /** + * Get the site to backfill. + * + * @return ResourceSite + */ + abstract protected function getSite(): ResourceSite; + + /** + * Query third-party APIs to find Resource mapping. + * + * @return ExternalResource|null + * + * @throws RequestException + */ + abstract protected function getResource(): ?ExternalResource; +} diff --git a/app/Pipes/Wiki/BackfillStudios.php b/app/Pipes/Wiki/BackfillStudios.php new file mode 100644 index 000000000..87a3f6553 --- /dev/null +++ b/app/Pipes/Wiki/BackfillStudios.php @@ -0,0 +1,135 @@ + + */ +abstract class BackfillStudios extends BasePipe +{ + /** + * Handle an incoming request. + * + * @param User $user + * @param Closure(User): mixed $next + * @return mixed + * + * @throws RequestException + */ + public function handle(User $user, Closure $next): mixed + { + if ($this->relation()->getQuery()->exists()) { + Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Studios."); + + return $next($user); + } + + $studios = $this->getStudios(); + + if (! empty($studios)) { + $this->attachStudios($studios); + } + + if ($this->relation()->getQuery()->doesntExist()) { + $this->sendNotification($user, "{$this->label()} '{$this->getModel()->getName()}' has no Studios after backfilling. Please review."); + } + + return $next($user); + } + + /** + * Get or create Studio from name (case-insensitive). + * + * @param string $name + * @return Studio + */ + protected function getOrCreateStudio(string $name): Studio + { + $column = Studio::ATTRIBUTE_NAME; + $studio = Studio::query() + ->where(DB::raw("lower($column)"), Str::lower($name)) + ->first(); + + if (! $studio instanceof Studio) { + Log::info("Creating studio '$name'"); + + $studio = Studio::query()->create([ + Studio::ATTRIBUTE_NAME => $name, + Studio::ATTRIBUTE_SLUG => Str::slug($name, '_'), + ]); + } + + return $studio; + } + + /** + * Ensure Studio has Resource. + * + * @param Studio $studio + * @param ResourceSite $site + * @param int $id + * @return void + */ + protected function ensureStudioHasResource(Studio $studio, ResourceSite $site, int $id): void + { + $studioResource = ExternalResource::query() + ->where(ExternalResource::ATTRIBUTE_SITE, $site->value) + ->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id) + ->whereHas(ExternalResource::RELATION_STUDIOS, fn (Builder $studioQuery) => $studioQuery->whereKey($studio)) + ->first(); + + if (! $studioResource instanceof ExternalResource) { + Log::info("Creating studio resource with site '$site->value' and id '$id'"); + + $studioResource = ExternalResource::query()->create([ + ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id, + ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatStudioResourceLink($site, $id), + ExternalResource::ATTRIBUTE_SITE => $site->value, + ]); + } + + if (StudioResource::query() + ->where($studio->getKeyName(), $studio->getKey()) + ->where($studioResource->getKeyName(), $studioResource->getKey()) + ->doesntExist() + ) { + Log::info("Attaching resource '$studioResource->link' to studio '{$studio->getName()}'"); + $studioResource->studios()->attach($studio); + } + } + + /** + * Attach Studios. + * + * @param Studio[] $studios + * @return void + */ + abstract protected function attachStudios(array $studios): void; + + /** + * Query third-party API for Studios. + * + * @return Studio[] + * + * @throws RequestException + */ + abstract protected function getStudios(): array; +} diff --git a/app/Pipes/Wiki/Studio/BackfillStudioImage.php b/app/Pipes/Wiki/Studio/BackfillStudioImage.php new file mode 100644 index 000000000..d64c0146c --- /dev/null +++ b/app/Pipes/Wiki/Studio/BackfillStudioImage.php @@ -0,0 +1,73 @@ + + */ +abstract class BackfillStudioImage extends BackfillImage +{ + /** + * Create a new pipe instance. + * + * @param Studio $studio + */ + public function __construct(Studio $studio) + { + parent::__construct($studio); + } + + /** + * Get the model passed into the pipeline. + * + * @return Studio + */ + public function getModel(): Studio + { + return $this->model; + } + + /** + * Get the relation to images. + * + * @return BelongsToMany + */ + protected function relation(): BelongsToMany + { + return $this->getModel()->images(); + } + + /** + * Get the nova resource. + * + * @return class-string + */ + protected function resource(): string + { + return StudioResource::class; + } + + /** + * Attach Image to Studio. + * + * @param Image $image + * @return void + */ + protected function attachImage(Image $image): void + { + Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'"); + $this->relation()->attach($image); + } +} diff --git a/app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php b/app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php new file mode 100644 index 000000000..45cffafbd --- /dev/null +++ b/app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php @@ -0,0 +1,58 @@ +getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL); + if ($malResource instanceof ExternalResource) { + return $this->getMalImage($malResource); + } + + return null; + } + + /** + * Query MAL API for large cover image. + * + * @param ExternalResource $malResource + * @return Image|null + * + * @throws RequestException + */ + protected function getMalImage(ExternalResource $malResource): ?Image + { + return $this->createImage("https://cdn.myanimelist.net/img/common/companies/$malResource->external_id.png"); + } +} diff --git a/app/Pivots/StudioImage.php b/app/Pivots/StudioImage.php new file mode 100644 index 000000000..4c718536c --- /dev/null +++ b/app/Pivots/StudioImage.php @@ -0,0 +1,67 @@ + StudioImageCreated::class, + 'deleted' => StudioImageDeleted::class, + ]; + + /** + * Gets the studio that owns the studio image. + * + * @return BelongsTo + */ + public function studio(): BelongsTo + { + return $this->belongsTo(Studio::class, StudioImage::ATTRIBUTE_STUDIO); + } + + /** + * Gets the image that owns the studio image. + * + * @return BelongsTo + */ + public function image(): BelongsTo + { + return $this->belongsTo(Image::class, StudioImage::ATTRIBUTE_IMAGE); + } +} diff --git a/app/Policies/Wiki/ImagePolicy.php b/app/Policies/Wiki/ImagePolicy.php index 7375e8dc7..2b1a5ea36 100644 --- a/app/Policies/Wiki/ImagePolicy.php +++ b/app/Policies/Wiki/ImagePolicy.php @@ -8,8 +8,10 @@ use App\Models\Auth\User; use App\Models\Wiki\Anime; use App\Models\Wiki\Artist; use App\Models\Wiki\Image; +use App\Models\Wiki\Studio; use App\Pivots\AnimeImage; use App\Pivots\ArtistImage; +use App\Pivots\StudioImage; use Illuminate\Auth\Access\HandlesAuthorization; /** @@ -175,4 +177,44 @@ class ImagePolicy { return $user->can('update image'); } + + /** + * Determine whether the user can attach any studio to the image. + * + * @param User $user + * @return bool + */ + public function attachAnyStudio(User $user): bool + { + return $user->can('update image'); + } + + /** + * Determine whether the user can attach a studio to the image. + * + * @param User $user + * @param Image $image + * @param Studio $studio + * @return bool + */ + public function attachStudio(User $user, Image $image, Studio $studio): bool + { + $attached = StudioImage::query() + ->where($image->getKeyName(), $image->getKey()) + ->where($studio->getKeyName(), $studio->getKey()) + ->exists(); + + return ! $attached && $user->can('update image'); + } + + /** + * Determine whether the user can detach a studio from the image. + * + * @param User $user + * @return bool + */ + public function detachStudio(User $user): bool + { + return $user->can('update image'); + } } diff --git a/app/Policies/Wiki/StudioPolicy.php b/app/Policies/Wiki/StudioPolicy.php index 58cad7ff3..9bdbd34ca 100644 --- a/app/Policies/Wiki/StudioPolicy.php +++ b/app/Policies/Wiki/StudioPolicy.php @@ -6,8 +6,10 @@ namespace App\Policies\Wiki; use App\Models\Auth\User; use App\Models\Wiki\Anime; +use App\Models\Wiki\Image; use App\Models\Wiki\Studio; use App\Pivots\AnimeStudio; +use App\Pivots\StudioImage; use Illuminate\Auth\Access\HandlesAuthorization; /** @@ -166,4 +168,44 @@ class StudioPolicy { return $user->can('update studio'); } + + /** + * Determine whether the user can attach any image to the studio. + * + * @param User $user + * @return bool + */ + public function attachAnyImage(User $user): bool + { + return $user->can('update studio'); + } + + /** + * Determine whether the user can attach an image to the studio. + * + * @param User $user + * @param Studio $studio + * @param Image $image + * @return bool + */ + public function attachImage(User $user, Studio $studio, Image $image): bool + { + $attached = StudioImage::query() + ->where($studio->getKeyName(), $studio->getKey()) + ->where($image->getKeyName(), $image->getKey()) + ->exists(); + + return ! $attached && $user->can('update studio'); + } + + /** + * Determine whether the user can detach an image from the studio. + * + * @param User $user + * @return bool + */ + public function detachImage(User $user): bool + { + return $user->can('update studio'); + } } diff --git a/app/Scout/Elasticsearch/Api/Schema/Wiki/StudioSchema.php b/app/Scout/Elasticsearch/Api/Schema/Wiki/StudioSchema.php index 8635514ac..4d72dd247 100644 --- a/app/Scout/Elasticsearch/Api/Schema/Wiki/StudioSchema.php +++ b/app/Scout/Elasticsearch/Api/Schema/Wiki/StudioSchema.php @@ -6,6 +6,7 @@ namespace App\Scout\Elasticsearch\Api\Schema\Wiki; use App\Http\Api\Include\AllowedInclude; use App\Http\Api\Schema\Wiki\ExternalResourceSchema; +use App\Http\Api\Schema\Wiki\ImageSchema; use App\Http\Resources\Wiki\Resource\StudioResource; use App\Models\Wiki\Studio; use App\Scout\Elasticsearch\Api\Field\Base\IdField; @@ -49,6 +50,7 @@ class StudioSchema extends Schema return [ new AllowedInclude(new AnimeSchema(), Studio::RELATION_ANIME), new AllowedInclude(new ExternalResourceSchema(), Studio::RELATION_RESOURCES), + new AllowedInclude(new ImageSchema(), Studio::RELATION_IMAGES), ]; } diff --git a/database/factories/Pivots/StudioImageFactory.php b/database/factories/Pivots/StudioImageFactory.php new file mode 100644 index 000000000..749076a58 --- /dev/null +++ b/database/factories/Pivots/StudioImageFactory.php @@ -0,0 +1,38 @@ + + */ +class StudioImageFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = StudioImage::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2022_06_17_163940_create_studio_image.php b/database/migrations/2022_06_17_163940_create_studio_image.php new file mode 100644 index 000000000..c18aaf58f --- /dev/null +++ b/database/migrations/2022_06_17_163940_create_studio_image.php @@ -0,0 +1,42 @@ +timestamps(6); + $table->unsignedBigInteger(StudioImage::ATTRIBUTE_STUDIO); + $table->foreign(StudioImage::ATTRIBUTE_STUDIO)->references(Studio::ATTRIBUTE_ID)->on(Studio::TABLE)->cascadeOnDelete(); + $table->unsignedBigInteger(StudioImage::ATTRIBUTE_IMAGE); + $table->foreign(StudioImage::ATTRIBUTE_IMAGE)->references(Image::ATTRIBUTE_ID)->on(Image::TABLE)->cascadeOnDelete(); + $table->primary([StudioImage::ATTRIBUTE_STUDIO, StudioImage::ATTRIBUTE_IMAGE]); + }); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists(StudioImage::TABLE); + } +}; diff --git a/resources/lang/en/nova.php b/resources/lang/en/nova.php index 42a08b1c6..c8cb6de17 100644 --- a/resources/lang/en/nova.php +++ b/resources/lang/en/nova.php @@ -67,6 +67,8 @@ return [ 'backfill_resources' => 'Backfill Resources', 'backfill_small_cover_help' => 'Use Anilist Resource to map Small Cover Image', 'backfill_small_cover' => 'Backfill Small Cover', + 'backfill_studio_large_cover_help' => 'Use MAL Resource to map Large Cover Image', + 'backfill_studio' => 'Backfill Studio', 'backfill_studios' => 'Backfill Studios', 'balance_balance_help' => 'Current balance of the account with current usage', 'balance_date_help' => 'The month and year for the balance that we are tracking', @@ -180,10 +182,11 @@ return [ 'status' => 'Status', 'studio_create_resource_action_success' => 'The Resource has been created and attached to the Studio', 'studio_create_resource_action' => 'Create :site Resource for Studio', + 'studio_image_lens' => 'Studio Without :facet Image', 'studio_name_help' => 'The display title of the Studio', 'studio_resource_lens' => 'Studio Without :site Resource', 'studio_slug_help' => 'Used as the URL Slug / Model Route Key. By default, this should be the Name lowercased and "_" replacing spaces. Shortenings/Abbreviations are also accepted.', - 'studio_unlinked_lens' => 'Studio Without Anime', + 'studio_unlinked_lens' => 'Studio Without Anime or Studio', 'studio' => 'Studio', 'studios' => 'Studios', 'subbed' => 'Subbed', diff --git a/tests/Feature/Events/Pivot/StudioImageTest.php b/tests/Feature/Events/Pivot/StudioImageTest.php new file mode 100644 index 000000000..0d4d88d48 --- /dev/null +++ b/tests/Feature/Events/Pivot/StudioImageTest.php @@ -0,0 +1,53 @@ +createOne(); + $image = Image::factory()->createOne(); + + $studio->images()->attach($image); + + Event::assertDispatched(StudioImageCreated::class); + } + + /** + * When a Studio is detached from an Image or vice versa, a StudioImageDeleted event shall be dispatched. + * + * @return void + */ + public function testStudioImageDeletedEventDispatched(): void + { + Event::fake(); + + $studio = Studio::factory()->createOne(); + $image = Image::factory()->createOne(); + + $studio->images()->attach($image); + $studio->images()->detach($image); + + Event::assertDispatched(StudioImageDeleted::class); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php index 071b74582..d25103024 100644 --- a/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php +++ b/tests/Feature/Http/Api/Wiki/Studio/StudioIndexTest.php @@ -8,6 +8,8 @@ use App\Contracts\Http\Api\Field\SortableField; use App\Enums\Http\Api\Filter\TrashedStatus; use App\Enums\Http\Api\Sort\Direction; use App\Enums\Models\Wiki\AnimeSeason; +use App\Enums\Models\Wiki\ImageFacet; +use App\Enums\Models\Wiki\ResourceSite; use App\Http\Api\Criteria\Filter\TrashedCriteria; use App\Http\Api\Criteria\Paging\Criteria; use App\Http\Api\Criteria\Paging\OffsetCriteria; @@ -24,6 +26,8 @@ use App\Http\Resources\Wiki\Collection\StudioCollection; use App\Http\Resources\Wiki\Resource\StudioResource; use App\Models\BaseModel; use App\Models\Wiki\Anime; +use App\Models\Wiki\ExternalResource; +use App\Models\Wiki\Image; use App\Models\Wiki\Studio; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Sequence; @@ -536,4 +540,88 @@ class StudioIndexTest extends TestCase ) ); } + + /** + * The Studio Index Endpoint shall support constrained eager loading of resources by site. + * + * @return void + */ + public function testResourcesBySite(): void + { + $siteFilter = ResourceSite::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + ExternalResource::ATTRIBUTE_SITE => $siteFilter->description, + ], + IncludeParser::param() => Studio::RELATION_RESOURCES, + ]; + + Studio::factory() + ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Studio::RELATION_RESOURCES) + ->count($this->faker->randomDigitNotNull()) + ->create(); + + $studios = Studio::with([ + Studio::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) { + $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value); + }, + ]) + ->get(); + + $response = $this->get(route('api.studio.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new StudioCollection($studios, new StudioReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Studio Index Endpoint shall support constrained eager loading of images by facet. + * + * @return void + */ + public function testImagesByFacet(): void + { + $facetFilter = ImageFacet::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Image::ATTRIBUTE_FACET => $facetFilter->description, + ], + IncludeParser::param() => Studio::RELATION_IMAGES, + ]; + + Studio::factory() + ->has(Image::factory()->count($this->faker->randomDigitNotNull())) + ->count($this->faker->randomDigitNotNull()) + ->create(); + + $anime = Studio::with([ + Studio::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) { + $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value); + }, + ]) + ->get(); + + $response = $this->get(route('api.studio.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new StudioCollection($anime, new StudioReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } } diff --git a/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php b/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php index 1335913ba..245374961 100644 --- a/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php +++ b/tests/Feature/Http/Api/Wiki/Studio/StudioShowTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Tests\Feature\Http\Api\Wiki\Studio; use App\Enums\Models\Wiki\AnimeSeason; +use App\Enums\Models\Wiki\ImageFacet; +use App\Enums\Models\Wiki\ResourceSite; use App\Http\Api\Field\Field; use App\Http\Api\Include\AllowedInclude; use App\Http\Api\Parser\FieldParser; @@ -14,6 +16,8 @@ use App\Http\Api\Query\Wiki\Studio\StudioReadQuery; use App\Http\Api\Schema\Wiki\StudioSchema; use App\Http\Resources\Wiki\Resource\StudioResource; use App\Models\Wiki\Anime; +use App\Models\Wiki\ExternalResource; +use App\Models\Wiki\Image; use App\Models\Wiki\Studio; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -238,4 +242,84 @@ class StudioShowTest extends TestCase ) ); } + + /** + * The Studio Show Endpoint shall support constrained eager loading of resources by site. + * + * @return void + */ + public function testResourcesBySite(): void + { + $siteFilter = ResourceSite::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + ExternalResource::ATTRIBUTE_SITE => $siteFilter->description, + ], + IncludeParser::param() => Studio::RELATION_RESOURCES, + ]; + + $studio = Studio::factory() + ->has(ExternalResource::factory()->count($this->faker->randomDigitNotNull()), Studio::RELATION_RESOURCES) + ->createOne(); + + $studio->unsetRelations()->load([ + Studio::RELATION_RESOURCES => function (BelongsToMany $query) use ($siteFilter) { + $query->where(ExternalResource::ATTRIBUTE_SITE, $siteFilter->value); + }, + ]); + + $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new StudioResource($studio, new StudioReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Studio Show Endpoint shall support constrained eager loading of images by facet. + * + * @return void + */ + public function testImagesByFacet(): void + { + $facetFilter = ImageFacet::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Image::ATTRIBUTE_FACET => $facetFilter->description, + ], + IncludeParser::param() => Studio::RELATION_IMAGES, + ]; + + $studio = Studio::factory() + ->has(Image::factory()->count($this->faker->randomDigitNotNull())) + ->createOne(); + + $studio->unsetRelations()->load([ + Studio::RELATION_IMAGES => function (BelongsToMany $query) use ($facetFilter) { + $query->where(Image::ATTRIBUTE_FACET, $facetFilter->value); + }, + ]); + + $response = $this->get(route('api.studio.show', ['studio' => $studio] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new StudioResource($studio, new StudioReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } } diff --git a/tests/Feature/Jobs/Pivot/StudioImageTest.php b/tests/Feature/Jobs/Pivot/StudioImageTest.php new file mode 100644 index 000000000..c51d849a1 --- /dev/null +++ b/tests/Feature/Jobs/Pivot/StudioImageTest.php @@ -0,0 +1,57 @@ +createOne(); + $image = Image::factory()->createOne(); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Bus::fake(SendDiscordNotificationJob::class); + + $studio->images()->attach($image); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } + + /** + * When a Studio is detached from an Image or vice versa, a SendDiscordNotification job shall be dispatched. + * + * @return void + */ + public function testStudioImageDeletedSendsDiscordNotification(): void + { + $studio = Studio::factory()->createOne(); + $image = Image::factory()->createOne(); + + $studio->images()->attach($image); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Bus::fake(SendDiscordNotificationJob::class); + + $studio->images()->detach($image); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } +} diff --git a/tests/Unit/Models/Wiki/ImageTest.php b/tests/Unit/Models/Wiki/ImageTest.php index ca4d38ed8..145075a16 100644 --- a/tests/Unit/Models/Wiki/ImageTest.php +++ b/tests/Unit/Models/Wiki/ImageTest.php @@ -8,8 +8,10 @@ use App\Enums\Models\Wiki\ImageFacet; use App\Models\Wiki\Anime; use App\Models\Wiki\Artist; use App\Models\Wiki\Image; +use App\Models\Wiki\Studio; use App\Pivots\AnimeImage; use App\Pivots\ArtistImage; +use App\Pivots\StudioImage; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\Testing\File; @@ -102,6 +104,25 @@ class ImageTest extends TestCase static::assertEquals(ArtistImage::class, $image->artists()->getPivotClass()); } + /** + * Image shall have a many-to-many relationship with the type Studio. + * + * @return void + */ + public function testStudios(): void + { + $studioCount = $this->faker->randomDigitNotNull(); + + $image = Image::factory() + ->has(Studio::factory()->count($studioCount)) + ->createOne(); + + static::assertInstanceOf(BelongsToMany::class, $image->studios()); + static::assertEquals($studioCount, $image->studios()->count()); + static::assertInstanceOf(Studio::class, $image->studios()->first()); + static::assertEquals(StudioImage::class, $image->studios()->getPivotClass()); + } + /** * The image shall not be deleted from storage when the Image is deleted. * diff --git a/tests/Unit/Models/Wiki/StudioTest.php b/tests/Unit/Models/Wiki/StudioTest.php index 60b91db85..b2afe9d0e 100644 --- a/tests/Unit/Models/Wiki/StudioTest.php +++ b/tests/Unit/Models/Wiki/StudioTest.php @@ -6,8 +6,10 @@ namespace Tests\Unit\Models\Wiki; use App\Models\Wiki\Anime; use App\Models\Wiki\ExternalResource; +use App\Models\Wiki\Image; use App\Models\Wiki\Studio; use App\Pivots\AnimeStudio; +use App\Pivots\StudioImage; use App\Pivots\StudioResource; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Testing\WithFaker; @@ -108,4 +110,23 @@ class StudioTest extends TestCase static::assertInstanceOf(ExternalResource::class, $studio->resources()->first()); static::assertEquals(StudioResource::class, $studio->resources()->getPivotClass()); } + + /** + * Studio shall have a many-to-many relationship with the type Image. + * + * @return void + */ + public function testImages(): void + { + $imageCount = $this->faker->randomDigitNotNull(); + + $studio = Studio::factory() + ->has(Image::factory()->count($imageCount)) + ->createOne(); + + static::assertInstanceOf(BelongsToMany::class, $studio->images()); + static::assertEquals($imageCount, $studio->images()->count()); + static::assertInstanceOf(Image::class, $studio->images()->first()); + static::assertEquals(StudioImage::class, $studio->images()->getPivotClass()); + } } diff --git a/tests/Unit/Pivots/StudioImageTest.php b/tests/Unit/Pivots/StudioImageTest.php new file mode 100644 index 000000000..ffd9388c8 --- /dev/null +++ b/tests/Unit/Pivots/StudioImageTest.php @@ -0,0 +1,52 @@ +for(Studio::factory()) + ->for(Image::factory()) + ->createOne(); + + static::assertInstanceOf(BelongsTo::class, $studioImage->studio()); + static::assertInstanceOf(Studio::class, $studioImage->studio()->first()); + } + + /** + * An StudioImage shall belong to an Image. + * + * @return void + */ + public function testImage(): void + { + $studioImage = StudioImage::factory() + ->for(Studio::factory()) + ->for(Image::factory()) + ->createOne(); + + static::assertInstanceOf(BelongsTo::class, $studioImage->image()); + static::assertInstanceOf(Image::class, $studioImage->image()->first()); + } +}