#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
This commit is contained in:
paranarimasu
2022-06-23 12:44:45 -05:00
committed by GitHub
parent 101feb031f
commit ad07aa3703
64 changed files with 1799 additions and 353 deletions
@@ -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,
];
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Events\Pivot\StudioImage;
use App\Events\Base\Pivot\PivotCreatedEvent;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
/**
* Class StudioImageCreated.
*
* @extends PivotCreatedEvent<Studio, Image>
*/
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()}**'.";
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Events\Pivot\StudioImage;
use App\Events\Base\Pivot\PivotDeletedEvent;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
/**
* Class StudioImageDeleted.
*
* @extends PivotDeletedEvent<Studio, Image>
*/
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()}**'.";
}
}
+1
View File
@@ -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),
];
}
@@ -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'),
@@ -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;
}
@@ -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));
+15
View File
@@ -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<int, Studio> $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();
}
}
+15
View File
@@ -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<int, Anime> $anime
* @property Collection<int, Image> $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();
}
}
@@ -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<string, BackfillAnimePipe>
* @return array<string, BasePipe>
*/
protected function getPipeMapping(Anime $anime): array
{
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Nova\Actions\Wiki\Studio;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\Auth\User;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pipes\BasePipe;
use App\Pipes\Wiki\Studio\Image\BackfillLargeCoverImage;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Container\Container;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Pipeline\Pipeline;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Laravel\Nova\Actions\Action;
use Laravel\Nova\Fields\ActionFields;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\Heading;
use Laravel\Nova\Http\Requests\NovaRequest;
/**
* Class BackfillStudioAction.
*/
class BackfillStudioAction extends Action implements ShouldQueue
{
use InteractsWithQueue;
use Queueable;
final public const BACKFILL_LARGE_COVER = 'backfill_large_cover';
/**
* Create a new action instance.
*
* @param User $user
*/
public function __construct(protected User $user)
{
}
/**
* Get the displayable name of the action.
*
* @return string
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function name(): string
{
return __('nova.backfill_studio');
}
/**
* Perform the action on the given models.
*
* @param ActionFields $fields
* @param Collection<int, Studio> $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<string, BasePipe>
*/
protected function getPipeMapping(Studio $studio): array
{
return [
self::BACKFILL_LARGE_COVER => new BackfillLargeCoverImage($studio),
];
}
}
+2 -1
View File
@@ -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);
}
/**
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Nova\Lenses\Studio;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Nova\Lenses\BaseLens;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\LensRequest;
use Laravel\Nova\Http\Requests\NovaRequest;
/**
* Class StudioCoverLargeLens.
*/
class StudioCoverLargeLens extends BaseLens
{
/**
* Get the displayable name of the lens.
*
* @return string
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function name(): string
{
return __('nova.studio_image_lens', ['facet' => 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';
}
}
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+15 -2
View File
@@ -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(),
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+46 -2
View File
@@ -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(),
+2 -2
View File
@@ -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.
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Pipes;
use App\Contracts\Pipes\Pipe;
use App\Models\Auth\User;
use App\Models\BaseModel;
use App\Nova\Resources\BaseResource;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
use Laravel\Nova\Notifications\NovaNotification;
/**
* Class BasePipe.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class BasePipe implements Pipe
{
/**
* Create a new pipe instance.
*
* @param TModel $model
*/
public function __construct(protected BaseModel $model)
{
}
/**
* Get the model passed into the pipeline.
*
* @return TModel
*/
abstract public function getModel(): BaseModel;
/**
* Get the human-friendly label for the underlying model.
*
* @return string
*/
protected function label(): string
{
return Str::headline(class_basename($this->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<BaseResource>
*/
abstract protected function resource(): string;
}
+37 -80
View File
@@ -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<Anime>
*/
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<BaseResource>
*/
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;
}
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime;
use App\Contracts\Pipes\Pipe;
use App\Models\Auth\User;
use App\Models\Wiki\Anime;
use App\Nova\Resources\Wiki\Anime as AnimeResource;
use Laravel\Nova\Notifications\NovaNotification;
/**
* Class BackfillAnimePipe.
*/
abstract class BackfillAnimePipe implements Pipe
{
/**
* Create new pipe instance.
*
* @param Anime $anime
*/
public function __construct(protected Anime $anime)
{
}
/**
* Send notification for user to review anime without studios.
*
* @param User $user
* @param string $message
* @return void
*/
protected function sendNotification(User $user, string $message): void
{
$uriKey = AnimeResource::uriKey();
$user->notify(
NovaNotification::make()
->icon('flag')
->message($message)
->type(NovaNotification::WARNING_TYPE)
->url("/resources/$uriKey/{$this->anime->getKey()}")
);
}
}
+45 -50
View File
@@ -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<Anime>
*/
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<BaseResource>
*/
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;
}
@@ -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) {
@@ -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) {
@@ -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');
}
@@ -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);
}
@@ -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);
}
@@ -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');
}
@@ -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);
}
@@ -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<Anime>
*/
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<BaseResource>
*/
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));
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\Auth\User;
use App\Models\Wiki\Image;
use App\Pipes\BasePipe;
use Closure;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Testing\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Class BackfillImage.
*
* @template TModel of \App\Models\BaseModel
* @extends BasePipe<TModel>
*/
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;
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Auth\User;
use App\Models\Wiki\ExternalResource;
use App\Pipes\BasePipe;
use Closure;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillResource.
*
* @template TModel of \App\Models\BaseModel
* @extends BasePipe<TModel>
*/
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;
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Auth\User;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Studio;
use App\Pipes\BasePipe;
use App\Pivots\StudioResource;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Class BackfillStudios.
*
* @template TModel of \App\Models\BaseModel
* @extends BasePipe<TModel>
*/
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;
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki\Studio;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Nova\Resources\BaseResource;
use App\Nova\Resources\Wiki\Studio as StudioResource;
use App\Pipes\Wiki\BackfillImage;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnimeImage.
*
* @extends BackfillImage<Studio>
*/
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<BaseResource>
*/
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);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Pipes\Wiki\Studio\Image;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use App\Pipes\Wiki\Studio\BackfillStudioImage;
use Illuminate\Http\Client\RequestException;
/**
* Class BackfillLargeCoverImage.
*/
class BackfillLargeCoverImage extends BackfillStudioImage
{
/**
* Get the facet to backfill.
*
* @return ImageFacet
*/
protected function getFacet(): ImageFacet
{
return ImageFacet::COVER_LARGE();
}
/**
* Query third-party APIs to find Image.
*
* @return Image|null
*
* @throws RequestException
*/
protected function getImage(): ?Image
{
$malResource = $this->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");
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Pivots;
use App\Events\Pivot\StudioImage\StudioImageCreated;
use App\Events\Pivot\StudioImage\StudioImageDeleted;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use Database\Factories\Pivots\StudioImageFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class StudioImage.
*
* @property Studio $studio
* @property Image $image
*
* @method static StudioImageFactory factory(...$parameters)
*/
class StudioImage extends BasePivot
{
final public const TABLE = 'studio_image';
final public const ATTRIBUTE_STUDIO = 'studio_id';
final public const ATTRIBUTE_IMAGE = 'image_id';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = StudioImage::TABLE;
/**
* The event map for the model.
*
* Allows for object-based events for native Eloquent events.
*
* @var array
*/
protected $dispatchesEvents = [
'created' => 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);
}
}
+42
View File
@@ -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');
}
}
+42
View File
@@ -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');
}
}
@@ -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),
];
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Database\Factories\Pivots;
use App\Pivots\StudioImage;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Class StudioImageFactory.
*
* @method StudioImage createOne($attributes = [])
* @method StudioImage makeOne($attributes = [])
*
* @extends Factory<StudioImage>
*/
class StudioImageFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<StudioImage>
*/
protected $model = StudioImage::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition(): array
{
return [
//
];
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
if (! Schema::hasTable(StudioImage::TABLE)) {
Schema::create(StudioImage::TABLE, function (Blueprint $table) {
$table->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);
}
};
+4 -1
View File
@@ -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',
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Events\Pivot;
use App\Events\Pivot\StudioImage\StudioImageCreated;
use App\Events\Pivot\StudioImage\StudioImageDeleted;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
/**
* Class StudioImageTest.
*/
class StudioImageTest extends TestCase
{
/**
* When a Studio is attached to an Image or vice versa, a StudioImageCreated event shall be dispatched.
*
* @return void
*/
public function testStudioImageCreatedEventDispatched(): void
{
Event::fake();
$studio = Studio::factory()->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);
}
}
@@ -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
)
);
}
}
@@ -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
)
);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Jobs\Pivot;
use App\Constants\Config\FlagConstants;
use App\Jobs\SendDiscordNotificationJob;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
/**
* Class StudioImageTest.
*/
class StudioImageTest extends TestCase
{
/**
* When a Studio is attached to an Image or vice versa, a SendDiscordNotification job shall be dispatched.
*
* @return void
*/
public function testStudioImageCreatedSendsDiscordNotification(): void
{
$studio = Studio::factory()->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);
}
}
+21
View File
@@ -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.
*
+21
View File
@@ -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());
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Pivots;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Testing\WithoutEvents;
use Tests\TestCase;
/**
* Class StudioImageTest.
*/
class StudioImageTest extends TestCase
{
use WithoutEvents;
/**
* An StudioImage shall belong to a Studio.
*
* @return void
*/
public function testStudio(): void
{
$studioImage = StudioImage::factory()
->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());
}
}