Files
animal_management/controllers/AnimalsController.php
2026-04-04 13:13:00 -04:00

1711 lines
68 KiB
PHP

<?php
namespace humhub\modules\animal_management\controllers;
use humhub\modules\animal_management\models\Animal;
use humhub\modules\animal_management\models\AnimalFieldValue;
use humhub\modules\animal_management\models\AnimalGalleryItem;
use humhub\modules\animal_management\models\AnimalMedicalVisit;
use humhub\modules\animal_management\models\AnimalProgressUpdate;
use humhub\modules\animal_management\models\AnimalTransfer;
use humhub\modules\animal_management\models\AnimalTransferEvent;
use humhub\modules\animal_management\models\forms\DisplaySettingsForm;
use humhub\modules\animal_management\models\forms\AnimalForm;
use humhub\modules\animal_management\models\forms\AnimalMedicalVisitForm;
use humhub\modules\animal_management\models\forms\AnimalProgressUpdateForm;
use humhub\modules\animal_management\models\forms\TransferRequestForm;
use humhub\modules\animal_management\notifications\TransferNotifier;
use humhub\modules\animal_management\permissions\ManageAnimals;
use humhub\modules\animal_management\services\GalleryIntegrationService;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\content\components\ContentContainerControllerAccess;
use humhub\modules\rescue_foundation\components\UploadStandards;
use humhub\modules\rescue_foundation\models\RescueFieldDefinition;
use humhub\modules\space\models\Space;
use Yii;
use yii\helpers\FileHelper;
use yii\filters\VerbFilter;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\UploadedFile;
class AnimalsController extends ContentContainerController
{
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['verbs'] = [
'class' => VerbFilter::class,
'actions' => [
'delete' => ['post'],
'add-medical-visit' => ['post'],
'add-progress-update' => ['post'],
'add-gallery-images' => ['post'],
'remove-gallery-image' => ['post'],
'transfer-respond' => ['post'],
'transfer-complete' => ['post'],
'transfer-cancel' => ['post'],
],
];
return $behaviors;
}
protected function getAccessRules()
{
return [
[ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN, Space::USERGROUP_MODERATOR], 'actions' => ['create', 'edit', 'delete', 'transfer', 'add-medical-visit', 'add-medical-visit-inline', 'edit-medical-visit', 'add-progress-update', 'add-progress-update-inline', 'edit-progress-update', 'add-gallery-images', 'remove-gallery-image', 'transfer-respond', 'transfer-complete', 'transfer-cancel']],
];
}
public function actionIndex()
{
$queryValue = trim((string)Yii::$app->request->get('q', ''));
$statusFilter = trim((string)Yii::$app->request->get('status', ''));
$speciesFilter = trim((string)Yii::$app->request->get('species', ''));
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::syncSpaceAnimalGalleries($this->contentContainer);
}
$viewMode = trim((string)Yii::$app->request->get('view', 'tiles'));
if (!in_array($viewMode, ['tiles', 'table'], true)) {
$viewMode = 'tiles';
}
$sortKey = trim((string)Yii::$app->request->get('sort', 'updated_at'));
$sortDirection = strtolower(trim((string)Yii::$app->request->get('direction', 'desc'))) === 'asc' ? 'asc' : 'desc';
$availableSorts = [
'animal_uid' => 'animal_uid',
'name' => 'name',
'species' => 'species',
'status' => 'status',
'updated_at' => 'updated_at',
'last_medical' => 'last_medical',
];
if (!isset($availableSorts[$sortKey])) {
$sortKey = 'updated_at';
}
$availableColumns = [
'animal_uid' => Yii::t('AnimalManagementModule.base', 'ID'),
'name' => Yii::t('AnimalManagementModule.base', 'Name'),
'species' => Yii::t('AnimalManagementModule.base', 'Species'),
'status' => Yii::t('AnimalManagementModule.base', 'Status'),
'last_medical' => Yii::t('AnimalManagementModule.base', 'Last Medical Visit'),
'updated_at' => Yii::t('AnimalManagementModule.base', 'Updated'),
];
$defaultColumns = ['animal_uid', 'name', 'species', 'status', 'last_medical', 'updated_at'];
$colsRaw = Yii::$app->request->get('cols', implode(',', $defaultColumns));
if (is_array($colsRaw)) {
$selectedColumns = array_values(array_intersect(array_keys($availableColumns), $colsRaw));
} else {
$selectedColumns = array_values(array_intersect(array_keys($availableColumns), array_map('trim', explode(',', (string)$colsRaw))));
}
if (empty($selectedColumns)) {
$selectedColumns = $defaultColumns;
}
if (!in_array('name', $selectedColumns, true)) {
$selectedColumns[] = 'name';
}
$query = Animal::find()
->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]);
if ($queryValue !== '') {
$query->andWhere([
'or',
['like', 'animal_uid', $queryValue],
['like', 'name', $queryValue],
['like', 'species', $queryValue],
['like', 'breed', $queryValue],
]);
}
if ($statusFilter !== '' && isset(Animal::statusOptions()[$statusFilter])) {
$query->andWhere(['status' => $statusFilter]);
}
if ($speciesFilter !== '') {
$query->andWhere(['species' => $speciesFilter]);
}
if ($sortKey !== 'last_medical') {
$sortColumn = $availableSorts[$sortKey];
$query->orderBy([
$sortColumn => $sortDirection === 'asc' ? SORT_ASC : SORT_DESC,
'id' => SORT_DESC,
]);
} else {
$query->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC]);
}
$animals = $query->all();
$speciesOptions = Animal::find()
->select('species')
->where(['contentcontainer_id' => $this->contentContainer->contentcontainer_id])
->andWhere(['not', ['species' => null]])
->andWhere(['!=', 'species', ''])
->distinct()
->orderBy(['species' => SORT_ASC])
->column();
$speciesOptions = array_values(array_filter(array_map(static function ($species) {
return trim((string)$species);
}, $speciesOptions)));
$latestMedicalVisitByAnimal = [];
$animalIds = array_map(static function (Animal $animal): int {
return (int)$animal->id;
}, $animals);
$animalImageUrls = $this->resolveAnimalImageUrls($animalIds, ['profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'], false);
$tileFieldOverrides = $this->resolveDisplayFieldOverrides($animalIds, 'tile_display_fields');
if (!empty($animalIds)) {
$visits = AnimalMedicalVisit::find()
->where(['animal_id' => $animalIds])
->orderBy(['animal_id' => SORT_ASC, 'visit_at' => SORT_DESC, 'id' => SORT_DESC])
->all();
foreach ($visits as $visit) {
$animalId = (int)$visit->animal_id;
if (!isset($latestMedicalVisitByAnimal[$animalId])) {
$latestMedicalVisitByAnimal[$animalId] = $visit;
}
}
}
if ($sortKey === 'last_medical') {
usort($animals, static function (Animal $a, Animal $b) use ($latestMedicalVisitByAnimal, $sortDirection): int {
$aVisit = $latestMedicalVisitByAnimal[(int)$a->id] ?? null;
$bVisit = $latestMedicalVisitByAnimal[(int)$b->id] ?? null;
$aTs = $aVisit ? strtotime((string)$aVisit->visit_at) : 0;
$bTs = $bVisit ? strtotime((string)$bVisit->visit_at) : 0;
if ($aTs === false) {
$aTs = 0;
}
if ($bTs === false) {
$bTs = 0;
}
$cmp = $aTs <=> $bTs;
if ($cmp === 0) {
$cmp = strcmp((string)$a->updated_at, (string)$b->updated_at);
}
return $sortDirection === 'asc' ? $cmp : -$cmp;
});
}
$incomingTransfers = AnimalTransfer::find()
->where(['to_contentcontainer_id' => $this->contentContainer->contentcontainer_id])
->andWhere(['status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]])
->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC])
->all();
$outgoingTransfers = AnimalTransfer::find()
->where(['from_contentcontainer_id' => $this->contentContainer->contentcontainer_id])
->orderBy(['updated_at' => SORT_DESC, 'id' => SORT_DESC])
->limit(30)
->all();
$transferAnimalIds = [];
foreach (array_merge($incomingTransfers, $outgoingTransfers) as $transfer) {
if ($transfer instanceof AnimalTransfer) {
$transferAnimalIds[] = (int)$transfer->animal_id;
}
}
$transferAnimalImageUrls = $this->resolveAnimalImageUrls(
$transferAnimalIds,
['cover_image_url', 'profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'],
true
);
return $this->render('index', [
'animals' => $animals,
'queryValue' => $queryValue,
'statusFilter' => $statusFilter,
'speciesFilter' => $speciesFilter,
'viewMode' => $viewMode,
'sortKey' => $sortKey,
'sortDirection' => $sortDirection,
'availableColumns' => $availableColumns,
'selectedColumns' => $selectedColumns,
'speciesOptions' => $speciesOptions,
'latestMedicalVisitByAnimal' => $latestMedicalVisitByAnimal,
'animalImageUrls' => $animalImageUrls,
'tileFields' => $this->getTileFieldSettings(),
'tileFieldOverrides' => $tileFieldOverrides,
'space' => $this->contentContainer,
'canManage' => $this->canManageAnimals(),
'incomingTransfers' => $incomingTransfers,
'outgoingTransfers' => $outgoingTransfers,
'transferAnimalImageUrls' => $transferAnimalImageUrls,
]);
}
public function actionView(int $id)
{
$animal = $this->findAnimal($id);
$canManage = $this->canManageAnimals();
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
}
$medicalVisitsQuery = $animal->getMedicalVisits();
$medicalSchema = Yii::$app->db->schema->getTableSchema(AnimalMedicalVisit::tableName(), true);
if ($medicalSchema !== null && isset($medicalSchema->columns['post_to_animal_feed'])) {
$medicalVisitsQuery->andWhere(['post_to_animal_feed' => 1]);
}
$medicalVisits = $medicalVisitsQuery->orderBy(['visit_at' => SORT_DESC, 'id' => SORT_DESC])->all();
$progressUpdatesQuery = $animal->getProgressUpdates();
$progressSchema = Yii::$app->db->schema->getTableSchema(AnimalProgressUpdate::tableName(), true);
if ($progressSchema !== null && isset($progressSchema->columns['post_to_animal_feed'])) {
$progressUpdatesQuery->andWhere(['post_to_animal_feed' => 1]);
}
$progressUpdates = $progressUpdatesQuery->orderBy(['update_at' => SORT_DESC, 'id' => SORT_DESC])->all();
$transfers = $animal->getTransfers()->orderBy(['created_at' => SORT_DESC, 'id' => SORT_DESC])->all();
$transferEvents = $animal->getTransferEvents()->orderBy(['id' => SORT_DESC])->limit(100)->all();
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$customFieldValues = $animal->getCustomFieldDisplayValues($canManage);
$animalImageUrls = $this->resolveAnimalImageUrls([(int)$animal->id], ['cover_image_url', 'image_url', 'photo_url', 'photo'], false);
$animalCoverImageUrl = trim((string)($animalImageUrls[(int)$animal->id] ?? ''));
$detailHeroFields = $this->getDetailHeroFieldSettings();
$heroOverrides = $this->resolveDisplayFieldOverrides([(int)$animal->id], 'hero_display_fields');
if (!empty($heroOverrides[(int)$animal->id])) {
$detailHeroFields = $heroOverrides[(int)$animal->id];
}
return $this->render('view', [
'space' => $this->contentContainer,
'animal' => $animal,
'canManage' => $canManage,
'medicalVisits' => $medicalVisits,
'progressUpdates' => $progressUpdates,
'transfers' => $transfers,
'transferEvents' => $transferEvents,
'galleryItems' => $galleryItems,
'customFieldValues' => $customFieldValues,
'animalCoverImageUrl' => $animalCoverImageUrl,
'detailHeroFields' => $detailHeroFields,
]);
}
public function actionMedicalVisits(int $id)
{
$animal = $this->findAnimal($id);
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
}
$canManage = $this->canManageAnimals();
$this->ensureMedicalMediaFieldDefinition();
$medicalVisitForm = new AnimalMedicalVisitForm(['animal' => $animal]);
$medicalVisits = $animal->getMedicalVisits()->orderBy(['visit_at' => SORT_DESC, 'id' => SORT_DESC])->all();
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
return $this->render('medical-visits', [
'space' => $this->contentContainer,
'animal' => $animal,
'canManage' => $canManage,
'medicalVisitForm' => $medicalVisitForm,
'medicalVisits' => $medicalVisits,
'galleryItems' => $galleryItems,
]);
}
public function actionProgressUpdates(int $id)
{
$animal = $this->findAnimal($id);
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
}
$canManage = $this->canManageAnimals();
$progressUpdateForm = new AnimalProgressUpdateForm(['animal' => $animal]);
$progressUpdates = $animal->getProgressUpdates()->orderBy(['update_at' => SORT_DESC, 'id' => SORT_DESC])->all();
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
return $this->render('progress-updates', [
'space' => $this->contentContainer,
'animal' => $animal,
'canManage' => $canManage,
'progressUpdateForm' => $progressUpdateForm,
'progressUpdates' => $progressUpdates,
'galleryItems' => $galleryItems,
]);
}
public function actionCreate()
{
$model = new AnimalForm(['contentContainer' => $this->contentContainer]);
if (Yii::$app->request->isPost) {
$model->load(Yii::$app->request->post());
$model->coverImageFile = UploadedFile::getInstanceByName('AnimalForm[coverImageFile]')
?: UploadedFile::getInstance($model, 'coverImageFile');
$model->profileImageFile = UploadedFile::getInstanceByName('AnimalForm[profileImageFile]')
?: UploadedFile::getInstance($model, 'profileImageFile');
}
if (Yii::$app->request->isPost && $model->save()) {
$savedAnimal = $model->getAnimal();
if ($savedAnimal instanceof Animal) {
$this->syncPrimaryImagesToGallery($savedAnimal);
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($savedAnimal, $this->contentContainer);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Animal created.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
return $this->render('create', [
'model' => $model,
'space' => $this->contentContainer,
'isEdit' => false,
'animal' => null,
]);
}
public function actionEdit(int $id)
{
$animal = $this->findAnimal($id);
$model = new AnimalForm(['contentContainer' => $this->contentContainer]);
$model->setAnimal($animal);
if (Yii::$app->request->isPost) {
$model->load(Yii::$app->request->post());
$model->coverImageFile = UploadedFile::getInstanceByName('AnimalForm[coverImageFile]')
?: UploadedFile::getInstance($model, 'coverImageFile');
$model->profileImageFile = UploadedFile::getInstanceByName('AnimalForm[profileImageFile]')
?: UploadedFile::getInstance($model, 'profileImageFile');
}
if (Yii::$app->request->isPost && $model->save()) {
$savedAnimal = $model->getAnimal();
if ($savedAnimal instanceof Animal) {
$this->syncPrimaryImagesToGallery($savedAnimal);
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($savedAnimal, $this->contentContainer);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Animal updated.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]));
}
return $this->render('create', [
'model' => $model,
'space' => $this->contentContainer,
'isEdit' => true,
'animal' => $animal,
]);
}
public function actionDelete(int $id)
{
if (!$this->canManageAnimals()) {
throw new ForbiddenHttpException('You are not allowed to delete animals.');
}
$animal = $this->findAnimal($id);
$localGalleryPaths = $this->collectAnimalLocalGalleryImagePaths((int)$animal->id);
if ($animal->delete() === false) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not delete animal.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $id]));
}
$deletedFiles = 0;
foreach ($localGalleryPaths as $path) {
$absolutePath = Yii::getAlias('@webroot') . $path;
if (!is_file($absolutePath)) {
continue;
}
if (@unlink($absolutePath)) {
$deletedFiles++;
}
}
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::syncSpaceAnimalGalleries($this->contentContainer);
}
if ($deletedFiles > 0) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Animal deleted and {count} gallery image(s) removed.', ['count' => $deletedFiles]));
} else {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Animal deleted.'));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
public function actionTransfer(int $id)
{
$animal = $this->findAnimal($id);
$form = new TransferRequestForm([
'animal' => $animal,
'sourceSpace' => $this->contentContainer,
]);
if ($form->load(Yii::$app->request->post()) && $form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request sent.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]));
}
return $this->render('transfer', [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
]);
}
public function actionAddGalleryImages(int $id)
{
$animal = $this->findAnimal($id);
$uploadedFiles = UploadedFile::getInstancesByName('galleryImages');
$maxUploadCount = 10;
if (empty($uploadedFiles)) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'No gallery images were selected.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery');
}
if (count($uploadedFiles) > $maxUploadCount) {
$uploadedFiles = array_slice($uploadedFiles, 0, $maxUploadCount);
$this->view->info(Yii::t('AnimalManagementModule.base', 'Only the first {count} selected images were processed.', ['count' => $maxUploadCount]));
}
$allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions());
$allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes());
$maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES;
$existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all();
$existingHashes = [];
foreach ($existingItems as $existingItem) {
$existingUrl = trim((string)$existingItem->getImageUrl());
if ($existingUrl === '') {
continue;
}
$hash = $this->computeImageContentHash($existingUrl);
if ($hash !== null) {
$existingHashes[$hash] = true;
}
}
$added = 0;
foreach ($uploadedFiles as $uploadedFile) {
if (!$uploadedFile instanceof UploadedFile) {
continue;
}
$extension = strtolower((string)$uploadedFile->extension);
$mimeType = strtolower((string)$uploadedFile->type);
if (!in_array($extension, $allowedExtensions, true)) {
continue;
}
if ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true)) {
continue;
}
if ($uploadedFile->size > $maxBytes) {
continue;
}
$storedPath = $this->storeGalleryUpload($animal, $uploadedFile);
if ($storedPath === null) {
continue;
}
$storedHash = $this->computeImageContentHash($storedPath);
$exactExists = AnimalGalleryItem::find()
->where(['animal_id' => (int)$animal->id, 'file_path' => $storedPath])
->exists();
if ($exactExists || ($storedHash !== null && isset($existingHashes[$storedHash]))) {
$absolute = Yii::getAlias('@webroot') . $storedPath;
if (is_file($absolute)) {
@unlink($absolute);
}
continue;
}
$item = new AnimalGalleryItem();
$item->animal_id = (int)$animal->id;
$item->file_path = $storedPath;
$item->source_type = 'upload';
$item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id;
if ($item->save()) {
$added++;
if ($storedHash !== null) {
$existingHashes[$storedHash] = true;
}
}
}
if ($added > 0) {
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
}
$this->view->success(Yii::t('AnimalManagementModule.base', '{count} image(s) added to gallery.', ['count' => $added]));
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'No gallery images were added. Check image type/size requirements.'));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery');
}
public function actionRemoveGalleryImage(int $id, int $galleryId)
{
$animal = $this->findAnimal($id);
$item = AnimalGalleryItem::findOne(['id' => $galleryId, 'animal_id' => (int)$animal->id]);
if (!$item instanceof AnimalGalleryItem) {
throw new NotFoundHttpException('Gallery image not found.');
}
$path = trim((string)$item->file_path);
$deletePhysicalFile = false;
if ($path !== '' && substr($path, 0, 1) === '/') {
$stillReferencedByGallery = AnimalGalleryItem::find()
->where(['animal_id' => (int)$animal->id, 'file_path' => $path])
->andWhere(['!=', 'id', (int)$item->id])
->exists();
$stillReferencedByPrimaryImage = $this->isPrimaryImagePathReferenced((int)$animal->id, $path);
$deletePhysicalFile = !$stillReferencedByGallery && !$stillReferencedByPrimaryImage;
}
if ($item->delete() !== false) {
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
}
if ($deletePhysicalFile) {
$absolute = Yii::getAlias('@webroot') . $path;
if (is_file($absolute)) {
@unlink($absolute);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Gallery image removed.'));
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not remove gallery image.'));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery');
}
public function actionAddMedicalVisit(int $id)
{
$animal = $this->findAnimal($id);
$this->ensureMedicalMediaFieldDefinition();
$form = new AnimalMedicalVisitForm(['animal' => $animal]);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
if ($form->load(Yii::$app->request->post())) {
$this->applyMedicalMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit added.'));
} else {
$message = Yii::t('AnimalManagementModule.base', 'Could not save medical visit.');
if ($form->hasErrors()) {
$message .= ' ' . implode(' ', array_values($form->getFirstErrors()));
}
$this->view->error($message);
}
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not save medical visit.'));
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
public function actionAddMedicalVisitInline(int $id)
{
$animal = $this->findAnimal($id);
$this->ensureMedicalMediaFieldDefinition();
$form = new AnimalMedicalVisitForm(['animal' => $animal]);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'medical-visits'));
$isInline = (int)Yii::$app->request->get('inline', 1) === 1;
if ($form->load(Yii::$app->request->post())) {
$this->applyMedicalMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit added.'));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'medical-add-inline',
'refreshSelectors' => ['#animal-medical-panel', '#animal-gallery-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
}
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'returnTo' => $returnTo,
'galleryItems' => $galleryItems,
'isInline' => $isInline,
];
if ($isInline) {
return $this->renderAjax('add-medical-visit', $params);
}
return $this->render('add-medical-visit', $params);
}
public function actionEditMedicalVisit(int $id, int $visitId)
{
$animal = $this->findAnimal($id);
$visit = $animal->getMedicalVisits()->andWhere(['id' => $visitId])->one();
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
$isInline = (int)Yii::$app->request->get('inline', 0) === 1;
if (!$visit instanceof \humhub\modules\animal_management\models\AnimalMedicalVisit) {
throw new NotFoundHttpException('Medical visit not found.');
}
$this->ensureMedicalMediaFieldDefinition();
$form = new AnimalMedicalVisitForm(['animal' => $animal]);
$form->setMedicalVisit($visit);
if ($form->load(Yii::$app->request->post())) {
$this->applyMedicalMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Medical visit updated.'));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'medical-edit-inline-' . (int)$visit->id,
'refreshSelectors' => ['#animal-medical-panel', '#animal-gallery-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
}
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'medicalVisit' => $visit,
'returnTo' => $returnTo,
'galleryItems' => $galleryItems,
'isInline' => $isInline,
];
if ($isInline) {
return $this->renderAjax('edit-medical-visit', $params);
}
return $this->render('edit-medical-visit', $params);
}
public function actionAddProgressUpdate(int $id)
{
$animal = $this->findAnimal($id);
$form = new AnimalProgressUpdateForm(['animal' => $animal]);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
if ($form->load(Yii::$app->request->post())) {
$this->applyProgressMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update added.'));
} else {
$message = Yii::t('AnimalManagementModule.base', 'Could not save progress update.');
if ($form->hasErrors()) {
$message .= ' ' . implode(' ', array_values($form->getFirstErrors()));
}
$this->view->error($message);
}
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not save progress update.'));
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
public function actionAddProgressUpdateInline(int $id)
{
$animal = $this->findAnimal($id);
$form = new AnimalProgressUpdateForm(['animal' => $animal]);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'progress-updates'));
$isInline = (int)Yii::$app->request->get('inline', 1) === 1;
if ($form->load(Yii::$app->request->post())) {
$this->applyProgressMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update added.'));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'progress-add-inline',
'refreshSelectors' => ['#animal-progress-panel', '#animal-gallery-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
}
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'returnTo' => $returnTo,
'galleryItems' => $galleryItems,
'isInline' => $isInline,
];
if ($isInline) {
return $this->renderAjax('add-progress-update', $params);
}
return $this->render('add-progress-update', $params);
}
public function actionEditProgressUpdate(int $id, int $updateId)
{
$animal = $this->findAnimal($id);
$update = $animal->getProgressUpdates()->andWhere(['id' => $updateId])->one();
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
$isInline = (int)Yii::$app->request->get('inline', 0) === 1;
if (!$update instanceof \humhub\modules\animal_management\models\AnimalProgressUpdate) {
throw new NotFoundHttpException('Progress update not found.');
}
$form = new AnimalProgressUpdateForm(['animal' => $animal]);
$form->setProgressUpdate($update);
if ($form->load(Yii::$app->request->post())) {
$this->applyProgressMediaInput($form, $animal);
if ($form->save()) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Progress update updated.'));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'progress-edit-inline-' . (int)$update->id,
'refreshSelectors' => ['#animal-progress-panel', '#animal-gallery-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
Yii::warning([
'message' => 'Progress update save failed.',
'animal_id' => (int)$animal->id,
'progress_update_id' => (int)$update->id,
'is_inline' => $isInline,
'errors' => $form->getErrors(),
], 'animal_management.progress_media');
} elseif (Yii::$app->request->isPost) {
Yii::warning([
'message' => 'Progress update form load failed on POST.',
'animal_id' => (int)$animal->id,
'progress_update_id' => (int)$update->id,
'is_inline' => $isInline,
'post_keys' => array_keys((array)Yii::$app->request->post()),
], 'animal_management.progress_media');
}
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'progressUpdate' => $update,
'returnTo' => $returnTo,
'galleryItems' => $galleryItems,
'isInline' => $isInline,
];
if ($isInline) {
return $this->renderAjax('edit-progress-update', $params);
}
return $this->render('edit-progress-update', $params);
}
public function actionTransferRespond(int $id, string $decision)
{
$transfer = AnimalTransfer::findOne([
'id' => $id,
'to_contentcontainer_id' => $this->contentContainer->contentcontainer_id,
]);
if (!$transfer instanceof AnimalTransfer) {
throw new NotFoundHttpException('Transfer request not found.');
}
if ($transfer->status !== AnimalTransfer::STATUS_REQUESTED) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'This transfer can no longer be responded to.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
if ($decision === 'accept') {
if ($transfer->markAccepted()) {
$originator = Yii::$app->user->getIdentity();
TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_ACCEPTED, $originator);
$this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request accepted.'));
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not accept transfer request.'));
}
} elseif ($decision === 'decline') {
if ($transfer->markDeclined()) {
$originator = Yii::$app->user->getIdentity();
TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_DECLINED, $originator);
$animal = $transfer->animal;
if ($animal instanceof Animal && $animal->status === Animal::STATUS_TRANSFER_PENDING) {
$hasOpenTransfers = AnimalTransfer::find()
->where(['animal_id' => $animal->id, 'status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]])
->exists();
if (!$hasOpenTransfers) {
$animal->status = Animal::STATUS_ACTIVE;
$animal->save(false, ['status', 'updated_at']);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request declined.'));
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not decline transfer request.'));
}
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Unknown transfer decision.'));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
public function actionTransferComplete(int $id)
{
$transfer = AnimalTransfer::findOne([
'id' => $id,
'to_contentcontainer_id' => $this->contentContainer->contentcontainer_id,
'status' => AnimalTransfer::STATUS_ACCEPTED,
]);
if (!$transfer instanceof AnimalTransfer) {
throw new NotFoundHttpException('Accepted transfer not found.');
}
$animal = $transfer->animal;
if (!$animal instanceof Animal) {
throw new NotFoundHttpException('Animal not found.');
}
$animal->contentcontainer_id = $this->contentContainer->contentcontainer_id;
$animal->status = Animal::STATUS_ACTIVE;
if (!$animal->save(false, ['contentcontainer_id', 'status', 'updated_at'])) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not move animal ownership.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
if (!$transfer->markCompleted()) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not complete transfer.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
$originator = Yii::$app->user->getIdentity();
TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_COMPLETED, $originator);
$openTransfers = AnimalTransfer::find()
->where(['animal_id' => $animal->id])
->andWhere(['status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]])
->andWhere(['!=', 'id', $transfer->id])
->all();
foreach ($openTransfers as $openTransfer) {
if (!$openTransfer instanceof AnimalTransfer) {
continue;
}
$openTransfer->markCancelled(Yii::t('AnimalManagementModule.base', 'Transfer cancelled because another transfer was completed.'));
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer completed. Animal ownership moved to this rescue.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]));
}
public function actionTransferCancel(int $id)
{
$transfer = AnimalTransfer::findOne([
'id' => $id,
'from_contentcontainer_id' => $this->contentContainer->contentcontainer_id,
]);
if (!$transfer instanceof AnimalTransfer) {
throw new NotFoundHttpException('Transfer request not found.');
}
if (!in_array($transfer->status, [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED], true)) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'This transfer can no longer be cancelled.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
if (!$transfer->markCancelled(Yii::t('AnimalManagementModule.base', 'Transfer cancelled by source rescue.'))) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Could not cancel transfer request.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
$originator = Yii::$app->user->getIdentity();
TransferNotifier::notifyStatusChange($transfer, AnimalTransferEvent::EVENT_CANCELLED, $originator);
$animal = $transfer->animal;
if ($animal instanceof Animal && $animal->status === Animal::STATUS_TRANSFER_PENDING) {
$hasOpenTransfers = AnimalTransfer::find()
->where(['animal_id' => $animal->id, 'status' => [AnimalTransfer::STATUS_REQUESTED, AnimalTransfer::STATUS_ACCEPTED]])
->exists();
if (!$hasOpenTransfers) {
$animal->status = Animal::STATUS_ACTIVE;
$animal->save(false, ['status', 'updated_at']);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Transfer request cancelled.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/index'));
}
private function findAnimal(int $id): Animal
{
$animal = Animal::findOne(['id' => $id, 'contentcontainer_id' => $this->contentContainer->contentcontainer_id]);
if (!$animal instanceof Animal) {
throw new NotFoundHttpException('Animal not found.');
}
return $animal;
}
private function collectAnimalLocalGalleryImagePaths(int $animalId): array
{
if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) {
return [];
}
$items = AnimalGalleryItem::find()->where(['animal_id' => $animalId])->all();
$paths = [];
foreach ($items as $item) {
$path = trim((string)$item->file_path);
if ($path === '' || substr($path, 0, 1) !== '/') {
continue;
}
$paths[$path] = true;
}
return array_keys($paths);
}
private function redirectToAnimalPage(int $animalId, string $returnTo): Response
{
if ($returnTo === 'medical-visits') {
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/medical-visits', ['id' => $animalId]));
}
if ($returnTo === 'progress-updates') {
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/progress-updates', ['id' => $animalId]));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animalId]));
}
private function canManageAnimals(): bool
{
if ($this->contentContainer->can(ManageAnimals::class)) {
return true;
}
if ($this->contentContainer instanceof Space && $this->contentContainer->isAdmin()) {
return true;
}
return false;
}
private function getTileFieldSettings(): array
{
$settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer);
$raw = $settings->get('tileFields', json_encode(DisplaySettingsForm::DEFAULT_TILE_FIELDS));
return $this->normalizeDisplayFields($raw, DisplaySettingsForm::DEFAULT_TILE_FIELDS);
}
private function getDetailHeroFieldSettings(): array
{
$settings = Yii::$app->getModule('animal_management')->settings->contentContainer($this->contentContainer);
$raw = $settings->get('detailHeroFields', json_encode(DisplaySettingsForm::DEFAULT_DETAIL_FIELDS));
return $this->normalizeDisplayFields($raw, DisplaySettingsForm::DEFAULT_DETAIL_FIELDS);
}
private function normalizeDisplayFields($raw, array $default): array
{
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return $default;
}
$raw = $decoded;
}
if (!is_array($raw)) {
return $default;
}
$allowed = array_keys(DisplaySettingsForm::fieldOptions());
$normalized = [];
foreach ($raw as $field) {
$field = trim((string)$field);
if ($field === '' || !in_array($field, $allowed, true)) {
continue;
}
if (!in_array($field, $normalized, true)) {
$normalized[] = $field;
}
}
return !empty($normalized) ? $normalized : $default;
}
private function resolveAnimalImageUrls(array $animalIds, array $imageFieldOrder = [], bool $allowGalleryFallback = false): array
{
$animalIds = array_values(array_unique(array_map('intval', $animalIds)));
if (empty($animalIds)) {
return [];
}
if (!class_exists(RescueFieldDefinition::class)
|| Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null
|| Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null
) {
return [];
}
if (empty($imageFieldOrder)) {
$imageFieldOrder = ['cover_image_url', 'profile_image_url', 'photo_url', 'image_url', 'profile_image', 'photo'];
}
$definitions = RescueFieldDefinition::find()
->select(['id', 'field_key'])
->where([
'module_id' => 'animal_management',
'group_key' => 'animal_profile',
'field_key' => $imageFieldOrder,
'is_active' => 1,
])
->all();
if (empty($definitions)) {
return [];
}
$definitionPriority = [];
foreach ($definitions as $definition) {
$priority = array_search((string)$definition->field_key, $imageFieldOrder, true);
if ($priority === false) {
$priority = 999;
}
$definitionPriority[(int)$definition->id] = (int)$priority;
}
if (empty($definitionPriority)) {
return [];
}
$valueRows = AnimalFieldValue::find()
->where(['animal_id' => $animalIds, 'field_definition_id' => array_keys($definitionPriority)])
->all();
$imageUrls = [];
$chosenPriorityByAnimal = [];
foreach ($valueRows as $valueRow) {
$animalId = (int)$valueRow->animal_id;
$valueText = trim((string)$valueRow->value_text);
if ($valueText === '') {
continue;
}
$priority = $definitionPriority[(int)$valueRow->field_definition_id] ?? 999;
if (!isset($chosenPriorityByAnimal[$animalId]) || $priority < $chosenPriorityByAnimal[$animalId]) {
$chosenPriorityByAnimal[$animalId] = $priority;
$imageUrls[$animalId] = $valueText;
}
}
$missingAnimalIds = [];
foreach ($animalIds as $animalId) {
if (!isset($imageUrls[$animalId])) {
$missingAnimalIds[] = (int)$animalId;
}
}
if ($allowGalleryFallback && !empty($missingAnimalIds) && Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) !== null) {
$galleryItems = AnimalGalleryItem::find()
->where(['animal_id' => $missingAnimalIds])
->orderBy(['animal_id' => SORT_ASC, 'id' => SORT_DESC])
->all();
foreach ($galleryItems as $galleryItem) {
$animalId = (int)$galleryItem->animal_id;
if (isset($imageUrls[$animalId])) {
continue;
}
$url = trim((string)$galleryItem->getImageUrl());
if ($url === '') {
continue;
}
$imageUrls[$animalId] = $url;
}
}
return $imageUrls;
}
private function resolveDisplayFieldOverrides(array $animalIds, string $fieldKey): array
{
$animalIds = array_values(array_unique(array_map('intval', $animalIds)));
if (empty($animalIds)) {
return [];
}
if (!class_exists(RescueFieldDefinition::class)
|| Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null
|| Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null
) {
return [];
}
$definition = RescueFieldDefinition::findOne([
'module_id' => 'animal_management',
'group_key' => 'animal_profile',
'field_key' => $fieldKey,
]);
if (!$definition instanceof RescueFieldDefinition) {
return [];
}
$rows = AnimalFieldValue::find()
->where(['animal_id' => $animalIds, 'field_definition_id' => (int)$definition->id])
->all();
$allowed = array_keys(DisplaySettingsForm::fieldOptions());
$result = [];
foreach ($rows as $row) {
$raw = trim((string)$row->value_text);
if ($raw === '') {
continue;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$decoded = array_map('trim', explode(',', $raw));
}
$normalized = [];
foreach ($decoded as $field) {
$field = trim((string)$field);
if ($field === '' || !in_array($field, $allowed, true)) {
continue;
}
if (!in_array($field, $normalized, true)) {
$normalized[] = $field;
}
}
if (!empty($normalized)) {
$result[(int)$row->animal_id] = $normalized;
}
}
return $result;
}
private function storeGalleryUpload(Animal $animal, UploadedFile $file): ?string
{
$random = Yii::$app->security->generateRandomString(8);
$extension = strtolower((string)$file->extension);
$fileName = 'gallery-' . time() . '-' . $random . '.' . $extension;
$candidateDirs = [
'/uploads/animal-management/gallery/' . (int)$animal->id,
'/uploads/animal-management-runtime/gallery/' . (int)$animal->id,
'/uploads/animal-media/gallery/' . (int)$animal->id,
'/assets/animal-management-runtime/gallery/' . (int)$animal->id,
];
foreach ($candidateDirs as $relativeDir) {
$absoluteDir = Yii::getAlias('@webroot') . $relativeDir;
try {
FileHelper::createDirectory($absoluteDir, 0775, true);
} catch (\Throwable $e) {
Yii::warning([
'message' => $e->getMessage(),
'relative_dir' => $relativeDir,
'absolute_dir' => $absoluteDir,
], 'animal_management.gallery_upload_dir');
continue;
}
if (!is_dir($absoluteDir) || !is_writable($absoluteDir)) {
continue;
}
$absolutePath = $absoluteDir . '/' . $fileName;
if ($file->saveAs($absolutePath)) {
return $relativeDir . '/' . $fileName;
}
}
Yii::warning([
'message' => 'Could not save uploaded gallery file in any candidate directory.',
'animal_id' => (int)$animal->id,
'file_name' => $fileName,
'candidate_dirs' => $candidateDirs,
], 'animal_management.gallery_upload_dir');
return null;
}
private function computeImageContentHash(string $pathOrUrl): ?string
{
$pathOrUrl = trim($pathOrUrl);
if ($pathOrUrl === '') {
return null;
}
$absolutePath = $pathOrUrl;
if (substr($absolutePath, 0, 1) === '/') {
$absolutePath = Yii::getAlias('@webroot') . $absolutePath;
}
if (!is_file($absolutePath) || !is_readable($absolutePath)) {
return null;
}
$hash = @hash_file('sha1', $absolutePath);
return is_string($hash) && $hash !== '' ? $hash : null;
}
private function syncPrimaryImagesToGallery(Animal $animal): void
{
if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null
|| Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null
|| Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null
) {
return;
}
$definitions = RescueFieldDefinition::find()
->select(['id', 'field_key'])
->where([
'module_id' => 'animal_management',
'group_key' => 'animal_profile',
'field_key' => ['cover_image_url', 'profile_image_url'],
])
->all();
if (empty($definitions)) {
return;
}
$byDefinitionId = [];
foreach ($definitions as $definition) {
$byDefinitionId[(int)$definition->id] = (string)$definition->field_key;
}
$fieldRows = AnimalFieldValue::find()
->where([
'animal_id' => (int)$animal->id,
'field_definition_id' => array_keys($byDefinitionId),
])
->all();
if (empty($fieldRows)) {
return;
}
$existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all();
$existingByPath = [];
$existingHashes = [];
foreach ($existingItems as $existingItem) {
$path = trim((string)$existingItem->file_path);
if ($path !== '') {
$existingByPath[$path] = true;
}
$url = trim((string)$existingItem->getImageUrl());
if ($url !== '') {
$existingByPath[$url] = true;
}
$hash = $this->computeImageContentHash($url !== '' ? $url : $path);
if ($hash !== null) {
$existingHashes[$hash] = true;
}
}
foreach ($fieldRows as $fieldRow) {
$imagePath = trim((string)$fieldRow->value_text);
if ($imagePath === '') {
continue;
}
if (!$this->isResolvableImageUrl($imagePath)) {
continue;
}
if (isset($existingByPath[$imagePath])) {
continue;
}
$hash = $this->computeImageContentHash($imagePath);
if ($hash !== null && isset($existingHashes[$hash])) {
continue;
}
$fieldKey = $byDefinitionId[(int)$fieldRow->field_definition_id] ?? 'upload';
$item = new AnimalGalleryItem();
$item->animal_id = (int)$animal->id;
$item->file_path = $imagePath;
$item->source_type = $fieldKey === 'cover_image_url' ? 'cover' : 'profile';
$item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id;
if ($item->save()) {
$existingByPath[$imagePath] = true;
if ($hash !== null) {
$existingHashes[$hash] = true;
}
} else {
Yii::warning($item->getErrors(), 'animal_management.primary_image_gallery_sync');
}
}
}
private function isPrimaryImagePathReferenced(int $animalId, string $path): bool
{
if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null
|| Yii::$app->db->schema->getTableSchema('rescue_animal_field_value', true) === null
) {
return false;
}
$definitionIds = RescueFieldDefinition::find()
->select('id')
->where([
'module_id' => 'animal_management',
'group_key' => 'animal_profile',
'field_key' => ['cover_image_url', 'profile_image_url'],
])
->column();
if (empty($definitionIds)) {
return false;
}
return AnimalFieldValue::find()
->where([
'animal_id' => $animalId,
'field_definition_id' => $definitionIds,
'value_text' => $path,
])
->exists();
}
private function isResolvableImageUrl(string $pathOrUrl): bool
{
$pathOrUrl = trim($pathOrUrl);
if ($pathOrUrl === '') {
return false;
}
if (preg_match('/^https?:\/\//i', $pathOrUrl)) {
return true;
}
if (substr($pathOrUrl, 0, 1) !== '/') {
return false;
}
$absolutePath = Yii::getAlias('@webroot') . $pathOrUrl;
return is_file($absolutePath) && is_readable($absolutePath);
}
private function applyProgressMediaInput(AnimalProgressUpdateForm $form, Animal $animal): void
{
$removeProgressMedia = (int)Yii::$app->request->post('removeProgressMedia', 0) === 1;
if ($removeProgressMedia) {
$form->customFields['media_reference'] = '';
return;
}
$selectedGalleryPath = trim((string)Yii::$app->request->post('progressMediaGalleryPath', ''));
if ($selectedGalleryPath === '') {
$selectedGalleryPath = trim((string)Yii::$app->request->post('AnimalProgressUpdateForm.progressMediaGalleryPath', ''));
}
if ($selectedGalleryPath === '') {
$formPayload = Yii::$app->request->post('AnimalProgressUpdateForm', []);
if (is_array($formPayload) && isset($formPayload['progressMediaGalleryPath'])) {
$selectedGalleryPath = trim((string)$formPayload['progressMediaGalleryPath']);
}
}
$uploadedMedia = UploadedFile::getInstanceByName('progressMediaUpload');
if (!$uploadedMedia instanceof UploadedFile) {
$uploadedMedia = UploadedFile::getInstanceByName('AnimalProgressUpdateForm[progressMediaUpload]');
}
if (!$uploadedMedia instanceof UploadedFile) {
$uploadedCandidates = UploadedFile::getInstancesByName('progressMediaUpload');
if (!empty($uploadedCandidates) && $uploadedCandidates[0] instanceof UploadedFile) {
$uploadedMedia = $uploadedCandidates[0];
}
}
if ($uploadedMedia instanceof UploadedFile) {
$allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions());
$allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes());
$maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES;
$extension = strtolower((string)$uploadedMedia->extension);
$mimeType = strtolower((string)$uploadedMedia->type);
if (!in_array($extension, $allowedExtensions, true)
|| ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true))
|| $uploadedMedia->size > $maxBytes
) {
$form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Progress media image must be a valid image within size limits.'));
return;
}
$storedPath = $this->storeGalleryUpload($animal, $uploadedMedia);
if ($storedPath === null) {
Yii::warning([
'message' => 'storeGalleryUpload returned null for progress media.',
'animal_id' => (int)$animal->id,
'progress_update_id' => $form->progressUpdate instanceof AnimalProgressUpdate ? (int)$form->progressUpdate->id : null,
'uploaded_media_name' => (string)$uploadedMedia->name,
], 'animal_management.progress_media');
$form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not upload progress media image.'));
return;
}
$this->addImagePathToGalleryIfMissing($animal, $storedPath, 'progress');
$form->customFields['media_reference'] = $storedPath;
return;
}
if (
$selectedGalleryPath !== ''
&& (preg_match('/^https?:\/\//i', $selectedGalleryPath) || substr($selectedGalleryPath, 0, 1) === '/')
) {
$this->addImagePathToGalleryIfMissing($animal, $selectedGalleryPath, 'progress');
$form->customFields['media_reference'] = $selectedGalleryPath;
return;
}
// Preserve existing media reference when no explicit media action was posted.
if ($form->progressUpdate instanceof AnimalProgressUpdate) {
$existingMedia = $this->resolveProgressMediaReferenceFromRecord($form->progressUpdate);
if ($existingMedia !== '') {
$form->customFields['media_reference'] = $existingMedia;
}
}
}
private function resolveProgressMediaReferenceFromRecord(AnimalProgressUpdate $record): string
{
foreach ($record->getCustomFieldDisplayValues(true) as $customField) {
if ((string)($customField['field_key'] ?? '') !== 'media_reference') {
continue;
}
$value = trim((string)($customField['value'] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function applyMedicalMediaInput(AnimalMedicalVisitForm $form, Animal $animal): void
{
if ((int)Yii::$app->request->post('removeMedicalMedia', 0) === 1) {
$form->customFields['medical_media_reference'] = '';
$form->customFields['media_reference'] = '';
return;
}
$selectedGalleryPath = trim((string)Yii::$app->request->post('medicalMediaGalleryPath', ''));
$uploadedMedia = UploadedFile::getInstanceByName('medicalMediaUpload');
if ($uploadedMedia instanceof UploadedFile) {
$allowedExtensions = array_map('strtolower', UploadStandards::imageExtensions());
$allowedMimeTypes = array_map('strtolower', UploadStandards::imageMimeTypes());
$maxBytes = (int)UploadStandards::IMAGE_MAX_BYTES;
$extension = strtolower((string)$uploadedMedia->extension);
$mimeType = strtolower((string)$uploadedMedia->type);
if (!in_array($extension, $allowedExtensions, true)
|| ($mimeType !== '' && !in_array($mimeType, $allowedMimeTypes, true))
|| $uploadedMedia->size > $maxBytes
) {
$form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Medical media image must be a valid image within size limits.'));
return;
}
$storedPath = $this->storeGalleryUpload($animal, $uploadedMedia);
if ($storedPath === null) {
$form->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not upload medical media image.'));
return;
}
$this->addImagePathToGalleryIfMissing($animal, $storedPath, 'medical');
$form->customFields['medical_media_reference'] = $storedPath;
$form->customFields['media_reference'] = $storedPath;
return;
}
if (
$selectedGalleryPath !== ''
&& (preg_match('/^https?:\/\//i', $selectedGalleryPath) || substr($selectedGalleryPath, 0, 1) === '/')
) {
$this->addImagePathToGalleryIfMissing($animal, $selectedGalleryPath, 'medical');
$form->customFields['medical_media_reference'] = $selectedGalleryPath;
$form->customFields['media_reference'] = $selectedGalleryPath;
}
}
private function ensureMedicalMediaFieldDefinition(): void
{
if (!class_exists(RescueFieldDefinition::class)
|| Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null
) {
return;
}
$definition = RescueFieldDefinition::findOne([
'module_id' => 'animal_management',
'field_key' => 'medical_media_reference',
]);
if ($definition instanceof RescueFieldDefinition) {
$needsSave = false;
if ((string)$definition->group_key !== 'animal_medical_visit') {
$definition->group_key = 'animal_medical_visit';
$needsSave = true;
}
if ((int)$definition->is_active !== 1) {
$definition->is_active = 1;
$needsSave = true;
}
if ((string)$definition->visibility !== 'public') {
$definition->visibility = 'public';
$needsSave = true;
}
if ((string)$definition->label === '') {
$definition->label = 'Media';
$needsSave = true;
}
if ($needsSave) {
$definition->updated_at = date('Y-m-d H:i:s');
$definition->save();
}
return;
}
$definition = new RescueFieldDefinition();
$definition->module_id = 'animal_management';
$definition->group_key = 'animal_medical_visit';
$definition->field_key = 'medical_media_reference';
$definition->label = 'Media';
$definition->input_type = 'text';
$definition->required = 0;
$definition->is_core = 1;
$definition->is_active = 1;
$definition->visibility = 'public';
$definition->options = '{}';
$definition->sort_order = 220;
$definition->created_at = date('Y-m-d H:i:s');
$definition->updated_at = date('Y-m-d H:i:s');
$definition->save();
}
private function addImagePathToGalleryIfMissing(Animal $animal, string $imagePath, string $sourceType = 'upload'): void
{
$imagePath = trim($imagePath);
if ($imagePath === '') {
return;
}
$existingItems = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all();
$incomingHash = $this->computeImageContentHash($imagePath);
foreach ($existingItems as $existingItem) {
$existingPath = trim((string)$existingItem->file_path);
$existingUrl = trim((string)$existingItem->getImageUrl());
if ($existingPath === $imagePath || $existingUrl === $imagePath) {
return;
}
if ($incomingHash !== null) {
$existingHash = $this->computeImageContentHash($existingUrl !== '' ? $existingUrl : $existingPath);
if ($existingHash !== null && hash_equals($incomingHash, $existingHash)) {
return;
}
}
}
if (substr($imagePath, 0, 1) !== '/') {
return;
}
$item = new AnimalGalleryItem();
$item->animal_id = (int)$animal->id;
$item->file_path = $imagePath;
$item->source_type = $sourceType;
$item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id;
$item->save();
}
}