Compare commits

...

41 Commits

Author SHA1 Message Date
Daniel O'Connor
41d5b9e2ef Manually generate 2025-09-10 14:22:06 +00:00
google-labs-jules[bot]
02db5b8130 Add API token generation, authentication, and CRUD for a number of the API resources (#4237)
* feat: Add API token generation and authentication

This commit introduces API token generation and authentication for write operations.

- Adds a section to the user's profile edit page to generate and display an API token.
- Reuses the `authentications` table to store the API token, avoiding the need for a database migration.
- Implements token-based authentication for the API using the `Authorization: Token token=...` header.
- Enables write operations for all API resources and ensures they are protected by the new authentication mechanism.
- Adds feature and request specs to test the new functionality.

* feat: Add API token generation and authentication

This commit introduces API token generation and authentication for write operations.

- Adds a section to the user's profile edit page to generate and display an API token.
- Reuses the `authentications` table to store the API token, avoiding the need for a database migration.
- Implements token-based authentication for the API using the `Authorization: Token token=...` header.
- Enables write operations for all API resources and ensures they are protected by the new authentication mechanism.
- Adds feature and request specs to test the new functionality.

* Mark as editable

* Refactor

* WIP - Authentication

* Implement more test coverage

* Split 401 and 403

* Before Create hooks

* Update harvest specs, defaulting to the first plant part - this may not be right

* Update coverage

* Update coverage

* Rubocop

* Rubocop

* Rubocop

* Fix coverage

* For now, mark photos immutable again

* Fix specs

* Fix specs

* Rubocop

* Fix specs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
2025-09-10 19:50:06 +09:30
Daniel O'Connor
4c0a63bd28 Merge pull request #4235 from Growstuff/activity_name
Fix UX
2025-09-09 22:20:06 +09:30
Daniel O'Connor
7f19891428 Merge branch 'dev' into activity_name 2025-09-09 22:19:57 +09:30
Daniel O'Connor
e322871740 Fix UX 2025-09-09 12:49:17 +00:00
Daniel O'Connor
35f18556fd Merge pull request #4234 from Growstuff/activity_name
Delegate
2025-09-09 22:16:41 +09:30
Daniel O'Connor
85034298ec Merge branch 'dev' of https://github.com/Growstuff/growstuff into activity_name 2025-09-09 12:45:14 +00:00
Daniel O'Connor
b2e959aded Delegate 2025-09-09 12:44:40 +00:00
Daniel O'Connor
a4e2bf5d54 Activity name (#4233)
* Add aliases

* Add aliases
2025-09-09 22:11:18 +09:30
Daniel O'Connor
9cd00b44bb Merge branch 'dev' into activity_name 2025-09-09 22:11:06 +09:30
Daniel O'Connor
2f0b8e9d76 Add aliases 2025-09-09 12:40:06 +00:00
Daniel O'Connor
bb4e2dd788 Add aliases (#4232) 2025-09-09 22:07:24 +09:30
Daniel O'Connor
fb78bcb0b0 Add aliases 2025-09-09 12:36:22 +00:00
google-labs-jules[bot]
e5c71f1dc4 Merge pull request #4230 from Growstuff/feature/add-more-filters
feat: Add more filters to API resources
2025-09-09 21:34:49 +09:30
dependabot[bot]
2d62891ef0 Merge pull request #4227 from Growstuff/dependabot/bundler/factory_bot_rails-6.5.1 2025-09-09 09:04:42 +00:00
dependabot[bot]
cf840582d5 Bump factory_bot_rails from 6.5.0 to 6.5.1
Bumps [factory_bot_rails](https://github.com/thoughtbot/factory_bot_rails) from 6.5.0 to 6.5.1.
- [Release notes](https://github.com/thoughtbot/factory_bot_rails/releases)
- [Changelog](https://github.com/thoughtbot/factory_bot_rails/blob/main/NEWS.md)
- [Commits](https://github.com/thoughtbot/factory_bot_rails/compare/v6.5.0...v6.5.1)

---
updated-dependencies:
- dependency-name: factory_bot_rails
  dependency-version: 6.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 08:56:00 +00:00
Daniel O'Connor
389d904d7c Merge pull request #4231 from Growstuff/dependabot/bundler/puma-7.0.2
Bump puma from 7.0.0 to 7.0.2
2025-09-09 18:24:37 +09:30
dependabot[bot]
5bfeb0ce03 Bump puma from 7.0.0 to 7.0.2
Bumps [puma](https://github.com/puma/puma) from 7.0.0 to 7.0.2.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/master/History.md)
- [Commits](https://github.com/puma/puma/compare/v7.0.0...v7.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 07:01:07 +00:00
Daniel O'Connor
11b1c84985 Update garden_resource to filter by owner (#4229) 2025-09-09 08:24:15 +09:30
Daniel O'Connor
a21a9e7a09 Update garden_resource to filter by owner 2025-09-08 17:38:03 +09:30
google-labs-jules[bot]
bc11a1b8db Merge pull request #4209 from Growstuff/extend-crop-model
Extend Crop Model and Migrate Data from OpenFarm
2025-09-07 20:03:18 +09:30
Daniel O'Connor
12f6b76dca Merge pull request #4223 from Growstuff/show-history
Render history of activities on gardens, plantings
2025-09-07 19:40:18 +09:30
Daniel O'Connor
dfc75d8916 Merge pull request #4224 from Growstuff/deep-link
Harvests > Unrated Planting > Deep link to content when rating
2025-09-07 18:12:07 +09:30
Daniel O'Connor
798eb1132f Rubocop 2025-09-07 08:10:41 +00:00
Daniel O'Connor
42036a3d3f Fix logic 2025-09-07 08:07:21 +00:00
Daniel O'Connor
47da5f18c9 Merge branch 'dev' into deep-link 2025-09-07 17:35:57 +09:30
Daniel O'Connor
d22555ee42 Deep link to content when rating 2025-09-07 08:04:14 +00:00
Daniel O'Connor
d0f4911bf6 Merge pull request #4221 from Growstuff/CloCkWeRX-patch-3
Update _modal.html.haml to sort consistently
2025-09-07 17:30:17 +09:30
Daniel O'Connor
2bc164bd2e Render history 2025-09-07 07:59:51 +00:00
Daniel O'Connor
6f9fbfa3cd Merge pull request #4220 from Growstuff/CloCkWeRX-patch-2
Fix garden order on planting new
2025-09-07 17:19:05 +09:30
Daniel O'Connor
47d1877568 Merge pull request #4205 from Growstuff/dependabot/github_actions/actions/setup-node-5
Bump actions/setup-node from 4 to 5
2025-09-07 17:18:50 +09:30
Daniel O'Connor
b0555ef89e Update _modal.html.haml to sort consistently 2025-09-07 17:12:14 +09:30
Daniel O'Connor
a5f9edea87 Merge pull request #4217 from Growstuff/skip-to-content
By default, skip to content when linking to garden(s)
2025-09-07 17:09:48 +09:30
Daniel O'Connor
3917f263b8 Fix garden order on planting new 2025-09-07 17:09:27 +09:30
Daniel O'Connor
f55f88c4af Merge pull request #4218 from Growstuff/fix-activities
Activities > Due Date > Only render if available
2025-09-07 15:06:20 +09:30
Daniel O'Connor
3725957065 Only render if available 2025-09-07 05:34:49 +00:00
Daniel O'Connor
8fbc02caf3 Timeline 2025-09-07 05:20:35 +00:00
dependabot[bot]
b2d8530923 Bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-07 05:19:53 +00:00
Daniel O'Connor
186f07109c Add skip link 2025-09-07 05:17:14 +00:00
Daniel O'Connor
b3c749566b More named anchors 2025-09-07 05:14:18 +00:00
Daniel O'Connor
1eb84b9765 By default, skip to content when linking to garden(s) 2025-09-07 05:11:30 +00:00
64 changed files with 3494 additions and 2497 deletions

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -89,7 +89,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -314,7 +314,7 @@ RSpec/MultipleExpectations:
# Offense count: 138
# Configuration parameters: AllowSubject.
RSpec/MultipleMemoizedHelpers:
Max: 14
Max: 20
# Offense count: 133
# Configuration parameters: EnforcedStyle, IgnoreSharedExamples.

View File

@@ -142,7 +142,7 @@ GEM
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (3.2.2)
bigdecimal (3.2.3)
bluecloth (2.2.0)
bonsai-elasticsearch-rails (7.0.1)
elasticsearch-model (< 8)
@@ -198,7 +198,7 @@ GEM
comfy_bootstrap_form (4.0.9)
rails (>= 5.0.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.4)
crass (1.0.6)
crowdin-api (1.12.0)
open-uri (>= 0.1.0, < 0.2.0)
@@ -257,9 +257,9 @@ GEM
excon (1.2.5)
logger
execjs (2.10.0)
factory_bot (6.5.4)
factory_bot (6.5.5)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.5.2)
@@ -475,7 +475,7 @@ GEM
date
stringio
public_suffix (6.0.1)
puma (7.0.0)
puma (7.0.2)
nio4r (~> 2.0)
query_diet (0.7.2)
racc (1.8.1)

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
module Api
module V1
class ActivitiesController < BaseController
end
end
end

View File

@@ -4,6 +4,40 @@ module Api
module V1
class BaseController < JSONAPI::ResourceController
abstract
protect_from_forgery with: :null_session
before_action :authenticate_member_from_token!
before_action :enforce_member_for_write_operations!, only: %i(create update destroy)
rescue_from CanCan::AccessDenied do
head :forbidden
end
def context
{
current_user: current_user,
current_ability: current_ability,
controller: self,
action: params[:action]
}
end
private
attr_reader :current_user
def enforce_member_for_write_operations!
head :unauthorized unless current_user
end
def authenticate_member_from_token!
authenticate_with_http_token do |token, _options|
auth = Authentication.find_by(token: token, provider: 'api')
if auth.present?
@current_user = auth.member
return true
end
end
end
end
end
end

View File

@@ -192,6 +192,8 @@ class CropsController < ApplicationController
:parent_id, :perennial,
:request_notes, :reason_for_rejection,
:rejection_notes,
:row_spacing, :spread, :height,
:sowing_method, :sun_requirements, :growing_degree_days,
scientific_names_attributes: %i(scientific_name _destroy id)
)
end

View File

@@ -20,6 +20,7 @@ class GardensController < DataController
def show
@current_plantings = @garden.plantings.current.where.not(failed: true).includes(:crop, :owner).order(planted_at: :desc)
@current_activities = @garden.activities.current.includes(:owner).order(created_at: :desc)
@finished_activities = @garden.activities.finished.includes(:owner).order(created_at: :desc)
@finished_plantings = @garden.plantings.finished.includes(:crop)
@suggested_companions = Crop.approved.where(
id: CropCompanion.where(crop_a_id: @current_plantings.select(:crop_id)).select(:crop_b_id)

View File

@@ -37,6 +37,7 @@ class PlantingsController < DataController
@photos = @planting.photos.includes(:owner).order(date_taken: :desc)
@harvests = Harvest.search(where: { planting_id: @planting.id })
@current_activities = @planting.activities.current.includes(:owner).order(created_at: :desc)
@finished_activities = @planting.activities.finished.includes(:owner).order(created_at: :desc)
@matching_seeds = matching_seeds
@crop = @planting.crop

View File

@@ -38,6 +38,12 @@ class RegistrationsController < Devise::RegistrationsController
end
end
def regenerate_api_token
current_member.regenerate_api_token
set_flash_message :notice, :api_token_regenerated
redirect_to edit_member_registration_path + '#apps'
end
def destroy
if @member.valid_password?(params.require(:member)[:current_password])
@member.discard

View File

@@ -38,9 +38,9 @@ module ApplicationHelper
return 'today' if from_time.is_a?(Date) && (from_time == to_time)
return 'now' if from_time == to_time
return distance_of_time_in_words(from_time, to_time, include_seconds:) + ' ago' if from_time > to_time
return "#{distance_of_time_in_words(from_time, to_time, include_seconds:)} ago" if from_time < to_time
'in ' + distance_of_time_in_words(from_time, to_time, include_seconds:)
"in #{distance_of_time_in_words(from_time, to_time, include_seconds:)}"
end
def count_github_contibutors

View File

@@ -30,4 +30,20 @@ class Activity < ApplicationRecord
def to_s
name
end
def garden_name
garden&.name
end
def garden_slug
garden&.slug
end
def planting_name
planting&.crop&.name
end
def planting_slug
planting&.crop&.slug
end
end

View File

@@ -8,14 +8,6 @@ module OpenFarmData
fetch_attr('main_image_path')
end
def height
fetch_attr('height')
end
def spread
fetch_attr('spread')
end
def svg_icon
icon = fetch_attr('svg_icon')
return icon if icon.present?
@@ -31,10 +23,6 @@ module OpenFarmData
fetch_attr('description')
end
def row_spacing
fetch_attr('row_spacing')
end
def common_names
fetch_attr('common_names')
end
@@ -43,22 +31,10 @@ module OpenFarmData
fetch_attr('binomial_name')
end
def sowing_method
fetch_attr('sowing_method')
end
def main_image_path
fetch_attr('main_image_path')
end
def sun_requirements
fetch_attr('sun_requirements')
end
def growing_degree_days
fetch_attr('growing_degree_days')
end
def processing_pictures
fetch_attr('processing_pictures')
end

View File

@@ -24,6 +24,20 @@ class Member < ApplicationRecord
has_many :notifications, foreign_key: 'recipient_id', inverse_of: :recipient
has_many :sent_notifications, foreign_key: 'sender_id', inverse_of: :sender, class_name: "Notification"
has_many :authentications, dependent: :destroy
has_one :api_token, -> { where(provider: 'api') }, class_name: 'Authentication', dependent: :destroy
def api_token?
api_token.present?
end
def regenerate_api_token
api_token.destroy if api_token?
create_api_token(
provider: 'api',
uid: id,
token: SecureRandom.hex(16)
)
end
has_many :photos, inverse_of: :owner
has_many :likes, dependent: :destroy

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module Api
module V1
class ActivityResource < BaseResource
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_one :garden
has_one :planting
attribute :name
attribute :description
attribute :category
attribute :finished
attribute :due_date
filter :owner
filter :owner_id
filter :garden
filter :garden_id
filter :planting
filter :planting_id
filter :category
end
end
end

View File

@@ -3,8 +3,7 @@
module Api
module V1
class CropResource < BaseResource
immutable
immutable # TODO: Re-evaluate this later
filter :approval_status, default: 'approved'
has_many :plantings

View File

@@ -3,13 +3,22 @@
module Api
module V1
class GardenResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_many :plantings
has_many :photos
attribute :name
filter :owner
filter :owner_id
filter :active
filter :garden_type
filter :location
filter :slug
end
end
end

View File

@@ -3,11 +3,17 @@
module Api
module V1
class HarvestResource < BaseResource
immutable
before_save do
@model.owner = context[:current_user]
@model.crop_id = @model.planting.crop_id if @model.planting_id
@model.harvested_at = Time.zone.now if @model.harvested_at.blank?
@model.plant_part = PlantPart.first
end
has_one :crop
has_one :planting
has_one :owner, class_name: 'Member'
# has_one :plant_part
has_many :photos
attribute :harvested_at
@@ -16,6 +22,15 @@ module Api
attribute :weight_quantity
attribute :weight_unit
attribute :si_weight
filter :owner
filter :owner_id
filter :crop
filter :crop_id
filter :planting
filter :planting_id
filter :plant_part
filter :harvested_at
end
end
end

View File

@@ -3,7 +3,10 @@
module Api
module V1
class PhotoResource < BaseResource
immutable
immutable # TODO: Re-evaluate this.
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_many :plantings

View File

@@ -3,7 +3,9 @@
module Api
module V1
class PlantingResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :garden
has_one :crop
@@ -36,6 +38,10 @@ module Api
filter :owner
filter :owner_id
filter :finished
filter :active, apply: ->(records, _value, _options) { records.active }
filter :failed, apply: ->(records, _value, _options) { records.failed }
filter :sunniness
filter :perennial, apply: ->(records, _value, _options) { records.perennial }
attribute :percentage_grown
delegate :percentage_grown, to: :@model

View File

@@ -3,7 +3,9 @@
module Api
module V1
class SeedResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_one :crop
@@ -17,6 +19,15 @@ module Api
attribute :organic
attribute :gmo
attribute :heirloom
filter :owner
filter :owner_id
filter :crop
filter :crop_id
filter :tradable_to
filter :organic
filter :gmo
filter :heirloom
end
end
end

View File

@@ -1,6 +1,16 @@
# frozen_string_literal: true
class BaseResource < JSONAPI::Resource
immutable
abstract
[:create, :update, :remove].each do |action|
set_callback action, :before, :authorize
end
# Check authorisation for write operations.
# NOTE: At a later time, we may require API tokens for READ operations.
def authorize
# context[:action] is simply context[:controller].params[:action]
context[:current_ability].authorize! context[:action].to_sym, @model
end
end

View File

@@ -23,8 +23,9 @@
.card-body
= link_to activity_path(slug: activity.slug) do
%h4= activity.name
%small.due-date{title: activity.due_date}
= standard_time_distance(activity.due_date.to_date, Time.zone.now.to_date)
- if activity.due_date
%small.due-date{title: activity.due_date}
= standard_time_distance(activity.due_date.to_date, Time.zone.now.to_date)
%div
%small.text-justify{title: activity.description}= activity.description.truncate(150)
%p

View File

@@ -41,6 +41,14 @@
= f.radio_button(:perennial, true, label: "Perennial")
%span.help-block Living more than two years
%h2 OpenFarm Data
= f.number_field :row_spacing, label: 'Row Spacing (cm)', min: 0
= f.number_field :spread, label: 'Spread (cm)', min: 0
= f.number_field :height, label: 'Height (cm)', min: 0
= f.text_field :sowing_method
= f.text_field :sun_requirements
= f.number_field :growing_degree_days, min: 0
- unless @crop.approved?
= link_to 'Search wikipedia', "https://en.wikipedia.org/w/index.php?search=#{@crop.name}", target: '_blank'
= f.url_field :en_wikipedia_url, id: "en_wikipedia_url", label: 'Wikipedia URL'

View File

@@ -0,0 +1,33 @@
- if crop.row_spacing || crop.spread || crop.height || crop.sowing_method || crop.sun_requirements || crop.growing_degree_days
= cute_icon
.card
.card-body
%h4 OpenFarm Data
%ul.list-group.list-group-flush
- if crop.row_spacing
%li.list-group-item
%strong Row Spacing:
= crop.row_spacing
cm
- if crop.spread
%li.list-group-item
%strong Spread:
= crop.spread
cm
- if crop.height
%li.list-group-item
%strong Height:
= crop.height
cm
- if crop.sowing_method
%li.list-group-item
%strong Sowing Method:
= crop.sowing_method
- if crop.sun_requirements
%li.list-group-item
%strong Sun Requirements:
= crop.sun_requirements
- if crop.growing_degree_days
%li.list-group-item
%strong Growing Degree Days:
= crop.growing_degree_days

View File

@@ -111,6 +111,8 @@
= render 'harvests', crop: @crop
= render 'find_seeds', crop: @crop
= render 'openfarm_data', crop: @crop
= cute_icon
.card
.card-body

View File

@@ -15,3 +15,16 @@
method: :delete, class: "remove btn btn-danger"
- else
= link_to 'Connect to Flickr', '/members/auth/flickr', class: 'btn'
%hr
.row
.col-md-12
%p
= image_tag "icons/post.svg", size: "32x32", alt: 'API logo'
- if current_member.api_token?
Your API token is
%code= current_member.api_token.token
= link_to "Regenerate", regenerate_api_token_path,
data: { confirm: "Are you sure? Your old token will stop working immediately." },
method: :post, class: "remove btn btn-danger"
- else
= link_to 'Generate API Token', regenerate_api_token_path, method: :post, class: 'btn btn-primary'

View File

@@ -16,3 +16,8 @@
.col-md-12
%p Nothing has been planted here.
- if @finished_activities&.size&.positive?
%h2 Finished activities in garden
.index-cards
- @finished_activities.each do |activity|
= render "activities/card", activity: activity

View File

@@ -11,6 +11,9 @@
.row
.col-md-2
%small
%a{href: "#content"}
Skip to main content
= render 'layouts/nav', model: Garden
%label
= link_to show_inactive_tickbox_path('gardens', owner: @owner, show_all: @show_all) do
@@ -20,7 +23,7 @@
%hr/
= render @owner
.col-md-10
.col-md-10#content
- if @gardens.empty?
%p There are no gardens to display.
- if can?(:create, Garden) && @owner == current_member

View File

@@ -9,4 +9,4 @@
- if @harvest.planting.present? && @harvest.planting.overall_rating.blank?
.alert.alert-info{role: "alert"}
This harvest is from a planting that hasn't been rated yet.
= link_to "Rate this planting", edit_planting_path(@harvest.planting), class: 'alert-link'
= link_to "Rate this planting", edit_planting_path(@harvest.planting, anchor: "planting_overall_rating"), class: 'alert-link'

View File

@@ -11,11 +11,11 @@
%br
%p
- if current_member.plantings.active.any?
= link_to member_path(current_member, anchor: "#content"), class: 'btn btn-dark' do
= link_to member_path(current_member, anchor: "content"), class: 'btn btn-dark' do
= planting_icon
Track my plantings
%p
= link_to member_gardens_path(current_member), class: 'btn btn-dark' do
= link_to member_gardens_path(current_member, anchor: "content"), class: 'btn btn-dark' do
= garden_icon
Show me my garden
- else

View File

@@ -2,10 +2,10 @@
%ul.navbar-nav.mr-auto.bg-dark
- if signed_in?
%li.nav-item
= link_to timeline_index_path, method: :get, class: 'nav-link text-white' do
= link_to timeline_index_path, method: :get, class: 'nav-link text-white', title: "Timeline" do
= image_tag 'icons/notification.svg', class: 'img img-icon', alt: "Notifications"
%li.nav-item
= link_to member_gardens_path(current_member), class: 'nav-link text-white', title: "My gardens" do
= link_to member_gardens_path(current_member, anchor: "content"), class: 'nav-link text-white', title: "My gardens" do
= image_icon 'gardens'
%li.nav-item.dropdown
%a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button"}

View File

@@ -83,14 +83,14 @@
.row
%section.order-3.order-md-1.col-12= render "map", member: @member
- if @harvesting.size.positive?
%section.harvests.order-2.order-md-1.col-12
%section.harvests.order-2.order-md-1.col-12#harvests
%h2 Ready to harvest
.index-cards
- @harvesting.each do |planting|
= render 'plantings/thumbnail', planting: planting
- if @others.size.positive?
%section.planting-progress.order-2.order-md-1.col-12
%section.planting-progress.order-2.order-md-1.col-12#planting-progress
%h2 Progress report
%p Still growing and not ready for harvesting.
.list-group
@@ -99,7 +99,7 @@
%span= render 'plantings/tiny', planting: planting
%span= render 'plantings/progress', planting: planting
- if @late.size.positive?
%section.late.order-2.order-md-1.col-12
%section.late.order-2.order-md-1.col-12#late
%h2 Late
%p
These plantings are at the end of their lifecycle.
@@ -109,7 +109,7 @@
- @late.each do |planting|
= render 'plantings/thumbnail', planting: planting
- if @super_late.any?
%section.superlate.order-2.order-md-1.col-12
%section.superlate.order-2.order-md-1.col-12#superlate
%h2 Super late
%p
We suspect the following plantings finished long ago and no longer need tracking.
@@ -122,14 +122,14 @@
planted on #{planting.planted_at.to_date}
- if @harvests.any?
%section.havests.order-2.order-md-1.col-12
%section.havests.order-2.order-md-1.col-12#recent-harvests
%h2 Recent Harvests
.index-cards
- @harvests.each do |harvest|
= render 'harvests/thumbnail', harvest: harvest
- if @activity.any?
%section.activity.order-2.order-md-1.col-12
%section.activity.order-2.order-md-1.col-12#activity
%h2 Activity
.list-group
- @activity.each do |event|

View File

@@ -27,7 +27,7 @@
.row
.col-md-8
= f.collection_radio_buttons(:garden_id, @planting.owner.gardens.active,
= f.collection_radio_buttons(:garden_id, @planting.owner.gardens.active.order_by_name,
:id, :name, required: true,
label: 'Where did you plant it?')
= link_to "Add a garden.", new_garden_path

View File

@@ -9,7 +9,7 @@
%p Which garden is the planting in?
%ul.list-group
- planting.owner.gardens.active.order(:name).each do |garden|
- planting.owner.gardens.active.order_by_name.each do |garden|
%li.list-group-item
= link_to plantings_path(planting: {crop_id: planting.crop_id, garden_id: garden.id}), method: :post do
.md-v-line

View File

@@ -89,7 +89,11 @@
- else
.col-md-12
%p Nothing is currently planned here.
- if @finished_activities&.size&.positive?
%h2 Finished activities for planting
.index-cards
- @finished_activities.each do |activity|
= render "activities/card", activity: activity
.col-md-4.col-xs-12
= render @planting.crop

View File

@@ -1,8 +1,10 @@
# frozen_string_literal: true
class UnauthorisedError < JSONAPI::Error
end
JSONAPI.configure do |config|
# built in paginators are :none, :offset, :paged
config.default_paginator = :offset
config.default_page_size = 10
config.maximum_page_size = 100
config.exception_class_whitelist = [CanCan::AccessDenied, UnauthorisedError]
end

View File

@@ -54,6 +54,7 @@ en:
You updated your account successfully, but we need to verify your new email address. Please check your email and click on the confirm
link to finalize confirming your new email address.
destroyed: 'Bye! Your account was successfully cancelled. We hope to see you again soon.'
api_token_regenerated: 'Your API token has been regenerated.'
unlocks:
send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.'
unlocked: 'Your account has been unlocked successfully. Please sign in to continue.'

View File

@@ -16,6 +16,7 @@ Rails.application.routes.draw do
}
devise_scope :member do
get '/members/unsubscribe/:message' => 'members#unsubscribe', as: 'unsubscribe_member'
post '/members/regenerate_api_token' => 'registrations#regenerate_api_token', as: 'regenerate_api_token'
end
match '/members/:id/finish_signup' => 'members#finish_signup', via: %i(get patch), as: :finish_signup
@@ -141,6 +142,7 @@ Rails.application.routes.draw do
namespace :api do
namespace :v1 do
jsonapi_resources :activities
jsonapi_resources :crops
jsonapi_resources :gardens
jsonapi_resources :harvests

View File

@@ -0,0 +1,10 @@
class AddFieldsToCrops < ActiveRecord::Migration[5.2]
def change
add_column :crops, :row_spacing, :integer
add_column :crops, :spread, :integer
add_column :crops, :height, :integer
add_column :crops, :sowing_method, :string
add_column :crops, :sun_requirements, :string
add_column :crops, :growing_degree_days, :integer
end
end

View File

@@ -0,0 +1,21 @@
class PopulateCropFieldsFromOpenfarmData < ActiveRecord::Migration[5.2]
def up
Crop.find_each do |crop|
if crop.openfarm_data.present?
attributes = crop.openfarm_data.fetch('attributes', {})
crop.update_columns(
row_spacing: attributes['row_spacing'],
spread: attributes['spread'],
height: attributes['height'],
sowing_method: attributes['sowing_method'],
sun_requirements: attributes['sun_requirements'],
growing_degree_days: attributes['growing_degree_days']
)
end
end
end
def down
# This migration is not reversible.
end
end

View File

@@ -252,6 +252,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_09_01_130830) do
t.jsonb "openfarm_data"
t.integer "harvests_count", default: 0
t.integer "photo_associations_count", default: 0
t.integer "row_spacing"
t.integer "spread"
t.integer "height"
t.string "sowing_method"
t.string "sun_requirements"
t.integer "growing_degree_days"
t.index ["creator_id"], name: "index_crops_on_creator_id"
t.index ["name"], name: "index_crops_on_name"
t.index ["parent_id"], name: "index_crops_on_parent_id"

View File

@@ -100,6 +100,36 @@ describe CropsController do
it { expect { subject }.to change(Crop, :count).by(1) }
it { expect { subject }.to change(AlternateName, :count).by(2) }
it { expect { subject }.to change(ScientificName, :count).by(1) }
context 'with openfarm data' do
let(:crop_params) do
{
crop: {
name: 'aubergine',
en_wikipedia_url: "https://en.wikipedia.org/wiki/Eggplant",
row_spacing: 10,
spread: 20,
height: 30,
sowing_method: 'direct',
sun_requirements: 'full sun',
growing_degree_days: 100
},
alt_name: { '1': "egg plant", '2': "purple apple" },
sci_name: { '1': "fancy sci name", '2': "" }
}
end
it 'saves openfarm data' do
subject
crop = Crop.last
expect(crop.row_spacing).to eq(10)
expect(crop.spread).to eq(20)
expect(crop.height).to eq(30)
expect(crop.sowing_method).to eq('direct')
expect(crop.sun_requirements).to eq('full sun')
expect(crop.growing_degree_days).to eq(100)
end
end
end
end

View File

@@ -19,6 +19,14 @@ describe "Crop", :js do
click_button class: "add-altname-row"
fill_in "alt_name[3]", with: "Jazmin"
fill_in "alt_name[4]", with: "Matsurika"
fill_in "crop_row_spacing", with: "12"
fill_in "crop_spread", with: "30"
fill_in "crop_height", with: "10"
fill_in "crop_sowing_method", with: "directly into final position"
fill_in "crop_sun_requirements", with: "full sun"
fill_in "crop_growing_degree_days", with: 100
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
describe "member token management", :js do
include_context 'signed in member'
before do
visit edit_member_registration_path
click_on "Apps"
end
it "can generate an API token" do
expect(page).to have_no_content("Your API token is")
click_on "Generate API Token"
expect(page).to have_content("Your API token is")
member.reload
expect(member.api_token).to be_present
end
context "with an existing token" do
before do
member.regenerate_api_token
visit edit_member_registration_path
click_on "Apps"
end
it "can regenerate an API token" do
old_token = member.api_token.token
expect(page).to have_content("Your API token is")
accept_confirm do
click_on "Regenerate"
end
expect(page).to have_content("Your API token is")
expect(member.reload.api_token.token).not_to eq(old_token)
end
end
end

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Activities', type: :request do
subject { JSON.parse response.body }
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:activity) { FactoryBot.create(:activity, garden: create(:garden), planting: create(:planting)) }
let!(:activity2) { FactoryBot.create(:activity) }
it '#index' do
get('/api/v1/activities', params: {}, headers:)
expect(subject['data'].size).to eq(2)
end
it '#show' do
get("/api/v1/activities/#{activity.id}", params: {}, headers:)
expect(subject['data']['id']).to eq(activity.id.to_s)
end
context 'filtering' do
it 'filters by owner' do
get("/api/v1/activities?filter[owner-id]=#{activity.owner.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(activity.id.to_s)
end
it 'filters by garden' do
get("/api/v1/activities?filter[garden-id]=#{activity.garden.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(activity.id.to_s)
end
it 'filters by planting' do
get("/api/v1/activities?filter[planting-id]=#{activity.planting.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(activity.id.to_s)
end
it 'filters by category' do
get("/api/v1/activities?filter[category]=#{activity.category}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(2)
expect(subject['data'][0]['id']).to eq(activity.id.to_s)
expect(subject['data'][1]['id']).to eq(activity2.id.to_s)
end
end
end

View File

@@ -50,21 +50,138 @@ RSpec.describe 'Gardens', type: :request do
expect(subject['data']).to include(garden_encoded_as_json_api)
end
it '#create' do
expect do
post '/api/v1/gardens', params: { 'garden' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
context 'filtering' do
let!(:garden2) { FactoryBot.create(:garden, active: false, garden_type: FactoryBot.create(:garden_type)) }
pending 'filters by active' do
get('/api/v1/gardens?filter[active]=true', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(garden.id.to_s)
end
it 'filters by garden_type' do
get("/api/v1/gardens?filter[garden_type]=#{garden2.garden_type.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(garden2.id.to_s)
end
it 'filters by owner' do
get("/api/v1/gardens?filter[owner_id]=#{garden2.owner.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(2)
expect(subject['data'][1]['id']).to eq(garden2.id.to_s)
end
end
it '#update' do
expect do
post "/api/v1/gardens/#{garden.id}", params: { 'garden' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
describe '#create' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:garden_params) do
{
data: {
type: 'gardens',
attributes: {
name: 'My API Garden'
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
post '/api/v1/gardens', params: garden_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 201 Created with a valid token' do
post '/api/v1/gardens', params: garden_params, headers: auth_headers
expect(response).to have_http_status(:created)
expect(member.gardens.count).to eq(2) # 1 from after_create callback, 1 from api
end
end
it '#delete' do
expect do
delete "/api/v1/gardens/#{garden.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
describe '#update' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:garden) { create(:garden, owner: member) }
let(:other_member_garden) { create(:garden) }
let(:update_params) do
{
data: {
type: 'gardens',
id: garden.id.to_s,
attributes: {
name: 'An updated garden'
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
patch "/api/v1/gardens/#{garden.id}", params: update_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 200 OK with a valid token for own garden' do
patch "/api/v1/gardens/#{garden.id}", params: update_params, headers: auth_headers
expect(response).to have_http_status(:ok)
expect(garden.reload.name).to eq('An updated garden')
end
it 'returns 403 Forbidden for another member\'s garden' do
update_params_for_other = {
data: {
type: 'gardens',
id: other_member_garden.id.to_s,
attributes: {
name: 'An updated garden'
}
}
}.to_json
patch "/api/v1/gardens/#{other_member_garden.id}", params: update_params_for_other, headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
describe '#delete' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let!(:garden) { create(:garden, owner: member) }
let(:other_member_garden) { create(:garden) }
it 'returns 401 Unauthorized without a token' do
delete "/api/v1/gardens/#{garden.id}", headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 204 No Content with a valid token for own garden' do
delete "/api/v1/gardens/#{garden.id}", headers: auth_headers
expect(response).to have_http_status(:no_content)
expect(Garden.find_by(id: garden.id)).to be_nil
end
it 'returns 403 Forbidden for another member\'s garden' do
delete "/api/v1/gardens/#{other_member_garden.id}", headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
end

View File

@@ -76,25 +76,153 @@ RSpec.describe 'Harvests', type: :request do
it { expect(subject['data']).to eq(harvest_encoded_as_json_api) }
end
it '#create' do
expect do
put '/api/v1/harvests', headers:, params: {
'harvest' => { 'description' => 'can i make this' }
}
end.to raise_error ActionController::RoutingError
context 'filtering' do
let!(:harvest2) { FactoryBot.create(:harvest, planting: create(:planting)) }
it 'filters by crop' do
get("/api/v1/harvests?filter[crop_id]=#{harvest2.crop.id}", params: {}, headers:)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(harvest2.id.to_s)
end
it 'filters by planting' do
get("/api/v1/harvests?filter[planting_id]=#{harvest2.planting.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(harvest2.id.to_s)
end
it 'filters by plant_part' do
get("/api/v1/harvests?filter[plant_part]=#{harvest2.plant_part.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(harvest2.id.to_s)
end
it 'filters by owner' do
get("/api/v1/harvests?filter[owner_id]=#{harvest2.owner.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(harvest2.id.to_s)
end
end
it '#update' do
expect do
post "/api/v1/harvests/#{harvest.id}", headers:, params: {
'harvest' => { 'description' => 'can i modify this' }
}
end.to raise_error ActionController::RoutingError
describe '#create' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:crop) { create(:crop) }
let(:planting) { create(:planting, owner: member) }
let(:plant_part) { create(:plant_part) }
let(:harvest_params) do
{
data: {
type: 'harvests',
attributes: {
description: 'My API harvests'
},
relationships: {
planting: { data: { type: 'plantings', id: planting.id } }
# plant_part: { data: { type: 'plant_parts', id: plant_part.id } }
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
post '/api/v1/harvests', params: harvest_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 201 Created with a valid token' do
post '/api/v1/harvests', params: harvest_params, headers: auth_headers
expect(response).to have_http_status(:created)
expect(member.harvests.count).to eq(1)
end
end
it '#delete' do
expect do
delete "/api/v1/harvests/#{harvest.id}", headers:, params: {}
end.to raise_error ActionController::RoutingError
describe '#update' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:harvest) { create(:harvest, owner: member) }
let(:other_member_harvest) { create(:harvest) }
let(:update_params) do
{
data: {
type: 'harvests',
id: harvest.id.to_s,
attributes: {
description: 'An updated harvest'
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
patch "/api/v1/harvests/#{harvest.id}", params: update_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 200 OK with a valid token for own harvest' do
patch "/api/v1/harvests/#{harvest.id}", params: update_params, headers: auth_headers
expect(response).to have_http_status(:ok)
expect(harvest.reload.description).to eq('An updated harvest')
end
it 'returns 403 Forbidden for another member\'s harvest' do
update_params_for_other = {
data: {
type: 'harvests',
id: other_member_harvest.id.to_s,
attributes: {
description: 'An updated harvest'
}
}
}.to_json
patch "/api/v1/harvests/#{other_member_harvest.id}", params: update_params_for_other, headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
describe '#delete' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let!(:harvest) { create(:harvest, owner: member) }
let(:other_member_harvest) { create(:harvest) }
it 'returns 401 Unauthorized without a token' do
delete "/api/v1/harvests/#{harvest.id}", headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 204 No Content with a valid token for own harvest' do
delete "/api/v1/harvests/#{harvest.id}", headers: auth_headers
expect(response).to have_http_status(:no_content)
expect(Garden.find_by(id: harvest.id)).to be_nil
end
it 'returns 403 Forbidden for another member\'s harvest' do
delete "/api/v1/harvests/#{other_member_harvest.id}", headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
end

View File

@@ -95,24 +95,119 @@ RSpec.describe 'Plantings', type: :request do
expect(subject['data']).to eq(planting_encoded_as_json_api)
end
it '#create' do
expect do
post '/api/v1/plantings', params: { 'planting' => { 'description' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
describe '#create' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:crop) { create(:crop) }
let(:garden) { create(:garden, owner: member) }
let(:planting_params) do
{
data: {
type: 'plantings',
attributes: {
description: 'My API plantings'
},
relationships: {
crop: { data: { type: 'crops', id: crop.id } },
garden: { data: { type: 'gardens', id: garden.id } }
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
post '/api/v1/plantings', params: planting_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 201 Created with a valid token' do
post '/api/v1/plantings', params: planting_params, headers: auth_headers
expect(response).to have_http_status(:created)
expect(member.plantings.count).to eq(1)
end
end
it '#update' do
expect do
post "/api/v1/plantings/#{planting.id}", headers:, params: {
'planting' => { 'description' => 'can i modify this' }
}
end.to raise_error ActionController::RoutingError
describe '#update' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:planting) { create(:planting, owner: member) }
let(:other_member_planting) { create(:planting) }
let(:update_params) do
{
data: {
type: 'plantings',
id: planting.id.to_s,
attributes: {
description: 'An updated planting'
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
patch "/api/v1/plantings/#{planting.id}", params: update_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 200 OK with a valid token for own planting' do
patch "/api/v1/plantings/#{planting.id}", params: update_params, headers: auth_headers
expect(response).to have_http_status(:ok)
expect(planting.reload.description).to eq('An updated planting')
end
it 'returns 403 Forbidden for another member\'s planting' do
update_params_for_other = {
data: {
type: 'plantings',
id: other_member_planting.id.to_s,
attributes: {
description: 'An updated planting'
}
}
}.to_json
patch "/api/v1/plantings/#{other_member_planting.id}", params: update_params_for_other, headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
it '#delete' do
expect do
delete "/api/v1/plantings/#{planting.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
describe '#delete' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let!(:planting) { create(:planting, owner: member) }
let(:other_member_planting) { create(:planting) }
it 'returns 401 Unauthorized without a token' do
delete "/api/v1/plantings/#{planting.id}", headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 204 No Content with a valid token for own planting' do
delete "/api/v1/plantings/#{planting.id}", headers: auth_headers
expect(response).to have_http_status(:no_content)
expect(Garden.find_by(id: planting.id)).to be_nil
end
it 'returns 403 Forbidden for another member\'s planting' do
delete "/api/v1/plantings/#{other_member_planting.id}", headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
describe "by member/owner" do
@@ -140,4 +235,37 @@ RSpec.describe 'Plantings', type: :request do
end
end
end
context 'filtering' do
let!(:planting2) { FactoryBot.create(:planting, failed: true, sunniness: 'shade') }
let!(:perennial_planting) { FactoryBot.create(:planting, crop: FactoryBot.create(:crop, perennial: true)) }
it 'filters by failed' do
get('/api/v1/plantings?filter[failed]=true', params: {}, headers:)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(planting2.id.to_s)
end
it 'filters by sunniness' do
get('/api/v1/plantings?filter[sunniness]=shade', params: {}, headers:)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(planting2.id.to_s)
end
it 'filters by perennial' do
get('/api/v1/plantings?filter[perennial]=true', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(perennial_planting.id.to_s)
end
it 'filters by active' do
get('/api/v1/plantings?filter[active]=true', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(2)
expect(subject['data'][0]['id']).to eq(planting.id.to_s)
end
end
end

View File

@@ -61,21 +61,170 @@ RSpec.describe 'Seeds', type: :request do
it { expect(subject['data']).to eq(seed_encoded_as_json_api) }
end
it '#create' do
expect do
post '/api/v1/seeds', params: { 'seed' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
describe '#create' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:crop) { create(:crop) }
let(:seed_params) do
{
data: {
type: 'seeds',
attributes: {
description: 'My API seeds'
},
relationships: {
crop: { data: { type: 'crops', id: crop.id } }
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
post '/api/v1/seeds', params: seed_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 201 Created with a valid token' do
post '/api/v1/seeds', params: seed_params, headers: auth_headers
expect(response).to have_http_status(:created)
expect(member.seeds.count).to eq(1)
end
end
it '#update' do
expect do
post "/api/v1/seeds/#{seed.id}", params: { 'seed' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
describe '#update' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:crop) { create(:crop) }
let(:seed) { create(:seed, owner: member, crop: crop) }
let(:other_member_seed) { create(:seed) }
let(:update_params) do
{
data: {
type: 'seeds',
id: seed.id.to_s,
attributes: {
description: 'An updated seed'
}
}
}.to_json
end
it 'returns 401 Unauthorized without a token' do
patch "/api/v1/seeds/#{seed.id}", params: update_params, headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 200 OK with a valid token for own seed' do
patch "/api/v1/seeds/#{seed.id}", params: update_params, headers: auth_headers
expect(response).to have_http_status(:ok)
expect(seed.reload.description).to eq('An updated seed')
end
it 'returns 403 Forbidden for another member\'s seed' do
update_params_for_other = {
data: {
type: 'seeds',
id: other_member_seed.id.to_s,
attributes: {
description: 'An updated seed'
}
}
}.to_json
patch "/api/v1/seeds/#{other_member_seed.id}", params: update_params_for_other, headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
it '#delete' do
expect do
delete "/api/v1/seeds/#{seed.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
describe '#delete' do
let!(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:headers) { { 'Accept' => 'application/vnd.api+json', 'Content-Type' => 'application/vnd.api+json' } }
let(:auth_headers) { headers.merge('Authorization' => "Token token=#{token}") }
let(:crop) { create(:crop) }
let!(:seed) { create(:seed, owner: member, crop: crop) }
let(:other_member_seed) { create(:seed) }
it 'returns 401 Unauthorized without a token' do
delete "/api/v1/seeds/#{seed.id}", headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'returns 204 No Content with a valid token for own seed' do
delete "/api/v1/seeds/#{seed.id}", headers: auth_headers
expect(response).to have_http_status(:no_content)
expect(Seed.find_by(id: seed.id)).to be_nil
end
it 'returns 403 Forbidden for another member\'s seed' do
delete "/api/v1/seeds/#{other_member_seed.id}", headers: auth_headers
expect(response).to have_http_status(:forbidden)
end
end
context 'filtering' do
let!(:seed2) do
FactoryBot.create(:seed, tradable_to: 'nationally', organic: 'certified organic', gmo: 'certified GMO-free', heirloom: 'heirloom')
end
it 'filters by crop' do
get("/api/v1/seeds?filter[crop]=#{seed2.crop.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
it 'filters by tradable_to' do
get('/api/v1/seeds?filter[tradable_to]=nationally', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
it 'filters by organic' do
get('/api/v1/seeds?filter[organic]=certified organic', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
it 'filters by gmo' do
get('/api/v1/seeds?filter[gmo]=certified GMO-free', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
it 'filters by heirloom' do
get('/api/v1/seeds?filter[heirloom]=heirloom', params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
it 'filters by owner' do
get("/api/v1/seeds?filter[owner_id]=#{seed2.owner.id}", params: {}, headers:)
expect(response).to have_http_status(:ok)
expect(subject['data'].size).to eq(1)
expect(subject['data'][0]['id']).to eq(seed2.id.to_s)
end
end
end

View File

File diff suppressed because it is too large Load Diff