diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..2c39045 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,55 @@ +# Animal Management Installation Guide + +This guide installs the `animal_management` module in a reusable way for any HumHub instance. + +## 1. Requirements + +- HumHub `1.14+` +- Module directory access on the target instance +- Optional but recommended: `rescue_foundation` module + +## 2. Clone into HumHub Modules Directory + +The folder name must be exactly `animal_management`. + +```bash +git clone https://gitea.kelinreij.duckdns.org/humhub-modules/animal-management.git \ + /var/www/localhost/htdocs/protected/modules/animal_management +``` + +If the folder already exists: + +```bash +cd /var/www/localhost/htdocs/protected/modules/animal_management +git pull +``` + +## 3. Enable the Module + +In HumHub UI: + +1. Go to `Administration` -> `Modules`. +2. Enable `Animal Management`. +3. Enable it per space where needed. + +## 4. Run Migrations + +From the HumHub app host/container: + +```bash +php /var/www/localhost/htdocs/protected/yii migrate/up \ + --include-module-migrations=1 --interactive=0 +``` + +## 5. Verify + +1. Open a space where the module is enabled. +2. Confirm Animals views load. +3. Confirm medical/progress/transfer flows render without errors. + +## Docker Example + +```bash +docker exec humhub php /var/www/localhost/htdocs/protected/yii migrate/up \ + --include-module-migrations=1 --interactive=0 +``` diff --git a/controllers/AnimalsController.php b/controllers/AnimalsController.php index 8979c4b..6eb5c23 100644 --- a/controllers/AnimalsController.php +++ b/controllers/AnimalsController.php @@ -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; + } } diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php index 93cbf5b..f304445 100644 --- a/controllers/SettingsController.php +++ b/controllers/SettingsController.php @@ -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')); + } } diff --git a/events/AnimalTileRenderEvent.php b/events/AnimalTileRenderEvent.php new file mode 100644 index 0000000..d364ed6 --- /dev/null +++ b/events/AnimalTileRenderEvent.php @@ -0,0 +1,41 @@ +htmlFragments[] = $html; + } + + public function getHtml(): string + { + if (empty($this->htmlFragments)) { + return ''; + } + + return implode("\n", $this->htmlFragments); + } +} diff --git a/events/AnimalTileSizeEvent.php b/events/AnimalTileSizeEvent.php new file mode 100644 index 0000000..e732e66 --- /dev/null +++ b/events/AnimalTileSizeEvent.php @@ -0,0 +1,32 @@ +additionalHeightPx += $heightPx; + } + + public function getAdditionalHeightPx(): int + { + return max(0, $this->additionalHeightPx); + } +} diff --git a/services/AnimalStreamPublisherService.php b/services/AnimalStreamPublisherService.php index b396516..a2c47c8 100644 --- a/services/AnimalStreamPublisherService.php +++ b/services/AnimalStreamPublisherService.php @@ -61,6 +61,8 @@ class AnimalStreamPublisherService $entry->medical_visit_id = $medicalVisitId; $entry->progress_update_id = $progressUpdateId; $entry->content->container = $space; + // Respect each space's configured stream visibility policy. + $entry->content->visibility = (int)$space->getDefaultContentVisibility(); if (!$entry->save()) { Yii::warning([ diff --git a/services/ModuleSetupService.php b/services/ModuleSetupService.php new file mode 100644 index 0000000..dcda16b --- /dev/null +++ b/services/ModuleSetupService.php @@ -0,0 +1,72 @@ +getModule('animal_management')->settings->contentContainer($space); + if ($settings->get('searchBlockHeading') === null) { + $settings->set('searchBlockHeading', 'Animals Available for Intake & Transfer'); + $result['defaultsApplied'] = ['searchBlockHeading']; + } else { + $result['defaultsApplied'] = []; + } + + return $result; + } + + private static function applyModuleMigrations(): array + { + $migrationDir = dirname(__DIR__) . '/migrations'; + $files = glob($migrationDir . '/m*.php') ?: []; + sort($files, SORT_NATURAL); + + $existingVersions = Yii::$app->db->createCommand('SELECT version FROM migration')->queryColumn(); + $history = array_fill_keys($existingVersions, true); + + $applied = []; + $skipped = []; + + foreach ($files as $file) { + $version = pathinfo($file, PATHINFO_FILENAME); + if (isset($history[$version])) { + $skipped[] = $version; + continue; + } + + if (!class_exists($version, false)) { + require_once $file; + } + + if (!class_exists($version, false)) { + throw new \RuntimeException('Migration class not found: ' . $version); + } + + $migration = new $version(); + $ok = method_exists($migration, 'safeUp') ? $migration->safeUp() : $migration->up(); + if ($ok === false) { + throw new \RuntimeException('Migration failed: ' . $version); + } + + Yii::$app->db->createCommand()->insert('migration', [ + 'version' => $version, + 'apply_time' => time(), + ])->execute(); + + $applied[] = $version; + $history[$version] = true; + } + + return [ + 'applied' => $applied, + 'skipped' => $skipped, + ]; + } +} diff --git a/views/animals/_tablet_quick_donate_extras.php b/views/animals/_tablet_quick_donate_extras.php new file mode 100644 index 0000000..6137f15 --- /dev/null +++ b/views/animals/_tablet_quick_donate_extras.php @@ -0,0 +1,184 @@ + +