Compare commits

...

75 Commits

Author SHA1 Message Date
Daniel O'Connor
6784c6460d Merge pull request #4600 from Growstuff/dev
Release 85
2026-04-28 14:48:45 +09:30
Daniel O'Connor
e748da9a1f Merge pull request #4601 from Growstuff/links-wrong
Fix links further
2026-04-28 13:34:11 +09:30
Daniel O'Connor
4ac0dcb05b Merge branch 'dev' of https://github.com/Growstuff/growstuff into links-wrong 2026-04-28 04:03:44 +00:00
Daniel O'Connor
60390fcc06 Fix links further 2026-04-28 04:03:27 +00:00
Daniel O'Connor
55e6d99979 Merge pull request #4599 from Growstuff/links-wrong
Fix various breadcrumb links to avoid passing ?owner, which doesn't actually filter
2026-04-28 13:23:36 +09:30
Daniel O'Connor
dfac51ee97 Merge pull request #4595 from Growstuff/optimize-harvests-caching-memoization-9176733581563564983
Optimize Harvests with memoization and fragment caching
2026-04-28 13:20:01 +09:30
Daniel O'Connor
f24ca80394 Fix various breadcrumb links to avoid passing ?owner, which doesn't actually filter 2026-04-28 03:46:47 +00:00
Daniel O'Connor
7360bc968b Merge pull request #4596 from Growstuff/flickr-tag-filtering-3395495860406820072
Add Flickr tag filtering for adding photos
2026-04-28 13:04:40 +09:30
Daniel O'Connor
f680a6b25d Merge pull request #4597 from Growstuff/memoize-plantings-8206863373400530940
Memoize Planting-related methods for performance optimization
2026-04-28 13:04:17 +09:30
Daniel O'Connor
22638371c2 Update _harvests.html.haml 2026-04-28 13:01:02 +09:30
google-labs-jules[bot]
50ab6f39ee Optimize Harvests with memoization and caching
- Memoize display methods in `Harvest` model.
- Memoize calculation methods in `PredictHarvest` concern using `defined?` for nil safety.
- Add fragment caching to `app/views/harvests/_popover.html.haml`.
- Add fragment caching and query caching to `app/views/crops/_harvests.html.haml` with daily expiration for relative time strings.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-28 03:16:36 +00:00
google-labs-jules[bot]
2e0c8a910d Memoize Planting-related methods for performance optimization
This commit introduces memoization to various methods in the Planting model,
PredictPlanting and PredictHarvest concerns, PlantingsHelper, and
PlantingsController.

Specifically:
- Memoized database-intensive lookups like `nearby_same_crop`, `first_harvest_date`,
  and `last_harvest_date`.
- Memoized calculated fields like `finish_predicted_at`, `expected_lifespan`,
  and `age_in_days`.
- Optimized `PlantingsHelper#transplantable_gardens_by_owner` using a hash
  to cache results per planting instance within a request.
- Applied the `defined?(@variable)` pattern where appropriate to ensure
  efficient handling of `nil` results.

These changes reduce redundant database queries and expensive calculations,
particularly during view rendering where these methods are frequently accessed.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-28 03:07:18 +00:00
google-labs-jules[bot]
3c70ba12ca Allow filtering Flickr photos by tag when adding photos
- Update MemberFlickr concern to support tag-based search using flickr.photos.search
- Update PhotosController to handle the 'tag' parameter
- Add tag search input field to the 'New Photo' view
- Add test case to verify tag filtering in PhotosController spec

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-28 02:59:21 +00:00
google-labs-jules[bot]
0df7589feb Optimize Harvests with memoization and fragment caching
- Memoize display methods in `Harvest` model.
- Memoize calculation methods in `PredictHarvest` concern using `defined?` for nil safety.
- Add fragment caching to `app/views/harvests/_popover.html.haml`.
- Add fragment caching to `app/views/crops/_harvests.html.haml` with daily expiration for relative time strings.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-28 02:59:06 +00:00
Daniel O'Connor
8cdfda3660 Merge pull request #4594 from Growstuff/jules-10992479017878632568-8480ddd1
Optimize CropsHelper with caching and memoization
2026-04-28 12:03:03 +09:30
google-labs-jules[bot]
aa0ee65d78 Optimize CropsHelper with caching and memoization
- Implement instance-level memoization for `crop_or_parent` and `display_seed_availability`
- Use `Rails.cache.fetch` for `crop_jsonld_data` to improve performance of JSON-LD generation
- Optimize `display_seed_availability` to avoid redundant queries
- Fix a potential `NameError` in `crop_jsonld_data` by initializing `images` properly
- Ensure memoization keys handle non-persisted objects and nil results correctly

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-27 23:55:46 +00:00
Daniel O'Connor
355e9f84d5 Merge pull request #4591 from Growstuff/cache-charts-crops-data-7725540171807829398
Improve Charts::CropsController caching
2026-04-27 19:23:11 +09:30
Daniel O'Connor
dce32c5e3c Merge pull request #4586 from Growstuff/dependabot/github_actions/docker/build-push-action-7
Bump docker/build-push-action from 5 to 7
2026-04-27 18:35:25 +09:30
Daniel O'Connor
dbae34a958 Merge pull request #4587 from Growstuff/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-04-27 18:35:06 +09:30
Daniel O'Connor
9edee8400d Merge pull request #4589 from Growstuff/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 3 to 4
2026-04-27 18:34:50 +09:30
Daniel O'Connor
b775814614 Merge pull request #4593 from Growstuff/planting-photos
Try planting filtering
2026-04-27 18:17:35 +09:30
Daniel O'Connor
464017de6f Try planting filtering 2026-04-27 08:05:32 +00:00
dependabot[bot]
9184285388 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 08:00:55 +00:00
dependabot[bot]
dcd701fe9d Bump docker/build-push-action from 5 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 08:00:50 +00:00
dependabot[bot]
5a462bd740 Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 07:57:06 +00:00
Daniel O'Connor
ecf77313dc Merge pull request #4588 from Growstuff/dependabot/github_actions/docker/metadata-action-6
Bump docker/metadata-action from 5 to 6
2026-04-27 17:24:09 +09:30
Daniel O'Connor
0ee671fddb Merge pull request #4590 from Growstuff/dependabot/bundler/puma-8.0.1
Bump puma from 8.0.0 to 8.0.1
2026-04-27 17:23:56 +09:30
Daniel O'Connor
fc4276cacf Merge pull request #4592 from Growstuff/CloCkWeRX-patch-2
Ban Semrush
2026-04-27 17:19:53 +09:30
Daniel O'Connor
a2bb6c7162 Ban Semrush 2026-04-27 17:19:31 +09:30
google-labs-jules[bot]
ff9d99afe5 Improve Charts::CropsController with caching and refactoring
- Added Rails.cache.fetch to `sunniness` and `planted_from` actions.
- Refactored crop loading into a `before_action :set_crop`.
- Updated specs to verify caching behavior and ensure coverage.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-27 07:46:08 +00:00
dependabot[bot]
1b4b8f94d1 Bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 07:42:38 +00:00
Daniel O'Connor
66cc98051f Bump actions/checkout from 4 to 6 (#4585)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 17:11:32 +09:30
dependabot[bot]
9fe1fddac1 Bump puma from 8.0.0 to 8.0.1
Bumps [puma](https://github.com/puma/puma) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/main/History.md)
- [Commits](https://github.com/puma/puma/compare/v8.0.0...v8.0.1)

---
updated-dependencies:
- dependency-name: puma
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 07:26:19 +00:00
dependabot[bot]
1eac00705e Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 07:25:50 +00:00
Daniel O'Connor
37e9860fdf Update member_slug lookup to 404 when not found (#4584)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 14:19:50 +09:30
Daniel O'Connor
a5ed2b988d Merge pull request #4583 from Growstuff/dev
Release 84
2026-04-27 13:56:58 +09:30
Daniel O'Connor
bd637c3310 Cache what a crop is harvested for (#4582) 2026-04-27 13:32:45 +09:30
Daniel O'Connor
9abb0d02b9 Merge pull request #4581 from Growstuff/add-rack-attack-protection-3014929071908440304
Add Rack::Attack rate limiting and Fail2Ban protection
2026-04-27 13:23:17 +09:30
Daniel O'Connor
2e56f8cb2f Cache what a crop is harvested for 2026-04-27 03:52:11 +00:00
Daniel O'Connor
7ca689fa4a release83 (#4579)
* Ensure "mark as failed" option is available when viewing a crop

This change adds the "mark as failed" action to the crop view in two places:
1. In the "Crop Actions" button group, a new "Mark as failed" button is added if the current member has active plantings of that crop. Clicking it opens a modal to select which planting failed.
2. In the "See who's planted" list, an "Actions" dropdown is added to any plantings owned by the current member, which includes the "Mark as failed" option.

A new partial `app/views/plantings/_failed_modal.html.haml` was created to handle the planting selection modal.
`app/views/crops/_actions.html.haml` and `app/views/crops/_plantings.html.haml` were updated to include these new actions.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Admin - Members - optimise memory usage

* GBIF - optimise memory usage

* Memory usage

* Update .dockerignore to remove .ruby-version

Remove .ruby-version from .dockerignore

* Swap to modern expect style

* Swap to modern expect style

* Rubocop fixes

* Swap to modern expect style

* Merge pull request #4567 from Growstuff/memory-optimisation-3

Members - Nearest To - Memory improvements

* Merge pull request #4564 from Growstuff/memory-optimization-2149092598558110155

Memory usage optimization

* Add fragment cache for crop partials

* Add rake task to cleanup inactive members (#4574)

* Add members:cleanup_inactive rake task

This task identifies and deletes members who have not logged in for over
24 months and have no gardens, plantings, or other activity (posts,
comments, seeds, harvests, etc).

Includes support for DRY_RUN=true to preview deletions.
Added tests in spec/tasks/members_spec.rb.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Refactor activity check to Member#has_activity? and update rake task

- Added `Member#has_activity?` to encapsulate the check for gardens, plantings, and other activity.
- Updated `members:cleanup_inactive` rake task to use `Member#has_activity?`.
- Maintained `DRY_RUN` support and existing tests.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Apply suggestion from @CloCkWeRX

* Apply suggestions from code review

Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* Merge pull request #4578 from Growstuff/member-inactive-delete

Delete inactive members with no activity in 3 years

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 02:22:30 +09:30
Daniel O'Connor
3127f45d0f Merge pull request #4578 from Growstuff/member-inactive-delete
Delete inactive members with no activity in 3 years
2026-04-27 02:15:17 +09:30
Daniel O'Connor
15571940f5 Add fragment cache for crop partials (#4577) 2026-04-27 01:48:35 +09:30
Daniel O'Connor
8e7dd25e98 Add rake task to cleanup inactive members (#4574)
* Add members:cleanup_inactive rake task

This task identifies and deletes members who have not logged in for over
24 months and have no gardens, plantings, or other activity (posts,
comments, seeds, harvests, etc).

Includes support for DRY_RUN=true to preview deletions.
Added tests in spec/tasks/members_spec.rb.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Refactor activity check to Member#has_activity? and update rake task

- Added `Member#has_activity?` to encapsulate the check for gardens, plantings, and other activity.
- Updated `members:cleanup_inactive` rake task to use `Member#has_activity?`.
- Maintained `DRY_RUN` support and existing tests.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>

* Apply suggestion from @CloCkWeRX

* Apply suggestions from code review

Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 01:40:54 +09:30
Daniel O'Connor
2723599f27 Add fragment cache for crop partials 2026-04-26 16:07:29 +00:00
Daniel O'Connor
98c8bdc0bb Merge pull request #4564 from Growstuff/memory-optimization-2149092598558110155
Memory usage optimization
2026-04-27 00:37:51 +09:30
Daniel O'Connor
573daa8c8a Swap to modern expect style (#4571) 2026-04-26 22:58:04 +09:30
Daniel O'Connor
5174b1236e Merge pull request #4567 from Growstuff/memory-optimisation-3
Members - Nearest To - Memory improvements
2026-04-26 22:57:01 +09:30
Daniel O'Connor
5a349f8f1b Swap to modern expect style 2026-04-26 13:21:15 +00:00
Daniel O'Connor
0d850804cf Merge pull request #4570 from Growstuff/rubocop-tweaks
Rubocop fixes
2026-04-26 22:47:46 +09:30
Daniel O'Connor
161a934811 Merge pull request #4569 from Growstuff/plant_part_spec
Rubocop: Fix no expectation errors
2026-04-26 22:44:49 +09:30
Daniel O'Connor
8cfef5ce1a Rubocop fixes 2026-04-26 13:09:00 +00:00
Daniel O'Connor
6dacb0af74 Swap to modern expect style 2026-04-26 13:03:46 +00:00
Daniel O'Connor
7e2d36f99a Swap to modern expect style 2026-04-26 12:55:58 +00:00
Daniel O'Connor
3406d9e7bc Merge pull request #4568 from Growstuff/memory-usage-4
Posts - memory usage
2026-04-26 19:05:56 +09:30
Daniel O'Connor
7a91746f73 Update .dockerignore to remove .ruby-version
Remove .ruby-version from .dockerignore
2026-04-26 19:05:41 +09:30
Daniel O'Connor
209973e72b Memory usage 2026-04-26 09:26:52 +00:00
Daniel O'Connor
4848302eab Merge pull request #4565 from Growstuff/memory-usage-1
Admin - Members - optimise memory usage
2026-04-26 18:45:12 +09:30
Daniel O'Connor
920a28a144 Merge pull request #4566 from Growstuff/memory-usage-2
GBIF - optimise memory usage
2026-04-26 18:44:58 +09:30
Daniel O'Connor
fff7a14635 GBIF - optimise memory usage 2026-04-26 09:03:45 +00:00
Daniel O'Connor
0131c9b531 Admin - Members - optimise memory usage 2026-04-26 09:01:18 +00:00
Daniel O'Connor
1b091b2f6f Merge pull request #4453 from Growstuff/add-mark-as-failed-to-crop-view-13853484652230549508
Add "mark as failed" action to crop view
2026-04-26 14:44:09 +09:30
Daniel O'Connor
189067c66f Merge pull request #4563 from Growstuff/dev
Release 82
2026-04-26 14:31:31 +09:30
Daniel O'Connor
8d9bcc4559 Merge pull request #4524 from Growstuff/dev
release81
2026-04-23 20:31:28 +09:30
Daniel O'Connor
6e7e752fed Merge pull request #4487 from Growstuff/dev
Release 80
2026-04-11 14:51:48 +09:30
google-labs-jules[bot]
8bafba7f9d Ensure "mark as failed" option is available when viewing a crop
This change adds the "mark as failed" action to the crop view in two places:
1. In the "Crop Actions" button group, a new "Mark as failed" button is added if the current member has active plantings of that crop. Clicking it opens a modal to select which planting failed.
2. In the "See who's planted" list, an "Actions" dropdown is added to any plantings owned by the current member, which includes the "Mark as failed" option.

A new partial `app/views/plantings/_failed_modal.html.haml` was created to handle the planting selection modal.
`app/views/crops/_actions.html.haml` and `app/views/crops/_plantings.html.haml` were updated to include these new actions.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-02-22 00:08:58 +00:00
Daniel O'Connor
fe032b6b49 Merge pull request #4378 from Growstuff/dev
Fix minor complaints
2025-12-02 23:00:21 +10:30
Daniel O'Connor
959f914122 Merge pull request #4377 from Growstuff/dev
Update _head.html.haml
2025-12-02 22:51:41 +10:30
Daniel O'Connor
3f8fb799d5 Merge pull request #4376 from Growstuff/dev
More site verification bits
2025-12-02 22:48:29 +10:30
Daniel O'Connor
985ad120c7 Merge pull request #4375 from Growstuff/dev
Deploy various site verification things
2025-12-02 22:42:40 +10:30
Daniel O'Connor
d8e1e09dfb Merge pull request #4372 from Growstuff/fix-sitemap-upload (#4373)
Fix Sitemap Upload to S3

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-12-02 22:23:21 +10:30
Daniel O'Connor
477baec010 Merge pull request #4371 from Growstuff/dev
Deploy various maintenance tasks to prod
2025-12-02 02:27:07 +10:30
Daniel O'Connor
d5db389b23 Merge pull request #4360 from Growstuff/dev
Deploy: Missing PKFID
2025-12-01 22:22:30 +10:30
Daniel O'Connor
b866a2064b Merge pull request #4358 from Growstuff/dev
Hotfix: Reduce to member login_name
2025-12-01 20:34:18 +10:30
Daniel O'Connor
37519de615 Release 78 (#4357)
* Check presence of version members before accessing

* Fix(specs): Initialize @version_members in crops/show view spec

The `crops/show` view spec was failing with a `NoMethodError` because
the `@version_members` instance variable was `nil`. This variable is used
in the `_history` partial, which is rendered by the `show` view.

This commit fixes the spec by initializing `@version_members` to an
empty hash in the `before` block of the spec. This ensures that the
view can render without errors during the test run.

* Remove defunct gitter

* Merge pull request #4352 from Growstuff/add-public-food-key

feat: Add Public Food Key to Crop model

* Bump rubocop-rails from 2.34.1 to 2.34.2

Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.34.1 to 2.34.2.
- [Release notes](https://github.com/rubocop/rubocop-rails/releases)
- [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.34.1...v2.34.2)

---
updated-dependencies:
- dependency-name: rubocop-rails
  dependency-version: 2.34.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Optimize Data Improvement Page (#4356)

* feat: Add data improvement page to crops controller

This commit introduces a new data improvement page to the crops controller. The page displays tabbed lists of crops with missing data, allowing users to easily identify areas for data quality improvement.

The following data quality categories are included:
- Crops without photos
- Crops without descriptions
- Crops without a youtube video
- Crops without alternate names
- Crops without a scientific name with a wikidata id
- Crops without row spacing
- Crops without sun requirements
- Crops without height

All lists are sorted by planting count in descending order.

* refactor: Optimize data improvement page to load tab data on demand

This commit refactors the data improvement page to load data for each tab on demand, rather than loading all queries at once. This improves the performance of the page by only executing the query for the currently active tab.

The controller action now uses a `case` statement based on a `tab` URL parameter to execute the appropriate query. The view has been updated to pass this parameter when a tab is clicked.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* Merge pull request #4353 from Growstuff/feat/import-australian-food-data

Add Rake Task to Import Australian Food Data

* Fix styling

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:01:31 +10:30
Daniel O'Connor
8ddc717d9b Release 77 (#4345)
* Update crops_helper.rb

* feat: Add contribution links and conditional edit form

This commit introduces a series of changes to encourage user contributions for missing crop data.

On the crop show page, it adds links for logged-in users to:
- Add a description if one is not present.
- Add a YouTube video if one is not present.
- Add more attributes in the predictions section if any are missing.

On the crop edit page, the form now conditionally displays fields. For standard users, it only shows fields for attributes that are currently empty. For privileged users (wranglers), it displays all fields, allowing them to edit existing data.

* feat: Add schema.org markup to crop pages

Adds schema.org markup to the crop pages using the BioChemEntity type. The taxonomicRange attribute is used to list the scientific names of the crop, which will improve SEO.

* feat: Add Wikidata ID to scientific names

This commit introduces the ability to store and display the Wikidata ID for scientific names.

Changes include:
- A database migration to add the `wikidata_id` column to the `scientific_names` table.
- An update to the `scientific_names_controller` to permit the `wikidata_id` parameter.
- An update to the scientific name form to include a field for the Wikidata ID.
- An update to the crop show page to display a link to the Wikidata page for a scientific name.

* Delete db/migrate/20251129185029_add_wikidata_id_to_scientific_names.rb

* Update _schema_org.html.haml

* Move to crops helper

* Add more schema.org

* Specific form links

* Styling

* Render images

* Document future

* Render less on some pages

* Fix rendering

* Add version tracking to crops model (#4343)

* feat: Add version tracking to Crop model

This commit introduces version tracking for the Crop model using the PaperTrail gem.

Key changes include:
- Integrating `has_paper_trail` into the `Crop` model.
- Adding a "History" section to the crop show page to display a timeline of changes for that specific crop.
- Creating a new admin page for users with the "crop_wrangler" role to view a log of all recent crop edits, creations, and deletions.
- Fixing several N+1 query performance issues by eager-loading associated `Member` records in both the `CropsController` and the new `Admin::CropsController`.
- Refactoring view logic into a shared partial to reduce code duplication.

* Add papertrail

* Admin UI

* Add papertrail DB

* Add papertrail DB

* Rearrange

* Fix permissions

* Fix permissions

* Fix UI

* Fix UI

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>

* Add revert functionality to admin crops page (#4346)

* feat(admin): add revert functionality to crops page

This change adds a "Revert" button to the admin crops page, allowing crop wranglers to revert changes to a previous version.

It introduces a new `Admin::VersionsController` with a `revert` action that uses `paper_trail`'s `reify` method to restore a previous version of a `Crop` object.

The view is updated to include a "Revert" button, which is guarded by a `can?(:wrangle, Crop)` check to ensure only authorized users can see it.

The controller also includes an authorization check to prevent unauthorized users from accessing the revert action directly.

A feature spec is added to test the new functionality, including the authorization logic.

* Consistent UX

* Specs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-11-30 15:05:55 +10:30
78 changed files with 658 additions and 327 deletions

View File

@@ -7,7 +7,6 @@ tmp/*
node_modules
public/assets
.env
.ruby-version
.ruby-gemset
.editorconfig
.esignore

View File

@@ -14,13 +14,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -28,12 +28,12 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
push: true

View File

@@ -116,6 +116,8 @@ gem 'xmlrpc' # fixes rake error - can be removed if not needed later
gem 'puma'
gem 'rack-attack'
gem 'loofah', '>= 2.19.1'
gem 'rack-protection', '>= 2.0.1'

View File

@@ -498,11 +498,13 @@ GEM
date
stringio
public_suffix (7.0.5)
puma (8.0.0)
puma (8.0.1)
nio4r (~> 2.0)
query_diet (0.7.3)
racc (1.8.1)
rack (2.2.23)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-protection (3.2.0)
@@ -841,6 +843,7 @@ DEPENDENCIES
pry
puma
query_diet
rack-attack
rack-cors
rack-protection (>= 2.0.1)
rails (~> 7.2.0)

View File

@@ -7,9 +7,9 @@ class ActivitiesController < DataController
where = {}
where['active'] = true unless @show_all
if params[:member_slug]
@owner = Member.find_by(slug: params[:member_slug])
where['owner_id'] = @owner.id unless @owner.nil?
if params[:member_slug].present?
@owner = Member.find_by!(slug: params[:member_slug])
where['owner_id'] = @owner.id
end
@activities = Activity.search(

View File

@@ -8,9 +8,9 @@ module Admin
responders :flash
def index
@members = Member.all
@members = @members.where("login_name ILIKE ?", "%#{search_term}%") unless search_term.nil?
@members = @members.order(:login_name).paginate(page: params[:page])
@members = Member.order(:login_name)
@members = @members.where("login_name ILIKE ?", "%#{search_term}%") if search_term.present?
@members = @members.paginate(page: params[:page])
end
def edit

View File

@@ -12,7 +12,7 @@ class BlocksController < ApplicationController
else
flash[:error] = "Already blocking or error while blocking."
end
redirect_back fallback_location: root_path
redirect_back_or_to(root_path)
end
def destroy

View File

@@ -3,6 +3,7 @@
module Charts
class CropsController < ApplicationController
respond_to :json
before_action :set_crop
def sunniness
pie_chart_query 'sunniness'
@@ -13,20 +14,28 @@ module Charts
end
def harvested_for
@crop = Crop.find_by!(slug: params[:crop_slug])
render json: Harvest.joins(:plant_part)
.where(crop: @crop)
.group("plant_parts.name").count(:id)
data = Rails.cache.fetch("#{@crop.cache_key_with_version}/harvested_for", expires_in: 1.day) do
Harvest.joins(:plant_part)
.where(crop: @crop)
.group("plant_parts.name").count(:id)
end
render json: data
end
private
def pie_chart_query(field)
def set_crop
@crop = Crop.find_by!(slug: params[:crop_slug])
render json: Planting.where(crop: @crop)
.where.not(field.to_sym => nil)
.where.not(field.to_sym => '')
.group(field.to_sym).count(:id)
end
def pie_chart_query(field)
data = Rails.cache.fetch("#{@crop.cache_key_with_version}/#{field}", expires_in: 1.day) do
Planting.where(crop: @crop)
.where.not(field.to_sym => nil)
.where.not(field.to_sym => '')
.group(field.to_sym).count(:id)
end
render json: data
end
end
end

View File

@@ -2,7 +2,7 @@
class GardensController < DataController
def index
@owner = Member.find_by(slug: params[:member_slug])
@owner = Member.find_by!(slug: params[:member_slug]) if params[:member_slug].present?
@show_all = params[:all] == '1'
@show_jump_to = params[:member_slug].present? || false

View File

@@ -5,8 +5,8 @@ class HarvestsController < DataController
def index
where = {}
if params[:member_slug]
@owner = Member.find_by(slug: params[:member_slug])
if params[:member_slug].present?
@owner = Member.find_by!(slug: params[:member_slug])
where['owner_id'] = @owner.id
end

View File

@@ -39,7 +39,7 @@ class MessagesController < ApplicationController
recipient = Member.find(params[:recipient_id])
if recipient.already_blocking?(current_member)
flash[:error] = "You cannot send a message to a member who has blocked you."
redirect_back fallback_location: root_path
redirect_back_or_to(root_path)
return
end
body = params[:body]

View File

@@ -102,11 +102,12 @@ class PhotosController < ApplicationController
end
@current_set = params[:set]
@current_tag = params[:tag]
page = params[:page] || 1
@sets = current_member.flickr_sets
photos, total = current_member.flickr_photos(page, @current_set)
photos, total = current_member.flickr_photos(page, @current_set, @current_tag)
@photos = WillPaginate::Collection.create(page, 30, total) do |pager|
pager.replace photos
@@ -118,6 +119,8 @@ class PhotosController < ApplicationController
{ crops: @crop.id }
elsif params[:planting_id]
{ planting_id: @planting.id }
elsif params[:planting_slug]
{ plantings: @planting.id }
else
{}
end
@@ -126,5 +129,6 @@ class PhotosController < ApplicationController
def set_crop_and_planting
@crop = Crop.find params[:crop_slug] if params[:crop_slug]
@planting = Planting.find params[:planting_id] if params[:planting_id]
@planting ||= Planting.find params[:planting_slug] if params[:planting_slug]
end
end

View File

@@ -11,9 +11,9 @@ class PlantingsController < DataController
where = {}
where['active'] = true unless @show_all
if params[:member_slug]
@owner = Member.find_by(slug: params[:member_slug])
where['owner_id'] = @owner.id unless @owner.nil?
if params[:member_slug].present?
@owner = Member.find_by!(slug: params[:member_slug])
where['owner_id'] = @owner.id
end
if params[:crop_slug]
@@ -160,7 +160,7 @@ class PlantingsController < DataController
end
def matching_seeds
Seed.where(crop: @planting.crop, owner: @planting.owner)
@matching_seeds ||= Seed.where(crop: @planting.crop, owner: @planting.owner)
.where('(finished_at IS NULL OR finished_at >= ?)', @planting.planted_at)
.where('(saved_at IS NULL OR saved_at <= ?)', @planting.planted_at)
end

View File

@@ -8,7 +8,7 @@ class PostsController < ApplicationController
respond_to :rss, only: %i(index show)
def index
@author = Member.find_by(slug: params[:member_slug])
@author = Member.find_by!(slug: params[:member_slug]) if params[:member_slug].present?
@posts = posts
respond_with(@posts)
end

View File

@@ -5,7 +5,7 @@ class SeedsController < DataController
where = {}
if params[:member_slug].present?
@owner = Member.find_by(slug: params[:member_slug])
@owner = Member.find_by!(slug: params[:member_slug])
where['owner_id'] = @owner.id
end

View File

@@ -2,28 +2,47 @@
module CropsHelper
def crop_or_parent(crop, attribute)
default = crop.send(attribute)
return default if default.present?
@crop_or_parent_cache ||= {}
cache_key = [crop.persisted? ? crop.id : crop.object_id, attribute]
return @crop_or_parent_cache[cache_key] if @crop_or_parent_cache.key?(cache_key)
parent = crop
while parent = parent.parent
return parent.send(attribute) if parent&.send(attribute).present?
@crop_or_parent_cache[cache_key] = begin
value = crop.send(attribute)
if value.blank?
parent = crop
while (parent = parent.parent)
parent_value = parent.send(attribute)
if parent_value.present?
value = parent_value
break
end
end
end
value
end
# For scopes, arrays, etc return the empty value
default
end
def display_seed_availability(member, crop)
seeds = member.seeds.where(crop:)
total_quantity = seeds.where.not(quantity: nil).sum(:quantity)
@seed_availability_cache ||= {}
cache_key = [
member.persisted? ? member.id : member.object_id,
crop.persisted? ? crop.id : crop.object_id
]
return @seed_availability_cache[cache_key] if @seed_availability_cache.key?(cache_key)
return "You don't have any seeds of this crop." if seeds.none?
@seed_availability_cache[cache_key] = begin
seeds = member.seeds.where(crop:)
if total_quantity == 0
"You have an unknown quantity of seeds of this crop."
else
"You have #{total_quantity} #{Seed.model_name.human(count: total_quantity)} of this crop."
if seeds.none?
"You don't have any seeds of this crop."
else
total_quantity = seeds.where.not(quantity: nil).sum(:quantity)
if total_quantity == 0
"You have an unknown quantity of seeds of this crop."
else
"You have #{total_quantity} #{Seed.model_name.human(count: total_quantity)} of this crop."
end
end
end
end
@@ -40,53 +59,57 @@ module CropsHelper
end
def crop_jsonld_data(crop, full_attributes: true)
same_as_urls = [crop.en_wikipedia_url]
crop.scientific_names.each do |scientific_name|
same_as_urls << "https://www.wikidata.org/wiki/#{scientific_name.wikidata_id}" if scientific_name.wikidata_id.present?
end
subject_of_entities = []
if full_attributes
if crop.en_youtube_url.present?
subject_of_entities << {
'@type': "VideoObject",
url: crop.en_youtube_url
}
end
crop.posts.each do |post|
subject_of_entities << {
'@type': "SocialMediaPosting",
url: post_url(post),
author: {
'@type': 'Person',
name: post.author.login_name
},
datePublished: post.created_at
}
Rails.cache.fetch([crop.cache_key_with_version, "jsonld", full_attributes]) do
same_as_urls = [crop.en_wikipedia_url]
crop.scientific_names.each do |scientific_name|
if scientific_name.wikidata_id.present?
same_as_urls << "https://www.wikidata.org/wiki/#{scientific_name.wikidata_id}"
end
end
subject_of_entities = []
images = []
crop.photos.each do |photo|
images << photo.fullsize_url
if full_attributes
if crop.en_youtube_url.present?
subject_of_entities << {
'@type': "VideoObject",
url: crop.en_youtube_url
}
end
crop.posts.each do |post|
subject_of_entities << {
'@type': "SocialMediaPosting",
url: post_url(post),
author: {
'@type': 'Person',
name: post.author.login_name
},
datePublished: post.created_at
}
end
crop.photos.each do |photo|
images << photo.fullsize_url
end
end
# TODO: Review plantings, seeds, harvests as a subtype of social media post or event that ended? Or creative work?
# has_many :plantings, dependent: :destroy
# has_many :seeds, dependent: :destroy
# has_many :harvests, dependent: :destroy
{
'@context': "https://schema.org",
'@type': "BioChemEntity",
name: crop.name,
taxonomicRange: crop.scientific_names.map(&:name),
description: crop.description,
sameAs: same_as_urls,
alternateName: crop.alternate_names.map(&:name),
subjectOf: subject_of_entities,
image: images
}.compact
end
# TODO: Review plantings, seeds, harvests as a subtype of social media post or event that ended? Or creative work?
# has_many :plantings, dependent: :destroy
# has_many :seeds, dependent: :destroy
# has_many :harvests, dependent: :destroy
{
'@context': "https://schema.org",
'@type': "BioChemEntity",
name: crop.name,
taxonomicRange: crop.scientific_names.map(&:name),
description: crop.description,
sameAs: same_as_urls,
alternateName: crop.alternate_names.map(&:name),
subjectOf: subject_of_entities,
image: images
}.compact
end
end

View File

@@ -46,9 +46,13 @@ module PlantingsHelper
# Returns a list of gardens the planting can be transplanted to
# based on the planting's owner.
def transplantable_gardens_by_owner(planting)
garden_ids = planting.owner.gardens.select(:id).to_a + GardenCollaborator.where(member_id: planting.owner.id).select(:garden_id).to_a
@transplantable_gardens ||= {}
cache_key = planting.id || planting.object_id
@transplantable_gardens[cache_key] ||= begin
garden_ids = planting.owner.gardens.select(:id).to_a + GardenCollaborator.where(member_id: planting.owner.id).select(:garden_id).to_a
Garden.active.where.not(id: planting.garden_id).where(id: garden_ids)
Garden.active.where.not(id: planting.garden_id).where(id: garden_ids)
end
end
def days_from_now_to_last_harvest(planting)

View File

@@ -31,8 +31,9 @@ class Comment < ApplicationRecord
def author_is_not_blocked
return unless author
if commentable.author.already_blocking?(author)
errors.add(:base, "You cannot comment on a post of a member who has blocked you.")
end
return unless commentable.author.already_blocking?(author)
errors.add(:base, "You cannot comment on a post of a member who has blocked you.")
end
end

View File

@@ -40,8 +40,15 @@ module MemberFlickr
# Fetches a collection of photos from Flickr
# Returns a [[page of photos], total] pair.
# Total is needed for pagination.
def flickr_photos(page_num = 1, set = nil)
result = if set
def flickr_photos(page_num = 1, set = nil, tags = nil)
result = if tags.present?
flickr.photos.search(
user_id: 'me',
tags: tags,
page: page_num,
per_page: 30
)
elsif set.present?
flickr.photosets.getPhotos(
photoset_id: set,
page: page_num,

View File

@@ -6,23 +6,31 @@ module PredictHarvest
included do
# dates
def first_harvest_date
harvests_with_dates.minimum(:harvested_at)
return @first_harvest_date if defined?(@first_harvest_date)
@first_harvest_date = harvests_with_dates.minimum(:harvested_at)
end
def last_harvest_date
harvests_with_dates.maximum(:harvested_at)
return @last_harvest_date if defined?(@last_harvest_date)
@last_harvest_date = harvests_with_dates.maximum(:harvested_at)
end
def first_harvest_predicted_at
return unless crop.median_days_to_first_harvest.present? && planted_at.present?
return @first_harvest_predicted_at if defined?(@first_harvest_predicted_at)
planted_at + crop.median_days_to_first_harvest.days
@first_harvest_predicted_at = if crop.median_days_to_first_harvest.present? && planted_at.present?
planted_at + crop.median_days_to_first_harvest.days
end
end
def last_harvest_predicted_at
return unless crop.median_days_to_last_harvest.present? && planted_at.present?
return @last_harvest_predicted_at if defined?(@last_harvest_predicted_at)
planted_at + crop.median_days_to_last_harvest.days
@last_harvest_predicted_at = if crop.median_days_to_last_harvest.present? && planted_at.present?
planted_at + crop.median_days_to_last_harvest.days
end
end
# actions
@@ -65,16 +73,18 @@ module PredictHarvest
end
def neighbours_for_harvest_predictions
# use this planting's harvest if any
return harvests if harvests.size.positive?
# otherwise use nearby plantings
if location
return Harvest.where(planting: nearby_same_crop.has_harvests)
.where.not(planting_id: nil)
@neighbours_for_harvest_predictions ||= begin
# use this planting's harvest if any
if harvests.size.positive?
harvests
# otherwise use nearby plantings
elsif location
Harvest.where(planting: nearby_same_crop.has_harvests)
.where.not(planting_id: nil)
else
Harvest.none
end
end
Harvest.none
end
private

View File

@@ -13,40 +13,49 @@ module PredictPlanting
# dates
def finish_predicted_at
if planted_at.blank? || failed?
nil
elsif crop.median_lifespan.present?
planted_at + crop.median_lifespan.days
elsif crop.parent.present? && crop.parent.median_lifespan.present?
planted_at + crop.parent.median_lifespan.days
end
return @finish_predicted_at if defined?(@finish_predicted_at)
@finish_predicted_at = if planted_at.blank? || failed?
nil
elsif crop.median_lifespan.present?
planted_at + crop.median_lifespan.days
elsif crop.parent.present? && crop.parent.median_lifespan.present?
planted_at + crop.parent.median_lifespan.days
end
end
# days
def expected_lifespan
if actual_lifespan.present?
actual_lifespan
elsif crop.median_lifespan.present?
crop.median_lifespan
elsif crop.parent.present? && crop.parent.median_lifespan.present?
crop.parent.median_lifespan
end
return @expected_lifespan if defined?(@expected_lifespan)
@expected_lifespan = if actual_lifespan.present?
actual_lifespan
elsif crop.median_lifespan.present?
crop.median_lifespan
elsif crop.parent.present? && crop.parent.median_lifespan.present?
crop.parent.median_lifespan
end
end
def actual_lifespan
return unless planted_at.present? && finished_at.present? && !failed?
return @actual_lifespan if defined?(@actual_lifespan)
(finished_at - planted_at).to_i
@actual_lifespan = if planted_at.present? && finished_at.present? && !failed?
(finished_at - planted_at).to_i
end
end
def age_in_days
return if planted_at.blank?
return if failed?
return @age_in_days if defined?(@age_in_days)
known_last_day ||= finished_at || Time.zone.today
known_last_day = Time.zone.today if known_last_day > Time.zone.today
@age_in_days = if planted_at.blank? || failed?
nil
else
known_last_day = finished_at || Time.zone.today
known_last_day = Time.zone.today if known_last_day > Time.zone.today
(known_last_day - planted_at).to_i
(known_last_day - planted_at).to_i
end
end
def percentage_grown

View File

@@ -20,8 +20,9 @@ class Follow < ApplicationRecord
def follower_is_not_blocked
return unless follower
if followed.already_blocking?(follower)
errors.add(:base, "You cannot follow a member who has blocked you.")
end
return unless followed.already_blocking?(follower)
errors.add(:base, "You cannot follow a member who has blocked you.")
end
end

View File

@@ -109,37 +109,49 @@ class Harvest < ApplicationRecord
def to_s
# 50 individual apples, weighing 3lb
# 2 buckets of apricots, weighing 10kg
"#{quantity_to_human} #{unit_to_human} #{crop_name_to_human} #{weight_to_human}".strip
@to_s ||= "#{quantity_to_human} #{unit_to_human} #{crop_name_to_human} #{weight_to_human}".strip
end
def quantity_to_human
return number_to_human(quantity.to_s, strip_insignificant_zeros: true) if quantity
""
@quantity_to_human ||= if quantity
number_to_human(quantity.to_s, strip_insignificant_zeros: true)
else
""
end
end
def unit_to_human
return "" unless quantity && unit
return 'individual' if unit == 'individual'
return "#{unit} of" if quantity == 1
"#{unit.pluralize} of"
@unit_to_human ||= begin
if !quantity || !unit
""
elsif unit == 'individual'
'individual'
elsif quantity == 1
"#{unit} of"
else
"#{unit.pluralize} of"
end
end
end
def weight_to_human
return "" unless weight_quantity
"weighing #{number_to_human(weight_quantity, strip_insignificant_zeros: true)} #{weight_unit}"
@weight_to_human ||= if weight_quantity
"weighing #{number_to_human(weight_quantity, strip_insignificant_zeros: true)} #{weight_unit}"
else
""
end
end
def crop_name_to_human
if unit != 'individual' # buckets of apricot*s*
crop.name.pluralize
elsif quantity == 1
crop.name
else
crop.name.pluralize
end.to_s
@crop_name_to_human ||= begin
if unit != 'individual' # buckets of apricot*s*
crop.name.pluralize
elsif quantity == 1
crop.name
else
crop.name.pluralize
end.to_s
end
end
private

View File

@@ -19,9 +19,10 @@ class Like < ApplicationRecord
def member_is_not_blocked
return unless member
author = likeable_author
if author && author.already_blocking?(member)
errors.add(:base, "You cannot like content of a member who has blocked you.")
end
return unless author&.already_blocking?(member)
errors.add(:base, "You cannot like content of a member who has blocked you.")
end
end

View File

@@ -173,12 +173,12 @@ class Member < ApplicationRecord
end
def self.nearest_to(place)
nearby_members = []
if place
latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 })
nearby_members = Member.located.sort_by { |x| x.distance_from([latitude, longitude]) } if latitude && longitude
end
nearby_members
return [] if place.blank?
latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 })
return [] unless latitude && longitude
Member.located.near([latitude, longitude], 1000)
end
def already_following?(member)
@@ -196,4 +196,25 @@ class Member < ApplicationRecord
def get_block(member)
blocks.find_by(blocked_id: member.id) if already_blocking?(member)
end
def has_activity?
(gardens.exists? && gardens.count > 1) ||
plantings.exists? ||
harvests.exists? ||
seeds.exists? ||
photos.exists? ||
forums.exists? ||
activities.exists? ||
posts.exists? ||
comments.exists? ||
requested_crops.exists? ||
created_crops.exists? ||
likes.exists? ||
created_alternate_names.exists? ||
created_scientific_names.exists? ||
follows.exists? ||
inverse_follows.exists? ||
blocks.exists? ||
inverse_blocks.exists?
end
end

View File

@@ -119,14 +119,18 @@ class Planting < ApplicationRecord
end
def nearby_same_crop
return Planting.none if location.blank? || latitude.blank? || longitude.blank?
return @nearby_same_crop if defined?(@nearby_same_crop)
# latitude, longitude = Geocoder.coordinates(location, params: { limit: 1 })
Planting.joins(:garden)
.where(crop:)
.located
.where('gardens.latitude < ? AND gardens.latitude > ?',
latitude + 10, latitude - 10)
@nearby_same_crop = if location.blank? || latitude.blank? || longitude.blank?
Planting.none
else
# latitude, longitude = Geocoder.coordinates(location, params: { limit: 1 })
Planting.joins(:garden)
.where(crop:)
.located
.where('gardens.latitude < ? AND gardens.latitude > ?',
latitude + 10, latitude - 10)
end
end
private

View File

@@ -49,9 +49,10 @@ class Post < ApplicationRecord
# return posts sorted by recent activity
def self.recently_active
Post.order(created_at: :desc).sort do |a, b|
b.recent_activity <=> a.recent_activity
end
left_joins(:comments)
.select('posts.*, COALESCE(MAX(comments.created_at), posts.created_at) AS last_activity_at')
.group('posts.id')
.order(Arel.sql('last_activity_at DESC'))
end
def owner_id

View File

@@ -85,7 +85,7 @@ class GbifService
end
def import!
Crop.order(updated_at: :desc).each do |crop|
Crop.order(updated_at: :desc).find_each do |crop|
Rails.logger.debug { "#{crop.id}, #{crop.name}" }
update_crop(crop) if crop.valid?
rescue ActiveRecord::RecordInvalid

View File

@@ -3,7 +3,7 @@
- content_for :breadcrumbs do
- if @owner
%li.breadcrumb-item= link_to 'Activities', activities_path
%li.breadcrumb-item.active= link_to "#{@owner}'s activities", activities_path(owner: @owner)
%li.breadcrumb-item.active= link_to "#{@owner}'s activities", member_activities_path(@owner)
- else
%li.breadcrumb-item.active= link_to 'Activities', activities_path

View File

@@ -22,8 +22,8 @@
.form-group
= f.label :crop_id, class: 'control-label col-md-2'
.col-md-8
= collection_select(:alternate_name, :crop_id,
Crop.all, :id, :name,
= select(:alternate_name, :crop_id,
Crop.order(:name).pluck(:name, :id),
{ selected: @alternate_name.crop_id || @crop.id },
class: 'form-control')

View File

@@ -1,6 +1,9 @@
- if crop.approved? && signed_in?
- active_plantings = current_member.plantings.where(crop: crop).active
.btn-group.crop-actions{"aria-label" => "Crop Actions", role: "group"}
= render 'plantings/modal', planting: Planting.new(crop: crop, owner: current_member)
= render 'harvests/modal', harvest: Harvest.new(crop: @crop, owner: current_member)
= render 'seeds/modal', seed: Seed.new(crop: @crop, owner: current_member)
- if active_plantings.any?
= render 'plantings/failed_modal', crop: crop, active_plantings: active_plantings

View File

@@ -85,7 +85,7 @@
-# Only crop wranglers see the crop hierarchy (for now)
- if can? :wrangle, @crop
= f.collection_select(:parent_id, Crop.all.order(:name), :id, :name,
= f.select(:parent_id, Crop.order(:name).pluck(:name, :id),
{ include_blank: true, label: 'Parent crop'})
%span.help-block Optional. For setting up crop hierarchies for varieties etc.

View File

@@ -5,14 +5,15 @@
%p Nobody has harvested this crop yet.
- unless crop.harvests.empty?
%ul.list-group.list-group-flush
- Harvest.where(crop: crop).includes(:owner).order(harvested_at: :desc).limit(3).each do |harvest|
%li.list-group-item
= link_to harvest_path(harvest), class: 'card-link' do
= harvest_icon
#{harvest.owner} harvested #{display_quantity(harvest)}.
.float-right= render 'members/location', member: harvest.owner
.harvest-timeago
%small #{standard_time_distance(harvest.harvested_at, Time.zone.now.to_date)}
- Rails.cache.fetch([crop, "recent_harvests", Time.zone.today]) do
- Harvest.where(crop: crop).includes(:owner).order(harvested_at: :desc).limit(3).each do |harvest|
%li.list-group-item
= link_to harvest_path(harvest), class: 'card-link' do
= harvest_icon
#{harvest.owner} harvested #{display_quantity(harvest)}.
.float-right= render 'members/location', member: harvest.owner
.harvest-timeago
%small #{standard_time_distance(harvest.harvested_at, Time.zone.now.to_date)}
%li.list-group-item= link_to "View all #{crop.name} harvests", crop_harvests_path(crop), class: 'card-link'
- if crop.approved?
- if current_member

View File

@@ -8,6 +8,8 @@
= link_to planting_path(planting), class: 'card-link' do
= planting_icon
= planting
- if can?(:edit, planting)
.float-right= render 'plantings/actions', planting: planting
.float-right= render 'members/location', member: planting.owner
.card-footer
- if crop.approved?

View File

@@ -73,10 +73,11 @@
= pie_chart crop_harvested_for_path(@crop, format: :json), legend: "bottom"
- if @crop.varieties.any?
%section.varieties
%h2 Varieties
.index-cards
= render 'varieties', crop: @crop
- cache [@crop, 'varieties'] do
%section.varieties
%h2 Varieties
.index-cards
= render 'varieties', crop: @crop
%section.crop-map
%h2
@@ -134,9 +135,11 @@
= render 'harvests', crop: @crop
= render 'find_seeds', crop: @crop
= render 'openfarm_data', crop: @crop
- cache [@crop, 'openfarm_data'] do
= render 'openfarm_data', crop: @crop
= render 'nutritional_data', crop: @crop
- cache [@crop, 'nutritional_data'] do
= render 'nutritional_data', crop: @crop
= cute_icon
.card

View File

@@ -1,3 +1,4 @@
%p
%small
= harvest.harvested_at
- cache harvest do
%p
%small
= harvest.harvested_at

View File

@@ -4,7 +4,7 @@
- content_for :breadcrumbs do
- if @owner
%li.breadcrumb-item= link_to 'Harvests', harvests_path
%li.breadcrumb-item.active= link_to "#{@owner}'s harvests", harvests_path(owner: @owner)
%li.breadcrumb-item.active= link_to "#{@owner}'s harvests", member_harvests_path(@owner)
- else
%li.breadcrumb-item.active= link_to "Harvests", harvests_path
.row

View File

@@ -21,13 +21,14 @@
Please select a photo from your recent uploads.
- if @sets && !@sets.empty?
%p
= bootstrap_form_tag(url: new_photo_path, method: :get, layout: :inline) do |f|
= f.select :set, options_for_select(@sets, @current_set), label: "Choose a photo album"
= hidden_field_tag :type, @type
= hidden_field_tag :id, @id
= f.submit "Search", class: "btn btn-success"
%p
= bootstrap_form_tag(url: new_photo_path, method: :get, layout: :inline) do |f|
- if @sets && !@sets.empty?
= f.select :set, options_for_select(@sets, @current_set), label: "Choose a photo album", include_blank: true
= f.text_field :tag, value: @current_tag, label: "or search by tag"
= hidden_field_tag :type, @type
= hidden_field_tag :id, @id
= f.submit "Search", class: "btn btn-success"
- if @sets && @current_set
%h2= @sets.key(@current_set)

View File

@@ -0,0 +1,26 @@
#modelFailedPlantingForm.modal.fade{"aria-hidden" => "true", "aria-labelledby" => "failed-planting-button", role: "dialog", tabindex: "-1"}
.modal-dialog{role: "document"}
.modal-content
.modal-header.text-center
%h4.modal-title.w-100.font-weight-bold Mark #{crop.name} planting as failed
%button.close{"aria-label" => "Close", "data-bs-dismiss" => "modal", type: "button"}
%span{"aria-hidden" => "true"} &#215;
.modal-body
%p Which planting would you like to mark as failed?
%ul.list-group
- active_plantings.each do |planting|
%li.list-group-item
= link_to planting_path(planting, planting: {failed: 1}), method: :put do
.d-flex.justify-content-between
%span
%h4= planting.garden.name
%p Planted #{planting.planted_at}
%span
= finished_icon
.mt-3.text-right
= link_to 'cancel', '', "data-bs-dismiss" => "modal", class: 'btn btn-secondary'
%a.btn#failed-planting-button{"data-bs-target" => "#modelFailedPlantingForm", "data-bs-toggle" => "modal", href: ""}
= finished_icon
Mark as failed

View File

@@ -4,7 +4,7 @@
- content_for :breadcrumbs do
- if @owner
%li.breadcrumb-item= link_to 'Plantings', plantings_path
%li.breadcrumb-item.active= link_to "#{@owner}'s plantings", plantings_path(owner: @owner)
%li.breadcrumb-item.active= link_to "#{@owner}'s plantings", member_plantings_path(@owner)
- else
%li.breadcrumb-item.active= link_to 'Plantings', plantings_path

View File

@@ -17,8 +17,8 @@
.form-group
= f.label :crop_id, class: 'control-label col-md-2'
.col-md-8
= collection_select(:scientific_name, :crop_id, Crop.all.order(:name), :id,
:name, { selected: @scientific_name.crop_id || @crop.id },
= select(:scientific_name, :crop_id, Crop.order(:name).pluck(:name, :id),
{ selected: @scientific_name.crop_id || @crop.id },
class: 'form-control')
.form-group
= f.label :name, class: 'control-label col-md-2'

View File

@@ -1,7 +1,7 @@
- content_for :breadcrumbs do
- if @owner
%li.breadcrumb-item= link_to 'Seeds', seeds_path
%li.breadcrumb-item.active= link_to "#{@owner}'s seeds", seeds_path(owner: @owner)
%li.breadcrumb-item.active= link_to "#{@owner}'s seeds", member_seeds_path(@owner)
- else
%li.breadcrumb-item.active= link_to 'Seeds', seeds_path

View File

@@ -73,6 +73,8 @@ module Growstuff
config.newsletter_list_id = ENV.fetch('GROWSTUFF_MAILCHIMP_NEWSLETTER_ID', nil)
# config.active_record.raise_in_transactional_callbacks = true
config.middleware.insert_before 0, Rack::Attack
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
class Rack::Attack
### Throttle Config ###
if Rails.env.production?
# Throttle requests to /plantings, /harvests, and /members to 10 per minute per IP
# Includes API routes
throttle('req/ip/restricted_routes', limit: 20, period: 1.minute) do |req|
if req.path =~ %r{^/(plantings|harvests|members)(/|$)} || req.path =~ %r{^/api/v1/(plantings|harvests|members)(/|$)}
req.ip
end
end
### Fail2Ban Config ###
# Block IPs that make too many requests to suspicious paths
# After 5 "bad" requests in 10 minutes, block the IP for 1 hour
blocklist('fail2ban/pentesters') do |req|
Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 5, findtime: 10.minutes, bantime: 1.hour) do
# The count for the IP is incremented if the return value is truthy.
req.path.include?('wp-admin') ||
req.path.include?('wp-login') ||
req.path.include?('cgi-bin') ||
req.path.end_with?('.php', '.asp', '.aspx', '.jsp', '.exe', '.env', '.git')
end
end
end
### Custom Response Headers ###
# Add Retry-After header to throttled responses
self.throttled_response_retry_after_header = true
end

View File

@@ -8,6 +8,6 @@ class CreateBlocks < ActiveRecord::Migration[6.1]
t.timestamps
end
add_index :blocks, [:blocker_id, :blocked_id], unique: true
add_index :blocks, %i(blocker_id blocked_id), unique: true
end
end

33
lib/tasks/members.rake Normal file
View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
namespace :members do
desc "Remove inactive members with no activity and last login > 24 months ago"
# usage: rake members:cleanup_inactive
# usage: DRY_RUN=true rake members:cleanup_inactive
task cleanup_inactive: :environment do
limit_date = 3.years.ago
dry_run = ENV.fetch('DRY_RUN', 'false') == 'true'
inactive_members = Member.where("last_sign_in_at < ? OR (last_sign_in_at IS NULL AND created_at < ?)", limit_date, limit_date)
count = 0
inactive_members.find_each do |member|
# Check for activity using the model method
unless member.has_activity?
if dry_run
puts "[DRY RUN] Would delete inactive member: #{member.login_name} (ID: #{member.id}, Last login: #{member.last_sign_in_at || 'Never'}, Created: #{member.created_at})"
else
puts "Deleting inactive member: #{member.login_name} (ID: #{member.id}, Last login: #{member.last_sign_in_at || 'Never'}, Created: #{member.created_at})"
member.destroy
end
count += 1
end
end
if dry_run
puts "Total inactive members that would be deleted: #{count}"
else
puts "Total inactive members deleted: #{count}"
end
end
end

View File

@@ -123,13 +123,27 @@ Disallow: /
User-agent: WebReaper
Disallow: /
# Per their statement, semrushbot respects crawl-delay directives
# We want them to overall stay within reasonable request rates to
# the backend (20 rps); keeping in mind that the crawl-delay will
# be applied by site and not globally by the bot, 5 seconds seem
# like a reasonable approximation
# Semrush seem to crawl everything.
User-agent: SemrushBot
Crawl-delay: 5
Disallow: /
User-agent: SiteAuditBot
Disallow: /
User-agent: SemrushBot-BA
Disallow: /
User-agent: SemrushBot-SI
Disallow: /
User-agent: SemrushBot-SWA
Disallow: /
User-agent: SplitSignalBot
Disallow: /
User-agent: SemrushBot-OCOB
Disallow: /
#
# Friendly, low-speed bots are welcome viewing pages, but not

View File

@@ -7,21 +7,42 @@ describe Charts::CropsController do
let(:crop) { create(:crop) }
describe 'sunniness' do
before { get :sunniness, params: { crop_slug: crop.to_param } }
it "returns a successful response" do
get :sunniness, params: { crop_slug: crop.to_param }
expect(response).to be_successful
end
it { expect(response).to be_successful }
it "caches the result" do
cache_key = "#{crop.cache_key_with_version}/sunniness"
expect(Rails.cache).to receive(:fetch).with(cache_key, expires_in: 1.day).and_call_original
get :sunniness, params: { crop_slug: crop.to_param }
end
end
describe 'planted_from' do
before { get :planted_from, params: { crop_slug: crop.to_param } }
it "returns a successful response" do
get :planted_from, params: { crop_slug: crop.to_param }
expect(response).to be_successful
end
it { expect(response).to be_successful }
it "caches the result" do
cache_key = "#{crop.cache_key_with_version}/planted_from"
expect(Rails.cache).to receive(:fetch).with(cache_key, expires_in: 1.day).and_call_original
get :planted_from, params: { crop_slug: crop.to_param }
end
end
describe 'harvested_for' do
before { get :harvested_for, params: { crop_slug: crop.to_param } }
it "returns a successful response" do
get :harvested_for, params: { crop_slug: crop.to_param }
expect(response).to be_successful
end
it { expect(response).to be_successful }
it "caches the result" do
cache_key = "#{crop.cache_key_with_version}/harvested_for"
expect(Rails.cache).to receive(:fetch).with(cache_key, expires_in: 1.day).and_call_original
get :harvested_for, params: { crop_slug: crop.to_param }
end
end
end
end

View File

@@ -49,9 +49,9 @@ describe ForumsController do
let(:valid_attributes) { { name: "New Forum", description: "A new forum", owner_id: admin.id } }
it "creates a new Forum" do
expect {
expect do
post :create, params: { forum: valid_attributes }
}.to change(Forum, :count).by(1)
end.to change(Forum, :count).by(1)
end
it "redirects to the created forum" do
@@ -81,9 +81,9 @@ describe ForumsController do
describe "DELETE #destroy" do
it "destroys the requested forum" do
forum # ensure forum exists
expect {
expect do
delete :destroy, params: { id: forum.to_param }
}.to change(Forum, :count).by(-1)
end.to change(Forum, :count).by(-1)
end
it "redirects to the forums list" do

View File

@@ -60,6 +60,7 @@ describe PhotosController, :search do
sign_in member
member.stub(:flickr_photos) { [[], 0] }
member.stub(:flickr_sets) { { "foo" => "bar" } }
member.stub(:flickr_auth_valid?) { true }
controller.stub(:current_member) { member }
end
@@ -85,6 +86,16 @@ describe PhotosController, :search do
it { expect(assigns(:item)).to eq garden }
it { expect(flash[:alert]).not_to be_present }
end
describe "filtering by tag" do
let(:tag) { "tomato" }
it "passes the tag to flickr_photos" do
expect(member).to receive(:flickr_photos).with(anything, nil, tag).and_return([[], 0])
get :new, params: { type: "planting", id: planting.id, tag: tag }
expect(assigns(:current_tag)).to eq tag
end
end
end
describe "POST create" do

View File

@@ -9,18 +9,18 @@ describe PlacesController do
describe "GET show" do
before do
@member_london = create(:london_member)
@member_south_pole = create(:south_pole_member)
@london_member = create(:london_member)
@edinburgh_member = create(:edinburgh_member)
end
it "assigns place name" do
get :show, params: { place: @member_london.location }
assigns(:place).should eq @member_london.location
get :show, params: { place: @london_member.location }
assigns(:place).should eq @london_member.location
end
it "assigns nearby members" do
get :show, params: { place: @member_london.location }
assigns(:nearby_members).should eq [@member_london, @member_south_pole]
get :show, params: { place: @london_member.location }
assigns(:nearby_members).should eq [@london_member, @edinburgh_member]
end
end

View File

@@ -18,8 +18,8 @@ describe PlantPart do
@h2 = create(:harvest,
crop: @maize,
plant_part: @pp1)
@pp1.crops.should include @tomato
@pp1.crops.should include @maize
expect(@pp1.crops).to include @tomato
expect(@pp1.crops).to include @maize
end
it "doesn't duplicate crops" do
@@ -31,6 +31,6 @@ describe PlantPart do
@h2 = create(:harvest,
crop: @maize,
plant_part: @pp1)
@pp1.crops.should eq [@maize]
expect(@pp1.crops).to eq [@maize]
end
end

View File

@@ -7,35 +7,35 @@ describe Seed do
let(:seed) { build(:seed, owner:) }
it 'saves a basic seed' do
seed.save.should be(true)
expect(seed.save).to be(true)
end
it "has a slug" do
seed.save
seed.slug.should match(/tamateapokaiwhenua-magic-bean/)
expect(seed.slug).to match(/tamateapokaiwhenua-magic-bean/)
end
context 'quantity' do
it 'allows integer quantities' do
@seed = build(:seed, quantity: 99)
@seed.should be_valid
expect(@seed).to be_valid
end
it "doesn't allow decimal quantities" do
@seed = build(:seed, quantity: 99.9)
@seed.should_not be_valid
expect(@seed).not_to be_valid
end
it "doesn't allow non-numeric quantities" do
@seed = build(:seed, quantity: 'foo')
@seed.should_not be_valid
expect(@seed).not_to be_valid
end
it "allows blank quantities" do
@seed = build(:seed, quantity: nil)
@seed.should be_valid
expect(@seed).to be_valid
@seed = build(:seed, quantity: '')
@seed.should be_valid
expect(@seed).to be_valid
end
end
@@ -43,14 +43,14 @@ describe Seed do
it 'all valid tradable_to values should work' do
%w(nowhere locally nationally internationally).each do |t|
@seed = build(:seed, tradable_to: t)
@seed.should be_valid
expect(@seed).to be_valid
end
end
it 'refuses invalid tradable_to values' do
@seed = build(:seed, tradable_to: 'not valid')
@seed.should_not be_valid
@seed.errors[:tradable_to].should include(
expect(@seed).not_to be_valid
expect(@seed.errors[:tradable_to]).to include(
"You may only trade seed nowhere, locally, " \
"nationally, or internationally"
)
@@ -58,35 +58,35 @@ describe Seed do
it 'does not allow nil or blank values' do
@seed = build(:seed, tradable_to: nil)
@seed.should_not be_valid
expect(@seed).not_to be_valid
@seed = build(:seed, tradable_to: '')
@seed.should_not be_valid
expect(@seed).not_to be_valid
end
it 'tradable gives the right answers' do
@seed = create(:seed, tradable_to: 'nowhere')
@seed.tradable.should be false
expect(@seed.tradable).to be false
@seed = create(:seed, tradable_to: 'locally')
@seed.tradable.should be true
expect(@seed.tradable).to be true
@seed = create(:seed, tradable_to: 'nationally')
@seed.tradable.should be true
expect(@seed.tradable).to be true
@seed = create(:seed, tradable_to: 'internationally')
@seed.tradable.should be true
expect(@seed.tradable).to be true
end
it 'recognises a tradable seed' do
create(:tradable_seed).tradable.should == true
expect(create(:tradable_seed).tradable).to be true
end
it 'recognises an untradable seed' do
create(:untradable_seed).tradable.should == false
expect(create(:untradable_seed).tradable).to be false
end
it 'scopes correctly' do
@tradable = create(:tradable_seed)
@untradable = create(:untradable_seed)
described_class.tradable.should include @tradable
described_class.tradable.should_not include @untradable
expect(described_class.tradable).to include @tradable
expect(described_class.tradable).not_to include @untradable
end
end
@@ -95,7 +95,7 @@ describe Seed do
['certified organic', 'non-certified organic',
'conventional/non-organic', 'unknown'].each do |t|
@seed = build(:seed, organic: t)
@seed.should be_valid
expect(@seed).to be_valid
end
end
@@ -103,31 +103,31 @@ describe Seed do
['certified GMO-free', 'non-certified GMO-free',
'GMO', 'unknown'].each do |t|
@seed = build(:seed, gmo: t)
@seed.should be_valid
expect(@seed).to be_valid
end
end
it 'all valid heirloom values should work' do
%w(heirloom hybrid unknown).each do |t|
@seed = build(:seed, heirloom: t)
@seed.should be_valid
expect(@seed).to be_valid
end
end
it 'refuses invalid organic/GMO/heirloom values' do
%i(organic gmo heirloom).each do |field|
@seed = build(:seed, field => 'not valid')
@seed.should_not be_valid
@seed.errors[field].should_not be_empty
expect(@seed).not_to be_valid
expect(@seed.errors[field]).not_to be_empty
end
end
it 'does not allow nil or blank values' do
%i(organic gmo heirloom).each do |field|
@seed = build(:seed, field => nil)
@seed.should_not be_valid
expect(@seed).not_to be_valid
@seed = build(:seed, field => '')
@seed.should_not be_valid
expect(@seed).not_to be_valid
end
end
end
@@ -136,13 +136,13 @@ describe Seed do
it 'returns seeds with a plant_before date in the past' do
expired_seed = create(:seed, plant_before: 1.day.ago)
not_expired_seed = create(:seed, plant_before: 1.day.from_now)
described_class.expired.should include expired_seed
described_class.expired.should_not include not_expired_seed
expect(described_class.expired).to include expired_seed
expect(described_class.expired).not_to include not_expired_seed
end
it 'does not return finished seeds' do
expired_seed = create(:seed, plant_before: 1.day.ago, finished: true)
described_class.expired.should_not include expired_seed
expect(described_class.expired).not_to include expired_seed
end
end
@@ -158,11 +158,11 @@ describe Seed do
@seed3 = create(:tradable_seed)
@seed4 = create(:seed)
described_class.interesting.should include @seed1
described_class.interesting.should_not include @seed2
described_class.interesting.should_not include @seed3
described_class.interesting.should_not include @seed4
described_class.interesting.size.should == 1
expect(described_class.interesting).to include @seed1
expect(described_class.interesting).not_to include @seed2
expect(described_class.interesting).not_to include @seed3
expect(described_class.interesting).not_to include @seed4
expect(described_class.interesting.size).to eq 1
end
end
@@ -172,7 +172,7 @@ describe Seed do
before { seed.photos << create(:photo, owner: seed.owner) }
it 'is found in has_photos scope' do
described_class.has_photos.should include(seed)
expect(described_class.has_photos).to include(seed)
end
end

View File

@@ -37,9 +37,9 @@ describe "Forums" do
before { sign_in admin }
it "creates a new forum" do
expect {
expect do
post forums_path, params: { forum: { name: "New Request Forum", description: "Desc", owner_id: admin.id } }
}.to change(Forum, :count).by(1)
end.to change(Forum, :count).by(1)
expect(response).to redirect_to(forum_path(Forum.last))
end
end

View File

@@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
require 'rake'
describe 'members:cleanup_inactive' do
before :all do
Rails.application.load_tasks
end
let(:cleanup_task) { Rake::Task['members:cleanup_inactive'] }
before do
cleanup_task.reenable
end
it "deletes inactive members with no gardens and no other activity" do
inactive_no_activity = create(:member, last_sign_in_at: 25.months.ago)
# We must explicitly remove the default garden to test the "no gardens" condition
inactive_no_activity.gardens.destroy_all
expect(inactive_no_activity.gardens.count).to eq(0)
cleanup_task.invoke
expect(Member.exists?(inactive_no_activity.id)).to be_falsey
end
it "does not delete inactive members with a garden (even if empty)" do
inactive_with_garden = create(:member, last_sign_in_at: 25.months.ago)
# They have 1 default garden
expect(inactive_with_garden.gardens.count).to eq(1)
cleanup_task.invoke
expect(Member.exists?(inactive_with_garden.id)).to be_truthy
end
it "does not delete members with recent login" do
recent_member = create(:member, last_sign_in_at: 1.month.ago)
recent_member.gardens.destroy_all
cleanup_task.invoke
expect(Member.exists?(recent_member.id)).to be_truthy
end
it "does not delete inactive members with activity (posts)" do
inactive_with_post = create(:member, last_sign_in_at: 25.months.ago)
inactive_with_post.gardens.destroy_all
create(:post, author: inactive_with_post)
cleanup_task.invoke
expect(Member.exists?(inactive_with_post.id)).to be_truthy
end
it "honors DRY_RUN environment variable" do
inactive_no_activity = create(:member, last_sign_in_at: 25.months.ago)
inactive_no_activity.gardens.destroy_all
ENV['DRY_RUN'] = 'true'
cleanup_task.invoke
ENV['DRY_RUN'] = nil
expect(Member.exists?(inactive_no_activity.id)).to be_truthy
end
end

View File

@@ -15,11 +15,11 @@ describe 'comments/index.rss.haml' do
end
it 'shows RSS feed title' do
rendered.should have_content "Recent comments on all posts"
expect(rendered).to have_content "Recent comments on all posts"
end
it 'shows item title' do
rendered.should have_content "Comment by #{@author.login_name}"
expect(rendered).to have_content "Comment by #{@author.login_name}"
end
it 'escapes html for link to post' do
@@ -28,6 +28,6 @@ describe 'comments/index.rss.haml' do
end
it 'shows content of comments' do
rendered.should have_content "OMG LOL"
expect(rendered).to have_content "OMG LOL"
end
end

View File

@@ -13,7 +13,7 @@ describe "crops/_grown_for" do
it 'shows plant parts' do
render partial: 'crops/grown_for', locals: { crop: }
rendered.should have_content plant_path.name
expect(rendered).to have_content plant_path.name
assert_select "a", href: plant_part_path(plant_path)
end
end

View File

@@ -12,10 +12,10 @@ describe "crops/_popover" do
end
it 'has a scientific name' do
rendered.should have_content 'Solanum lycopersicum'
expect(rendered).to have_content 'Solanum lycopersicum'
end
it 'shows count of plantings' do
rendered.should have_content '1 time'
expect(rendered).to have_content '1 time'
end
end

View File

@@ -33,7 +33,7 @@ describe "crops/index.html.haml" do
context "downloads" do
it "offers data downloads" do
render
rendered.should have_content "The data on this page is available in the following formats:"
expect(rendered).to have_content "The data on this page is available in the following formats:"
assert_select "a", href: crops_path(format: 'csv')
assert_select "a", href: crops_path(format: 'json')
assert_select "a", href: crops_path(format: 'rss')

View File

@@ -13,11 +13,11 @@ describe 'crops/index.rss.haml' do
end
it 'shows RSS feed title' do
rendered.should have_content "Recently added crops"
expect(rendered).to have_content "Recently added crops"
end
it 'shows names of crops' do
rendered.should have_content @tomato.name
rendered.should have_content @maize.name
expect(rendered).to have_content @tomato.name
expect(rendered).to have_content @maize.name
end
end

View File

@@ -10,6 +10,6 @@ describe 'devise/confirmations/new.html.haml', type: "view" do
end
it 'contains a login field' do
rendered.should have_content "Enter either your login name or your email address"
expect(rendered).to have_content "Enter either your login name or your email address"
end
end

View File

@@ -10,11 +10,11 @@ describe 'devise/mailer/confirmation_instructions.html.haml', type: "view" do
end
it 'has a confirmation link' do
rendered.should have_content 'Confirm my account'
expect(rendered).to have_content 'Confirm my account'
end
it 'has a link to the homepage' do
rendered.should have_content root_url
expect(rendered).to have_content root_url
end
end
end

View File

@@ -12,8 +12,8 @@ describe 'devise/mailer/reset_password_instructions.html.haml', type: "view" do
end
it 'has some of the right text' do
rendered.should have_content 'Change my password'
rendered.should have_content 'Someone has requested a link to reset your password'
expect(rendered).to have_content 'Change my password'
expect(rendered).to have_content 'Someone has requested a link to reset your password'
end
end
end

View File

@@ -9,11 +9,11 @@ describe 'devise/mailer/unlock_instructions.html.haml', type: "view" do
end
it "explains what's happened" do
rendered.should have_content "account has been locked"
expect(rendered).to have_content "account has been locked"
end
it "has an unlock link" do
rendered.should have_content "Unlock my account"
expect(rendered).to have_content "Unlock my account"
end
end
end

View File

@@ -16,7 +16,7 @@ describe 'devise/registrations/edit.html.haml', type: "view" do
it 'has some fields' do
render
rendered.should have_content 'Email'
expect(rendered).to have_content 'Email'
end
context 'email section' do

View File

@@ -13,7 +13,7 @@ describe 'devise/registrations/new.html.haml', type: "view" do
end
it 'has some fields' do
rendered.should have_content 'Email'
expect(rendered).to have_content 'Email'
end
it 'has a checkbox for newsletter subscription' do

View File

@@ -13,8 +13,8 @@ describe 'devise/sessions/new.html.haml', type: "view" do
end
it 'has some fields' do
rendered.should have_content 'Remember me'
rendered.should have_content 'Password'
expect(rendered).to have_content 'Remember me'
expect(rendered).to have_content 'Password'
end
end
end

View File

@@ -10,7 +10,7 @@ describe 'home/_blurb.html.haml', type: "view" do
end
it 'has description' do
rendered.should have_content 'is a community of food gardeners'
expect(rendered).to have_content 'is a community of food gardeners'
end
it 'has signup section' do
@@ -19,7 +19,7 @@ describe 'home/_blurb.html.haml', type: "view" do
end
it 'has a link to sign in' do
rendered.should have_content "Or sign in if you already have an account"
expect(rendered).to have_content "Or sign in if you already have an account"
assert_select "a", href: new_member_session_path
end
end

View File

@@ -5,6 +5,6 @@ require 'rails_helper'
describe 'home/_stats.html.haml', type: "view" do
it 'has activity stats' do
render
rendered.should have_content "So far, 0 members have planted 0 crops"
expect(rendered).to have_content "So far, 0 members have planted 0 crops"
end
end

View File

@@ -29,8 +29,8 @@ describe 'home/index.html.haml', type: "view" do
end
it 'show interesting members' do
rendered.should have_content @member.login_name
rendered.should have_content @member.location
expect(rendered).to have_content @member.login_name
expect(rendered).to have_content @member.location
end
end
end

View File

@@ -14,24 +14,24 @@ describe 'layouts/_header.html.haml', type: "view" do
end
it 'has signup/signin links' do
rendered.should have_content 'Sign up'
rendered.should have_content 'Sign in'
expect(rendered).to have_content 'Sign up'
expect(rendered).to have_content 'Sign in'
end
it 'has a Crops link' do
rendered.should have_content "Crops"
expect(rendered).to have_content "Crops"
end
it 'has a Seeds link' do
rendered.should have_content "Seeds"
expect(rendered).to have_content "Seeds"
end
it 'has a Places link' do
rendered.should have_content "Community Map"
expect(rendered).to have_content "Community Map"
end
it 'has a Community section' do
rendered.should have_content "Community"
expect(rendered).to have_content "Community"
end
it 'links to members' do
@@ -62,7 +62,7 @@ describe 'layouts/_header.html.haml', type: "view" do
context "login name" do
it 'has member login name' do
rendered.should have_content @member.login_name.to_s
expect(rendered).to have_content @member.login_name.to_s
end
it "shows link to member's gardens" do
@@ -83,19 +83,19 @@ describe 'layouts/_header.html.haml', type: "view" do
end
it 'shows signout link' do
rendered.should have_content 'Sign out'
expect(rendered).to have_content 'Sign out'
end
it 'shows inbox link' do
rendered.should have_content 'Inbox'
rendered.should_not match(/Inbox \d+/)
expect(rendered).to have_content 'Inbox'
expect(rendered).not_to match(/Inbox \d+/)
end
context 'has notifications' do
it 'shows inbox count' do
create(:notification, recipient: @member)
render
rendered.should have_content 'Inbox 1'
expect(rendered).to have_content 'Inbox 1'
end
end
end

View File

@@ -11,6 +11,6 @@ describe 'layouts/application.html.haml', type: "view" do
Rails.application.config.analytics_code = '<script>console.log("foo!");</script>'
render
assert_select "script", text: 'console.log("foo!");'
rendered.should_not have_content 'script'
expect(rendered).to have_no_content 'script'
end
end

View File

@@ -109,7 +109,7 @@ describe "photos/show" do
end
it "contains the phrase 'All rights reserved'" do
rendered.should have_content "All rights reserved"
expect(rendered).to have_content "All rights reserved"
end
end
end

View File

@@ -22,7 +22,7 @@ describe "places/show" do
it "shows the names of nearby members" do
@nearby_members.each do |m|
rendered.should have_content m.login_name
expect(rendered).to have_content m.login_name
end
end
end

View File

@@ -45,7 +45,7 @@ describe "plantings/index.html.haml" do
it "provides data links" do
render
rendered.should have_content "The data on this page is available in the following formats:"
expect(rendered).to have_content "The data on this page is available in the following formats:"
assert_select "a", href: plantings_path(format: 'csv')
assert_select "a", href: plantings_path(format: 'json')
assert_select "a", href: plantings_path(format: 'rss')

View File

@@ -54,11 +54,11 @@ describe "posts/_single" do
end
it "shows edited at" do
rendered.should have_content "edited at"
expect(rendered).to have_content "edited at"
end
it "shows the updated time" do
rendered.should have_content @post.updated_at.to_fs(:default)
expect(rendered).to have_content @post.updated_at.to_fs(:default)
end
end
@@ -74,11 +74,11 @@ describe "posts/_single" do
end
it "shows edited at time" do
rendered.should have_content "edited at"
expect(rendered).to have_content "edited at"
end
it "shows updated time" do
rendered.should have_content @comment.updated_at
expect(rendered).to have_content @comment.updated_at
end
end
@@ -93,7 +93,7 @@ describe "posts/_single" do
end
it "does not show edited at" do
rendered.should_not have_content "edited at #{@post.updated_at}"
expect(rendered).to have_no_content "edited at #{@post.updated_at}"
end
end
@@ -109,7 +109,7 @@ describe "posts/_single" do
end
it "does not show edited at" do
rendered.should_not have_content "edited at #{@comment.updated_at}"
expect(rendered).to have_no_content "edited at #{@comment.updated_at}"
end
end
end

View File

@@ -27,7 +27,7 @@ describe "posts/edit" do
end
it 'no forum mentioned' do
rendered.should_not have_content "This post will be posted in the forum"
expect(rendered).to have_no_content "This post will be posted in the forum"
end
context "forum specified" do
@@ -44,7 +44,7 @@ describe "posts/edit" do
end
it 'tells the user what forum it will be posted in' do
rendered.should have_content "This post will be posted in the forum #{@forum.name}"
expect(rendered).to have_content "This post will be posted in the forum #{@forum.name}"
end
end
end

View File

@@ -14,19 +14,19 @@ describe 'posts/show.rss.haml' do
end
it 'shows RSS feed title' do
rendered.should have_content "Recent comments on #{@post.subject}"
expect(rendered).to have_content "Recent comments on #{@post.subject}"
end
it 'shows item title' do
rendered.should have_content "Comment by #{@author.login_name}"
expect(rendered).to have_content "Comment by #{@author.login_name}"
end
it 'escapes html for link to post' do
# it's then unescaped by 'render' so we don't actually look for &lt;
rendered.should have_content '<a href='
expect(rendered).to have_content '<a href='
end
it 'shows content of comments' do
rendered.should have_content "OMG LOL"
expect(rendered).to have_content "OMG LOL"
end
end