1025 lines
36 KiB
PHP
1025 lines
36 KiB
PHP
<?php
|
|
|
|
namespace humhub\modules\animal_management\models\forms;
|
|
|
|
use humhub\modules\animal_management\models\Animal;
|
|
use humhub\modules\animal_management\models\AnimalFieldValue;
|
|
use humhub\modules\animal_management\models\AnimalGalleryItem;
|
|
use humhub\modules\content\components\ContentContainerActiveRecord;
|
|
use humhub\modules\rescue_foundation\components\UploadStandards;
|
|
use humhub\modules\rescue_foundation\models\RescueFieldDefinition;
|
|
use humhub\modules\space\models\Space;
|
|
use Yii;
|
|
use yii\base\Model;
|
|
use yii\helpers\FileHelper;
|
|
use yii\helpers\Json;
|
|
use yii\web\UploadedFile;
|
|
|
|
class AnimalForm extends Model
|
|
{
|
|
public const MODULE_ID = 'animal_management';
|
|
|
|
public ContentContainerActiveRecord $contentContainer;
|
|
|
|
public string $name = '';
|
|
public string $species = '';
|
|
public string $breed = '';
|
|
public string $sex = '';
|
|
public string $status = Animal::STATUS_INTAKE;
|
|
public bool $in_possession = true;
|
|
public string $location_name = '';
|
|
public string $city = '';
|
|
public string $state = '';
|
|
public string $zip = '';
|
|
public string $public_summary = '';
|
|
public string $medical_notes = '';
|
|
public UploadedFile|string|null $coverImageFile = null;
|
|
public UploadedFile|string|null $profileImageFile = null;
|
|
public bool $removeCoverImage = false;
|
|
public bool $removeProfileImage = false;
|
|
public string $coverImageGalleryPath = '';
|
|
public string $profileImageGalleryPath = '';
|
|
public array $tileDisplayFields = [];
|
|
public array $heroDisplayFields = [];
|
|
public array $customFields = [];
|
|
|
|
private array $fieldConfig = [];
|
|
private array $customFieldDefinitions = [];
|
|
private ?Animal $animal = null;
|
|
private ?string $existingCoverImagePath = null;
|
|
private ?string $existingProfileImagePath = null;
|
|
|
|
public function init()
|
|
{
|
|
parent::init();
|
|
$this->bootstrapFieldConfig();
|
|
}
|
|
|
|
public function rules()
|
|
{
|
|
return [
|
|
[['species', 'breed', 'sex', 'location_name', 'city', 'state', 'zip'], 'string', 'max' => 120],
|
|
[['name'], 'string', 'max' => 190],
|
|
[['status'], 'in', 'range' => array_keys(Animal::statusOptions())],
|
|
[['public_summary', 'medical_notes'], 'string'],
|
|
[['in_possession'], 'boolean'],
|
|
[['state'], 'string', 'max' => 2],
|
|
[['zip'], 'string', 'max' => 10],
|
|
[['removeCoverImage', 'removeProfileImage'], 'boolean'],
|
|
[['coverImageGalleryPath', 'profileImageGalleryPath'], 'string', 'max' => 500],
|
|
[['coverImageGalleryPath', 'profileImageGalleryPath'], 'validateGalleryImageSelection'],
|
|
[['tileDisplayFields', 'heroDisplayFields'], 'default', 'value' => []],
|
|
[['tileDisplayFields', 'heroDisplayFields'], 'each', 'rule' => ['in', 'range' => array_keys(DisplaySettingsForm::fieldOptions())]],
|
|
[['coverImageFile', 'profileImageFile'], 'file',
|
|
'skipOnEmpty' => true,
|
|
'extensions' => UploadStandards::imageExtensions(),
|
|
'checkExtensionByMimeType' => true,
|
|
'mimeTypes' => UploadStandards::imageMimeTypes(),
|
|
'maxSize' => UploadStandards::IMAGE_MAX_BYTES,
|
|
],
|
|
[['customFields'], 'safe'],
|
|
[['name', 'species', 'breed', 'sex', 'status', 'location_name', 'city', 'state', 'zip', 'public_summary', 'medical_notes'], 'validateConfiguredRequiredFields'],
|
|
[['customFields'], 'validateCustomFieldValues'],
|
|
];
|
|
}
|
|
|
|
public function save(): bool
|
|
{
|
|
if (!$this->validate()) {
|
|
return false;
|
|
}
|
|
|
|
$transaction = Yii::$app->db->beginTransaction();
|
|
|
|
try {
|
|
$animal = $this->animal ?: new Animal();
|
|
$animal->contentcontainer_id = $this->contentContainer->contentcontainer_id;
|
|
$animal->name = trim($this->name);
|
|
$animal->species = trim($this->species);
|
|
$animal->breed = trim($this->breed);
|
|
$animal->sex = trim($this->sex);
|
|
$animal->status = $this->status;
|
|
$animal->in_possession = $this->in_possession ? 1 : 0;
|
|
$animal->public_summary = trim($this->public_summary);
|
|
$animal->medical_notes = trim($this->medical_notes);
|
|
|
|
if ($this->in_possession) {
|
|
$animal->location_name = $this->getDefaultLocationName();
|
|
$animal->city = '';
|
|
$animal->state = '';
|
|
$animal->zip = '';
|
|
} else {
|
|
$animal->location_name = trim($this->location_name);
|
|
$animal->city = trim($this->city);
|
|
$animal->state = strtoupper(trim($this->state));
|
|
$animal->zip = trim($this->zip);
|
|
}
|
|
|
|
if (!$animal->save()) {
|
|
$this->addErrors($animal->getErrors());
|
|
$transaction->rollBack();
|
|
return false;
|
|
}
|
|
|
|
if (!$this->saveCustomFieldValues($animal)) {
|
|
$transaction->rollBack();
|
|
return false;
|
|
}
|
|
|
|
if (!$this->saveImageFields($animal)) {
|
|
$transaction->rollBack();
|
|
return false;
|
|
}
|
|
|
|
if (!$this->saveDisplayFieldOverrides($animal)) {
|
|
$transaction->rollBack();
|
|
return false;
|
|
}
|
|
|
|
$transaction->commit();
|
|
$this->animal = $animal;
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
$transaction->rollBack();
|
|
Yii::error($e, 'animal_management.animal_form_save');
|
|
|
|
$message = (string)$e->getMessage();
|
|
if (stripos($message, 'Permission denied') !== false) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save uploaded image due to file permission restrictions. Please contact your administrator.'));
|
|
} else {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Unexpected error while saving animal profile.'));
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getAnimal(): ?Animal
|
|
{
|
|
return $this->animal;
|
|
}
|
|
|
|
public function setAnimal(Animal $animal): void
|
|
{
|
|
$this->animal = $animal;
|
|
$this->name = (string)$animal->name;
|
|
$this->species = (string)$animal->species;
|
|
$this->breed = (string)$animal->breed;
|
|
$this->sex = (string)$animal->sex;
|
|
$this->status = (string)$animal->status;
|
|
$this->in_possession = ((int)$animal->in_possession) === 1;
|
|
$this->location_name = (string)$animal->location_name;
|
|
$this->city = (string)$animal->city;
|
|
$this->state = (string)$animal->state;
|
|
$this->zip = (string)$animal->zip;
|
|
$this->public_summary = (string)$animal->public_summary;
|
|
$this->medical_notes = (string)$animal->medical_notes;
|
|
|
|
$this->loadCustomFieldValues($animal);
|
|
$this->existingCoverImagePath = $this->getImageFieldValue($animal, 'cover_image_url');
|
|
$this->existingProfileImagePath = $this->getImageFieldValue($animal, 'profile_image_url');
|
|
$this->coverImageGalleryPath = (string)($this->existingCoverImagePath ?? '');
|
|
$this->profileImageGalleryPath = (string)($this->existingProfileImagePath ?? '');
|
|
$this->tileDisplayFields = $this->readDisplayFieldOverride($animal, 'tile_display_fields');
|
|
$this->heroDisplayFields = $this->readDisplayFieldOverride($animal, 'hero_display_fields');
|
|
}
|
|
|
|
public function getStatusOptions(): array
|
|
{
|
|
return Animal::statusOptions();
|
|
}
|
|
|
|
public function isFieldActive(string $fieldKey): bool
|
|
{
|
|
if (!isset($this->fieldConfig[$fieldKey])) {
|
|
return true;
|
|
}
|
|
|
|
return (int)$this->fieldConfig[$fieldKey]['is_active'] === 1;
|
|
}
|
|
|
|
public function isFieldRequired(string $fieldKey): bool
|
|
{
|
|
if (!isset($this->fieldConfig[$fieldKey])) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$this->fieldConfig[$fieldKey]['required'] === 1;
|
|
}
|
|
|
|
public function getFieldLabel(string $fieldKey): string
|
|
{
|
|
if (!isset($this->fieldConfig[$fieldKey])) {
|
|
return $this->getAttributeLabel($fieldKey);
|
|
}
|
|
|
|
$label = trim((string)$this->fieldConfig[$fieldKey]['label']);
|
|
return $label !== '' ? $label : $this->getAttributeLabel($fieldKey);
|
|
}
|
|
|
|
public function getCustomFieldDefinitions(): array
|
|
{
|
|
return $this->customFieldDefinitions;
|
|
}
|
|
|
|
public function getCustomFieldSelectOptions(string $fieldKey): array
|
|
{
|
|
if (!isset($this->customFieldDefinitions[$fieldKey])) {
|
|
return [];
|
|
}
|
|
|
|
return $this->parseSelectOptions((string)$this->customFieldDefinitions[$fieldKey]['options']);
|
|
}
|
|
|
|
public function getExistingCoverImagePath(): ?string
|
|
{
|
|
return $this->existingCoverImagePath;
|
|
}
|
|
|
|
public function getExistingProfileImagePath(): ?string
|
|
{
|
|
return $this->existingProfileImagePath;
|
|
}
|
|
|
|
public function getGalleryImageOptions(): array
|
|
{
|
|
if (!$this->animal instanceof Animal || Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) {
|
|
return [];
|
|
}
|
|
|
|
$items = AnimalGalleryItem::find()
|
|
->where(['animal_id' => (int)$this->animal->id])
|
|
->orderBy(['id' => SORT_DESC])
|
|
->all();
|
|
|
|
$options = [];
|
|
foreach ($items as $item) {
|
|
$url = trim((string)$item->getImageUrl());
|
|
if ($url === '' || isset($options[$url]) || !$this->isResolvableImageUrl($url)) {
|
|
continue;
|
|
}
|
|
|
|
$options[$url] = '#' . (int)$item->id . ' - ' . basename((string)parse_url($url, PHP_URL_PATH));
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
public function validateConfiguredRequiredFields(string $attribute): void
|
|
{
|
|
if (!$this->isFieldActive($attribute) || !$this->isFieldRequired($attribute)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->in_possession && in_array($attribute, ['location_name', 'city', 'state', 'zip'], true)) {
|
|
return;
|
|
}
|
|
|
|
$value = $this->{$attribute};
|
|
if (is_string($value)) {
|
|
$value = trim($value);
|
|
}
|
|
|
|
if ($value === '' || $value === null) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', [
|
|
'{field}' => $this->getFieldLabel($attribute),
|
|
]));
|
|
}
|
|
}
|
|
|
|
public function validateCustomFieldValues(string $attribute): void
|
|
{
|
|
foreach ($this->customFieldDefinitions as $fieldKey => $definition) {
|
|
$value = $this->customFields[$fieldKey] ?? null;
|
|
$label = (string)$definition['label'];
|
|
$inputType = (string)$definition['input_type'];
|
|
$required = ((int)$definition['required']) === 1;
|
|
|
|
if ($required && $this->isCustomValueEmpty($value, $inputType)) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} is required.', ['{field}' => $label]));
|
|
continue;
|
|
}
|
|
|
|
if ($this->isCustomValueEmpty($value, $inputType)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized = is_array($value) ? '' : trim((string)$value);
|
|
|
|
if ($inputType === 'number' && !is_numeric($normalized)) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid number.', ['{field}' => $label]));
|
|
continue;
|
|
}
|
|
|
|
if (($inputType === 'date' || $inputType === 'datetime') && strtotime($normalized) === false) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', '{field} must be a valid date/time.', ['{field}' => $label]));
|
|
continue;
|
|
}
|
|
|
|
if ($inputType === 'select') {
|
|
$options = $this->getCustomFieldSelectOptions($fieldKey);
|
|
if (!empty($options) && !array_key_exists($normalized, $options)) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Invalid selection for {field}.', ['{field}' => $label]));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function validateGalleryImageSelection(string $attribute): void
|
|
{
|
|
$selectedPath = trim((string)$this->{$attribute});
|
|
if ($selectedPath === '') {
|
|
return;
|
|
}
|
|
|
|
if (!$this->animal instanceof Animal) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Gallery image selection is available after creating the animal profile.'));
|
|
return;
|
|
}
|
|
|
|
if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Gallery storage is unavailable.'));
|
|
return;
|
|
}
|
|
|
|
$options = $this->getGalleryImageOptions();
|
|
$exists = isset($options[$selectedPath]);
|
|
|
|
if (!$exists) {
|
|
$this->addError($attribute, Yii::t('AnimalManagementModule.base', 'Selected gallery image is not available for this animal.'));
|
|
}
|
|
}
|
|
|
|
private function bootstrapFieldConfig(): void
|
|
{
|
|
$this->fieldConfig = $this->defaultFieldConfig();
|
|
|
|
if (!class_exists(RescueFieldDefinition::class)) {
|
|
return;
|
|
}
|
|
|
|
if (Yii::$app->db->schema->getTableSchema('rescue_field_definition', true) === null) {
|
|
return;
|
|
}
|
|
|
|
$definitions = RescueFieldDefinition::find()
|
|
->where(['module_id' => self::MODULE_ID])
|
|
->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_ASC])
|
|
->all();
|
|
|
|
foreach ($definitions as $definition) {
|
|
$fieldKey = (string)$definition->field_key;
|
|
if (isset($this->fieldConfig[$fieldKey])) {
|
|
$this->fieldConfig[$fieldKey] = [
|
|
'label' => (string)$definition->label,
|
|
'required' => (int)$definition->required,
|
|
'is_active' => (int)$definition->is_active,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
if ((int)$definition->is_active !== 1) {
|
|
continue;
|
|
}
|
|
|
|
if ((string)$definition->group_key !== 'animal_profile') {
|
|
continue;
|
|
}
|
|
|
|
if (in_array($fieldKey, ['cover_image_url', 'profile_image_url', 'photo_url', 'image_url', 'profile_image', 'photo', 'tile_display_fields', 'hero_display_fields'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$this->customFieldDefinitions[$fieldKey] = [
|
|
'id' => (int)$definition->id,
|
|
'field_key' => $fieldKey,
|
|
'label' => trim((string)$definition->label) !== '' ? (string)$definition->label : $fieldKey,
|
|
'input_type' => (string)$definition->input_type,
|
|
'required' => (int)$definition->required,
|
|
'visibility' => (string)$definition->visibility,
|
|
'options' => (string)$definition->options,
|
|
'sort_order' => (int)$definition->sort_order,
|
|
];
|
|
}
|
|
}
|
|
|
|
private function saveCustomFieldValues(Animal $animal): bool
|
|
{
|
|
if (empty($this->customFieldDefinitions)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($this->customFieldDefinitions as $fieldKey => $definition) {
|
|
$fieldDefinitionId = (int)$definition['id'];
|
|
if ($fieldDefinitionId === 0) {
|
|
continue;
|
|
}
|
|
|
|
$inputType = (string)$definition['input_type'];
|
|
$rawValue = $this->customFields[$fieldKey] ?? null;
|
|
$valueText = $this->normalizeCustomValue($rawValue, $inputType);
|
|
|
|
$record = AnimalFieldValue::findOne([
|
|
'animal_id' => (int)$animal->id,
|
|
'field_definition_id' => $fieldDefinitionId,
|
|
]);
|
|
|
|
if ($this->isCustomValueEmpty($valueText, $inputType)) {
|
|
if ($record instanceof AnimalFieldValue) {
|
|
$record->delete();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!$record instanceof AnimalFieldValue) {
|
|
$record = new AnimalFieldValue();
|
|
$record->animal_id = (int)$animal->id;
|
|
$record->field_definition_id = $fieldDefinitionId;
|
|
}
|
|
|
|
$record->value_text = $valueText;
|
|
$record->value_json = null;
|
|
|
|
if (!$record->save()) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save custom field {field}.', [
|
|
'{field}' => (string)$definition['label'],
|
|
]));
|
|
foreach ($record->getFirstErrors() as $error) {
|
|
$this->addError('customFields', $error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($this->hasErrors('customFields')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function loadCustomFieldValues(Animal $animal): void
|
|
{
|
|
if (empty($this->customFieldDefinitions)) {
|
|
return;
|
|
}
|
|
|
|
$existingValues = AnimalFieldValue::find()
|
|
->where(['animal_id' => (int)$animal->id])
|
|
->indexBy('field_definition_id')
|
|
->all();
|
|
|
|
foreach ($this->customFieldDefinitions as $fieldKey => $definition) {
|
|
$fieldDefinitionId = (int)$definition['id'];
|
|
if ($fieldDefinitionId === 0 || !isset($existingValues[$fieldDefinitionId])) {
|
|
continue;
|
|
}
|
|
|
|
$value = (string)$existingValues[$fieldDefinitionId]->value_text;
|
|
if ((string)$definition['input_type'] === 'boolean') {
|
|
$this->customFields[$fieldKey] = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true) ? '1' : '0';
|
|
} else {
|
|
$this->customFields[$fieldKey] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
private function normalizeCustomValue($value, string $inputType): ?string
|
|
{
|
|
if ($inputType === 'boolean') {
|
|
return !empty($value) ? '1' : '0';
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
return Json::encode($value);
|
|
}
|
|
|
|
return trim((string)$value);
|
|
}
|
|
|
|
private function isCustomValueEmpty($value, string $inputType): bool
|
|
{
|
|
if ($inputType === 'boolean') {
|
|
return false;
|
|
}
|
|
|
|
if (is_array($value)) {
|
|
return empty($value);
|
|
}
|
|
|
|
return trim((string)$value) === '';
|
|
}
|
|
|
|
private function parseSelectOptions(string $options): array
|
|
{
|
|
$options = trim($options);
|
|
if ($options === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = null;
|
|
try {
|
|
$decoded = Json::decode($options, true);
|
|
} catch (\Throwable $e) {
|
|
$decoded = null;
|
|
}
|
|
|
|
if (is_array($decoded)) {
|
|
$result = [];
|
|
if (array_values($decoded) === $decoded) {
|
|
foreach ($decoded as $item) {
|
|
$item = (string)$item;
|
|
if ($item === '') {
|
|
continue;
|
|
}
|
|
$result[$item] = $item;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
foreach ($decoded as $key => $value) {
|
|
$key = (string)$key;
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
$result[$key] = (string)$value;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
$result = [];
|
|
foreach (preg_split('/[\r\n,]+/', $options) as $item) {
|
|
$item = trim((string)$item);
|
|
if ($item === '') {
|
|
continue;
|
|
}
|
|
|
|
$result[$item] = $item;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function defaultFieldConfig(): array
|
|
{
|
|
return [
|
|
'name' => ['label' => 'Name', 'required' => 0, 'is_active' => 1],
|
|
'species' => ['label' => 'Species', 'required' => 1, 'is_active' => 1],
|
|
'breed' => ['label' => 'Breed', 'required' => 0, 'is_active' => 1],
|
|
'sex' => ['label' => 'Sex', 'required' => 0, 'is_active' => 1],
|
|
'status' => ['label' => 'Status', 'required' => 1, 'is_active' => 1],
|
|
'in_possession' => ['label' => 'In possession', 'required' => 1, 'is_active' => 1],
|
|
'location_name' => ['label' => 'Location name', 'required' => 0, 'is_active' => 1],
|
|
'city' => ['label' => 'City', 'required' => 0, 'is_active' => 1],
|
|
'state' => ['label' => 'State', 'required' => 0, 'is_active' => 1],
|
|
'zip' => ['label' => 'ZIP', 'required' => 0, 'is_active' => 1],
|
|
'public_summary' => ['label' => 'Public summary', 'required' => 0, 'is_active' => 1],
|
|
'medical_notes' => ['label' => 'Medical notes', 'required' => 0, 'is_active' => 1],
|
|
];
|
|
}
|
|
|
|
private function getDefaultLocationName(): string
|
|
{
|
|
if ($this->contentContainer instanceof Space) {
|
|
return trim((string)$this->contentContainer->name);
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function saveImageFields(Animal $animal): bool
|
|
{
|
|
if ($this->removeCoverImage && !empty($this->existingCoverImagePath)) {
|
|
$this->deleteStoredFile($this->existingCoverImagePath, (int)$animal->id);
|
|
if (!$this->setImageFieldValue($animal, 'cover_image_url', null, 'Cover image URL', 230)) {
|
|
return false;
|
|
}
|
|
$this->existingCoverImagePath = null;
|
|
}
|
|
|
|
if ($this->removeProfileImage && !empty($this->existingProfileImagePath)) {
|
|
$this->deleteStoredFile($this->existingProfileImagePath, (int)$animal->id);
|
|
if (!$this->setImageFieldValue($animal, 'profile_image_url', null, 'Profile image URL', 240)) {
|
|
return false;
|
|
}
|
|
$this->existingProfileImagePath = null;
|
|
}
|
|
|
|
if ($this->coverImageFile instanceof UploadedFile) {
|
|
if (!empty($this->existingCoverImagePath)) {
|
|
$this->deleteStoredFile($this->existingCoverImagePath, (int)$animal->id);
|
|
}
|
|
|
|
$stored = $this->storeImage($this->coverImageFile, 'cover', $animal);
|
|
if ($stored === null) {
|
|
$this->addError('coverImageFile', Yii::t('AnimalManagementModule.base', 'Could not upload cover image.'));
|
|
return false;
|
|
}
|
|
|
|
if (!$this->setImageFieldValue($animal, 'cover_image_url', $stored, 'Cover image URL', 230)) {
|
|
return false;
|
|
}
|
|
|
|
$this->existingCoverImagePath = $stored;
|
|
}
|
|
|
|
$selectedCoverFromGallery = trim((string)$this->coverImageGalleryPath);
|
|
if (!$this->removeCoverImage && $selectedCoverFromGallery !== '' && !($this->coverImageFile instanceof UploadedFile)) {
|
|
if (!$this->setImageFieldValue($animal, 'cover_image_url', $selectedCoverFromGallery, 'Cover image URL', 230)) {
|
|
return false;
|
|
}
|
|
|
|
$this->existingCoverImagePath = $selectedCoverFromGallery;
|
|
}
|
|
|
|
if ($this->profileImageFile instanceof UploadedFile) {
|
|
if (!empty($this->existingProfileImagePath)) {
|
|
$this->deleteStoredFile($this->existingProfileImagePath, (int)$animal->id);
|
|
}
|
|
|
|
$stored = $this->storeImage($this->profileImageFile, 'profile', $animal);
|
|
if ($stored === null) {
|
|
$this->addError('profileImageFile', Yii::t('AnimalManagementModule.base', 'Could not upload profile image.'));
|
|
return false;
|
|
}
|
|
|
|
if (!$this->setImageFieldValue($animal, 'profile_image_url', $stored, 'Profile image URL', 240)) {
|
|
return false;
|
|
}
|
|
|
|
$this->existingProfileImagePath = $stored;
|
|
}
|
|
|
|
$selectedProfileFromGallery = trim((string)$this->profileImageGalleryPath);
|
|
if (!$this->removeProfileImage && $selectedProfileFromGallery !== '' && !($this->profileImageFile instanceof UploadedFile)) {
|
|
if (!$this->setImageFieldValue($animal, 'profile_image_url', $selectedProfileFromGallery, 'Profile image URL', 240)) {
|
|
return false;
|
|
}
|
|
|
|
$this->existingProfileImagePath = $selectedProfileFromGallery;
|
|
}
|
|
|
|
if (!$this->syncPrimaryImagesToGallery($animal)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function readDisplayFieldOverride(Animal $animal, string $fieldKey): array
|
|
{
|
|
$raw = $this->getImageFieldValue($animal, $fieldKey);
|
|
if ($raw === null) {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode((string)$raw, true);
|
|
if (!is_array($decoded)) {
|
|
$decoded = array_map('trim', explode(',', (string)$raw));
|
|
}
|
|
|
|
$allowed = array_keys(DisplaySettingsForm::fieldOptions());
|
|
$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;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function saveDisplayFieldOverrides(Animal $animal): bool
|
|
{
|
|
$tileFields = $this->normalizeDisplayFields($this->tileDisplayFields);
|
|
$heroFields = $this->normalizeDisplayFields($this->heroDisplayFields);
|
|
|
|
if (!$this->setImageFieldValue($animal, 'tile_display_fields', empty($tileFields) ? null : json_encode($tileFields), 'Tile Display Fields', 250)) {
|
|
return false;
|
|
}
|
|
|
|
if (!$this->setImageFieldValue($animal, 'hero_display_fields', empty($heroFields) ? null : json_encode($heroFields), 'Hero Display Fields', 251)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function normalizeDisplayFields(array $fields): array
|
|
{
|
|
$allowed = array_keys(DisplaySettingsForm::fieldOptions());
|
|
$normalized = [];
|
|
foreach ($fields as $field) {
|
|
$field = trim((string)$field);
|
|
if ($field === '' || !in_array($field, $allowed, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (!in_array($field, $normalized, true)) {
|
|
$normalized[] = $field;
|
|
}
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function storeImage(UploadedFile $file, string $prefix, Animal $animal): ?string
|
|
{
|
|
$random = Yii::$app->security->generateRandomString(8);
|
|
$extension = strtolower((string)$file->extension);
|
|
$fileName = $prefix . '-' . time() . '-' . $random . '.' . $extension;
|
|
|
|
$candidateDirs = [
|
|
'/uploads/animal-management/animals/' . (int)$animal->id,
|
|
'/uploads/animal-management-runtime/animals/' . (int)$animal->id,
|
|
'/uploads/animal-media/animals/' . (int)$animal->id,
|
|
];
|
|
|
|
foreach ($candidateDirs as $relativeDir) {
|
|
$absoluteDir = Yii::getAlias('@webroot') . $relativeDir;
|
|
try {
|
|
FileHelper::createDirectory($absoluteDir, 0775, true);
|
|
} catch (\Throwable $e) {
|
|
Yii::warning($e->getMessage(), 'animal_management.image_upload_dir');
|
|
continue;
|
|
}
|
|
|
|
if (!is_dir($absoluteDir) || !is_writable($absoluteDir)) {
|
|
continue;
|
|
}
|
|
|
|
$absolutePath = $absoluteDir . '/' . $fileName;
|
|
if ($file->saveAs($absolutePath)) {
|
|
return $relativeDir . '/' . $fileName;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function deleteStoredFile(?string $relativePath, int $animalId): void
|
|
{
|
|
if (empty($relativePath)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->isGalleryReferencedPath($animalId, $relativePath)) {
|
|
return;
|
|
}
|
|
|
|
$absolutePath = Yii::getAlias('@webroot') . $relativePath;
|
|
if (is_file($absolutePath)) {
|
|
@unlink($absolutePath);
|
|
}
|
|
}
|
|
|
|
private function getImageFieldValue(Animal $animal, string $fieldKey): ?string
|
|
{
|
|
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 null;
|
|
}
|
|
|
|
$definition = RescueFieldDefinition::findOne([
|
|
'module_id' => self::MODULE_ID,
|
|
'group_key' => 'animal_profile',
|
|
'field_key' => $fieldKey,
|
|
]);
|
|
|
|
if (!$definition instanceof RescueFieldDefinition) {
|
|
return null;
|
|
}
|
|
|
|
$fieldValue = AnimalFieldValue::findOne([
|
|
'animal_id' => (int)$animal->id,
|
|
'field_definition_id' => (int)$definition->id,
|
|
]);
|
|
|
|
if (!$fieldValue instanceof AnimalFieldValue) {
|
|
return null;
|
|
}
|
|
|
|
$value = trim((string)$fieldValue->value_text);
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
|
|
private function setImageFieldValue(Animal $animal, string $fieldKey, ?string $path, string $label, int $sortOrder): bool
|
|
{
|
|
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 true;
|
|
}
|
|
|
|
$definition = RescueFieldDefinition::findOne([
|
|
'module_id' => self::MODULE_ID,
|
|
'group_key' => 'animal_profile',
|
|
'field_key' => $fieldKey,
|
|
]);
|
|
|
|
$isInternalDisplayOverride = in_array($fieldKey, ['tile_display_fields', 'hero_display_fields'], true);
|
|
|
|
if (!$definition instanceof RescueFieldDefinition) {
|
|
$definition = new RescueFieldDefinition();
|
|
$definition->module_id = self::MODULE_ID;
|
|
$definition->group_key = 'animal_profile';
|
|
$definition->field_key = $fieldKey;
|
|
$definition->label = $label;
|
|
$definition->input_type = 'text';
|
|
$definition->required = 0;
|
|
$definition->is_core = $isInternalDisplayOverride ? 1 : 0;
|
|
$definition->is_active = 1;
|
|
$definition->visibility = $isInternalDisplayOverride ? 'internal' : 'public';
|
|
$definition->options = '{}';
|
|
$definition->sort_order = $sortOrder;
|
|
$definition->created_at = date('Y-m-d H:i:s');
|
|
$definition->updated_at = date('Y-m-d H:i:s');
|
|
|
|
if (!$definition->save()) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not create image field definition.'));
|
|
return false;
|
|
}
|
|
} elseif ($isInternalDisplayOverride) {
|
|
$definition->label = $label;
|
|
$definition->input_type = 'text';
|
|
$definition->required = 0;
|
|
$definition->is_core = 1;
|
|
$definition->is_active = 1;
|
|
$definition->visibility = 'internal';
|
|
$definition->sort_order = $sortOrder;
|
|
$definition->updated_at = date('Y-m-d H:i:s');
|
|
|
|
if (!$definition->save()) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not update display override field definition.'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$record = AnimalFieldValue::findOne([
|
|
'animal_id' => (int)$animal->id,
|
|
'field_definition_id' => (int)$definition->id,
|
|
]);
|
|
|
|
$path = trim((string)$path);
|
|
if ($path === '') {
|
|
if ($record instanceof AnimalFieldValue) {
|
|
$record->delete();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (!$record instanceof AnimalFieldValue) {
|
|
$record = new AnimalFieldValue();
|
|
$record->animal_id = (int)$animal->id;
|
|
$record->field_definition_id = (int)$definition->id;
|
|
}
|
|
|
|
$record->value_text = $path;
|
|
$record->value_json = null;
|
|
|
|
if (!$record->save()) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not save image path field value.'));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function syncPrimaryImagesToGallery(Animal $animal): bool
|
|
{
|
|
if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) {
|
|
return true;
|
|
}
|
|
|
|
$coverPath = $this->getImageFieldValue($animal, 'cover_image_url');
|
|
$profilePath = $this->getImageFieldValue($animal, 'profile_image_url');
|
|
|
|
if (!$this->ensureGalleryItemForPath($animal, $coverPath, 'cover')) {
|
|
return false;
|
|
}
|
|
|
|
if (!$this->ensureGalleryItemForPath($animal, $profilePath, 'profile')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function ensureGalleryItemForPath(Animal $animal, ?string $imagePath, string $sourceType): bool
|
|
{
|
|
$imagePath = trim((string)$imagePath);
|
|
if ($imagePath === '') {
|
|
return true;
|
|
}
|
|
|
|
if (!$this->isResolvableImageUrl($imagePath)) {
|
|
return true;
|
|
}
|
|
|
|
$targetHash = $this->computeImageContentHash($imagePath);
|
|
|
|
$exists = AnimalGalleryItem::find()
|
|
->where(['animal_id' => (int)$animal->id, 'file_path' => $imagePath])
|
|
->exists();
|
|
|
|
if (!$exists) {
|
|
$items = AnimalGalleryItem::find()->where(['animal_id' => (int)$animal->id])->all();
|
|
foreach ($items as $item) {
|
|
$itemUrl = trim((string)$item->getImageUrl());
|
|
if ($itemUrl === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($itemUrl === $imagePath) {
|
|
$exists = true;
|
|
break;
|
|
}
|
|
|
|
if ($targetHash !== null) {
|
|
$itemHash = $this->computeImageContentHash($itemUrl);
|
|
if ($itemHash !== null && hash_equals($targetHash, $itemHash)) {
|
|
$exists = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($exists) {
|
|
return true;
|
|
}
|
|
|
|
$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;
|
|
|
|
if (!$item->save()) {
|
|
$this->addError('customFields', Yii::t('AnimalManagementModule.base', 'Could not add profile image to gallery.'));
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function isGalleryReferencedPath(int $animalId, string $relativePath): bool
|
|
{
|
|
if (Yii::$app->db->schema->getTableSchema('rescue_animal_gallery_item', true) === null) {
|
|
return false;
|
|
}
|
|
|
|
return AnimalGalleryItem::find()
|
|
->where(['animal_id' => $animalId, 'file_path' => $relativePath])
|
|
->exists();
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|