chore: sync module from working instance and add install guide

This commit is contained in:
Kelin Rescue Hub
2026-04-09 14:11:34 -04:00
parent 20adb1bd1e
commit 039c12233e
23 changed files with 4577 additions and 394 deletions

View File

@@ -42,6 +42,8 @@ class AnimalsController extends ContentContainerController
'add-medical-visit' => ['post'],
'add-progress-update' => ['post'],
'add-gallery-images' => ['post'],
'add-gallery-images-inline' => ['get', 'post'],
'transfer-inline' => ['get', 'post'],
'remove-gallery-image' => ['post'],
'transfer-respond' => ['post'],
'transfer-complete' => ['post'],
@@ -55,7 +57,7 @@ class AnimalsController extends ContentContainerController
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']],
[ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN, Space::USERGROUP_MODERATOR], 'actions' => ['create', 'edit', 'delete', 'transfer', 'transfer-inline', 'add-medical-visit', 'add-medical-visit-inline', 'edit-medical-visit', 'add-progress-update', 'add-progress-update-inline', 'edit-progress-update', 'add-gallery-images', 'add-gallery-images-inline', 'remove-gallery-image', 'transfer-respond', 'transfer-complete', 'transfer-cancel']],
];
}
@@ -68,9 +70,13 @@ class AnimalsController extends ContentContainerController
GalleryIntegrationService::syncSpaceAnimalGalleries($this->contentContainer);
}
$viewMode = trim((string)Yii::$app->request->get('view', 'tiles'));
if (!in_array($viewMode, ['tiles', 'table'], true)) {
if (!in_array($viewMode, ['tiles', 'tiles2', 'rows', 'tablet', 'table'], true)) {
$viewMode = 'tiles';
}
$isFocusModeRequest = trim((string)Yii::$app->request->get('focus', '')) === '1';
if ($isFocusModeRequest) {
$viewMode = 'tablet';
}
$sortKey = trim((string)Yii::$app->request->get('sort', 'updated_at'));
$sortDirection = strtolower(trim((string)Yii::$app->request->get('direction', 'desc'))) === 'asc' ? 'asc' : 'desc';
@@ -165,6 +171,33 @@ class AnimalsController extends ContentContainerController
return (int)$animal->id;
}, $animals);
$animalDonationGoalsByAnimal = [];
$donationGoalClass = 'humhub\\modules\\donations\\models\\DonationGoal';
if (!empty($animalIds)
&& $this->contentContainer instanceof Space
&& $this->contentContainer->moduleManager->isEnabled('donations')
&& class_exists($donationGoalClass)
&& Yii::$app->db->schema->getTableSchema($donationGoalClass::tableName(), true) !== null
) {
$donationGoals = $donationGoalClass::find()
->where([
'contentcontainer_id' => $this->contentContainer->contentcontainer_id,
'goal_type' => $donationGoalClass::TYPE_ANIMAL,
])
->andWhere(['target_animal_id' => $animalIds])
->orderBy(['is_active' => SORT_DESC, 'id' => SORT_DESC])
->all();
foreach ($donationGoals as $goal) {
$animalId = (int)$goal->target_animal_id;
if ($animalId <= 0 || isset($animalDonationGoalsByAnimal[$animalId])) {
continue;
}
$animalDonationGoalsByAnimal[$animalId] = $goal;
}
}
$animalImageUrls = $this->resolveAnimalImageUrls($animalIds, ['profile_image_url', 'profile_image', 'photo_url', 'image_url', 'photo'], false);
$tileFieldOverrides = $this->resolveDisplayFieldOverrides($animalIds, 'tile_display_fields');
@@ -245,6 +278,7 @@ class AnimalsController extends ContentContainerController
'animalImageUrls' => $animalImageUrls,
'tileFields' => $this->getTileFieldSettings(),
'tileFieldOverrides' => $tileFieldOverrides,
'animalDonationGoalsByAnimal' => $animalDonationGoalsByAnimal,
'space' => $this->contentContainer,
'canManage' => $this->canManageAnimals(),
'incomingTransfers' => $incomingTransfers,
@@ -257,6 +291,10 @@ class AnimalsController extends ContentContainerController
{
$animal = $this->findAnimal($id);
$canManage = $this->canManageAnimals();
$layoutMode = trim((string)Yii::$app->request->get('layout', 'default'));
if (!in_array($layoutMode, ['default', 'tablet'], true)) {
$layoutMode = 'default';
}
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($animal, $this->contentContainer);
@@ -279,8 +317,10 @@ class AnimalsController extends ContentContainerController
$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] ?? ''));
$animalCoverImageUrls = $this->resolveAnimalImageUrls([(int)$animal->id], ['cover_image_url', 'image_url', 'photo_url', 'photo'], false);
$animalProfileImageUrls = $this->resolveAnimalImageUrls([(int)$animal->id], ['profile_image_url', 'profile_image', 'image_url', 'photo_url', 'photo'], false);
$animalCoverImageUrl = trim((string)($animalCoverImageUrls[(int)$animal->id] ?? ''));
$animalProfileImageUrl = trim((string)($animalProfileImageUrls[(int)$animal->id] ?? ''));
$detailHeroFields = $this->getDetailHeroFieldSettings();
$heroOverrides = $this->resolveDisplayFieldOverrides([(int)$animal->id], 'hero_display_fields');
if (!empty($heroOverrides[(int)$animal->id])) {
@@ -298,7 +338,9 @@ class AnimalsController extends ContentContainerController
'galleryItems' => $galleryItems,
'customFieldValues' => $customFieldValues,
'animalCoverImageUrl' => $animalCoverImageUrl,
'animalProfileImageUrl' => $animalProfileImageUrl,
'detailHeroFields' => $detailHeroFields,
'layoutMode' => $layoutMode,
]);
}
@@ -352,6 +394,7 @@ class AnimalsController extends ContentContainerController
public function actionCreate()
{
$model = new AnimalForm(['contentContainer' => $this->contentContainer]);
$intakeGoalPayload = (array)Yii::$app->request->post('IntakeGoal', []);
if (Yii::$app->request->isPost) {
$model->load(Yii::$app->request->post());
@@ -368,6 +411,11 @@ class AnimalsController extends ContentContainerController
if ($this->contentContainer instanceof Space) {
GalleryIntegrationService::ensureAnimalGallery($savedAnimal, $this->contentContainer);
}
$goalError = $this->createIntakeAnimalGoal($savedAnimal, $intakeGoalPayload);
if ($goalError !== null) {
$this->view->error($goalError);
}
}
$this->view->success(Yii::t('AnimalManagementModule.base', 'Animal created.'));
@@ -379,6 +427,7 @@ class AnimalsController extends ContentContainerController
'space' => $this->contentContainer,
'isEdit' => false,
'animal' => null,
'showIntakeGoalSection' => $this->canUseIntakeGoalSection(),
]);
}
@@ -460,6 +509,7 @@ class AnimalsController extends ContentContainerController
public function actionTransfer(int $id)
{
$animal = $this->findAnimal($id);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
$form = new TransferRequestForm([
'animal' => $animal,
@@ -468,107 +518,74 @@ class AnimalsController extends ContentContainerController
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->redirectToAnimalPage((int)$animal->id, $returnTo);
}
return $this->render('transfer', [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'isInline' => false,
'returnTo' => $returnTo,
]);
}
public function actionTransferInline(int $id)
{
$animal = $this->findAnimal($id);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
$isInline = (int)Yii::$app->request->get('inline', 1) === 1;
$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.'));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'transfer-add-inline',
'refreshSelectors' => ['#animal-transfer-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'model' => $form,
'isInline' => $isInline,
'returnTo' => $returnTo,
];
if ($isInline) {
return $this->renderAjax('transfer', $params);
}
return $this->render('transfer', $params);
}
public function actionAddGalleryImages(int $id)
{
$animal = $this->findAnimal($id);
$uploadedFiles = UploadedFile::getInstancesByName('galleryImages');
$maxUploadCount = 10;
$result = $this->processGalleryUploads($animal);
if (empty($uploadedFiles)) {
if (!$result['hadSelection']) {
$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]));
if ($result['wasLimited']) {
$this->view->info(Yii::t('AnimalManagementModule.base', 'Only the first {count} selected images were processed.', ['count' => $result['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 ($result['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]));
$this->view->success(Yii::t('AnimalManagementModule.base', '{count} image(s) added to gallery.', ['count' => $result['added']]));
} else {
$this->view->error(Yii::t('AnimalManagementModule.base', 'No gallery images were added. Check image type/size requirements.'));
}
@@ -576,6 +593,63 @@ class AnimalsController extends ContentContainerController
return $this->redirect($this->contentContainer->createUrl('/animal_management/animals/view', ['id' => $animal->id]) . '#animal-gallery');
}
public function actionAddGalleryImagesInline(int $id)
{
$animal = $this->findAnimal($id);
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'view'));
$isInline = (int)Yii::$app->request->get('inline', 1) === 1;
$maxUploadCount = 10;
$errorMessage = '';
$infoMessage = '';
if (Yii::$app->request->isPost) {
$result = $this->processGalleryUploads($animal);
if (!$result['hadSelection']) {
$errorMessage = Yii::t('AnimalManagementModule.base', 'No gallery images were selected.');
} else {
if ($result['wasLimited']) {
$infoMessage = Yii::t('AnimalManagementModule.base', 'Only the first {count} selected images were processed.', ['count' => $result['maxUploadCount']]);
}
if ($result['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' => $result['added']]));
if ($isInline) {
return $this->renderAjax('inline-edit-result', [
'collapseId' => 'gallery-add-inline',
'refreshSelectors' => ['#animal-gallery-panel'],
]);
}
return $this->redirectToAnimalPage((int)$animal->id, $returnTo);
}
$errorMessage = Yii::t('AnimalManagementModule.base', 'No gallery images were added. Check image type/size requirements.');
}
}
$galleryItems = $animal->getGalleryItems()->orderBy(['id' => SORT_DESC])->limit(120)->all();
$params = [
'space' => $this->contentContainer,
'animal' => $animal,
'galleryItems' => $galleryItems,
'isInline' => $isInline,
'returnTo' => $returnTo,
'maxUploadCount' => $maxUploadCount,
'errorMessage' => $errorMessage,
'infoMessage' => $infoMessage,
];
if ($isInline) {
return $this->renderAjax('add-gallery-images-inline', $params);
}
return $this->render('add-gallery-images-inline', $params);
}
public function actionRemoveGalleryImage(int $id, int $galleryId)
{
$animal = $this->findAnimal($id);
@@ -646,6 +720,9 @@ class AnimalsController extends ContentContainerController
$animal = $this->findAnimal($id);
$this->ensureMedicalMediaFieldDefinition();
$form = new AnimalMedicalVisitForm(['animal' => $animal]);
if (trim((string)$form->visit_at) === '') {
$form->visit_at = date('Y-m-d\TH:i');
}
$returnTo = (string)Yii::$app->request->post('returnTo', Yii::$app->request->get('returnTo', 'medical-visits'));
$isInline = (int)Yii::$app->request->get('inline', 1) === 1;
@@ -1258,6 +1335,105 @@ class AnimalsController extends ContentContainerController
return $result;
}
private function processGalleryUploads(Animal $animal): array
{
$uploadedFiles = UploadedFile::getInstancesByName('galleryImages');
$maxUploadCount = 10;
$hadSelection = !empty($uploadedFiles);
$wasLimited = false;
if (!$hadSelection) {
return [
'hadSelection' => false,
'wasLimited' => false,
'maxUploadCount' => $maxUploadCount,
'added' => 0,
];
}
if (count($uploadedFiles) > $maxUploadCount) {
$uploadedFiles = array_slice($uploadedFiles, 0, $maxUploadCount);
$wasLimited = true;
}
$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;
}
}
}
return [
'hadSelection' => true,
'wasLimited' => $wasLimited,
'maxUploadCount' => $maxUploadCount,
'added' => $added,
];
}
private function storeGalleryUpload(Animal $animal, UploadedFile $file): ?string
{
$random = Yii::$app->security->generateRandomString(8);
@@ -1707,4 +1883,74 @@ class AnimalsController extends ContentContainerController
$item->created_by = Yii::$app->user->isGuest ? null : (int)Yii::$app->user->id;
$item->save();
}
private function canUseIntakeGoalSection(): bool
{
if (!($this->contentContainer instanceof Space)) {
return false;
}
if (!$this->contentContainer->moduleManager->isEnabled('donations')) {
return false;
}
$goalFormClass = 'humhub\\modules\\donations\\models\\forms\\DonationGoalForm';
$goalClass = 'humhub\\modules\\donations\\models\\DonationGoal';
if (!class_exists($goalFormClass) || !class_exists($goalClass)) {
return false;
}
return Yii::$app->db->schema->getTableSchema($goalClass::tableName(), true) !== null;
}
private function createIntakeAnimalGoal(Animal $animal, array $payload): ?string
{
if ((int)($payload['enabled'] ?? 0) !== 1) {
return null;
}
if (!$this->canUseIntakeGoalSection()) {
return Yii::t('AnimalManagementModule.base', 'Animal was created, but donation goal setup is not available in this space.');
}
$goalFormClass = 'humhub\\modules\\donations\\models\\forms\\DonationGoalForm';
$goalClass = 'humhub\\modules\\donations\\models\\DonationGoal';
$targetAmount = max(0.0, (float)($payload['target_amount'] ?? 0));
if ($targetAmount <= 0) {
return Yii::t('AnimalManagementModule.base', 'Animal was created, but the intake donation goal target must be greater than zero.');
}
$title = trim((string)($payload['title'] ?? ''));
if ($title === '') {
$title = Yii::t('AnimalManagementModule.base', '{animalName} Care Fund', ['animalName' => $animal->getDisplayName()]);
}
$description = trim((string)($payload['description'] ?? ''));
$imagePath = trim((string)($payload['image_path'] ?? ''));
$isActive = (int)($payload['is_active'] ?? 1) === 1;
$goalForm = new $goalFormClass();
$goalForm->contentContainer = $this->contentContainer;
$goalForm->goal_type = $goalClass::TYPE_ANIMAL;
$goalForm->target_animal_id = (int)$animal->id;
$goalForm->title = $title;
$goalForm->description = $description;
$goalForm->target_amount = $targetAmount;
$goalForm->is_active = $isActive;
if ($imagePath !== '') {
$goalForm->imageGalleryPath = $imagePath;
}
$savedGoal = $goalForm->save();
if ($savedGoal === null) {
$firstErrors = $goalForm->getFirstErrors();
$errorText = !empty($firstErrors) ? implode(' ', array_values($firstErrors)) : Yii::t('AnimalManagementModule.base', 'Unknown donation goal error.');
return Yii::t('AnimalManagementModule.base', 'Animal was created, but donation goal could not be saved: {error}', ['error' => $errorText]);
}
return null;
}
}

View File

@@ -5,11 +5,13 @@ namespace humhub\modules\animal_management\controllers;
use humhub\modules\animal_management\models\Animal;
use humhub\modules\animal_management\models\forms\DisplaySettingsForm;
use humhub\modules\animal_management\models\forms\FieldDefinitionSettingsForm;
use humhub\modules\animal_management\services\ModuleSetupService;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\content\components\ContentContainerControllerAccess;
use humhub\modules\rescue_foundation\widgets\RescueSettingsMenu;
use humhub\modules\space\models\Space;
use Yii;
use yii\web\BadRequestHttpException;
class SettingsController extends ContentContainerController
{
@@ -53,6 +55,37 @@ class SettingsController extends ContentContainerController
'animalCount' => (int)$animalCount,
'fieldSettingsForm' => $fieldSettingsForm,
'displaySettingsForm' => $displaySettingsForm,
'space' => $this->contentContainer,
]);
}
public function actionSetup()
{
if (!Yii::$app->request->isPost) {
throw new BadRequestHttpException('Invalid request method.');
}
if (!$this->contentContainer instanceof Space) {
$this->view->error(Yii::t('AnimalManagementModule.base', 'Setup can only be run inside a space.'));
return $this->redirect($this->contentContainer->createUrl('/animal_management/settings'));
}
try {
$result = ModuleSetupService::runForSpace($this->contentContainer);
$appliedCount = count($result['applied'] ?? []);
if ($appliedCount > 0) {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Setup completed. Applied {count} migration(s).', [
'count' => $appliedCount,
]));
} else {
$this->view->success(Yii::t('AnimalManagementModule.base', 'Setup completed. No pending migrations were found.'));
}
} catch (\Throwable $e) {
Yii::error($e, 'animal_management.setup');
$this->view->error(Yii::t('AnimalManagementModule.base', 'Setup failed. Please check logs and try again.'));
}
return $this->redirect($this->contentContainer->createUrl('/animal_management/settings'));
}
}