radfusion/app/Models/Study.php
2025-01-25 23:53:35 +06:00

547 lines
16 KiB
PHP

<?php
namespace App\Models;
use App\Domain\ACL\Permission;
use App\Domain\Report\ReportStatus;
use App\Domain\Study\Priority;
use App\Domain\Study\WorkflowLevel;
use App\Models\Traits\HasDepartment;
use App\Models\Traits\HashableId;
use App\Models\Traits\HasOrganization;
use App\Services\Pacs\PacsUrlGen;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Study extends BaseModel implements HasMedia
{
use HasDepartment;
use HashableId;
use HasOrganization;
use HasTimestamps;
use InteractsWithMedia;
public const string MEDIA_COLLECTION = 'attachments';
public const string FALLBACK_IMAGE = 'imgs/doc-pdf.png';
public static function buildRadiologistQuery(Builder $query, int $userId): Builder
{
return $query->where(function (Builder $q) use ($userId) {
$q
->where('reading_physician_id', $userId)
->orWhere('locking_physician_id', $userId)
->orWhere('approving_physician_id', $userId)
->orWhereHas('assignedPhysicians', function (Builder $subQuery) use ($userId) {
$subQuery->where('user_id', $userId);
});
});
}
public function details(): HasOne
{
return $this->hasOne(StudyDetails::class);
}
public function attachments(): HasMany
{
return $this->hasMany(StudyAttachment::class);
}
public function reports(): HasMany
{
return $this->hasMany(StudyReport::class);
}
public function shares(): HasMany
{
return $this->hasMany(SharedStudy::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
public function scopeUnlocked(Builder $query): Builder
{
return $query->whereNotNull('locked_at');
}
public function getHistoryLink(): string
{
$user = auth()->user();
if ($user->may(Permission::StudyHistoryEdit)) {
return route('staff.history.edit', $this->hash);
}
if ($user->may(Permission::StudyHistoryView)) {
return route('staff.history.view', $this->hash);
}
return '#';
}
public function getMetadataLink(): string
{
$user = auth()->user();
if ($user->may(Permission::StudyMetadataEdit)) {
return route('staff.meta.edit', $this->hash);
}
if ($user->may(Permission::StudyMetadataView)) {
return route('staff.meta.view', $this->hash);
}
return '#';
}
public function getAge(): ?string
{
$dob = $this->patient_birthdate;
return $dob ? (int) Carbon::make($dob)->diffInYears() . 'Y' : null;
}
public function sexAge(): string
{
$age = $this->getAge();
if (blank($age) && blank($this->patient_sex)) {
return '~';
}
return sprintf('%s / %s', $age ?? '~', $this->patient_sex);
}
public function numInstances(): string
{
return "{$this->image_count} / {$this->series_count}";
}
public function readingPhysician(): BelongsTo
{
return $this->belongsTo(User::class, 'reading_physician_id');
}
public function lockingPhysician(): BelongsTo
{
return $this->belongsTo(User::class, 'locking_physician_id');
}
public function assignedPhysicians(): BelongsToMany
{
return $this->belongsToMany(User::class, 'study_assignments');
}
public function dicomServer(): BelongsTo
{
return $this->belongsTo(DicomServer::class);
}
public function isAssigned(User|int|null $user = null): bool
{
return $this->assignedPhysicians->contains(me($user)->id);
}
public function isUserInStudyAssignmentsOrReadingPhysician(User|int|null $user = null): bool
{
$count = self::buildRadiologistQuery(self::where('id', $this->id), me($user)->id)->count();
return $count > 0;
}
public function isActive(): bool
{
return $this->archived_at === null;
}
public function isArchived(): bool
{
return $this->archived_at !== null;
}
public function getReportStatusLedAttribute(): string
{
$color = match ($this->report_status) {
ReportStatus::Unread => 'bg-white',
// ReportStatus::Opened => 'bg-secondary',
ReportStatus::Preliminary => 'bg-info',
ReportStatus::Finalized => 'bg-primary',
ReportStatus::Approved => 'bg-success',
default => 'bg-light',
};
// <i class="fa-solid fa-spinner"></i>
$icon = match ($this->report_status) {
ReportStatus::Unread => 'spinner text-muted',
ReportStatus::Preliminary => 'pen-to-square',
ReportStatus::Finalized => 'badge-check',
ReportStatus::Approved => 'shield-check',
default => 'spinner text-muted',
};
return sprintf('<span class="badge badge-center rounded-pill %s"><i class="fa-solid fa-%s"></i></span>', $color, $icon);
}
public function getArchiveLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::archive($this->dicomServer, $this);
}
return null;
}
public function getStoneLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::stoneViewer($this->dicomServer, $this);
}
return null;
}
public function getOhifLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::ohifViewer($this->dicomServer, $this);
}
return null;
}
public function getOhifSegmentationLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::ohifSegmentation($this->dicomServer, $this);
}
return null;
}
public function getOhifMprLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::ohifViewerMpr($this->dicomServer, $this);
}
return null;
}
public function getWeasisLink(): ?string
{
if (me()->may(Permission::StudyDownload)) {
return PacsUrlGen::weasisUrl($this->dicomServer, $this);
}
return null;
}
public function links(): array
{
return [
'history' => $this->getHistoryLink(),
'metadata' => $this->getMetadataLink(),
'zip' => $this->getArchiveLink(),
'stone' => $this->getStoneLink(),
'ohif' => $this->getOhifLink(),
'weasis' => $this->getWeasisLink(),
'ohif.mpr' => $this->getOhifMprLink(),
'ohif.seg' => $this->getOhifSegmentationLink(),
];
}
public function allowed(): array
{
return [
'zip' => filled($this->getArchiveLink()),
'stone' => filled($this->getStoneLink()),
'ohif' => filled($this->getOhifLink()),
'weasis' => filled($this->getWeasisLink()),
'ohif.mpr' => filled($this->getOhifMprLink()),
'ohif.seg' => filled($this->getOhifSegmentationLink()),
];
}
public function sanitizedPatientName(): string
{
$name = preg_replace('/[^[:alnum:][:space:]]/u', ' ', $this->patient_name);
return Str::of($name)
->slug(' ')
->title();
}
public function sanitizedStudyDescription(): string
{
$name = preg_replace('/[^[:alnum:][:space:]\/\-\&]/u', ' ', $this->study_description);
$name = Str::of($name)->title();
$lut = [
'ct' => 'CT',
'mr' => 'MR',
'mri' => 'MRI',
'hrct' => 'HRCT',
'kub' => 'KUB',
'ap' => 'AP',
'pa' => 'PA',
'with' => 'w/',
];
foreach ($lut as $search => $replace) {
$name = preg_replace("/\b{$search}\b/i", $replace, $name);
}
return $name;
}
public function toArray(): array
{
return array_merge(parent::toArray(), [
'disk_size_human' => human_filesize($this->disk_size),
'reader_name' => $this->readingPhysician?->display_name,
// 'assigned_physician_name' => $this->assignedPhysician?->display_name,
'reader_photo' => $this->readingPhysician?->profile_photo_url,
'report_status_led' => $this->getReportStatusLedAttribute(),
'priority_icon' => $this->getPriorityIcon(),
'sex_age' => $this->sexAge(),
'num_instances' => $this->numInstances(),
'links' => $this->links(),
'allowed' => $this->allowed(),
]);
}
public function getPriorityIcon(): string
{
$tpl = '<span class="badge badge-center rounded-pill bg-%s">
<i class="fa-solid text-white fa-%s"></i>
</span>';
return match ($this->priority) {
Priority::Stat => Blade::render('_partials._img-tooltip',
[
'src' => asset('imgs/stat.png'),
'class' => 'msg-icon',
'tip' => 'STAT',
]),
Priority::High => Blade::render('_partials._img-tooltip',
[
'src' => asset('imgs/warning.png'),
'class' => 'msg-icon',
'tip' => 'High Priority',
]),
Priority::Low => sprintf($tpl, 'light', 'chevrons-down'),
default => '',
};
}
public function registerMediaConversions(?Media $media = null): void
{
// $media->extension
$this->addMediaConversion('tn')
->width(48)
->height(48)
->sharpen(10)
->performOnCollections(Study::MEDIA_COLLECTION)
->nonQueued();
}
public function isLocked(): bool
{
return $this->locked_at !== null;
}
public function isUnlocked(): bool
{
return $this->locked_at === null;
}
public function lockStudy(User|int|null $user = null, ?WorkflowLevel $status = null): void
{
$params = [
'locking_physician_id' => me($user)->id,
'locked_at' => now(),
];
if ($status) {
$params['workflow_level'] = $status->value;
}
$this->update($params);
}
public function unlockStudy(): void
{
$this->update(
[
'locking_physician_id' => null,
'locked_at' => null,
]
);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection(self::MEDIA_COLLECTION)
->useFallbackUrl(asset(self::FALLBACK_IMAGE))
->useFallbackUrl(asset(self::FALLBACK_IMAGE), 'tn')
->useFallbackPath(public_path(self::FALLBACK_IMAGE))
->useFallbackPath(public_path(self::FALLBACK_IMAGE), 'tn');
}
public function getGenderIconAttribute(): string
{
return match (strtolower($this->patient_sex ?? '')) {
'f' => 'fa-venus text-danger',
'm' => 'fa-mars text-primary',
default => 'fa-genderless',
};
}
public function getGenderNameAttribute(): string
{
return match (strtolower($this->patient_sex ?? '')) {
'f' => 'Female',
'm' => 'Male',
default => 'Other',
};
}
public function getPatientDemographic(): string
{
$parts = array_purge([
Str::limit($this->sanitizedPatientName(), 20),
$this->patient_sex,
$this->patient_id,
]);
if (! empty($parts)) {
return implode(' ^ ', $parts);
}
return '';
}
public function isLockedBy(User|int|null $user = null): bool
{
return $this->locking_physician_id === me($user)->id;
}
public function isReadBy(User|int|null $user = null): bool
{
return $this->reading_physician_id === me($user)->id;
}
public function isBookmarkedBy(User|int|null $user = null): bool
{
return $this->bookmarkedByUsers()->where('user_id', me($user)->id)->exists();
}
/**
* Returns true if study has been locked by the given user or is unlocked. Returns false if the study was locked by another user.
*/
public function canObtainLock(User|int|null $user = null): bool
{
return $this->isUnlocked() || $this->isLockedBy($user);
}
public function setReportStatus(ReportStatus $status, User|int|null $user = null): void
{
$user_id = me($user)->id;
$params = ['report_status' => $status->value];
switch ($status) {
case ReportStatus::Finalized:
$params['reading_physician_id'] = $user_id;
$params['read_at'] = now();
break;
case ReportStatus::Approved:
if ($this->reading_physician_id === null) {
$params['reading_physician_id'] = $user_id;
$params['read_at'] = now();
}
$params['approving_physician_id'] = $user_id;
$params['approved_at'] = now();
break;
}
$this->update($params);
}
public function canEditReport(): bool
{
// todo: check if the study disallows reporting
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Finalized);
}
public function reportStatusBefore(ReportStatus $checkpoint): bool
{
return $this->report_status->value < $checkpoint->value;
}
public function canEditMetadata(): bool
{
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Finalized);
}
public function canAssignRad(): bool
{
if ($this->isLocked()) {
return false;
}
return $this->isActive() && $this->reportStatusBefore(ReportStatus::Preliminary);
}
public function hasReports(): bool
{
return $this->reports->isNotEmpty();
}
public function isReportReady(): bool
{
return ($this->report_status->value == ReportStatus::Finalized->value) ||
($this->report_status->value == ReportStatus::Approved->value);
}
public function isStudyComplete(): bool
{
return $this->report_status->value >= ReportStatus::Finalized->value;
}
public function bookmarkedByUsers()
{
return $this->belongsToMany(User::class, 'study_bookmarks');
}
protected function casts(): array
{
return [
'workflow_level' => WorkflowLevel::class,
'report_status' => ReportStatus::class,
'priority' => Priority::class,
'archived_at' => 'immutable_datetime',
'approved_at' => 'immutable_datetime',
'received_at' => 'immutable_datetime',
'read_at' => 'immutable_datetime',
'assigned_at' => 'immutable_datetime',
'study_date' => 'immutable_datetime',
'locked_at' => 'immutable_datetime',
'patient_birthdate' => 'immutable_date',
];
}
}