EscolaLMS/Courses

View on GitHub
src/Models/Course.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

namespace EscolaLms\Courses\Models;

use EscolaLms\Categories\Models\Category;
use EscolaLms\Core\Models\Traits\QueryCacheable;
use EscolaLms\Core\Models\User as CoreUser;
use EscolaLms\Courses\Database\Factories\CourseFactory;
use EscolaLms\Courses\Enum\CourseStatusEnum;
use EscolaLms\Courses\Enum\PlatformVisibility;
use EscolaLms\Courses\Events\CourseStatusChanged;
use EscolaLms\Tags\Models\Tag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Peopleaps\Scorm\Model\ScormScoModel;

/**
 * @OA\Schema(
 *      schema="Course",
 *      required={"title"},
 *      @OA\Property(
 *          property="id",
 *          description="id",
 *          type="integer",
 *      ),
 *      @OA\Property(
 *          property="title",
 *          description="title",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="summary",
 *          description="summary",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="image_path",
 *          description="image_path",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="video_path",
 *          description="video_path",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="image_url",
 *          description="image_url",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="video_url",
 *          description="video_url",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="duration",
 *          description="duration",
 *          type="string"
 *      ),
 *     @OA\Property(
 *          property="scorm_sco_id",
 *          description="scorm_sco_id",
 *          type="integer",
 *      ),
 *      @OA\Property(
 *          property="image",
 *          description="image",
 *          type="file",
 *      ),
 *      @OA\Property(
 *          property="video",
 *          description="video",
 *          type="file"
 *      ),
 *      @OA\Property(
 *          property="status",
 *          description="status",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="subtitle",
 *          description="subtitle",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="language",
 *          description="language",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="description",
 *          description="description",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="level",
 *          description="level",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="poster",
 *          description="poster",
 *          type="file",
 *      ),
 *      @OA\Property(
 *          property="poster_path",
 *          description="poster_path",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="poster_url",
 *          description="poster_url",
 *          type="string"
 *      ),
 *      @OA\Property(
 *          property="active_from",
 *          description="active_from",
 *          type="datetime"
 *      ),
 *      @OA\Property(
 *          property="active_to",
 *          description="active_to",
 *          type="datetime"
 *      ),
 *      @OA\Property(
 *          property="hours_to_complete",
 *          description="hours_to_complete",
 *          type="integer"
 *      ),
 *      @OA\Property(
 *          property="findable",
 *          description="findable",
 *          type="boolean"
 *      ),
 *      @OA\Property(
 *          property="target_group",
 *          description="target group",
 *          type="string",
 *      ),
 *     @OA\Property(
 *          property="teaser_url",
 *          description="teaser url",
 *          type="string",
 *      ),
 *      @OA\Property(
 *          property="public",
 *          description="free access, public audience ",
 *          type="boolean",
 *      ),
 *      @OA\Property(
 *          property="fields",
 *          description="additional fields",
 *          type="json",
 *      ),
 * )
 *
 * @property-read \Illuminate\Database\Eloquent\Collection|\EscolaLms\Courses\Models\Lesson[] $lessons
 * @property-read \Illuminate\Database\Eloquent\Collection|\EscolaLms\Courses\Models\Topic[] $topics
 * @property string $status
 * @property ?Carbon $active_from
 * @property ?Carbon $active_to
 * @property bool $is_active
 * @property string $title
 * @property ?string $summary
 * @property ?string $image_path
 * @property ?string $video_path
 * @property ?string $duration
 * @property ?int $scorm_sco_id
 * @property ?string $subtitle
 * @property ?string $language
 * @property ?string $description
 * @property ?string $level
 * @property ?string $poster_path
 * @property int $hours_to_complete
 * @property bool $findable
 * @property ?string $target_group
 * @property ?string $teaser_url
 * @property ?bool $public
 * @property array $fields
 */

class Course extends Model
{
    use HasFactory;
    use QueryCacheable;

    public $table = 'courses';

    /** Backwards compatibility */
    protected ?int $author_id = null;

    public $fillable = [
        'title',
        'summary',
        'image_path',
        'video_path',
        'duration',
        'author_id',
        'status',
        'subtitle',
        'language',
        'description',
        'level',
        'scorm_sco_id',
        'poster_path',
        'active_from',
        'active_to',
        'hours_to_complete',
        'findable',
        'target_group',
        'teaser_url',
        'public',
        'fields',
    ];

    /**
     * The attributes that should be casted to native types.
     *
     * @var array
     */
    protected $casts = [
        'id' => 'integer',
        'title' => 'string',
        'summary' => 'string',
        'image_path' => 'string',
        'video_path' => 'string',
        'duration' => 'string',
        'status' => 'string',
        'subtitle' => 'string',
        'language' => 'string',
        'description' => 'string',
        'level' => 'string',
        'scorm_sco_id' => 'integer',
        'poster_path' => 'string',
        'active_from' => 'datetime',
        'active_to' => 'datetime',
        'hours_to_complete' => 'integer',
        'findable' => 'boolean',
        'target_group' => 'string',
        'teaser_url' => 'string',
        'public' => 'boolean',
        'fields' => 'json',
    ];

    /**
     * Validation rules
     *
     * @return array<string, string>
     */
    public static function rules(): array
    {
        return [
            'title' => 'string|max:255',
            'summary' => 'nullable|string',
            'image_path' => 'nullable|string|max:255',
            'video_path' => 'nullable|string|max:255',
            'duration' => 'nullable|string|max:255',
            'authors' => ['nullable', 'array'],
            'authors.*' => ['integer'],
            'image' => 'file|image',
            'video' => 'file|mimes:mp4,ogg,webm',
            'status' => ['string', Rule::in(CourseStatusEnum::getValues())],
            'subtitle' => 'nullable|string|max:255',
            'language' => 'nullable|string|max:2',
            'description' => 'nullable|string',
            'level' => 'nullable|string|max:100',
            'scorm_sco_id' => 'nullable|exists:scorm_sco,id',
            'poster_path' => 'nullable|string|max:255',
            'poster' => 'file|image',
            'active_from' => 'date|nullable',
            'active_to' => 'date|nullable',
            'hours_to_complete' => 'integer|nullable',
            'findable' => 'boolean',
            'target_group' => 'nullable|string|max:100',
            'teaser_url' => 'nullable|string',
            'public' => 'nullable|boolean',
            'fields' => 'nullable|array',
        ];
    }

    protected $appends = ['image_url', 'video_url', 'poster_url'];

    public function authors(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'course_author', 'course_id', 'author_id')->using(CourseAuthorPivot::class);
    }

    /** Backwards compatibility */
    public function getAuthorAttribute(): ?User
    {
        return $this->authors->first();
    }

    /** Backwards compatibility */
    public function getAuthorIdAttribute(): ?int
    {
        $author = $this->author;
        if ($author) {
            return $author->id;
        }
        return $this->author_id;
    }

    /** Backwards compatibility */
    public function setAuthorAttribute(User $author)
    {
        $this->setAuthorIdAttribute($author->getKey());
    }

    /** Backwards compatibility */
    public function setAuthorIdAttribute(?int $author_id)
    {
        if ($this->exists && !is_null($author_id)) {
            $this->authors()->syncWithoutDetaching([$author_id]);
        }
        $this->author_id = $author_id;
    }

    public function hasAuthor(CoreUser|User $author): bool
    {
        return !is_null($this->authors()->where('author_id', $author->id)->first());
    }

    public function lessons(): HasMany
    {
        return $this->hasMany(Lesson::class, 'course_id');
    }

    public function categories(): BelongsToMany
    {
        return $this->belongsToMany(Category::class);
    }

    public function tags(): MorphMany
    {
        return $this->morphMany(Tag::class, 'morphable');
    }

    protected static function newFactory(): CourseFactory
    {
        return \EscolaLms\Courses\Database\Factories\CourseFactory::new();
    }

    public function getImageUrlAttribute(): ?string
    {
        if ($this->attributes['image_path'] ?? null) {
            $path = trim(trim($this->attributes['image_path'], '/'));
            if ($path) {
                $value = Storage::url($path);
                return preg_match('/^(http|https):.*$/', $value, $oa) ?
                    $value :
                    url($value);
            }
        }
        return null;
    }

    public function getVideoUrlAttribute(): ?string
    {
        if ($this->attributes['video_path'] ?? null) {
            $path = trim(trim($this->attributes['video_path'], '/'));
            if ($path) {
                $value = Storage::url($path);
                return preg_match('/^(http|https):.*$/', $value, $oa) ?
                    $value :
                    url($value);
            }
        }
        return null;
    }

    public function getPosterUrlAttribute(): ?string
    {
        if ($this->attributes['poster_path'] ?? null) {
            $path = trim(trim($this->attributes['poster_path'], '/'));
            if ($path) {
                $value = Storage::url($path);
                return preg_match('/^(http|https):.*$/', $value, $oa) ?
                    $value :
                    url($value);
            }
        }
        return null;
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)->using(CourseUserPivot::class)->withTimestamps();
    }

    public function groups(): BelongsToMany
    {
        return $this->belongsToMany(Group::class)->using(CourseGroupPivot::class)->withTimestamps();
    }

    public function topics(): HasManyThrough
    {
        return $this->hasManyThrough(Topic::class, Lesson::class, 'course_id', 'lesson_id');
    }

    public function scormSco(): BelongsTo
    {
        return $this->belongsTo(ScormScoModel::class, 'scorm_sco_id');
    }

    public function getIsPublishedAttribute(): bool
    {
        return in_array($this->status, [CourseStatusEnum::PUBLISHED, CourseStatusEnum::PUBLISHED_UNACTIVATED]);
    }

    public function getIsActiveAttribute(): bool
    {
        return $this->status === CourseStatusEnum::PUBLISHED
            && (is_null($this->active_from) || Carbon::now()->greaterThanOrEqualTo($this->active_from))
            && (is_null($this->active_to) || Carbon::now()->lessThanOrEqualTo($this->active_to));
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query
            ->where('courses.status', '=', CourseStatusEnum::PUBLISHED)
            ->where(function (Builder $query) {
                return $query->whereDate('active_from', '>=', Carbon::now())->orWhereNull('active_from');
            })
            ->where(function (Builder $query) {
                return $query->whereDate('active_to', '<=', Carbon::now())->orWhereNull('active_to');
            });
    }

    public function hasUser(CoreUser|User $user): bool
    {
        $groupIds = $this->groups->pluck('id')->toArray();
        $childGroups = $this->getChildGroups($groupIds);
        $allGroups = array_merge($groupIds, $childGroups);

        $inGroup = DB::table('group_user')
            ->whereIn('group_id', $allGroups)
            ->where('user_id', $user->getKey())
            ->exists();

        return $this->users()
                ->where('users.id', $user->getKey())
                ->where(fn(Builder $query) => $query->whereNull('end_date')->orWhereDate('end_date', '>=', Carbon::now()))
                ->exists()
            || $inGroup;
    }

    private function getChildGroups(array $groupIds): array
    {
        $childGroups = DB::table('groups')->whereIn('parent_id', $groupIds)->pluck('id')->toArray();
        if (count($childGroups) > 0) {
            $childGroups = array_merge($childGroups, $this->getChildGroups($childGroups));
        }
        return $childGroups;
    }

    protected static function booted()
    {
        self::creating(function (Course $course) {
            if (is_null($course->findable)) {
                $course->findable = config('escolalms_courses.platform_visibility', PlatformVisibility::VISIBILITY_PUBLIC) === PlatformVisibility::VISIBILITY_PUBLIC;
            }
        });
        /** Backwards compatibility */
        self::saved(function (Course $course) {
            if ($course->author_id) {
                $course->authors()->syncWithoutDetaching([$course->author_id]);
            }
        });

        self::updated(function (Course $course) {
            if ($course->wasChanged('status')) {
                event(new CourseStatusChanged($course));
            }
        });
    }

    public function getMorphClass()
    {
        return self::class;
    }
}