Compare commits

..

41 Commits

Author SHA1 Message Date
Daniel O'Connor
6ee86b6b38 Merge branch 'dev' into refactor-hardcoded-emails 2026-04-27 00:36:06 +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
google-labs-jules[bot]
3b60e8f974 Implement blocking feature (#4199)
* Implement blocking feature

This commit introduces a blocking feature that allows members to block other members.

A blocked member is prevented from:
- following the blocker
- sending private messages to the blocker
- replying to the blocker's posts
- liking the blocker's content

The implementation includes:
- A new `Block` model and a corresponding database table.
- Updates to the `Member` model to include associations for blocks.
- A new `BlocksController` to handle blocking and unblocking actions.
- New routes for the `BlocksController`.
- UI changes to add block/unblock buttons to the member profile page.
- Validations in the `Follow`, `Comment`, and `Like` models to enforce the blocking rules.
- A check in the `MessagesController` to prevent sending messages to a member who has blocked the sender.
- A callback in the `Block` model to destroy the follow relationship when a block is created.
- New feature and model specs to test the blocking functionality.

* Implement blocking feature and fix failing tests

This commit introduces a blocking feature that allows members to block other members.

A blocked member is prevented from:
- following the blocker
- sending private messages to the blocker
- replying to the blocker's posts
- liking the blocker's content

The implementation includes:
- A new `Block` model and a corresponding database table.
- Updates to the `Member` model to include associations for blocks.
- A new `BlocksController` to handle blocking and unblocking actions.
- New routes for the `BlocksController`.
- UI changes to add block/unblock buttons to the member profile page.
- Validations in the `Follow`, `Comment`, and `Like` models to enforce the blocking rules.
- A check in the `MessagesController` to prevent sending messages to a member who has blocked the sender.
- A callback in the `Block` model to destroy the follow relationship when a block is created.
- New feature and model specs to test the blocking functionality.

This commit also fixes a failing test in the blocking feature. The error was caused by the validation being called even when the `member` association was `nil`. A guard has been added to the validation methods in the `Like`, `Follow`, and `Comment` models to prevent this from happening.

* Generate schema

* Fix tests

* Add permissions

* Define Block permissions in Ability model

The feature specs for member blocking were failing because the "Block"
link was not being rendered on member profiles. This was due to the
lack of explicit create and destroy permissions for the Block resource
in the Ability model, which is used by CanCanCan to authorize actions
and by the view to conditionally show links.

This change adds the necessary permissions to `member_abilities`:
- Allows members to create blocks (except for blocking themselves).
- Allows members to destroy blocks where they are the blocker.

These rules ensure that the "Block" and "Unblock" links are correctly
rendered and authorized for signed-in members.

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

* Comment out specs for now

---------

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: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-26 14:22:32 +09:30
google-labs-jules[bot]
7ed3a97263 Improve test coverage of ability_spec (#4283)
* Improve test coverage of ability_spec

* Fix specs

* Rubocop

---------

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: Daniel O'Connor <365751+CloCkWeRX@users.noreply.github.com>
2026-04-26 14:21:36 +09:30
Daniel O'Connor
2aa697a6d6 Add comprehensive test coverage for forums (#4561)
* Add comprehensive test coverage for forums

- Added `spec/controllers/forums_controller_spec.rb` to test all CRUD actions and authorization for guest, member, and admin roles.
- Added `spec/features/forums_spec.rb` to cover user-facing features such as browsing forums and creating posts from within a forum.
- Updated `spec/requests/forums_spec.rb` to cover basic request flow and JSON response formats.

Note: Tests were verified for content and logic but execution in the sandbox environment was blocked by missing infrastructure (PostgreSQL and Elasticsearch).

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

* Fix specs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-26 14:18:28 +09:30
Daniel O'Connor
ed87d23ece Merge pull request #4560 from Growstuff/fix-i18n-locale-texts-16171345716630423189
Fix Rails/I18nLocaleTexts RuboCop errors
2026-04-26 13:36:10 +09:30
Daniel O'Connor
c20160e3db Merge pull request #4559 from Growstuff/update-crop-wrangling-links-4812587945321495224
Update crop wrangling guide links to GitHub wiki
2026-04-26 12:40:09 +09:30
google-labs-jules[bot]
700cb76e3a Update crop wrangling guide links to GitHub wiki
Updated links to the crop wrangling guide in the scientific names and
alternate names forms to point to the new GitHub wiki location.
Verified that other occurrences in the codebase already use the new
URL.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-26 01:46:35 +00:00
Daniel O'Connor
5f834c475f Merge pull request #4557 from Growstuff/fix-rspec-expect-in-hook-16350019958417127399
Fix RSpec/ExpectInHook issues
2026-04-26 04:25:16 +09:30
google-labs-jules[bot]
6c7903c2a5 Fix RSpec/ExpectInHook offenses
- Move expectations from `before` hooks to `it` blocks.
- Ensure controller actions are called after expectations are set in controller specs.
- Replace synchronization expectations in hooks with Capybara `find` calls.
- Remove RSpec/ExpectInHook from .rubocop_todo.yml.

Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com>
2026-04-25 18:39:43 +00:00
Daniel O'Connor
dfa963cd65 Rubocop: RSpec/EmptyExampleGroup (#4554)
* Rubocop: RSpec/EmptyExampleGroup

* Undo renaming

* Apply suggestion from @CloCkWeRX

* Apply suggestion from @CloCkWeRX
2026-04-26 03:18:50 +09:30
Daniel O'Connor
163289e853 Fix RSpec/IndexedLet RuboCop issues in spec files (#4556)
* Fix RSpec/IndexedLet RuboCop issues in spec files

Replace indexed let variable names with descriptive names across 11 spec files.
This improves readability and complies with the RSpec/IndexedLet rule.

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

* Rubocop

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-26 03:10:03 +09:30
Daniel O'Connor
2001b355c4 Add Docker and CI Support (#4461)
* Add Docker, Docker Compose, and GitHub Actions CI support

- Added a production-ready `Dockerfile` based on Ruby 3.3.8-bullseye.
- Added `entrypoint.sh` to handle Rails server PID cleanup.
- Added `.dockerignore` to optimize build context.
- Added `docker-compose.yml` for local orchestration of Rails, PostgreSQL 17, and Elasticsearch 7.4.0.
- Added GitHub Actions workflow in `.github/workflows/docker-build-push.yml` to build and push the image to GHCR on pushes to the `dev` branch.

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

* Swap to 3.4.8

* Node 22

* Apply suggestion from @CloCkWeRX

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-26 03:07:31 +09:30
Daniel O'Connor
6a0b09b047 Merge pull request #4555 from Growstuff/remove-old-compass-rails
Remove old compass rails
2026-04-26 03:00:37 +09:30
Daniel O'Connor
4b7e0cf5d7 Merge pull request #4553 from Growstuff/RSpec/EmptyLineAfterExample
Rubocop: RSpec/EmptyLineAfterExample
2026-04-26 02:37:58 +09:30
Daniel O'Connor
a3af82d935 Merge pull request #4552 from Growstuff/Lint/SymbolConversion
Rubocop: Lint/SymbolConversion
2026-04-26 02:37:48 +09:30
Daniel O'Connor
051509b59f Merge pull request #4551 from Growstuff/Style/PercentLiteralDelimiters
Rubocop: Style/PercentLiteralDelimiters
2026-04-26 02:25:43 +09:30
Daniel O'Connor
a133eddf21 Rubocop: RSpec/EmptyLineAfterExample 2026-04-25 16:52:34 +00:00
Daniel O'Connor
0577c73833 Rubocop: Lint/SymbolConversion 2026-04-25 16:48:31 +00:00
Daniel O'Connor
7522d992b4 Rubocop: Style/PercentLiteralDelimiters 2026-04-25 16:46:15 +00:00
Daniel O'Connor
83de2fe889 Regenerate 2026-04-25 16:45:00 +00:00
Daniel O'Connor
dc11a1674d Merge branch 'dev' into refactor-hardcoded-emails 2026-04-24 00:03:30 +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
4085014e06 Merge branch 'dev' into refactor-hardcoded-emails 2025-10-09 22:54:33 +10:30
Daniel O'Connor
18986ee133 Merge branch 'dev' into refactor-hardcoded-emails 2025-09-01 15:40:20 +09:30
google-labs-jules[bot]
f5a4ba60fe Refactor hardcoded emails to a central configuration 2025-08-31 05:38:33 +00:00
117 changed files with 1459 additions and 571 deletions

30
.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
.git
.github
.devcontainer
log/*
tmp/*
!tmp/keep
node_modules
public/assets
.env
.ruby-gemset
.editorconfig
.esignore
.eslintrc.json
.haml-lint.yml
.overcommit.yml
.rspec
.rubocop.yml
.rubocop_todo.yml
.scss-lint.yml
.travis.yml
.yamllint
CODE_OF_CONDUCT.md
CONTRIBUTING.md
CONTRIBUTORS.md
LICENSE.txt
README.md
TECH.md
docker-compose.yml
Dockerfile
.dockerignore

View File

@@ -108,5 +108,23 @@ jobs:
run: bundle exec rails db:prepare run: bundle exec rails db:prepare
- name: Run tests with test-queue - name: Run rspec (lib)
run: bundle exec test-queue rspec spec -fd run: bundle exec rspec spec/lib/ -fd --fail-fast
- name: Run rspec (services)
run: bundle exec rspec spec/services/ -fd --fail-fast
- name: Run rspec (models)
run: bundle exec rspec spec/models/ -fd --fail-fast
- name: Run rspec (controllers)
run: bundle exec rspec spec/controllers/ -fd --fail-fast
- name: Run rspec (views)
run: bundle exec rspec spec/views/ -fd --fail-fast
- name: Run rspec (routing)
run: bundle exec rspec spec/routing/ -fd --fail-fast
- name: Run rspec (request)
run: bundle exec rspec spec/requests/ -fd --fail-fast

43
.github/workflows/docker-build-push.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Docker Build and Push
on:
push:
branches:
- mainline
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config` # `rubocop --auto-gen-config`
# on 2026-03-01 05:17:50 UTC using RuboCop version 1.85.0. # on 2026-04-25 16:44:38 UTC using RuboCop version 1.86.1.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@@ -81,7 +81,7 @@ Layout/HashAlignment:
- 'spec/requests/api/v1/activities_request_spec.rb' - 'spec/requests/api/v1/activities_request_spec.rb'
- 'spec/requests/api/v1/members_request_spec.rb' - 'spec/requests/api/v1/members_request_spec.rb'
# Offense count: 6 # Offense count: 5
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings. # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https # URISchemes: http, https
@@ -92,7 +92,6 @@ Layout/LineLength:
- 'app/models/concerns/predict_planting.rb' - 'app/models/concerns/predict_planting.rb'
- 'app/models/crop.rb' - 'app/models/crop.rb'
- 'db/seeds.rb' - 'db/seeds.rb'
- 'spec/requests/api/v1/activities_request_spec.rb'
# Offense count: 1 # Offense count: 1
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
@@ -154,21 +153,6 @@ Lint/SuppressedException:
Exclude: Exclude:
- 'lib/tasks/testing.rake' - 'lib/tasks/testing.rake'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: strict, consistent
Lint/SymbolConversion:
Exclude:
- 'app/helpers/crops_helper.rb'
# Offense count: 7
# This cop supports safe autocorrection (--autocorrect).
Lint/UselessAssignment:
Exclude:
- 'config.rb'
- 'config/compass.rb'
# Offense count: 1 # Offense count: 1
Lint/UselessConstantScoping: Lint/UselessConstantScoping:
Exclude: Exclude:
@@ -242,18 +226,12 @@ RSpec/BeforeAfterAll:
Exclude: Exclude:
- 'spec/tasks/import_spec.rb' - 'spec/tasks/import_spec.rb'
# Offense count: 299 # Offense count: 298
# Configuration parameters: Prefixes, AllowedPatterns. # Configuration parameters: Prefixes, AllowedPatterns.
# Prefixes: when, with, without # Prefixes: when, with, without
RSpec/ContextWording: RSpec/ContextWording:
Enabled: false Enabled: false
# Offense count: 1
# Configuration parameters: IgnoredMetadata.
RSpec/DescribeClass:
Exclude:
- 'spec/tasks/import_spec.rb'
# Offense count: 36 # Offense count: 36
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants. # Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants.
@@ -264,47 +242,12 @@ RSpec/DescribedClass:
- 'spec/models/member_spec.rb' - 'spec/models/member_spec.rb'
- 'spec/services/timeline_service_spec.rb' - 'spec/services/timeline_service_spec.rb'
# Offense count: 13
# This cop supports unsafe autocorrection (--autocorrect-all).
RSpec/EmptyExampleGroup:
Exclude:
- 'spec/controllers/authentications_controller_spec.rb'
- 'spec/controllers/forums_controller_spec.rb'
- 'spec/controllers/home_controller_spec.rb'
- 'spec/controllers/likes_controller_spec.rb'
- 'spec/controllers/plant_parts_controller_spec.rb'
- 'spec/controllers/seeds_controller_spec.rb'
- 'spec/features/crops/crop_detail_page_spec.rb'
- 'spec/features/plantings/planting_a_crop_spec.rb'
- 'spec/requests/authentications_spec.rb'
- 'spec/views/home/index_spec.rb'
- 'spec/views/photos/edit.html.haml_spec.rb'
- 'spec/views/posts/_single.html.haml_spec.rb'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowConsecutiveOneLiners.
RSpec/EmptyLineAfterExample:
Exclude:
- 'spec/models/ability_spec.rb'
# Offense count: 146 # Offense count: 146
# Configuration parameters: CountAsOne. # Configuration parameters: CountAsOne.
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 27 Max: 27
# Offense count: 32 # Offense count: 32
RSpec/ExpectInHook:
Exclude:
- 'spec/controllers/garden_types_controller_spec.rb'
- 'spec/controllers/gardens_controller_spec.rb'
- 'spec/features/admin/forums_spec.rb'
- 'spec/features/admin/plant_parts_spec.rb'
- 'spec/features/admin/roles_spec.rb'
- 'spec/features/crops/crop_photos_spec.rb'
- 'spec/features/members/list_spec.rb'
- 'spec/features/plantings/planting_a_crop_spec.rb'
- 'spec/features/shared_examples/append_date.rb'
# Offense count: 1 # Offense count: 1
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
@@ -352,7 +295,6 @@ RSpec/IndexedLet:
- 'spec/features/percy/percy_spec.rb' - 'spec/features/percy/percy_spec.rb'
- 'spec/features/planting_reminder_spec.rb' - 'spec/features/planting_reminder_spec.rb'
- 'spec/features/timeline/index_spec.rb' - 'spec/features/timeline/index_spec.rb'
- 'spec/models/crop_spec.rb'
- 'spec/models/member_spec.rb' - 'spec/models/member_spec.rb'
- 'spec/views/forums/index.html.haml_spec.rb' - 'spec/views/forums/index.html.haml_spec.rb'
@@ -387,7 +329,7 @@ RSpec/MultipleDescribes:
Exclude: Exclude:
- 'spec/features/crops/crop_wranglers_spec.rb' - 'spec/features/crops/crop_wranglers_spec.rb'
# Offense count: 189 # Offense count: 191
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 19 Max: 19
@@ -402,7 +344,7 @@ RSpec/MultipleMemoizedHelpers:
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# Offense count: 111 # Offense count: 109
# Configuration parameters: AllowedGroups. # Configuration parameters: AllowedGroups.
RSpec/NestedGroups: RSpec/NestedGroups:
Max: 6 Max: 6
@@ -533,10 +475,6 @@ Rails/I18nLocaleAssignment:
Exclude: Exclude:
- 'spec/features/locale_spec.rb' - 'spec/features/locale_spec.rb'
# Offense count: 40
Rails/I18nLocaleTexts:
Enabled: false
# Offense count: 1 # Offense count: 1
# Configuration parameters: IgnoreScopes. # Configuration parameters: IgnoreScopes.
Rails/InverseOf: Rails/InverseOf:
@@ -578,7 +516,7 @@ Rails/RakeEnvironment:
- 'lib/tasks/i18n.rake' - 'lib/tasks/i18n.rake'
- 'lib/tasks/testing.rake' - 'lib/tasks/testing.rake'
# Offense count: 9 # Offense count: 8
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedReceivers. # Configuration parameters: AllowedReceivers.
# AllowedReceivers: ActionMailer::Preview, ActiveSupport::TimeZone # AllowedReceivers: ActionMailer::Preview, ActiveSupport::TimeZone
@@ -632,15 +570,6 @@ Rails/RootPathnameMethods:
- 'lib/tasks/import.rake' - 'lib/tasks/import.rake'
- 'spec/rails_helper.rb' - 'spec/rails_helper.rb'
# Offense count: 4
# Configuration parameters: ForbiddenMethods, AllowedMethods.
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
Rails/SkipsModelValidations:
Exclude:
- 'db/migrate/20240101010102_populate_crop_fields_from_openfarm_data.rb'
- 'db/migrate/20240810160538_set_default_language_for_existing_alternate_names.rb'
- 'db/migrate/20251128200506_add_description_to_crops.rb'
# Offense count: 21 # Offense count: 21
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Enabled: false Enabled: false
@@ -728,6 +657,14 @@ Style/FloatDivision:
Exclude: Exclude:
- 'app/models/concerns/predict_planting.rb' - 'app/models/concerns/predict_planting.rb'
# Offense count: 1
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: always, always_true, never
Style/FrozenStringLiteralComment:
Exclude:
- 'spec/lib/haml/filters/growstuff_markdown_spec.rb'
# Offense count: 2 # Offense count: 2
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
Style/GlobalStdStream: Style/GlobalStdStream:
@@ -792,13 +729,6 @@ Style/OptionalBooleanParameter:
- 'app/helpers/application_helper.rb' - 'app/helpers/application_helper.rb'
- 'app/models/concerns/member_newsletter.rb' - 'app/models/concerns/member_newsletter.rb'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: PreferredDelimiters.
Style/PercentLiteralDelimiters:
Exclude:
- 'db/migrate/20251130035700_create_versions.rb'
# Offense count: 1 # Offense count: 1
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: Methods. # Configuration parameters: Methods.

View File

@@ -248,6 +248,3 @@ linters:
ZeroUnit: ZeroUnit:
enabled: true enabled: true
Compass::*:
enabled: false

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
FROM ruby:3.4.8-trixie
# Install system dependencies
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
git \
curl \
gnupg2 \
shared-mime-info \
libvips \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set production environment
ENV RAILS_ENV=production \
BUNDLE_WITHOUT="development test" \
RAILS_SERVE_STATIC_FILES=true \
RAILS_LOG_TO_STDOUT=true
WORKDIR /app
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install --jobs 4 --retry 3
# Install JavaScript dependencies
COPY package.json yarn.lock ./
RUN yarn install --check-files
# Copy the application code
COPY . .
# Precompile assets
# Secret key base is needed for asset compilation but doesn't need to be the real one
RUN RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile
# Add a script to be executed every time the container starts.
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000
# Start the main process.
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

View File

@@ -196,7 +196,6 @@ group :test do
gem 'rails-controller-testing' gem 'rails-controller-testing'
gem "rspec-rebound" gem "rspec-rebound"
gem 'selenium-webdriver' gem 'selenium-webdriver'
gem 'test-queue'
gem 'timecop' gem 'timecop'
gem 'vcr' gem 'vcr'
end end

View File

@@ -719,7 +719,6 @@ GEM
unicode-display_width (>= 1.1.1, < 4) unicode-display_width (>= 1.1.1, < 4)
terser (1.2.7) terser (1.2.7)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
test-queue (0.11.1)
thor (1.5.0) thor (1.5.0)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.7.0) tilt (2.7.0)
@@ -874,7 +873,6 @@ DEPENDENCIES
sitemap_generator sitemap_generator
sprockets (< 4) sprockets (< 4)
terser terser
test-queue
timecop timecop
unicorn unicorn
validate_url validate_url

View File

@@ -15,7 +15,7 @@ module Admin
def create def create
@crop_companion = @crop.crop_companions.new(crop_companion_params) @crop_companion = @crop.crop_companions.new(crop_companion_params)
if @crop_companion.save if @crop_companion.save
redirect_to admin_crop_crop_companions_path(@crop), notice: 'Companion was successfully created.' redirect_to admin_crop_crop_companions_path(@crop), notice: t('crop_companions.created')
else else
render :new render :new
end end
@@ -24,7 +24,7 @@ module Admin
def destroy def destroy
@crop_companion = @crop.crop_companions.find(params[:id]) @crop_companion = @crop.crop_companions.find(params[:id])
@crop_companion.destroy @crop_companion.destroy
redirect_to admin_crop_crop_companions_path(@crop), notice: 'Companion was successfully destroyed.' redirect_to admin_crop_crop_companions_path(@crop), notice: t('crop_companions.deleted')
end end
private private

View File

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

View File

@@ -9,9 +9,9 @@ module Admin
@version = PaperTrail::Version.find(params[:id]) @version = PaperTrail::Version.find(params[:id])
@object = @version.reify @object = @version.reify
if @object.save if @object.save
redirect_to admin_crops_path, notice: "Reverted to version from #{@version.created_at.strftime('%B %d, %Y')}" redirect_to admin_crops_path, notice: t('messages.revert_success', date: @version.created_at.strftime('%B %d, %Y'))
else else
redirect_to admin_crops_path, alert: "Could not revert to version from #{@version.created_at.strftime('%B %d, %Y')}. Errors: #{@object.errors.full_messages.to_sentence}" redirect_to admin_crops_path, alert: t('messages.revert_error', date: @version.created_at.strftime('%B %d, %Y'), errors: @object.errors.full_messages.to_sentence)
end end
end end

View File

@@ -30,7 +30,7 @@ class AlternateNamesController < ApplicationController
@alternate_name = AlternateName.new(alternate_name_params) @alternate_name = AlternateName.new(alternate_name_params)
if @alternate_name.save if @alternate_name.save
redirect_to @alternate_name.crop, notice: 'Alternate name was successfully created.' redirect_to @alternate_name.crop, notice: t('alternate_names.created')
else else
render action: "new" render action: "new"
end end
@@ -40,7 +40,7 @@ class AlternateNamesController < ApplicationController
# PUT /alternate_names/1.json # PUT /alternate_names/1.json
def update def update
if @alternate_name.update(alternate_name_params) if @alternate_name.update(alternate_name_params)
redirect_to @alternate_name.crop, notice: 'Alternate name was successfully updated.' redirect_to @alternate_name.crop, notice: t('alternate_names.updated')
else else
render action: "edit" render action: "edit"
end end
@@ -51,7 +51,7 @@ class AlternateNamesController < ApplicationController
def destroy def destroy
@crop = @alternate_name.crop @crop = @alternate_name.crop
@alternate_name.destroy @alternate_name.destroy
redirect_to @crop, notice: 'Alternate name was successfully deleted.' redirect_to @crop, notice: t('alternate_names.deleted')
end end
private private

View File

@@ -24,9 +24,9 @@ class AuthenticationsController < ApplicationController
name: name:
) )
flash[:notice] = "Authentication successful." flash[:notice] = t('messages.auth_success')
else else
flash[:notice] = "Authentication failed." flash[:notice] = t('messages.auth_failed')
end end
redirect_to request.env['omniauth.origin'] || edit_member_registration_path redirect_to request.env['omniauth.origin'] || edit_member_registration_path
end end

View File

@@ -0,0 +1,32 @@
# frozen_string_literal: true
class BlocksController < ApplicationController
load_and_authorize_resource
skip_load_resource only: :create
def create
@block = current_member.blocks.build(blocked: Member.find(params[:blocked]))
if @block.save
flash[:notice] = "Blocked #{@block.blocked.login_name}"
else
flash[:error] = "Already blocking or error while blocking."
end
redirect_back_or_to(root_path)
end
def destroy
@block = current_member.blocks.find(params[:id])
@unblocked = @block.blocked
@block.destroy
flash[:notice] = "Unblocked #{@unblocked.login_name}"
redirect_to @unblocked
end
private
def block_params
params.permit(:id, :blocked)
end
end

View File

@@ -13,9 +13,9 @@ class FollowsController < ApplicationController
@follow = current_member.follows.build(followed: Member.find(params[:followed])) @follow = current_member.follows.build(followed: Member.find(params[:followed]))
if @follow.save if @follow.save
flash[:notice] = "Followed #{@follow.followed.login_name}" flash[:notice] = t('messages.followed', name: @follow.followed.login_name)
else else
flash[:error] = "Already following or error while following." flash[:error] = t('messages.follow_error')
end end
redirect_back_or_to(root_path) redirect_back_or_to(root_path)
end end
@@ -25,7 +25,7 @@ class FollowsController < ApplicationController
@unfollowed = @follow.followed @unfollowed = @follow.followed
@follow.destroy @follow.destroy
flash[:notice] = "Unfollowed #{@unfollowed.login_name}" flash[:notice] = t('messages.unfollowed', name: @unfollowed.login_name)
redirect_to @unfollowed redirect_to @unfollowed
end end

View File

@@ -32,14 +32,14 @@ class ForumsController < ApplicationController
# POST /forums.json # POST /forums.json
def create def create
@forum = Forum.new(forum_params) @forum = Forum.new(forum_params)
flash[:notice] = 'Forum was successfully created.' if @forum.save flash[:notice] = t('forums.created') if @forum.save
respond_with(@forum) respond_with(@forum)
end end
# PUT /forums/1 # PUT /forums/1
# PUT /forums/1.json # PUT /forums/1.json
def update def update
flash[:notice] = 'Forum was successfully updated.' if @forum.update(forum_params) flash[:notice] = t('forums.updated') if @forum.update(forum_params)
respond_with(@forum) respond_with(@forum)
end end
@@ -47,7 +47,7 @@ class ForumsController < ApplicationController
# DELETE /forums/1.json # DELETE /forums/1.json
def destroy def destroy
@forum.destroy @forum.destroy
flash[:notice] = 'Forum was successfully deleted' flash[:notice] = t('forums.deleted')
redirect_to forums_url redirect_to forums_url
end end

View File

@@ -14,7 +14,7 @@ class LikesController < ApplicationController
@like.likeable.reindex(refresh: true) @like.likeable.reindex(refresh: true)
success(@like, liked_by_member: true, status_code: :created) success(@like, liked_by_member: true, status_code: :created)
else else
failed(@like, message: 'Unable to like') failed(@like, message: t('messages.unable_to_like'))
end end
end end
@@ -29,7 +29,7 @@ class LikesController < ApplicationController
@like.likeable.reindex(refresh: true) @like.likeable.reindex(refresh: true)
success(@like, liked_by_member: false, status_code: :ok) success(@like, liked_by_member: false, status_code: :ok)
else else
failed(@like, message: 'Unable to unlike') failed(@like, message: t('messages.unable_to_unlike'))
end end
end end

View File

@@ -27,10 +27,21 @@ class MessagesController < ApplicationController
def create def create
if params[:conversation_id].present? if params[:conversation_id].present?
@conversation = Mailboxer::Conversation.find(params[:conversation_id]) @conversation = Mailboxer::Conversation.find(params[:conversation_id])
# Check if any of the recipients have blocked the sender
if @conversation.recipients.any? { |recipient| recipient.already_blocking?(current_member) }
flash[:error] = "You cannot reply to this conversation because one of the recipients has blocked you."
redirect_to conversation_path(@conversation)
return
end
current_member.reply_to_conversation(@conversation, params[:body]) current_member.reply_to_conversation(@conversation, params[:body])
redirect_to conversation_path(@conversation) redirect_to conversation_path(@conversation)
else else
recipient = Member.find(params[:recipient_id]) 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_or_to(root_path)
return
end
body = params[:body] body = params[:body]
subject = params[:subject] subject = params[:subject]
@conversation = current_member.send_message(recipient, body, subject) @conversation = current_member.send_message(recipient, body, subject)

View File

@@ -10,7 +10,7 @@ require './lib/actions/oauth_signup_action'
# #
class OmniauthCallbacksController < Devise::OmniauthCallbacksController class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def failure def failure
flash[:alert] = "Authentication failed." flash[:alert] = t('messages.auth_failed')
redirect_to request.env['omniauth.origin'] || "/" redirect_to request.env['omniauth.origin'] || "/"
end end

View File

@@ -33,7 +33,7 @@ class PlacesController < ApplicationController
def search def search
if params[:new_place].empty? if params[:new_place].empty?
redirect_to places_path, alert: 'Please enter a valid location' redirect_to places_path, alert: t('messages.invalid_location')
else else
redirect_to place_path(params[:new_place]) redirect_to place_path(params[:new_place])
end end

View File

@@ -116,11 +116,11 @@ class PlantingsController < DataController
new_planting.finished_at = nil new_planting.finished_at = nil
if new_planting.save if new_planting.save
redirect_to edit_planting_path(new_planting), notice: 'Planting was successfully transplanted.' redirect_to edit_planting_path(new_planting), notice: t('messages.transplant_success')
else else
# if the save fails, we should probably roll back the finishing of the original planting # if the save fails, we should probably roll back the finishing of the original planting
@planting.update(finished: false, finished_at: nil) @planting.update(finished: false, finished_at: nil)
redirect_to @planting, alert: "There was an error transplanting the planting: #{new_planting.errors.full_messages.to_sentence}" redirect_to @planting, alert: t('messages.transplant_error', errors: new_planting.errors.full_messages.to_sentence)
end end
end end

View File

@@ -29,17 +29,17 @@ class PostsController < ApplicationController
def create def create
params[:post][:author_id] = current_member.id params[:post][:author_id] = current_member.id
@post = Post.new(post_params) @post = Post.new(post_params)
flash[:notice] = 'Post was successfully created.' if @post.save flash[:notice] = t('posts.created') if @post.save
respond_with(@post) respond_with(@post)
end end
def update def update
flash[:notice] = 'Post was successfully updated.' if @post.update(post_params) flash[:notice] = t('posts.updated') if @post.update(post_params)
respond_with(@post) respond_with(@post)
end end
def destroy def destroy
flash[:notice] = 'Post was deleted.' if @post.destroy flash[:notice] = t('posts.deleted') if @post.destroy
respond_with(@post) respond_with(@post)
end end

View File

@@ -54,7 +54,7 @@ class ScientificNamesController < ApplicationController
def destroy def destroy
@crop = @scientific_name.crop @crop = @scientific_name.crop
@scientific_name.destroy @scientific_name.destroy
flash[:notice] = 'Scientific name was successfully deleted.' flash[:notice] = t('scientific_names.deleted')
respond_with(@crop) respond_with(@crop)
end end

View File

@@ -61,7 +61,7 @@ class SeedsController < DataController
@seed.finished ||= false @seed.finished ||= false
@seed.owner = current_member @seed.owner = current_member
@seed.crop = @seed.parent_planting.crop if @seed.parent_planting @seed.crop = @seed.parent_planting.crop if @seed.parent_planting
flash[:notice] = "Successfully added #{@seed.crop} seed to your stash." if @seed.save flash[:notice] = t('seeds.added_to_stash', crop: @seed.crop) if @seed.save
if params[:return] == 'planting' if params[:return] == 'planting'
respond_with(@seed, location: @seed.parent_planting) respond_with(@seed, location: @seed.parent_planting)
else else
@@ -70,7 +70,7 @@ class SeedsController < DataController
end end
def update def update
flash[:notice] = 'Seed was successfully updated.' if @seed.update(seed_params) flash[:notice] = t('seeds.updated') if @seed.update(seed_params)
respond_with(@seed) respond_with(@seed)
end end

View File

@@ -5,7 +5,7 @@ class SessionsController < Devise::SessionsController
def create def create
super do |_resource| super do |_resource|
flash[:alert] = "There are crops waiting to be wrangled." if Crop.pending_approval.present? && current_member.role?(:crop_wrangler) flash[:alert] = t('messages.crops_waiting') if Crop.pending_approval.present? && current_member.role?(:crop_wrangler)
end end
end end
end end

View File

@@ -62,7 +62,7 @@ module CropsHelper
'@type': 'Person', '@type': 'Person',
name: post.author.login_name name: post.author.login_name
}, },
'datePublished': post.created_at datePublished: post.created_at
} }
end end

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: 'from@example.com' default from: "Growstuff <#{Rails.configuration.x.email[:from]}>"
layout 'mailer' layout 'mailer'
end end

View File

@@ -2,7 +2,7 @@
class NotifierMailer < ApplicationMailer class NotifierMailer < ApplicationMailer
# include NotificationsHelper # include NotificationsHelper
default from: "Growstuff <#{ENV.fetch('GROWSTUFF_EMAIL', nil)}>" default from: "Growstuff <#{Rails.configuration.x.email[:from]}>"
def verifier def verifier
unless ENV['RAILS_SECRET_TOKEN'] unless ENV['RAILS_SECRET_TOKEN']

View File

@@ -164,6 +164,12 @@ class Ability
can :destroy, Follow can :destroy, Follow
cannot :destroy, Follow, followed_id: member.id # can't unfollow yourself cannot :destroy, Follow, followed_id: member.id # can't unfollow yourself
# blocking/unblocking permissions
can :create, Block
cannot :create, Block, blocked_id: member.id # can't block yourself
can :destroy, Block, blocker_id: member.id # can only unblock your own blocks
cannot :create, GardenType cannot :create, GardenType
cannot :update, GardenType cannot :update, GardenType
cannot :destroy, GardenType cannot :destroy, GardenType

18
app/models/block.rb Normal file
View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class Block < ApplicationRecord
belongs_to :blocker, class_name: "Member"
belongs_to :blocked, class_name: "Member"
validates :blocker_id, uniqueness: { scope: :blocked_id }
after_create :destroy_follow_relationship
private
def destroy_follow_relationship
# Destroy the follow relationship in both directions
Follow.where(follower: blocker, followed: blocked).destroy_all
Follow.where(follower: blocked, followed: blocker).destroy_all
end
end

View File

@@ -4,6 +4,7 @@ class Comment < ApplicationRecord
belongs_to :author, class_name: 'Member', inverse_of: :comments belongs_to :author, class_name: 'Member', inverse_of: :comments
belongs_to :commentable, polymorphic: true, counter_cache: true belongs_to :commentable, polymorphic: true, counter_cache: true
# validates :body, presence: true # validates :body, presence: true
validate :author_is_not_blocked
scope :post_order, -> { order(created_at: :asc) } # for display on post page scope :post_order, -> { order(created_at: :asc) } # for display on post page
@@ -25,4 +26,14 @@ class Comment < ApplicationRecord
def to_s def to_s
"#{author.login_name} commented on #{commentable.subject}" "#{author.login_name} commented on #{commentable.subject}"
end end
private
def author_is_not_blocked
return unless author
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 end

View File

@@ -57,13 +57,13 @@ class Crop < ApplicationRecord
validates :en_wikipedia_url, validates :en_wikipedia_url,
format: { format: {
with: %r{\Ahttps?://en\.wikipedia\.org/wiki/[[:alnum:]%_.()-]+\z}, with: %r{\Ahttps?://en\.wikipedia\.org/wiki/[[:alnum:]%_.()-]+\z},
message: 'is not a valid English Wikipedia URL' message: :not_a_valid_wikipedia_url
}, },
if: :approved? if: :approved?
validates :en_youtube_url, validates :en_youtube_url,
format: { format: {
with: %r{\A(?:https?://)?(?:www\.)?(?:youtube(?:-nocookie)?\.com/(?:(?:v|e(?:mbed)?)/|\S*?[?&]v=)|youtu\.be/)[a-zA-Z0-9_-]{11}(?:[?&]\S*)?\z}, with: %r{\A(?:https?://)?(?:www\.)?(?:youtube(?:-nocookie)?\.com/(?:(?:v|e(?:mbed)?)/|\S*?[?&]v=)|youtu\.be/)[a-zA-Z0-9_-]{11}(?:[?&]\S*)?\z},
message: 'is not a valid YouTube URL' message: :not_a_valid_youtube_url
}, },
allow_blank: true allow_blank: true
validates :name, uniqueness: { scope: :approval_status }, if: :pending? validates :name, uniqueness: { scope: :approval_status }, if: :pending?
@@ -190,12 +190,12 @@ class Crop < ApplicationRecord
return if rejected? return if rejected?
return unless reason_for_rejection.present? || rejection_notes.present? return unless reason_for_rejection.present? || rejection_notes.present?
errors.add(:approval_status, "must be rejected if a reason for rejection is present") errors.add(:approval_status, :rejection_reason_required)
end end
def must_have_meaningful_reason_for_rejection def must_have_meaningful_reason_for_rejection
return unless reason_for_rejection == "other" && rejection_notes.blank? return unless reason_for_rejection == "other" && rejection_notes.blank?
errors.add(:rejection_notes, "must be added if the reason for rejection is \"other\"") errors.add(:rejection_notes, :rejection_notes_required)
end end
end end

View File

@@ -4,6 +4,7 @@ class Follow < ApplicationRecord
belongs_to :follower, class_name: "Member", inverse_of: :follows belongs_to :follower, class_name: "Member", inverse_of: :follows
belongs_to :followed, class_name: "Member", inverse_of: :inverse_follows belongs_to :followed, class_name: "Member", inverse_of: :inverse_follows
validates :follower_id, uniqueness: { scope: :followed_id } validates :follower_id, uniqueness: { scope: :followed_id }
validate :follower_is_not_blocked
after_create do after_create do
Notification.create( Notification.create(
@@ -14,4 +15,14 @@ class Follow < ApplicationRecord
notifiable: self notifiable: self
) )
end end
private
def follower_is_not_blocked
return unless follower
return unless followed.already_blocking?(follower)
errors.add(:base, "You cannot follow a member who has blocked you.")
end
end end

View File

@@ -32,7 +32,7 @@ class Garden < ApplicationRecord
validates :name, uniqueness: { scope: :owner_id } validates :name, uniqueness: { scope: :owner_id }
validates :name, validates :name,
format: { without: /\n/, message: "must contain no newlines" }, format: { without: /\n/, message: :no_newlines },
allow_blank: false, presence: true, allow_blank: false, presence: true,
length: { maximum: 255 } length: { maximum: 255 }
@@ -53,7 +53,7 @@ class Garden < ApplicationRecord
"acres" => "acre" "acres" => "acre"
}.freeze }.freeze
validates :area_unit, inclusion: { in: AREA_UNITS_VALUES.values, validates :area_unit, inclusion: { in: AREA_UNITS_VALUES.values,
message: "%<value>s is not a valid area unit" }, message: :not_a_valid_area_unit },
allow_blank: true allow_blank: true
def cleanup_area def cleanup_area

View File

@@ -11,7 +11,7 @@ class GardenCollaborator < ApplicationRecord
return unless member return unless member
return unless garden return unless garden
errors.add(:member_id, "cannot be the garden owner") if garden.owner == member errors.add(:member_id, :cannot_be_garden_owner) if garden.owner == member
end end
def member_slug def member_slug

View File

@@ -58,18 +58,18 @@ class Harvest < ApplicationRecord
## ##
## Validations ## Validations
validates :crop, approved: true validates :crop, approved: true
validates :crop, presence: { message: "must be present and exist in our database" } validates :crop, presence: { message: :crop_not_found }
validates :plant_part, presence: { message: "must be present and exist in our database" } validates :plant_part, presence: { message: :crop_not_found }
validates :harvested_at, presence: true validates :harvested_at, presence: true
validates :quantity, allow_nil: true, numericality: { validates :quantity, allow_nil: true, numericality: {
only_integer: false, greater_than_or_equal_to: 0 only_integer: false, greater_than_or_equal_to: 0
} }
validates :unit, allow_blank: true, inclusion: { validates :unit, allow_blank: true, inclusion: {
in: UNITS_VALUES.values, message: "%<value>s is not a valid unit" in: UNITS_VALUES.values, message: :not_a_valid_unit
} }
validates :weight_quantity, allow_nil: true, numericality: { only_integer: false } validates :weight_quantity, allow_nil: true, numericality: { only_integer: false }
validates :weight_unit, allow_blank: true, inclusion: { validates :weight_unit, allow_blank: true, inclusion: {
in: WEIGHT_UNITS_VALUES.values, message: "%<value>s is not a valid unit" in: WEIGHT_UNITS_VALUES.values, message: :not_a_valid_unit
} }
validate :crop_must_match_planting validate :crop_must_match_planting
validate :owner_must_match_planting validate :owner_must_match_planting
@@ -147,7 +147,7 @@ class Harvest < ApplicationRecord
def crop_must_match_planting def crop_must_match_planting
return if planting.blank? # only check if we are linked to a planting return if planting.blank? # only check if we are linked to a planting
errors.add(:planting, "must be the same crop") unless crop == planting.crop errors.add(:planting, :same_crop_required) unless crop == planting.crop
end end
def owner_must_match_planting def owner_must_match_planting
@@ -155,14 +155,13 @@ class Harvest < ApplicationRecord
return if owner == planting.owner || planting.garden.garden_collaborators.where(member_id: owner).any? return if owner == planting.owner || planting.garden.garden_collaborators.where(member_id: owner).any?
errors.add(:owner, errors.add(:owner, :same_owner_required)
"of harvest must be the same as planting, or a collaborator on that garden")
end end
def harvest_must_be_after_planting def harvest_must_be_after_planting
# only check if we are linked to a planting # only check if we are linked to a planting
return unless harvested_at.present? && planting.present? && planting.planted_at.present? return unless harvested_at.present? && planting.present? && planting.planted_at.present?
errors.add(:planting, "cannot be harvested before planting") unless harvested_at > planting.planted_at errors.add(:planting, :harvest_after_planted) unless harvested_at > planting.planted_at
end end
end end

View File

@@ -5,4 +5,24 @@ class Like < ApplicationRecord
belongs_to :likeable, polymorphic: true, counter_cache: true, touch: true belongs_to :likeable, polymorphic: true, counter_cache: true, touch: true
validates :member, :likeable, presence: true validates :member, :likeable, presence: true
validates :member, uniqueness: { scope: :likeable } validates :member, uniqueness: { scope: :likeable }
validate :member_is_not_blocked
def likeable_author
if likeable.respond_to?(:author)
likeable.author
elsif likeable.respond_to?(:owner)
likeable.owner
end
end
private
def member_is_not_blocked
return unless member
author = likeable_author
return unless author&.already_blocking?(member)
errors.add(:base, "You cannot like content of a member who has blocked you.")
end
end end

View File

@@ -52,6 +52,15 @@ class Member < ApplicationRecord
has_many :followed, through: :follows has_many :followed, through: :follows
has_many :followers, through: :inverse_follows, source: :follower has_many :followers, through: :inverse_follows, source: :follower
#
# Blocking other members
has_many :blocks, class_name: "Block", foreign_key: "blocker_id", dependent: :destroy,
inverse_of: :blocker
has_many :inverse_blocks, class_name: "Block", foreign_key: "blocked_id",
dependent: :destroy, inverse_of: :blocked
has_many :blocked_members, through: :blocks, source: :blocked
has_many :blockers, through: :inverse_blocks, source: :blocker
# #
# Global data records this member created # Global data records this member created
has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify, has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify,
@@ -96,21 +105,21 @@ class Member < ApplicationRecord
validates :tos_agreement, acceptance: { allow_nil: true, accept: true } validates :tos_agreement, acceptance: { allow_nil: true, accept: true }
validates :login_name, validates :login_name,
length: { length: {
minimum: 2, maximum: 25, message: "should be between 2 and 25 characters long" minimum: 2, maximum: 25, message: :login_name_length
}, },
exclusion: { exclusion: {
in: %w(growstuff admin moderator staff nearby), message: "name is reserved" in: %w(growstuff admin moderator staff nearby), message: :login_name_reserved
}, },
format: { format: {
with: /\A\w+\z/, message: "may only include letters, numbers, or underscores" with: /\A\w+\z/, message: :login_name_format
}, },
uniqueness: { uniqueness: {
case_sensitive: false case_sensitive: false
} }
validates :website_url, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }, allow_blank: true validates :website_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
validates :other_url, format: { with: %r{\Ahttps?://}, message: "must start with http:// or https://" }, allow_blank: true validates :other_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
validates :instagram_handle, :facebook_handle, :bluesky_handle, validates :instagram_handle, :facebook_handle, :bluesky_handle,
format: { without: %r{\Ahttps?://|/}, message: "should be a handle, not a URL" }, allow_blank: true format: { without: %r{\Ahttps?://|/}, message: :handle_format }, allow_blank: true
# #
# Triggers # Triggers
@@ -164,12 +173,12 @@ class Member < ApplicationRecord
end end
def self.nearest_to(place) def self.nearest_to(place)
nearby_members = [] return [] if place.blank?
if place
latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 }) latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 })
nearby_members = Member.located.sort_by { |x| x.distance_from([latitude, longitude]) } if latitude && longitude return [] unless latitude && longitude
end
nearby_members Member.located.near([latitude, longitude], 1000)
end end
def already_following?(member) def already_following?(member)
@@ -179,4 +188,12 @@ class Member < ApplicationRecord
def get_follow(member) def get_follow(member)
follows.find_by(followed_id: member.id) if already_following?(member) follows.find_by(followed_id: member.id) if already_following?(member)
end end
def already_blocking?(member)
blocks.exists?(blocked_id: member.id)
end
def get_block(member)
blocks.find_by(blocked_id: member.id) if already_blocking?(member)
end
end end

View File

@@ -29,12 +29,12 @@ class PhotoAssociation < ApplicationRecord
def photo_and_item_have_same_owner def photo_and_item_have_same_owner
return if photographable_type == 'Crop' return if photographable_type == 'Crop'
errors.add(:photo, "must have same owner as item it links to") unless photographable.owner_id == photo.owner_id errors.add(:photo, :photo_owner_mismatch) unless photographable.owner_id == photo.owner_id
end end
def crop_present def crop_present
return unless %w(Planting Seed Harvest).include?(photographable_type) return unless %w(Planting Seed Harvest).include?(photographable_type)
errors.add(:crop_id, "failed to calculate crop") if crop_id.blank? errors.add(:crop_id, :calculate_crop_failed) if crop_id.blank?
end end
end end

View File

@@ -72,7 +72,7 @@ class Planting < ApplicationRecord
## ##
## Validations ## Validations
validates :garden, presence: true validates :garden, presence: true
validates :crop, presence: true, approved: { message: "must be present and exist in our database" } validates :crop, presence: true, approved: { message: :crop_must_be_approved }
validate :finished_must_be_after_planted validate :finished_must_be_after_planted
validate :owner_must_match_garden_owner validate :owner_must_match_garden_owner
validate :cannot_be_finished_and_failed validate :cannot_be_finished_and_failed
@@ -80,10 +80,10 @@ class Planting < ApplicationRecord
only_integer: true, greater_than_or_equal_to: 0 only_integer: true, greater_than_or_equal_to: 0
} }
validates :sunniness, allow_blank: true, inclusion: { validates :sunniness, allow_blank: true, inclusion: {
in: SUNNINESS_VALUES, message: "%<value>s is not a valid sunniness value" in: SUNNINESS_VALUES, message: :not_a_valid_sunniness
} }
validates :planted_from, allow_blank: true, inclusion: { validates :planted_from, allow_blank: true, inclusion: {
in: PLANTED_FROM_VALUES, message: "%<value>s is not a valid planting method" in: PLANTED_FROM_VALUES, message: :not_a_valid_planting_method
} }
validates :overall_rating, allow_blank: true, numericality: { validates :overall_rating, allow_blank: true, numericality: {
only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5 only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5
@@ -132,20 +132,19 @@ class Planting < ApplicationRecord
private private
def cannot_be_finished_and_failed def cannot_be_finished_and_failed
errors.add(:failed, "can't be true if planting is also finished") if finished && failed errors.add(:failed, :failed_and_finished) if finished && failed
end end
# check that any finished_at date occurs after planted_at # check that any finished_at date occurs after planted_at
def finished_must_be_after_planted def finished_must_be_after_planted
return unless planted_at && finished_at # only check if we have both return unless planted_at && finished_at # only check if we have both
errors.add(:finished_at, "must be after the planting date") unless planted_at < finished_at errors.add(:finished_at, :finished_after_planted) unless planted_at < finished_at
end end
def owner_must_match_garden_owner def owner_must_match_garden_owner
return if owner == garden.owner || garden.garden_collaborators.where(member_id: owner).any? return if owner == garden.owner || garden.garden_collaborators.where(member_id: owner).any?
errors.add(:owner, errors.add(:owner, :same_owner_required)
"must be the same as garden, or a collaborator on that garden")
end end
end end

View File

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

View File

@@ -28,7 +28,7 @@ class Seed < ApplicationRecord
# #
# Validations # Validations
validates :crop, approved: true validates :crop, approved: true
validates :crop, presence: { message: "must be present and exist in our database" } validates :crop, presence: { message: :crop_not_found }
validates :quantity, allow_nil: true, validates :quantity, allow_nil: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 } numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :days_until_maturity_min, allow_nil: true, validates :days_until_maturity_min, allow_nil: true,
@@ -36,20 +36,15 @@ class Seed < ApplicationRecord
validates :days_until_maturity_max, allow_nil: true, validates :days_until_maturity_max, allow_nil: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 } numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tradable_to, allow_blank: false, validates :tradable_to, allow_blank: false,
inclusion: { in: TRADABLE_TO_VALUES, message: "You may only trade seed nowhere, " \ inclusion: { in: TRADABLE_TO_VALUES, message: :tradable_to_inclusion }
"locally, nationally, or internationally" }
validates :organic, allow_blank: false, validates :organic, allow_blank: false,
inclusion: { in: ORGANIC_VALUES, message: "You must say whether the seeds " \ inclusion: { in: ORGANIC_VALUES, message: :organic_inclusion }
"are organic or not, or that you don't know" }
validates :gmo, allow_blank: false, validates :gmo, allow_blank: false,
inclusion: { in: GMO_VALUES, message: "You must say whether the seeds are " \ inclusion: { in: GMO_VALUES, message: :gmo_inclusion }
"genetically modified or not, or that you don't know" }
validates :heirloom, allow_blank: false, validates :heirloom, allow_blank: false,
inclusion: { in: HEIRLOOM_VALUES, message: "You must say whether the seeds" \ inclusion: { in: HEIRLOOM_VALUES, message: :heirloom_inclusion }
"are heirloom, hybrid, or unknown" }
validates :source, allow_blank: true, validates :source, allow_blank: true,
inclusion: { in: SOURCE_VALUES, message: "You must say where the seeds are from," \ inclusion: { in: SOURCE_VALUES, message: :source_inclusion }
"or that you don't know" }
# #
# Delegations # Delegations

View File

@@ -85,7 +85,7 @@ class GbifService
end end
def import! 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}" } Rails.logger.debug { "#{crop.id}, #{crop.name}" }
update_crop(crop) if crop.valid? update_crop(crop) if crop.valid?
rescue ActiveRecord::RecordInvalid rescue ActiveRecord::RecordInvalid

View File

@@ -16,7 +16,7 @@
%p %p
%span.help-block %span.help-block
For detailed crop wrangling guidelines, please consult the For detailed crop wrangling guidelines, please consult the
= link_to "crop wrangling guide", "http://wiki.growstuff.org/index.php/Crop_wrangling" = link_to "crop wrangling guide", "https://github.com/Growstuff/growstuff/wiki/Crop-Wrangling"
on the Growstuff wiki. on the Growstuff wiki.
.form-group .form-group

View File

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

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

View File

@@ -6,6 +6,7 @@
- @forums.each do |forum| - @forums.each do |forum|
%h2= forum %h2= forum
%p= forum.description
%p %p
= localize_plural(forum.posts, Post) = localize_plural(forum.posts, Post)
| |

View File

@@ -1,6 +1,11 @@
- if current_member && current_member != member # must be logged in, can't follow yourself - if current_member && current_member != member # must be logged in, can't follow yourself
- block = current_member.get_block(member)
- follow = current_member.get_follow(member) - follow = current_member.get_follow(member)
- if !follow && can?(:create, Follow) # not already following - if !block && !follow && can?(:create, Follow) # not already following, and not blocking
= link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success' = link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success'
- if follow && can?(:destroy, follow) # already following - if follow && can?(:destroy, follow) # already following
= link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block'
- if !block && can?(:create, Block) # not already blocking
= link_to 'Block', blocks_path(blocked: member), method: :post, class: 'btn btn-block btn-danger'
- if block && can?(:destroy, block) # already blocking
= link_to 'Unblock', block_path(block), method: :delete, class: 'btn btn-block'

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

@@ -11,7 +11,7 @@
%p %p
%span.help-block %span.help-block
For detailed crop wrangling guidelines, please consult the For detailed crop wrangling guidelines, please consult the
= link_to "crop wrangling guide", "http://wiki.growstuff.org/index.php/Crop_wrangling" = link_to "crop wrangling guide", "https://github.com/Growstuff/growstuff/wiki/Crop-Wrangling"
on the Growstuff wiki. on the Growstuff wiki.
.form-group .form-group

View File

@@ -1,24 +0,0 @@
# frozen_string_literal: true
# Require any additional compass plugins here.
# Set this to the root of your project when deployed:
http_path = "/"
css_dir = "app/assets/stylesheets"
sass_dir = "app/assets/stylesheets"
javascripts_dir = "app/assets/javascripts"
images_dir = "app/assets/images"
# You can select your preferred output style here (can be overridden via the command line):
# output_style = :expanded or :nested or :compact or :compressed
# To enable relative paths to assets via compass helper functions. Uncomment:
# relative_assets = true
# To disable debugging comments that display the original location of your selectors. Uncomment:
# line_comments = false
# If you prefer the indented syntax, you might want to regenerate this
# project again passing --syntax sass, or you can uncomment this:
preferred_syntax = :sass
# and then run:
# sass-convert -R --from scss --to sass sass scss && rm -rf sass && mv scss sass

View File

@@ -62,9 +62,9 @@ module Growstuff
# Growstuff-specific configuration variables # Growstuff-specific configuration variables
config.currency = 'AUD' config.currency = 'AUD'
config.bot_email = ENV.fetch('GROWSTUFF_EMAIL', nil) config.bot_email = Rails.configuration.x.email[:from]
config.user_agent = 'Growstuff' config.user_agent = 'Growstuff'
config.user_agent_email = "info@growstuff.org" config.user_agent_email = Rails.configuration.x.email[:from]
Gibbon::API.api_key = ENV['GROWSTUFF_MAILCHIMP_APIKEY'] || 'notarealkey' Gibbon::API.api_key = ENV['GROWSTUFF_MAILCHIMP_APIKEY'] || 'notarealkey'
# API key can't be blank or tests fail # API key can't be blank or tests fail

View File

@@ -1,4 +0,0 @@
# frozen_string_literal: true
# Require any additional compass plugins here.
project_type = :rails

View File

@@ -6,7 +6,7 @@ Devise.setup do |config|
# ==> Mailer Configuration # ==> Mailer Configuration
# Configure the e-mail address which will be shown in Devise::Mailer, # Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class with default "from" parameter. # note that it will be overwritten if you use your own mailer class with default "from" parameter.
config.mailer_sender = "Growstuff <#{ENV.fetch('GROWSTUFF_EMAIL', nil)}>" config.mailer_sender = "Growstuff <#{Rails.configuration.x.email[:from]}>"
config.secret_key = ENV.fetch('RAILS_SECRET_TOKEN', nil) config.secret_key = ENV.fetch('RAILS_SECRET_TOKEN', nil)

View File

@@ -0,0 +1,9 @@
module Growstuff
class Application < Rails::Application
config.x.email = {
from: ENV.fetch('GROWSTUFF_EMAIL', 'info@growstuff.org'),
admin: ENV.fetch('GROWSTUFF_ADMIN_EMAIL', 'sysadmin@growstuff.org'),
support: ENV.fetch('GROWSTUFF_SUPPORT_EMAIL', 'info@growstuff.org')
}
end
end

View File

@@ -63,6 +63,56 @@ en:
seed: seed:
one: seed one: seed
other: seeds other: seeds
errors:
messages:
crop_not_found: must be present and exist in our database
crop_must_be_approved: must be present and exist in our database
failed_and_finished: "can't be true if planting is also finished"
finished_after_planted: must be after the planting date
rejection_reason_required: must be rejected if a reason for rejection is present
rejection_notes_required: "must be added if the reason for rejection is \"other\""
same_crop_required: must be the same crop
harvest_after_planted: cannot be harvested before planting
cannot_be_garden_owner: cannot be the garden owner
same_owner_required: "of harvest must be the same as planting, or a collaborator on that garden"
photo_owner_mismatch: must have same owner as item it links to
calculate_crop_failed: failed to calculate crop
not_a_valid_wikipedia_url: is not a valid English Wikipedia URL
not_a_valid_youtube_url: is not a valid YouTube URL
not_a_valid_sunniness: "%{value} is not a valid sunniness value"
not_a_valid_planting_method: "%{value} is not a valid planting method"
not_a_valid_unit: "%{value} is not a valid unit"
not_a_valid_area_unit: "%{value} is not a valid area unit"
no_newlines: must contain no newlines
models:
member:
attributes:
login_name:
login_name_length: should be between 2 and 25 characters long
login_name_reserved: name is reserved
login_name_format: may only include letters, numbers, or underscores
website_url:
url_format: "must start with http:// or https://"
other_url:
url_format: "must start with http:// or https://"
instagram_handle:
handle_format: should be a handle, not a URL
facebook_handle:
handle_format: should be a handle, not a URL
bluesky_handle:
handle_format: should be a handle, not a URL
seed:
attributes:
tradable_to:
tradable_to_inclusion: "You may only trade seed nowhere, locally, nationally, or internationally"
organic:
organic_inclusion: "You must say whether the seeds are organic or not, or that you don't know"
gmo:
gmo_inclusion: "You must say whether the seeds are genetically modified or not, or that you don't know"
heirloom:
heirloom_inclusion: "You must say whether the seeds are heirloom, hybrid, or unknown"
source:
source_inclusion: "You must say where the seeds are from, or that you don't know"
application_helper: application_helper:
title: title:
title: title:
@@ -112,6 +162,9 @@ en:
forums: forums:
index: index:
title: Forums title: Forums
created: Forum was successfully created.
updated: Forum was successfully updated.
deleted: Forum was successfully deleted.
gardens: gardens:
created: Garden was successfully created. created: Garden was successfully created.
deleted: Garden was successfully deleted. deleted: Garden was successfully deleted.
@@ -208,6 +261,8 @@ en:
trade_to: Will trade to trade_to: Will trade to
unspecified: unspecified unspecified: unspecified
view_all: View all seeds view_all: View all seeds
added_to_stash: Successfully added %{crop} seed to your stash.
updated: Seed was successfully updated.
stats: stats:
member_linktext: "%{count} members" member_linktext: "%{count} members"
message_html: So far, %{member} have planted %{number_crops} %{number_plantings} in %{number_gardens}; and %{contributors} people have contributed to our code on %{github}! message_html: So far, %{member} have planted %{number_crops} %{number_plantings} in %{number_gardens}; and %{contributors} people have contributed to our code on %{github}!
@@ -275,6 +330,21 @@ en:
links: links:
my_gardens: My gardens my_gardens: My gardens
messages:
auth_success: Authentication successful.
auth_failed: Authentication failed.
crops_waiting: There are crops waiting to be wrangled.
followed: "Followed %{name}"
unfollowed: "Unfollowed %{name}"
follow_error: Already following or error while following.
transplant_success: Planting was successfully transplanted.
transplant_error: "There was an error transplanting the planting: %{errors}"
revert_success: "Reverted to version from %{date}"
revert_error: "Could not revert to version from %{date}. Errors: %{errors}"
invalid_location: Please enter a valid location
unable_to_like: Unable to like
unable_to_unlike: Unable to unlike
members: members:
edit_profile: Edit profile edit_profile: Edit profile
index: index:
@@ -342,10 +412,22 @@ en:
progress_0_not_planted_yet: 'Progress: 0% - not planted yet' progress_0_not_planted_yet: 'Progress: 0% - not planted yet'
posts: posts:
write_blog_post: Write blog post write_blog_post: Write blog post
created: Post was successfully created.
updated: Post was successfully updated.
deleted: Post was deleted.
index: index:
title: title:
author_posts: "%{author} posts" author_posts: "%{author} posts"
default: Everyone's posts default: Everyone's posts
scientific_names:
deleted: Scientific name was successfully deleted.
alternate_names:
created: Alternate name was successfully created.
updated: Alternate name was successfully updated.
deleted: Alternate name was successfully deleted.
crop_companions:
created: Companion was successfully created.
deleted: Companion was successfully destroyed.
seeds: seeds:
form: form:
trade_help: > trade_help: >
@@ -361,6 +443,7 @@ en:
owner_seeds: "%{owner} seeds" owner_seeds: "%{owner} seeds"
save_seeds: Save seeds save_seeds: Save seeds
string: "%{crop} seeds belonging to %{owner}" string: "%{crop} seeds belonging to %{owner}"
added_to_stash: Successfully added %{crop} seed to your stash.
unauthorized: unauthorized:
create: create:
all: Please sign in or sign up to create a %{subject}. all: Please sign in or sign up to create a %{subject}.

View File

@@ -105,6 +105,7 @@ Rails.application.routes.draw do
resources :forums resources :forums
resources :follows, only: %i(create destroy) resources :follows, only: %i(create destroy)
resources :blocks, only: %i(create destroy)
post 'likes' => 'likes#create' post 'likes' => 'likes#create'
delete 'likes' => 'likes#destroy' delete 'likes' => 'likes#destroy'
@@ -121,6 +122,7 @@ Rails.application.routes.draw do
resources :follows resources :follows
get 'followers' => 'follows#followers' get 'followers' => 'follows#followers'
resources :blocks, only: %i(create destroy)
end end
resources :messages resources :messages

View File

@@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateBlocks < ActiveRecord::Migration[6.1]
def change
create_table :blocks do |t|
t.references :blocker, foreign_key: { to_table: :members }
t.references :blocked, foreign_key: { to_table: :members }
t.timestamps
end
add_index :blocks, %i(blocker_id blocked_id), unique: true
end
end

View File

@@ -37,6 +37,6 @@ class CreateVersions < ActiveRecord::Migration[7.2]
t.string :event, null: false t.string :event, null: false
t.text :object, limit: TEXT_BYTES t.text :object, limit: TEXT_BYTES
end end
add_index :versions, %i[item_type item_id] add_index :versions, %i(item_type item_id)
end end
end end

View File

@@ -384,6 +384,16 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do
t.index ["member_id"], name: "index_authentications_on_member_id" t.index ["member_id"], name: "index_authentications_on_member_id"
end end
create_table "blocks", force: :cascade do |t|
t.bigint "blocker_id"
t.bigint "blocked_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["blocked_id"], name: "index_blocks_on_blocked_id"
t.index ["blocker_id", "blocked_id"], name: "index_blocks_on_blocker_id_and_blocked_id", unique: true
t.index ["blocker_id"], name: "index_blocks_on_blocker_id"
end
create_table "comfy_cms_categories", id: :serial, force: :cascade do |t| create_table "comfy_cms_categories", id: :serial, force: :cascade do |t|
t.integer "site_id", null: false t.integer "site_id", null: false
t.string "label", null: false t.string "label", null: false
@@ -972,6 +982,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_01_045000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "blocks", "members", column: "blocked_id"
add_foreign_key "blocks", "members", column: "blocker_id"
add_foreign_key "harvests", "plantings" add_foreign_key "harvests", "plantings"
add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id"
add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id" add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id"

76
docker-compose.yml Normal file
View File

@@ -0,0 +1,76 @@
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
elasticsearch:
condition: service_healthy
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/growstuff_prod
ELASTICSEARCH_URL: http://elasticsearch:9200/
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
RAILS_SERVE_STATIC_FILES: "true"
APP_DOMAIN_NAME: localhost:3000
APP_PROTOCOL: http
DEVISE_SECRET_KEY: secret
GROWSTUFF_EMAIL: "noreply@test.growstuff.org"
GROWSTUFF_FLICKR_KEY: secretkey
GROWSTUFF_FLICKR_SECRET: secretsecret
GROWSTUFF_SITE_NAME: "Growstuff (local)"
RAILS_SECRET_TOKEN: supersecret
SECRET_KEY_BASE: supersecretbase
db:
image: postgres:17
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
- .devcontainer/create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql
environment:
POSTGRES_USER: postgres
POSTGRES_DB: growstuff_prod
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.4.0
container_name: elasticsearch
restart: unless-stopped
environment:
- xpack.security.enabled=false
- discovery.type=single-node
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
cap_add:
- IPC_LOCK
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200 | grep tagline"]
interval: 10s
timeout: 10s
retries: 120
volumes:
- esdata01:/usr/share/elasticsearch/data
ports:
- 9200:9200
- 9300:9300
volumes:
postgres-data:
esdata01:

8
entrypoint.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
# Remove a potentially pre-existing server.pid for Rails.
rm -f /app/tmp/pids/server.pid
# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

View File

@@ -1,18 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe AuthenticationsController do
before do
@member = create(:member)
sign_in @member
controller.stub(:current_member) { @member }
@auth = create(:authentication, member: @member)
request.env['omniauth.auth'] = {
'provider' => 'foo',
'uid' => 'bar',
'info' => { 'nickname' => 'blah' },
'credentials' => { 'token' => 'blah', 'secret' => 'blah' }
}
end
end

View File

@@ -3,17 +3,111 @@
require 'rails_helper' require 'rails_helper'
describe ForumsController do describe ForumsController do
login_member(:admin_member) let(:admin) { create(:admin_member) }
let(:member) { create(:member) }
let(:forum) { create(:forum) }
def valid_attributes describe "GET #index" do
{ it "returns a success response" do
"name" => "MyString", get :index
"description" => "Something", expect(response).to be_successful
"owner_id" => 1 end
}
it "assigns @forums" do
forum # create forum
get :index
expect(assigns(:forums)).to include(forum)
end
end end
def valid_session describe "GET #show" do
{} it "returns a success response" do
get :show, params: { id: forum.to_param }
expect(response).to be_successful
end
end
context "as an admin" do
before { sign_in admin }
describe "GET #new" do
it "returns a success response" do
get :new
expect(response).to be_successful
end
end
describe "GET #edit" do
it "returns a success response" do
get :edit, params: { id: forum.to_param }
expect(response).to be_successful
end
end
describe "POST #create" do
context "with valid params" do
let(:valid_attributes) { { name: "New Forum", description: "A new forum", owner_id: admin.id } }
it "creates a new Forum" do
expect do
post :create, params: { forum: valid_attributes }
end.to change(Forum, :count).by(1)
end
it "redirects to the created forum" do
post :create, params: { forum: valid_attributes }
expect(response).to redirect_to(Forum.last)
end
end
end
describe "PUT #update" do
context "with valid params" do
let(:new_attributes) { { name: "Updated Name" } }
it "updates the requested forum" do
put :update, params: { id: forum.to_param, forum: new_attributes }
forum.reload
expect(forum.name).to eq("Updated Name")
end
it "redirects to the forum" do
put :update, params: { id: forum.to_param, forum: new_attributes }
expect(response).to redirect_to(forum)
end
end
end
describe "DELETE #destroy" do
it "destroys the requested forum" do
forum # ensure forum exists
expect do
delete :destroy, params: { id: forum.to_param }
end.to change(Forum, :count).by(-1)
end
it "redirects to the forums list" do
delete :destroy, params: { id: forum.to_param }
expect(response).to redirect_to(forums_url)
end
end
end
context "as a regular member" do
before { sign_in member }
describe "GET #new" do
it "denies access" do
get :new
expect(response).to redirect_to(root_path)
end
end
describe "POST #create" do
it "denies access" do
post :create, params: { forum: { name: "Forbidden" } }
expect(response).to redirect_to(root_path)
end
end
end end
end end

View File

@@ -24,29 +24,42 @@ RSpec.describe GardenTypesController do
describe 'changing existing records' do describe 'changing existing records' do
before do before do
allow(GardenType).to receive(:find).and_return(:garden_type) allow(GardenType).to receive(:find).and_return(:garden_type)
expect(garden_type).not_to receive(:save)
expect(garden_type).not_to receive(:save!)
expect(garden_type).not_to receive(:update)
expect(garden_type).not_to receive(:update!)
expect(garden_type).not_to receive(:destroy)
end end
describe 'GET edit' do describe 'GET edit' do
before { get :edit, params: { id: garden_type.to_param } } it "redirects to root" do
expect(garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(garden_type).not_to receive(:save!)
expect(garden_type).not_to receive(:update)
expect(garden_type).not_to receive(:update!)
expect(garden_type).not_to receive(:destroy)
get :edit, params: { id: garden_type.to_param }
expect(response).to redirect_to(root_path)
end
end end
describe 'POST update' do describe 'POST update' do
before { post :update, params: { id: garden_type.to_param, garden_type: valid_params } } it "redirects to root" do
expect(garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(garden_type).not_to receive(:save!)
expect(garden_type).not_to receive(:update)
expect(garden_type).not_to receive(:update!)
expect(garden_type).not_to receive(:destroy)
post :update, params: { id: garden_type.to_param, garden_type: valid_params }
expect(response).to redirect_to(root_path)
end
end end
describe 'DELETE' do describe 'DELETE' do
before { delete :destroy, params: { id: garden_type.to_param, params: { garden_type: valid_params } } } it "redirects to root" do
expect(garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(garden_type).not_to receive(:save!)
expect(garden_type).not_to receive(:update)
expect(garden_type).not_to receive(:update!)
expect(garden_type).not_to receive(:destroy)
delete :destroy, params: { id: garden_type.to_param, params: { garden_type: valid_params } }
expect(response).to redirect_to(root_path)
end
end end
end end
end end
@@ -60,30 +73,43 @@ RSpec.describe GardenTypesController do
let(:any_garden_type) { double('garden_type') } let(:any_garden_type) { double('garden_type') }
before do before do
expect(GardenType).to receive(:find).and_return(:any_garden_type) allow(GardenType).to receive(:find).and_return(:any_garden_type)
expect(any_garden_type).not_to receive(:save)
expect(any_garden_type).not_to receive(:save!)
expect(any_garden_type).not_to receive(:update)
expect(any_garden_type).not_to receive(:update!)
expect(any_garden_type).not_to receive(:destroy)
end end
describe 'GET edit' do describe 'GET edit' do
before { get :edit, params: { id: any_garden_type.to_param } } it "redirects to root" do
expect(any_garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(any_garden_type).not_to receive(:save!)
expect(any_garden_type).not_to receive(:update)
expect(any_garden_type).not_to receive(:update!)
expect(any_garden_type).not_to receive(:destroy)
get :edit, params: { id: any_garden_type.to_param }
expect(response).to redirect_to(root_path)
end
end end
describe 'POST update' do describe 'POST update' do
before { post :update, params: { id: any_garden_type.to_param, garden_type: valid_params } } it "redirects to root" do
expect(any_garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(any_garden_type).not_to receive(:save!)
expect(any_garden_type).not_to receive(:update)
expect(any_garden_type).not_to receive(:update!)
expect(any_garden_type).not_to receive(:destroy)
post :update, params: { id: any_garden_type.to_param, garden_type: valid_params }
expect(response).to redirect_to(root_path)
end
end end
describe 'DELETE' do describe 'DELETE' do
before { delete :destroy, params: { id: any_garden_type.to_param, params: { garden_type: valid_params } } } it "redirects to root" do
expect(any_garden_type).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(any_garden_type).not_to receive(:save!)
expect(any_garden_type).not_to receive(:update)
expect(any_garden_type).not_to receive(:update!)
expect(any_garden_type).not_to receive(:destroy)
delete :destroy, params: { id: any_garden_type.to_param, params: { garden_type: valid_params } }
expect(response).to redirect_to(root_path)
end
end end
end end
end end

View File

@@ -25,29 +25,42 @@ RSpec.describe GardensController do
describe 'changing existing records' do describe 'changing existing records' do
before do before do
allow(Garden).to receive(:find).and_return(:garden) allow(Garden).to receive(:find).and_return(:garden)
expect(garden).not_to receive(:save)
expect(garden).not_to receive(:save!)
expect(garden).not_to receive(:update)
expect(garden).not_to receive(:update!)
expect(garden).not_to receive(:destroy)
end end
describe 'GET edit' do describe 'GET edit' do
before { get :edit, params: { slug: garden.to_param } } it "redirects to login" do
expect(garden).not_to receive(:save)
it { expect(response).to redirect_to(new_member_session_path) } expect(garden).not_to receive(:save!)
expect(garden).not_to receive(:update)
expect(garden).not_to receive(:update!)
expect(garden).not_to receive(:destroy)
get :edit, params: { slug: garden.to_param }
expect(response).to redirect_to(new_member_session_path)
end
end end
describe 'POST update' do describe 'POST update' do
before { post :update, params: { slug: garden.to_param, garden: valid_params } } it "redirects to login" do
expect(garden).not_to receive(:save)
it { expect(response).to redirect_to(new_member_session_path) } expect(garden).not_to receive(:save!)
expect(garden).not_to receive(:update)
expect(garden).not_to receive(:update!)
expect(garden).not_to receive(:destroy)
post :update, params: { slug: garden.to_param, garden: valid_params }
expect(response).to redirect_to(new_member_session_path)
end
end end
describe 'DELETE' do describe 'DELETE' do
before { delete :destroy, params: { slug: garden.to_param, params: { garden: valid_params } } } it "redirects to login" do
expect(garden).not_to receive(:save)
it { expect(response).to redirect_to(new_member_session_path) } expect(garden).not_to receive(:save!)
expect(garden).not_to receive(:update)
expect(garden).not_to receive(:update!)
expect(garden).not_to receive(:destroy)
delete :destroy, params: { slug: garden.to_param, params: { garden: valid_params } }
expect(response).to redirect_to(new_member_session_path)
end
end end
end end
end end
@@ -61,30 +74,43 @@ RSpec.describe GardensController do
let(:not_my_garden) { double('garden') } let(:not_my_garden) { double('garden') }
before do before do
expect(Garden).to receive(:find).and_return(:not_my_garden) allow(Garden).to receive(:find).and_return(:not_my_garden)
expect(not_my_garden).not_to receive(:save)
expect(not_my_garden).not_to receive(:save!)
expect(not_my_garden).not_to receive(:update)
expect(not_my_garden).not_to receive(:update!)
expect(not_my_garden).not_to receive(:destroy)
end end
describe 'GET edit' do describe 'GET edit' do
before { get :edit, params: { slug: not_my_garden.to_param } } it "redirects to root" do
expect(not_my_garden).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(not_my_garden).not_to receive(:save!)
expect(not_my_garden).not_to receive(:update)
expect(not_my_garden).not_to receive(:update!)
expect(not_my_garden).not_to receive(:destroy)
get :edit, params: { slug: not_my_garden.to_param }
expect(response).to redirect_to(root_path)
end
end end
describe 'POST update' do describe 'POST update' do
before { post :update, params: { slug: not_my_garden.to_param, garden: valid_params } } it "redirects to root" do
expect(not_my_garden).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(not_my_garden).not_to receive(:save!)
expect(not_my_garden).not_to receive(:update)
expect(not_my_garden).not_to receive(:update!)
expect(not_my_garden).not_to receive(:destroy)
post :update, params: { slug: not_my_garden.to_param, garden: valid_params }
expect(response).to redirect_to(root_path)
end
end end
describe 'DELETE' do describe 'DELETE' do
before { delete :destroy, params: { slug: not_my_garden.to_param, params: { garden: valid_params } } } it "redirects to root" do
expect(not_my_garden).not_to receive(:save)
it { expect(response).to redirect_to(root_path) } expect(not_my_garden).not_to receive(:save!)
expect(not_my_garden).not_to receive(:update)
expect(not_my_garden).not_to receive(:update!)
expect(not_my_garden).not_to receive(:destroy)
delete :destroy, params: { slug: not_my_garden.to_param, params: { garden: valid_params } }
expect(response).to redirect_to(root_path)
end
end end
end end
end end

View File

@@ -15,12 +15,12 @@ describe HarvestsController, :search do
end end
describe "GET index" do describe "GET index" do
let!(:member1) { create(:member) } let!(:first_member) { create(:member) }
let(:member2) { create(:member) } let(:second_member) { create(:member) }
let(:tomato) { create(:tomato) } let(:tomato) { create(:tomato) }
let(:maize) { create(:maize) } let(:maize) { create(:maize) }
let!(:harvest1) { create(:harvest, owner_id: member1.id, crop_id: tomato.id) } let!(:tomato_harvest) { create(:harvest, owner_id: first_member.id, crop_id: tomato.id) }
let!(:harvest2) { create(:harvest, owner_id: member2.id, crop_id: maize.id) } let!(:maize_harvest) { create(:harvest, owner_id: second_member.id, crop_id: maize.id) }
before { Harvest.reindex } before { Harvest.reindex }
@@ -28,16 +28,16 @@ describe HarvestsController, :search do
before { get :index, params: {} } before { get :index, params: {} }
it { expect(assigns(:harvests).size).to eq 2 } it { expect(assigns(:harvests).size).to eq 2 }
it { expect(assigns(:harvests)[0].slug).to eq harvest1.slug } it { expect(assigns(:harvests)[0].slug).to eq tomato_harvest.slug }
it { expect(assigns(:harvests)[1].slug).to eq harvest2.slug } it { expect(assigns(:harvests)[1].slug).to eq maize_harvest.slug }
end end
describe "picks up owner from params and shows owner's harvests only" do describe "picks up owner from params and shows owner's harvests only" do
before { get :index, params: { member_slug: member1.slug } } before { get :index, params: { member_slug: first_member.slug } }
it { expect(assigns(:owner)).to eq member1 } it { expect(assigns(:owner)).to eq first_member }
it { expect(assigns(:harvests).size).to eq 1 } it { expect(assigns(:harvests).size).to eq 1 }
it { expect(assigns(:harvests)[0].slug).to eq harvest1.slug } it { expect(assigns(:harvests)[0].slug).to eq tomato_harvest.slug }
end end
describe "picks up crop from params and shows the harvests for the crop only" do describe "picks up crop from params and shows the harvests for the crop only" do
@@ -45,7 +45,7 @@ describe HarvestsController, :search do
it { expect(assigns(:crop)).to eq maize } it { expect(assigns(:crop)).to eq maize }
it { expect(assigns(:harvests).size).to eq 1 } it { expect(assigns(:harvests).size).to eq 1 }
it { expect(assigns(:harvests)[0].slug).to eq harvest2.slug } it { expect(assigns(:harvests)[0].slug).to eq maize_harvest.slug }
end end
describe "generates a csv" do describe "generates a csv" do

View File

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

View File

@@ -13,12 +13,12 @@ describe PlantingsController, :search do
end end
describe "GET index", :search do describe "GET index", :search do
let!(:member1) { create(:member) } let!(:first_member) { create(:member) }
let!(:member2) { create(:member) } let!(:second_member) { create(:member) }
let!(:tomato) { create(:tomato) } let!(:tomato) { create(:tomato) }
let!(:maize) { create(:maize) } let!(:maize) { create(:maize) }
let!(:planting1) { create(:planting, crop: tomato, owner: member1, created_at: 1.day.ago) } let!(:tomato_planting) { create(:planting, crop: tomato, owner: first_member, created_at: 1.day.ago) }
let!(:planting2) { create(:planting, crop: maize, owner: member2, created_at: 5.days.ago) } let!(:maize_planting) { create(:planting, crop: maize, owner: second_member, created_at: 5.days.ago) }
before do before do
Planting.reindex Planting.reindex
@@ -28,23 +28,23 @@ describe PlantingsController, :search do
before { get :index } before { get :index }
it { expect(assigns(:plantings).size).to eq 2 } it { expect(assigns(:plantings).size).to eq 2 }
it { expect(assigns(:plantings)[0]['slug']).to eq planting1.slug } it { expect(assigns(:plantings)[0]['slug']).to eq tomato_planting.slug }
it { expect(assigns(:plantings)[1]['slug']).to eq planting2.slug } it { expect(assigns(:plantings)[1]['slug']).to eq maize_planting.slug }
end end
describe "picks up owner from params and shows owner's plantings only" do describe "picks up owner from params and shows owner's plantings only" do
before { get :index, params: { member_slug: member1.slug } } before { get :index, params: { member_slug: first_member.slug } }
it { expect(assigns(:owner)).to eq member1 } it { expect(assigns(:owner)).to eq first_member }
it { expect(assigns(:plantings).size).to eq 1 } it { expect(assigns(:plantings).size).to eq 1 }
it { expect(assigns(:plantings).first['slug']).to eq planting1.slug } it { expect(assigns(:plantings).first['slug']).to eq tomato_planting.slug }
end end
describe "picks up crop from params and shows the plantings for the crop only" do describe "picks up crop from params and shows the plantings for the crop only" do
before { get :index, params: { crop_slug: maize.slug } } before { get :index, params: { crop_slug: maize.slug } }
it { expect(assigns(:crop)).to eq maize } it { expect(assigns(:crop)).to eq maize }
it { expect(assigns(:plantings).first['slug']).to eq planting2.slug } it { expect(assigns(:plantings).first['slug']).to eq maize_planting.slug }
end end
end end

View File

@@ -21,10 +21,6 @@ describe SeedsController, :search do
it { expect(response).to be_successful } it { expect(response).to be_successful }
context 'no parent planting' do
before { get :new }
end
context 'with parent planting' do context 'with parent planting' do
let!(:planting) { create(:planting, owner:) } let!(:planting) { create(:planting, owner:) }

View File

@@ -22,7 +22,6 @@ describe "forums", :js do
before do before do
visit forums_path visit forums_path
click_link "New forum" click_link "New forum"
expect(page).to have_current_path new_forum_path, ignore_query: true
fill_in 'Name', with: 'Discussion' fill_in 'Name', with: 'Discussion'
fill_in 'Description', with: "this is a new forum" fill_in 'Description', with: "this is a new forum"
select member.login_name, from: "Owner" select member.login_name, from: "Owner"

View File

@@ -23,7 +23,6 @@ describe "plant parts", :js do
before do before do
visit plant_parts_path visit plant_parts_path
click_link "New plant part" click_link "New plant part"
expect(page).to have_current_path new_plant_part_path, ignore_query: true
fill_in 'Name', with: "this is a new plant part" fill_in 'Name', with: "this is a new plant part"
click_button 'Save' click_button 'Save'
end end

View File

@@ -23,7 +23,6 @@ describe "roles", :js do
before do before do
visit admin_roles_path visit admin_roles_path
click_link "New role" click_link "New role"
expect(page).to have_current_path new_admin_role_path, ignore_query: true
fill_in 'Name', with: 'Discussion' fill_in 'Name', with: 'Discussion'
fill_in 'Description', with: "this is a new role" fill_in 'Description', with: "this is a new role"
click_button 'Save' click_button 'Save'

View File

@@ -15,41 +15,44 @@ describe "crop detail page", :js, :search do
let!(:planting) { create(:planting, crop:, owner: owner_member) } let!(:planting) { create(:planting, crop:, owner: owner_member) }
let!(:seed) { create(:seed, crop:, owner: owner_member) } let!(:seed) { create(:seed, crop:, owner: owner_member) }
let!(:photo1) { create(:photo, owner: owner_member) } let!(:first_planting_photo) { create(:photo, owner: owner_member) }
let!(:photo2) { create(:photo, owner: owner_member) } let!(:second_planting_photo) { create(:photo, owner: owner_member) }
let!(:photo3) { create(:photo, owner: owner_member) } let!(:first_harvest_photo) { create(:photo, owner: owner_member) }
let!(:photo4) { create(:photo, owner: owner_member) } let!(:second_harvest_photo) { create(:photo, owner: owner_member) }
let!(:photo5) { create(:photo, owner: owner_member) } let!(:first_seed_photo) { create(:photo, owner: owner_member) }
let!(:photo6) { create(:photo, owner: owner_member) } let!(:second_seed_photo) { create(:photo, owner: owner_member) }
before do before do
planting.photos << photo1 planting.photos << first_planting_photo
planting.photos << photo2 planting.photos << second_planting_photo
harvest.photos << photo3 harvest.photos << first_harvest_photo
harvest.photos << photo4 harvest.photos << second_harvest_photo
seed.photos << photo5 seed.photos << first_seed_photo
seed.photos << photo6 seed.photos << second_seed_photo
Crop.reindex Crop.reindex
visit crop_path(crop) visit crop_path(crop)
expect(crop.photos.count).to eq 6
expect(crop.photos.by_model(Planting).count).to eq 2
expect(page).to have_content 'Photos'
end end
shared_examples "shows photos" do shared_examples "shows photos" do
it "shows the photo section" do
expect(crop.photos.count).to eq 6
expect(crop.photos.by_model(Planting).count).to eq 2
expect(page).to have_content 'Photos'
end
describe "show planting photos" do describe "show planting photos" do
it { is_expected.to have_xpath("//img[contains(@src,'#{photo1.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{first_planting_photo.fullsize_url}')]") }
it { is_expected.to have_xpath("//img[contains(@src,'#{photo2.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{second_planting_photo.fullsize_url}')]") }
end end
describe "show harvest photos" do describe "show harvest photos" do
it { is_expected.to have_xpath("//img[contains(@src,'#{photo3.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{first_harvest_photo.fullsize_url}')]") }
it { is_expected.to have_xpath("//img[contains(@src,'#{photo4.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{second_harvest_photo.fullsize_url}')]") }
end end
describe "show seed photos" do describe "show seed photos" do
it { is_expected.to have_xpath("//img[contains(@src,'#{photo5.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{first_seed_photo.fullsize_url}')]") }
it { is_expected.to have_xpath("//img[contains(@src,'#{photo6.fullsize_url}')]") } it { is_expected.to have_xpath("//img[contains(@src,'#{second_seed_photo.fullsize_url}')]") }
end end
describe "link to more photos" do describe "link to more photos" do

View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
require 'rails_helper'
describe "Forums usage", :js do
let!(:forum) { create(:forum, name: "General Discussion", description: "Talk about anything") }
let(:member) { create(:member) }
describe "browsing forums" do
it "shows the list of forums" do
visit forums_path
expect(page).to have_content("General Discussion")
expect(page).to have_content("Talk about anything")
end
end
describe "viewing a forum" do
let!(:post) { create(:post, forum: forum, subject: "Hello World", author: member) }
it "shows forum details and posts" do
visit forum_path(forum)
expect(page).to have_css("h1", text: "General Discussion")
expect(page).to have_content("Talk about anything")
expect(page).to have_content("Hello World")
expect(page).to have_link("Post something")
end
end
describe "starting a new post from a forum" do
include_context 'signed in member'
it "pre-fills the forum when creating a new post" do
visit forum_path(forum)
click_link "Post something"
expect(page).to have_current_path(new_post_path(forum_id: forum.id))
expect(page).to have_content("This post will be posted in the forum #{forum.name}")
fill_in "post_subject", with: "My New Post"
fill_in "post_body", with: "Content of my post"
click_button "Post"
expect(page).to have_content("Post was successfully created")
expect(Post.last.forum).to eq(forum)
end
end
end

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'rails_helper'
describe "blocks", :js do
context "when signed in" do
include_context 'signed in member'
let(:other_member) { create(:member) }
it "your profile doesn't have a block button" do
visit member_path(member)
expect(page).to have_no_link "Block"
expect(page).to have_no_link "Unblock"
end
context "blocking another member" do
before { visit member_path(other_member) }
it "has a block button" do
expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug)
end
it "has correct message and unblock button" do
click_link 'Block'
expect(page).to have_content "Blocked #{other_member.login_name}"
expect(page).to have_link "Unblock", href: block_path(member.get_block(other_member))
end
it "has correct message and block button after unblock" do
click_link 'Block'
click_link 'Unblock'
expect(page).to have_content "Unblocked #{other_member.login_name}"
visit member_path(other_member) # unblocking redirects to root
expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug)
end
context "when a member is blocked" do
before do
click_link 'Block'
end
it "prevents following" do
visit member_path(other_member)
expect(page).to have_no_link "Follow"
end
xit "prevents messaging" do
visit new_message_path(recipient_id: other_member.id)
fill_in "Subject", with: "Test message"
fill_in "Body", with: "Test message body"
click_button "Send message"
expect(page).to have_content "You cannot send a message to a member who has blocked you."
end
xit "prevents commenting" do
post = create(:post, author: other_member)
visit post_path(post)
fill_in "comment_body", with: "Test comment"
click_button "Post Comment"
expect(page).to have_content "You cannot comment on a post of a member who has blocked you."
end
xit "prevents liking" do
post = create(:post, author: other_member)
visit post_path(post)
click_link "Like"
expect(page).to have_content "You cannot like content of a member who has blocked you."
end
end
end
end
end

View File

@@ -23,6 +23,17 @@ describe "follows", :js do
expect(page).to have_no_link "Unfollow" expect(page).to have_no_link "Unfollow"
end end
context "when the other member is blocked" do
before do
member.blocks.create(blocked: other_member)
visit member_path(other_member)
end
it "does not have a follow button" do
expect(page).to have_no_link "Follow"
end
end
context "following another member" do context "following another member" do
before { visit member_path(other_member) } before { visit member_path(other_member) }

View File

@@ -6,27 +6,29 @@ describe "members list" do
context "list all members" do context "list all members" do
subject { page.all("#maincontainer h4.login-name") } subject { page.all("#maincontainer h4.login-name") }
let!(:member1) { create(:member, login_name: "Archaeopteryx", confirmed_at: Time.zone.parse('2013-02-10')) } let!(:archaeopteryx) { create(:member, login_name: "Archaeopteryx", confirmed_at: Time.zone.parse('2013-02-10')) }
let!(:member2) { create(:member, login_name: "Zephyrosaurus", confirmed_at: Time.zone.parse('2014-01-11')) } let!(:zephyrosaurus) { create(:member, login_name: "Zephyrosaurus", confirmed_at: Time.zone.parse('2014-01-11')) }
let!(:member3) { create(:member, login_name: "Testingname", confirmed_at: Time.zone.parse('2014-05-09')) } let!(:testingname) { create(:member, login_name: "Testingname", confirmed_at: Time.zone.parse('2014-05-09')) }
before do before do
visit members_path visit members_path
expect(page).to have_css "#sort"
expect(page).to have_css "form"
end end
it "default alphabetical sort" do it "default alphabetical sort" do
expect(page).to have_css "#sort"
expect(page).to have_css "form"
click_button('Show') click_button('Show')
expect(subject.first).to have_text member1.login_name expect(subject.first).to have_text archaeopteryx.login_name
expect(subject.last).to have_text member2.login_name expect(subject.last).to have_text zephyrosaurus.login_name
end end
it "recently joined sort" do it "recently joined sort" do
expect(page).to have_css "#sort"
expect(page).to have_css "form"
select("recently", from: 'sort') select("recently", from: 'sort')
click_button('Show') click_button('Show')
expect(subject.first).to have_text member3.login_name expect(subject.first).to have_text testingname.login_name
expect(subject.last).to have_text member1.login_name expect(subject.last).to have_text archaeopteryx.login_name
end end
end end
end end

View File

@@ -118,15 +118,15 @@ describe "member profile", :js do
end end
context 'member has activities' do context 'member has activities' do
let!(:activity) { create(:activity, owner: member, due_date: 3.days.ago) } let!(:past_activity) { create(:activity, owner: member, due_date: 3.days.ago) }
let!(:activity2) { create(:activity, :planting, owner: member) } let!(:planting_activity) { create(:activity, :planting, owner: member) }
let!(:activity3) { create(:activity, :garden, owner: member) } let!(:garden_activity) { create(:activity, :garden, owner: member) }
before { visit member_path(member) } before { visit member_path(member) }
it { expect(page).to have_link href: activity_path(activity) } it { expect(page).to have_link href: activity_path(past_activity) }
it { expect(page).to have_link href: activity_path(activity2) } it { expect(page).to have_link href: activity_path(planting_activity) }
it { expect(page).to have_link href: activity_path(activity3) } it { expect(page).to have_link href: activity_path(garden_activity) }
end end
context 'member has seeds' do context 'member has seeds' do

View File

@@ -7,17 +7,17 @@ describe 'Test with visual testing', :js do
# on every run, so doesn't trigger percy to see changes # on every run, so doesn't trigger percy to see changes
before { Faker::Config.random = Random.new(42) } before { Faker::Config.random = Random.new(42) }
let!(:member) { create(:member, login_name: 'percy', preferred_avatar_uri: gravatar) } let!(:member) { create(:member, login_name: 'percy', preferred_avatar_uri: member_gravatar) }
let!(:crop_wrangler) { create(:crop_wrangling_member, login_name: 'croppy', preferred_avatar_uri: gravatar2) } let!(:crop_wrangler) { create(:crop_wrangling_member, login_name: 'croppy', preferred_avatar_uri: crop_wrangler_gravatar) }
let!(:admin_user) { create(:admin_member, login_name: 'janitor', preferred_avatar_uri: gravatar3) } let!(:admin_user) { create(:admin_member, login_name: 'janitor', preferred_avatar_uri: admin_gravatar) }
let!(:someone_else) { create(:edinburgh_member, login_name: 'ruby', preferred_avatar_uri: gravatar4) } let!(:someone_else) { create(:edinburgh_member, login_name: 'ruby', preferred_avatar_uri: someone_else_gravatar) }
let(:gravatar) { 'https://secure.gravatar.com/avatar/d021434aac03a7f7c7c0de60d07dad1c?size=150&default=identicon' } let(:member_gravatar) { 'https://secure.gravatar.com/avatar/d021434aac03a7f7c7c0de60d07dad1c?size=150&default=identicon' }
let(:gravatar2) { 'https://secure.gravatar.com/avatar/353d83d3677b142520987e1936fd093c?size=150&default=identicon' } let(:crop_wrangler_gravatar) { 'https://secure.gravatar.com/avatar/353d83d3677b142520987e1936fd093c?size=150&default=identicon' }
let(:gravatar3) { 'https://secure.gravatar.com/avatar/622db62c7beab8d5d8b7a80aa6385b2f?size=150&default=identicon' } let(:admin_gravatar) { 'https://secure.gravatar.com/avatar/622db62c7beab8d5d8b7a80aa6385b2f?size=150&default=identicon' }
let(:gravatar4) { 'https://secure.gravatar.com/avatar/7fd767571ff5ceefc7a687a543b2c402?size=150&default=identicon' } let(:someone_else_gravatar) { 'https://secure.gravatar.com/avatar/7fd767571ff5ceefc7a687a543b2c402?size=150&default=identicon' }
let!(:tomato) { create(:tomato, creator: someone_else) } let!(:tomato) { create(:tomato, creator: someone_else) }
let(:plant_part) { create(:plant_part, name: 'fruit') } let(:plant_part) { create(:plant_part, name: 'fruit') }
let(:tomato_photo) do let(:tomato_photo) do

View File

@@ -30,13 +30,13 @@ describe "Planting reminder email", :js do
context "when member has some plantings" do context "when member has some plantings" do
# Bangs are used on the following 2 let blocks in order to ensure that the plantings are present # Bangs are used on the following 2 let blocks in order to ensure that the plantings are present
# in the database before the email is generated: otherwise, they won't be present in the email. # in the database before the email is generated: otherwise, they won't be present in the email.
let!(:p1) { create(:predictable_planting, planted_at: 10.days.ago, garden: member.gardens.first, owner: member) } let!(:recent_planting) { create(:predictable_planting, planted_at: 10.days.ago, garden: member.gardens.first, owner: member) }
let!(:p2) { create(:predictable_planting, planted_at: 30.days.ago, garden: member.gardens.first, owner: member) } let!(:older_planting) { create(:predictable_planting, planted_at: 30.days.ago, garden: member.gardens.first, owner: member) }
describe "lists plantings" do describe "lists plantings" do
it { expect(mail).to have_content "Progress report" } it { expect(mail).to have_content "Progress report" }
it { expect(mail).to have_link p1.crop.to_s, href: planting_url(p1) } it { expect(mail).to have_link recent_planting.crop.to_s, href: planting_url(recent_planting) }
it { expect(mail).to have_link p2.crop.to_s, href: planting_url(p2) } it { expect(mail).to have_link older_planting.crop.to_s, href: planting_url(older_planting) }
it { expect(mail).to have_content "keep your garden records up to date" } it { expect(mail).to have_content "keep your garden records up to date" }
end end
end end
@@ -50,15 +50,15 @@ describe "Planting reminder email", :js do
context "when member has some harvests" do context "when member has some harvests" do
# Bangs are used on the following 2 let blocks in order to ensure that the plantings are present # Bangs are used on the following 2 let blocks in order to ensure that the plantings are present
# in the database before the spec is run. # in the database before the spec is run.
let!(:p1) { create(:predictable_planting, garden: member.gardens.first, owner: member, planted_at: 20.days.ago) } let!(:recent_planting) { create(:predictable_planting, garden: member.gardens.first, owner: member, planted_at: 20.days.ago) }
let!(:p2) { create(:predictable_planting, garden: member.gardens.first, owner: member) } let!(:older_planting) { create(:predictable_planting, garden: member.gardens.first, owner: member) }
let!(:h1) { create(:harvest, owner: member, planting: p1, harvested_at: 1.day.ago) } let!(:recent_harvest) { create(:harvest, owner: member, planting: recent_planting, harvested_at: 1.day.ago) }
let!(:h2) { create(:harvest, owner: member, planting: p2, harvested_at: 3.days.ago) } let!(:older_harvest) { create(:harvest, owner: member, planting: older_planting, harvested_at: 3.days.ago) }
describe "lists planting that are ready for harvest" do describe "lists planting that are ready for harvest" do
it { expect(mail).to have_content "Ready to harvest" } it { expect(mail).to have_content "Ready to harvest" }
it { expect(mail).to have_link p1.crop.name, href: planting_url(p1) } it { expect(mail).to have_link recent_planting.crop.name, href: planting_url(recent_planting) }
it { expect(mail).to have_link p2.crop.name, href: planting_url(p2) } it { expect(mail).to have_link older_planting.crop.name, href: planting_url(older_planting) }
it { expect(mail).to have_content "Harvested anything lately?" } it { expect(mail).to have_content "Harvested anything lately?" }
end end
end end

View File

@@ -286,9 +286,9 @@ describe "Planting a crop", :js, :search do
check "Mark as finished" check "Mark as finished"
click_button "Save" click_button "Save"
end end
it { expect(page).to have_css("img[alt='sun']") }
end end
xit { expect(page).to have_css("img[alt='sun']") }
end end
describe "Marking a planting as finished from the show page" do describe "Marking a planting as finished from the show page" do

View File

@@ -11,7 +11,7 @@ shared_examples "append date" do
click_link 'Actions' click_link 'Actions'
click_link link_text click_link link_text
within "div.datepicker" do within "div.datepicker" do
expect(page).to have_content this_month.to_s find(".datepicker-days", text: this_month.to_s)
find(".datepicker-days td.day", text: "21").click find(".datepicker-days td.day", text: "21").click
end end
end end

View File

@@ -4,23 +4,23 @@ require 'rails_helper'
describe "timeline", :js do describe "timeline", :js do
let(:member) { create(:member) } let(:member) { create(:member) }
let(:friend1) { create(:member) } let(:planting_friend) { create(:member) }
let(:friend2) { create(:member) } let(:post_friend) { create(:member) }
before do before do
member.followed << friend1 member.followed << planting_friend
member.followed << friend2 member.followed << post_friend
end end
describe 'visit timeline' do describe 'visit timeline' do
let!(:friend_planting) { create(:planting, owner: friend1, planted_at: 1.day.ago) } let!(:friend_planting) { create(:planting, owner: planting_friend, planted_at: 1.day.ago) }
let!(:friend_harvest) { create(:planting, owner: friend2, planted_at: 3.years.ago) } let!(:friend_harvest) { create(:planting, owner: post_friend, planted_at: 3.years.ago) }
let!(:finished_planting) { create(:finished_planting, owner: friend1) } let!(:finished_planting) { create(:finished_planting, owner: planting_friend) }
let!(:no_planted_at_planting) { create(:planting, owner: friend2, planted_at: nil) } let!(:no_planted_at_planting) { create(:planting, owner: post_friend, planted_at: nil) }
let!(:friend_photo) { create(:photo, owner: friend1) } let!(:friend_photo) { create(:photo, owner: planting_friend) }
let!(:friend_post) { create(:post, author: friend2) } let!(:friend_post) { create(:post, author: post_friend) }
let!(:liked_post) { create(:like, likeable: friend_photo, member: friend2) } let!(:liked_post) { create(:like, likeable: friend_photo, member: post_friend) }
let!(:liked_photo) { create(:like, likeable: friend_post, member: friend1) } let!(:liked_photo) { create(:like, likeable: friend_post, member: planting_friend) }
before do before do
login_as(member) login_as(member)
@@ -37,8 +37,8 @@ describe "timeline", :js do
end end
describe 'shows the friends you follow' do describe 'shows the friends you follow' do
it { expect(page).to have_link href: member_path(friend1) } it { expect(page).to have_link href: member_path(planting_friend) }
it { expect(page).to have_link href: member_path(friend2) } it { expect(page).to have_link href: member_path(post_friend) }
end end
end end
end end

View File

@@ -77,6 +77,90 @@ describe Ability do
end end
end end
context 'plantings' do
let(:approved_crop) { create(:crop, approval_status: 'approved') }
let(:unapproved_crop) { create(:crop, approval_status: 'unapproved') }
let(:garden) { create(:garden, owner: member) }
let(:planting) { create(:planting, garden: garden, crop: approved_crop, owner: member) }
let(:other_planting) { create(:planting, crop: approved_crop) }
let(:planting_with_unapproved_crop) { create(:planting, garden: garden, crop: unapproved_crop, owner: member) }
it 'can create a planting' do
ability.should be_able_to(:create, Planting)
end
it 'can manage their own planting with an approved crop' do
ability.should be_able_to(:update, planting)
ability.should be_able_to(:destroy, planting)
end
xit "can't manage their own planting with an unapproved crop" do
ability.should_not be_able_to(:update, planting_with_unapproved_crop)
ability.should_not be_able_to(:destroy, planting_with_unapproved_crop)
end
it "can't manage another member's planting" do
ability.should_not be_able_to(:update, other_planting)
ability.should_not be_able_to(:destroy, other_planting)
end
it 'can transplant their own planting' do
ability.should be_able_to(:transplant, planting)
end
context 'garden collaborator' do
let(:garden) { create(:garden) }
let(:planting_in_garden) { create(:planting, garden:, crop: approved_crop, owner: garden.owner) }
before do
garden.garden_collaborators.create(member:)
end
it 'can manage plantings in a garden they collaborate on' do
ability.should be_able_to(:update, planting_in_garden)
ability.should be_able_to(:destroy, planting_in_garden)
end
it 'can transplant a planting in a garden they collaborate on' do
ability.should be_able_to(:transplant, planting_in_garden)
end
end
end
context 'harvests' do
let(:harvest) { create(:harvest, owner: member) }
let(:other_harvest) { create(:harvest) }
it 'can create a harvest' do
ability.should be_able_to(:create, Harvest)
end
it 'can manage their own harvest' do
ability.should be_able_to(:update, harvest)
ability.should be_able_to(:destroy, harvest)
end
it "can't manage another member's harvest" do
ability.should_not be_able_to(:update, other_harvest)
ability.should_not be_able_to(:destroy, other_harvest)
end
context 'garden collaborator' do
let(:garden) { create(:garden) }
let(:planting_in_garden) { create(:planting, garden:, owner: garden.owner) }
let(:harvest_in_garden) { create(:harvest, planting: planting_in_garden, owner: planting_in_garden.owner) }
before do
garden.garden_collaborators.create(member:)
end
it 'can manage harvests in a garden they collaborate on' do
ability.should be_able_to(:update, harvest_in_garden)
ability.should be_able_to(:destroy, harvest_in_garden)
end
end
end
context 'plant parts' do context 'plant parts' do
let(:plant_part) { create(:plant_part) } let(:plant_part) { create(:plant_part) }
@@ -136,9 +220,117 @@ describe Ability do
it "can manage members" do it "can manage members" do
ability.should be_able_to(:destroy, create(:member)) ability.should be_able_to(:destroy, create(:member))
end end
it "cannot delete themselves" do it "cannot delete themselves" do
ability.should_not be_able_to(:destroy, member) ability.should_not be_able_to(:destroy, member)
end end
end end
end end
context 'activities' do
let(:activity) { create(:activity, owner: member) }
let(:other_activity) { create(:activity) }
it 'can create an activity' do
ability.should be_able_to(:create, Activity)
end
it 'can manage their own activity' do
ability.should be_able_to(:update, activity)
ability.should be_able_to(:destroy, activity)
end
it "can't manage another member's activity" do
ability.should_not be_able_to(:update, other_activity)
ability.should_not be_able_to(:destroy, other_activity)
end
context 'garden collaborator' do
let(:garden) { create(:garden) }
let(:activity_in_garden) { create(:activity, garden:) }
before do
garden.garden_collaborators.create(member:)
end
it 'can manage activities in a garden they collaborate on' do
ability.should be_able_to(:update, activity_in_garden)
ability.should be_able_to(:destroy, activity_in_garden)
end
end
end
context 'seeds' do
let(:seed) { create(:seed, owner: member) }
let(:other_seed) { create(:seed) }
it 'can create a seed' do
ability.should be_able_to(:create, Seed)
end
it 'can manage their own seed' do
ability.should be_able_to(:update, seed)
ability.should be_able_to(:destroy, seed)
end
it "can't manage another member's seed" do
ability.should_not be_able_to(:update, other_seed)
ability.should_not be_able_to(:destroy, other_seed)
end
end
context 'comments' do
let(:comment) { create(:comment, author: member) }
let(:other_comment) { create(:comment) }
it 'can create a comment' do
ability.should be_able_to(:create, Comment)
end
it 'can manage their own comment' do
ability.should be_able_to(:update, comment)
ability.should be_able_to(:destroy, comment)
end
it "can't manage another member's comment" do
ability.should_not be_able_to(:update, other_comment)
ability.should_not be_able_to(:destroy, other_comment)
end
end
context 'photos' do
let(:photo) { create(:photo, owner: member) }
let(:other_photo) { create(:photo) }
it 'can create a photo' do
ability.should be_able_to(:create, Photo)
end
it 'can manage their own photo' do
ability.should be_able_to(:update, photo)
ability.should be_able_to(:destroy, photo)
end
it "can't manage another member's photo" do
ability.should_not be_able_to(:update, other_photo)
ability.should_not be_able_to(:destroy, other_photo)
end
end
context 'likes' do
let(:like) { create(:like, member:) }
let(:other_like) { create(:like) }
it 'can create a like' do
ability.should be_able_to(:create, Like)
end
it 'can destroy their own like' do
ability.should be_able_to(:destroy, like)
end
it "can't destroy another member's like" do
ability.should_not be_able_to(:destroy, other_like)
end
end
end end

View File

@@ -41,6 +41,21 @@ describe Comment do
end end
end end
context "when the post author has blocked the comment author" do
let(:post_author) { create(:member) }
let(:comment_author) { create(:member) }
let(:post) { create(:post, author: post_author) }
before do
post_author.blocks.create(blocked: comment_author)
end
it "is not valid" do
comment = build(:comment, commentable: post, author: comment_author)
expect(comment).not_to be_valid
end
end
context "ordering" do context "ordering" do
before do before do
@m = create(:member) @m = create(:member)

View File

@@ -297,54 +297,54 @@ describe Crop do
subject { described_class.interesting } subject { described_class.interesting }
# first, a couple of candidate crops # first, a couple of candidate crops
let(:crop1) { create(:crop) } let(:first_crop) { create(:crop) }
let(:crop2) { create(:crop) } let(:second_crop) { create(:crop) }
let(:crop1_planting) { crop1.plantings.first } let(:first_crop_planting) { first_crop.plantings.first }
let(:crop2_planting) { crop2.plantings.first } let(:second_crop_planting) { second_crop.plantings.first }
let(:member) { create(:member, login_name: 'pikachu') } let(:member) { create(:member, login_name: 'pikachu') }
describe 'lists interesting crops' do describe 'lists interesting crops' do
before do before do
# they need 3+ plantings each to be interesting # they need 3+ plantings each to be interesting
create_list(:planting, 3, crop: crop1, owner: member) create_list(:planting, 3, crop: first_crop, owner: member)
create_list(:planting, 3, crop: crop2, owner: member) create_list(:planting, 3, crop: second_crop, owner: member)
# crops need 3+ photos to be interesting # crops need 3+ photos to be interesting
crop1_planting.photos = create_list :photo, 3, owner: member first_crop_planting.photos = create_list :photo, 3, owner: member
crop2_planting.photos = create_list :photo, 3, owner: member second_crop_planting.photos = create_list :photo, 3, owner: member
end end
it { is_expected.to include crop1 } it { is_expected.to include first_crop }
it { is_expected.to include crop2 } it { is_expected.to include second_crop }
it { expect(subject.size).to eq 2 } it { expect(subject.size).to eq 2 }
end end
describe 'crops without plantings are not interesting' do describe 'crops without plantings are not interesting' do
before do before do
# only crop1 has plantings # only first_crop has plantings
create_list(:planting, 3, crop: crop1, owner: member) create_list(:planting, 3, crop: first_crop, owner: member)
# ... and photos # ... and photos
crop1_planting.photos = create_list(:photo, 3, owner: member) first_crop_planting.photos = create_list(:photo, 3, owner: member)
end end
it { is_expected.to include crop1 } it { is_expected.to include first_crop }
it { is_expected.not_to include crop2 } it { is_expected.not_to include second_crop }
it { expect(subject.size).to eq 1 } it { expect(subject.size).to eq 1 }
end end
describe 'crops without photos are not interesting' do describe 'crops without photos are not interesting' do
before do before do
# both crops have plantings # both crops have plantings
create_list(:planting, 3, crop: crop1, owner: member) create_list(:planting, 3, crop: first_crop, owner: member)
create_list(:planting, 3, crop: crop2, owner: member) create_list(:planting, 3, crop: second_crop, owner: member)
# but only crop1 has photos # but only first_crop has photos
crop1_planting.photos = create_list(:photo, 3, owner: member) first_crop_planting.photos = create_list(:photo, 3, owner: member)
end end
it { is_expected.to include crop1 } it { is_expected.to include first_crop }
it { is_expected.not_to include crop2 } it { is_expected.not_to include second_crop }
it { expect(subject.size).to eq 1 } it { expect(subject.size).to eq 1 }
end end
end end

View File

@@ -63,6 +63,21 @@ describe Like do
expect(Like.all).not_to include like expect(Like.all).not_to include like
end end
context "when the likeable author has blocked the member" do
let(:likeable_author) { create(:member) }
let(:post_author) { create(:member) }
let(:post) { create(:post, author: likeable_author) }
before do
likeable_author.blocks.create(blocked: member)
end
it "is not valid" do
like = build(:like, likeable: post, member: member)
expect(like).not_to be_valid
end
end
it 'liked_by_members_names' do it 'liked_by_members_names' do
expect(post.liked_by_members_names).to eq [] expect(post.liked_by_members_names).to eq []
Like.create(member:, likeable: post) Like.create(member:, likeable: post)

View File

@@ -102,10 +102,10 @@ describe Member do
context 'newsletter scope' do context 'newsletter scope' do
it 'finds newsletter recipients' do it 'finds newsletter recipients' do
member1 = create(:member) regular_member = create(:member)
member2 = create(:newsletter_recipient_member) newsletter_member = create(:newsletter_recipient_member)
Member.wants_newsletter.should include member2 Member.wants_newsletter.should include newsletter_member
Member.wants_newsletter.should_not include member1 Member.wants_newsletter.should_not include regular_member
end end
end end
@@ -299,31 +299,31 @@ describe Member do
end end
context 'member who followed another member' do context 'member who followed another member' do
let(:member1) { create(:member) } let(:follower) { create(:member) }
let(:member2) { create(:member) } let(:followed_member) { create(:member) }
let(:member3) { create(:member) } let(:other_member) { create(:member) }
before do before do
@follow = member1.follows.create(follower_id: member1.id, followed_id: member2.id) @follow = follower.follows.create(follower_id: follower.id, followed_id: followed_member.id)
end end
context 'already_following' do context 'already_following' do
it 'detects that member is already following a member' do it 'detects that member is already following a member' do
expect(member1.already_following?(member2)).to be true expect(follower.already_following?(followed_member)).to be true
end end
it 'detects that member is not already following a member' do it 'detects that member is not already following a member' do
expect(member1.already_following?(member3)).to be false expect(follower.already_following?(other_member)).to be false
end end
end end
context 'get_follow' do context 'get_follow' do
it 'gets the correct follow for a followed member' do it 'gets the correct follow for a followed member' do
expect(member1.get_follow(member2).id).to eq @follow.id expect(follower.get_follow(followed_member).id).to eq @follow.id
end end
it 'returns nil for a member that is not followed' do it 'returns nil for a member that is not followed' do
expect(member1.get_follow(member3)).to be_nil expect(follower.get_follow(other_member)).to be_nil
end end
end end
end end

View File

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

View File

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

View File

@@ -3,11 +3,53 @@
require 'rails_helper' require 'rails_helper'
describe "Forums" do describe "Forums" do
let(:admin) { create(:admin_member) }
let(:forum) { create(:forum) }
describe "GET /forums" do describe "GET /forums" do
it "works! (now write some real specs)" do it "returns a successful response" do
# Run the generator again with the --webrat flag if you want to use webrat methods/matchers
get forums_path get forums_path
response.status.should be(200) expect(response).to have_http_status(:ok)
end
it "returns JSON when requested" do
get forums_path(format: :json)
expect(response).to have_http_status(:ok)
expect(response.content_type).to include("application/json")
end
end
describe "GET /forums/:id" do
it "returns a successful response" do
get forum_path(forum)
expect(response).to have_http_status(:ok)
end
it "returns JSON when requested" do
get forum_path(forum, format: :json)
expect(response).to have_http_status(:ok)
expect(response.content_type).to include("application/json")
end
end
describe "POST /forums" do
context "as an admin" do
before { sign_in admin }
it "creates a new forum" do
expect do
post forums_path, params: { forum: { name: "New Request Forum", description: "Desc", owner_id: admin.id } }
end.to change(Forum, :count).by(1)
expect(response).to redirect_to(forum_path(Forum.last))
end
end
context "as a guest" do
it "redirects to sign in or denies access" do
post forums_path, params: { forum: { name: "New Request Forum", description: "Desc" } }
# Depending on CanCan/Devise setup, it might be a redirect to login or root
expect(response).to redirect_to(new_member_session_path).or redirect_to(root_path)
end
end end
end end
end end

View File

@@ -15,11 +15,11 @@ describe 'comments/index.rss.haml' do
end end
it 'shows RSS feed title' do 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 end
it 'shows item title' do 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 end
it 'escapes html for link to post' do it 'escapes html for link to post' do
@@ -28,6 +28,6 @@ describe 'comments/index.rss.haml' do
end end
it 'shows content of comments' do it 'shows content of comments' do
rendered.should have_content "OMG LOL" expect(rendered).to have_content "OMG LOL"
end end
end end

View File

@@ -13,7 +13,7 @@ describe "crops/_grown_for" do
it 'shows plant parts' do it 'shows plant parts' do
render partial: 'crops/grown_for', locals: { crop: } 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) assert_select "a", href: plant_part_path(plant_path)
end end
end end

View File

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

View File

@@ -33,7 +33,7 @@ describe "crops/index.html.haml" do
context "downloads" do context "downloads" do
it "offers data downloads" do it "offers data downloads" do
render 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: 'csv')
assert_select "a", href: crops_path(format: 'json') assert_select "a", href: crops_path(format: 'json')
assert_select "a", href: crops_path(format: 'rss') assert_select "a", href: crops_path(format: 'rss')

View File

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

View File

@@ -10,6 +10,6 @@ describe 'devise/confirmations/new.html.haml', type: "view" do
end end
it 'contains a login field' do 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
end end

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More