EscolaLMS/Courses

View on GitHub
src/Repositories/TopicRepository.php

Summary

Maintainability
A
3 hrs
Test Coverage
B
84%
<?php

namespace EscolaLms\Courses\Repositories;

use Error;
use EscolaLms\Courses\Exceptions\TopicException;
use EscolaLms\Courses\Http\Requests\CreateTopicAPIRequest;
use EscolaLms\Courses\Http\Requests\UpdateTopicAPIRequest;
use EscolaLms\Courses\Models\Contracts\TopicContentContract;
use EscolaLms\Courses\Models\Contracts\TopicFileContentContract;
use EscolaLms\Courses\Models\Topic;
use EscolaLms\Courses\Models\TopicContent\AbstractTopicFileContent;
use EscolaLms\Courses\Repositories\Contracts\TopicRepositoryContract;
use EscolaLms\Files\Helpers\FileHelper;
use EscolaLms\Files\Rules\FileOrStringRule;
use EscolaLms\TopicTypes\Events\TopicTypeChanged;
use EscolaLms\TopicTypes\Models\TopicContent\AbstractTopicContent;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

/**
 * Class TopicRepository.
 *
 * @version April 27, 2021, 11:21 am UTC
 */
class TopicRepository extends BaseRepository implements TopicRepositoryContract
{
    /**
     * @var array
     *            All possible classes that can store content
     */
    private array $contentClasses = [];

    /**
     * @var array
     *            All possible classes that can store content
     */
    private array $resourceClasses = [];

    /**
     * @var array
     */
    protected $fieldSearchable = [
        'title',
        'lesson_id',
        'topicable_id',
        'topicable_type',
        'order',
        'active',
        'boolean',
    ];

    /**
     * Return searchable fields.
     *
     * @return array
     */
    public function getFieldsSearchable()
    {
        return $this->fieldSearchable;
    }

    /**
     * Configure the Model.
     **/
    public function model()
    {
        return Topic::class;
    }

    /**
     * @param string $class fullname of a class that can be content
     *
     * @return array list of unique classes
     */
    public function registerContentClass(string $class): array
    {
        if (!in_array($class, $this->contentClasses) && class_exists($class) && (is_a($class, TopicContentContract::class, true))) {
            $this->contentClasses[] = $class;
        }

        return $this->contentClasses;
    }

    public function registerContentClasses(array $classes): array
    {
        foreach ($classes as $class) {
            $this->registerContentClass($class);
        }

        return $this->contentClasses;
    }

    public function registerResourceClass(string $topicTypeClass, string $resourceClass, string $type = 'client'): array
    {
        if (!isset($this->resourceClasses[$type][$topicTypeClass]) && class_exists($resourceClass) && (is_a($resourceClass, JsonResource::class, true))) {
            $this->resourceClasses[$type][$topicTypeClass] = $resourceClass;
        }
        return $this->resourceClasses[$type];
    }

    public function registerResourceClasses(string $topicTypeClass, array $resourceClasses): array
    {
        foreach ($resourceClasses as $type => $resourceClass) {
            $this->registerResourceClass($topicTypeClass, $resourceClass, $type);
        }

        return $this->resourceClasses;
    }

    public function getResourceClass(string $topicTypeClass = null, string $type = 'client'): string
    {
        if (isset($this->resourceClasses[$type][$topicTypeClass])) {
            return $this->resourceClasses[$type][$topicTypeClass];
        }

        return '';
    }

    public function unregisterContentClass(string $class): array
    {
        if (($key = array_search($class, $this->contentClasses)) !== false) {
            unset($this->contentClasses[$key]);
        }

        return $this->contentClasses;
    }

    public function availableContentClasses(): array
    {
        return $this->contentClasses;
    }

    public function getById($id): Topic
    {
        /** @var Topic */
        return $this->model->newQuery()->findOrFail($id);
    }

    /**
     * Create model record.
     */
    public function create(array $input): Topic
    {
        $input = [
            'title' => $input['title'],
            'lesson_id' => $input['lesson_id'],
            'order' => $input['order'] ?? 0,
            'active' => true,
            'preview' => false,
            'summary' => isset($input['summary']) ? $input['summary'] : null,
            'introduction' => isset($input['introduction']) ? $input['introduction'] : null,
            'description' => isset($input['description']) ? $input['description'] : null,
            'can_skip' => (bool) ($input['can_skip'] ?? false),
        ];

        $model = $this->model->newInstance($input);
        $model->save();

        return $model;
    }

    /**
     * Update model record for given id.
     *
     * @return Topic
     */
    public function update(array $input, int $id): Topic
    {
        $query = $this->model->newQuery();

        /** @var Topic $model */
        $model = $query->with('topicable')->findOrFail($id);

        $model->fill($input);

        $model->save();

        return $model;
    }

    public function createFromRequest(CreateTopicAPIRequest $request): Topic
    {
        $validated = $request->validated();

        /** @var Topic $topic */
        $topic = $this->model->newInstance([
            'title' => $validated['title'],
            'lesson_id' => $validated['lesson_id'],
            'order' => $validated['order'] ?? 0,
            'active' => $validated['active'] ?? true,
            'preview' => $validated['preview'] ?? false,
            'summary' => $validated['summary'] ?? null,
            'introduction' => $validated['introduction'] ?? null,
            'description' => $validated['description'] ?? null,
            'can_skip' => $validated['can_skip'] ?? false,
            'json' => empty($validated['json']) ? null : json_decode($validated['json']),
            'duration' => $validated['duration'] ?? null,
        ]);
        $topic->save();

        $this->createTopicContentModelFromRequest($request, $topic);

        return $topic->loadMissing('topicable');
    }

    public function updateFromRequest(UpdateTopicAPIRequest $request): Topic
    {
        $topic = $request->getTopic()->loadMissing('topicable');

        if ($request->has('topicable_type')) {
            $class = $request->input('topicable_type');

            if (!in_array($class, $this->contentClasses)) {
                throw new Error("Type '$class' is not allowed");
            }
            if ($topic->topicable && $class === get_class($topic->topicable) && $request->hasAny(array_keys($class::rules()))) {
                $this->updateTopicContentModelFromRequest($request, $topic->topicable);
            } else {
                $topicContent = $this->createTopicContentModelFromRequest($request, $topic);
                $topic->topicable()->associate($topicContent);
            }
        }

        $validated = $request->validated();
        if (!empty($validated['json'])) {
            $validated['json'] = json_decode($validated['json'], true);
        }
        $topic->fill($validated);
        $topic->save();

        return $topic;
    }

    /**
     * @throws TopicException
     */
    private function createTopicContentModelFromRequest(FormRequest $request, Topic $topic): Model
    {
        $class = $request->input('topicable_type');

        if (!in_array($class, $this->contentClasses)) {
            throw new Error("Type '$class' is not allowed");
        }
        $model = new $class();
        assert($model instanceof TopicContentContract);
        assert($model instanceof Model);

        $modelRules = $model::rules();

        if ($model instanceof TopicFileContentContract) {
            foreach ($model->getFileKeyNames() as $fileKey) {
                $modelRules[$fileKey] = new FileOrStringRule($modelRules[$fileKey], "course/{$topic->course->getKey()}");
            }
        }

        $validator = Validator::make($request->all(), $modelRules);
        if ($validator->fails()) {
            throw new TopicException(TopicException::CONTENT_VALIDATION, $validator->errors()->toArray());
        }

        $attributes = $validator->validated();
        if ($model instanceof TopicFileContentContract) {
            $attributes = array_filter($attributes, fn ($attribute_key) => !in_array($attribute_key, $model->getFileKeyNames()), ARRAY_FILTER_USE_KEY);
            $model->storeUploadsFromRequest($request, $topic->storage_directory);
        }
        $model->fill($attributes);
        $model->save();
        $model->topic()->save($topic);

        return $model;
    }

    /**
     * @param FormRequest $request
     * @param $topicContent
     * @return Model
     * @throws TopicException
     * @throws \Illuminate\Validation\ValidationException
     *
     */
    private function updateTopicContentModelFromRequest(FormRequest $request,TopicContentContract $topicContent): Model
    {
        assert($topicContent instanceof Model);

        $rules = $this->getRulesForTopicContentUpdate($request, $topicContent);

        $validator = Validator::make($request->all(), $rules);
        if ($validator->fails()) {
            throw new TopicException(TopicException::CONTENT_VALIDATION, $validator->errors()->toArray());
        }
        $attributes = $validator->validated();
        if ($topicContent instanceof TopicFileContentContract) {
            Arr::forget($attributes, $topicContent->getFileKeyNames());
            $topicContent->storeUploadsFromRequest($request);
        }
        // we only update validated attributes and we removed validations for fields that would cause problems :)
        $topicContent->fill($attributes);
        $topicContent->save();

        return $topicContent;
    }

    private function getRulesForTopicContentUpdate(FormRequest $request, TopicContentContract $topicContent)
    {

        // we want to do partial update, so we add 'sometimes' to all rules
        $partialRules = array_map(fn ($field_rules) => is_array($field_rules) ? array_merge(['sometimes'], $field_rules) : 'sometimes' . $field_rules, $topicContent::rules());

        // don't try to validate file keys in request if they don't contain file during topic / topic content update
        if ($topicContent instanceof TopicFileContentContract) {
            foreach ($topicContent->getFileKeyNames() as $fileKeyName) {
                if (!$request->has($fileKeyName)) {
                    unset($partialRules[$fileKeyName]);
                } else {
                    // @phpstan-ignore-next-line
                    $prefixPath = 'course/' . $topicContent->topic->course->getKey();
                    $partialRules[$fileKeyName] = new FileOrStringRule($partialRules[$fileKeyName], $prefixPath);
                }
            }
        }

        return $partialRules;
    }

    public function delete(int $id): ?bool
    {
        $topic = $this->findWith($id, ['*'], ['topicable']);

        return !is_null($topic) && $this->deleteModel($topic);
    }

    public function deleteModel(Topic $topic): ?bool
    {
        if ($topic->delete()) {
            $topicable = $topic->topicable;
            if (is_a($topicable, AbstractTopicFileContent::class)) {
                /** @var AbstractTopicFileContent $topicable */
                $path = Storage::path($topicable->generateStoragePath());
                try {
                    File::cleanDirectory($path);
                    Storage::deleteDirectory($path);
                } catch (\Throwable $th) {
                }
            }
        }

        return true;
    }
}