diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4bf0f5f579..d6636cb11a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout source - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Poke config run: | cp config/example.storage.yml config/storage.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f764a804f9..eb40829765 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Setup ruby uses: actions/setup-ruby@v1 with: @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Setup ruby uses: actions/setup-ruby@v1 with: @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Setup ruby uses: actions/setup-ruby@v1 with: @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Setup ruby uses: actions/setup-ruby@v1 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04caddf41b..9b053e4c68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: OPENSTREETMAP_MEMCACHE_SERVERS: 127.0.0.1 steps: - name: Checkout source - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.3.0 - name: Setup ruby uses: actions/setup-ruby@v1 with: diff --git a/Design.md b/Design.md new file mode 100644 index 0000000000..e13dabef43 --- /dev/null +++ b/Design.md @@ -0,0 +1,197 @@ +# Microcosm + +Micrososm is a website that supports the activities of OpenStreetMap local user groups. These activities include: + +* membership tracking +* communication with members +* showcase recent achievements of the microcosm +* inform people about upcoming mapping events +* highlight places of staleness on the local map +* review changsets +* build walkable and bikable routes for fixing OSM bugs + +## Sample URLs + +There are various hosting options: + +> http://mappingdc.org/microcosm + +> http://openstreetmap.us/microcosms/mappingdc + +> http://openstreetmap.org/microcosms/paris + +# Users + +* Visitor - Someone who is interested in mapping, but not a member of a microcosm. +* Member - Primary user is a mapper who belongs to the microcosm. The mapper goes to events, does street level mapping, edits the map in this area. +* Organizer - Secondary user is the organizers/team of the microcosm. +* Administrator - OSM admin may create microcosms. + +# Features + +## As a Visitor + +### About the map + +- [ ] See Notes on the map that need resolution + +### About the microcosm + +- [x] See a list of microcosms +- [x] See description of the microcosm +- [x] See the members of the microcosm +- [x] See links to facebook and twitter +- [ ] See a map of the area +- [ ] See links to members OSM wiki, OSM help, OSM Forum, GitHub, Mapillary, HOT OSM, and Twitter accounts if they exist. +- [ ] See what the microcosm is working on +- [ ] See what's new in the microcosm: editing activity, project activity +- [ ] See feed from twitter +- [ ] Not be invited more than once per year + +### About the microcosm events + +- [x] See upcoming events for a microcosm +- [x] See all upcoming events +- [ ] See past events +- [ ] Get directions to the event + +### About the microcosm projects + +- [ ] See the projects of the microcosm + +## As a Member + +### About the microcosm + +- [ ] See details about other members +- [ ] See mayors of neighborhoods + +### About the member + +- [ ] See their profile +- [ ] See their upcoming events +- [ ] See their past events +- [ ] See their progress +- [ ] See where they have mapped +- [ ] Share that they belong to a Local Chapter +- [ ] Add friends (use OSM profile friends) + +### About events + +- [ ] Propose a new event +- [x] RSVP for an event + +### About projects + +- [ ] Propose a new project +- [ ] Elect to work on a project +- [ ] Work on a mapping task (task manager, local project) + +### Other + +## As an Organizer + +### About the mapathon + +- [ ] Can adjust the center location and bounds of the AOI + +### About the microcosm + +- [ ] Manage the description +- [ ] Set a hashtag for the microcosm +- [ ] Identify sister microcosms + +### Events + +- [x] Create an event +- [ ] Modify an event +- [ ] Generate Field Papers (Survey Papers) + +### Membership + +- [ ] Manage members +- [ ] Send a message to members +- [ ] Get notified about first time mappers in the area (https://github.com/cliffordsnow/newUsers) +- [ ] Invite people to join the microcosm + +### Quality Assurance + +- [ ] Measure the completeness of coverage +- [ ] Organize feeds (e.g. city bike station locations) +- [ ] Measure quality assurance + +## As an admin + +- [ ] Create microcosms +- [x] Edit microcosms +- [ ] Periodically scan the wiki for new user groups and local chapters (https://github.com/osmlab/localgroups/blob/master/osmgroups.geojson) + +# QA + +* https://wiki.openstreetmap.org/wiki/Keep_Right +* https://www.keepright.at/report_map.php?zoom=12&lat=39.95356&lon=-75.12364 +* https://github.com/keepright/keepright +* http://osmose.openstreetmap.fr/en/map/ +* https://wiki.openstreetmap.org/wiki/OSM_Inspector + + +# Use Cases + +## List reviews in the area + +Some people want their changes reviewed. Provide a list of these for the AOI. + +## An organizer organizes a street survey + +Assume: microcosm exists and has many users, event details have been selected + +Steps: + +1. Organizer notifies the microcosm about the event. +1. Members RSVP. +1. The event is held. + +## At an event members upload pictures of their survey notes + +At an event there may not be time for surveyors to enter all their data. They can take pictures of their notes and upload it to the microcosm for other people to map later. The notes are entered into a queue of tasks for others to assign to themselves. + +Build native apps for iOS and Android to use the camera and upload them to the server. + +## Build-a-mapathon + +* Help an organizer pick a location to map based on various criteria like location of development, staleness, feasability (mass transit), etc. +* Find a quiet place to sit and edit. +* Break down area to be surveyed into walkable pieces for teams. +* Print Field Papers. + +## Map Fixing for Individuals + +* Find map bugs and generate a bikable or walkable path to cover these points. +* It should be a max bang for your buck type of optimization (use pgrouting). +* Incorporate mobile apps like StreetComplete and OSMBugs. + +# Use + +* Feature flags - pda/flip, fetlife/rollout +* Internationalization + +# Ideas + +* nanocosm +* Map of user groups around the world http://usergroups.openstreetmap.de/ +* DC Wiki - How do we do x? e.g. sidewalks + +# See Also + +* https://wiki.openstreetmap.org/wiki/User_group +* https://github.com/maptime/maptime.github.io/blob/master/_data/chapters.json +* https://wiki.openstreetmap.org/wiki/User:Mvexel/New_User_Welcome_Message +* https://www.wmata.com/schedules/timetables/all-routes.cfm?State=DC +* https://github.com/fossgis/usergroups-bot - This is a bot written in Python, collection all Template:User_group together and generating a KML file to show them on a map: http://usergroups.openstreetmap.de + +# Integration + +* https://github.com/kort/kort +* StreetComplete + + diff --git a/Gemfile b/Gemfile index 9568ea5f8c..7210d8bb67 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem "rails-i18n", "~> 6.0.0" gem "rinku", ">= 2.0.6", :require => "rails_rinku" gem "strong_migrations" gem "validates_email_format_of", ">= 1.5.1" +gem "validate_url" # Native OSM extensions gem "quad_tile", "~> 1.0.1" @@ -119,6 +120,9 @@ gem "aws-sdk-s3" # Used to resize user images gem "mini_magick" +# Used to provide clean urls like /microcosm/mappingdc +gem "friendly_id" + # Gems useful for development group :development do gem "annotate" @@ -133,6 +137,8 @@ end group :test do gem "brakeman" gem "capybara", ">= 2.15" + gem "cucumber-rails", :require => false + gem "database_cleaner" gem "erb_lint", :require => false gem "factory_bot_rails" gem "minitest", "~> 5.1" diff --git a/Gemfile.lock b/Gemfile.lock index 6a579fd20c..11b0bd2d8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,7 +138,45 @@ GEM crack (0.4.5) rexml crass (1.0.6) + cucumber (5.2.0) + builder (~> 3.2, >= 3.2.4) + cucumber-core (~> 8.0, >= 8.0.1) + cucumber-create-meta (~> 2.0, >= 2.0.2) + cucumber-cucumber-expressions (~> 10.3, >= 10.3.0) + cucumber-gherkin (~> 15.0, >= 15.0.2) + cucumber-html-formatter (~> 9.0, >= 9.0.0) + cucumber-messages (~> 13.1, >= 13.1.0) + cucumber-wire (~> 4.0, >= 4.0.1) + diff-lcs (~> 1.4, >= 1.4.4) + multi_test (~> 0.1, >= 0.1.2) + sys-uname (~> 1.2, >= 1.2.1) + cucumber-core (8.0.1) + cucumber-gherkin (~> 15.0, >= 15.0.2) + cucumber-messages (~> 13.0, >= 13.0.1) + cucumber-tag-expressions (~> 2.0, >= 2.0.4) + cucumber-create-meta (2.0.4) + cucumber-messages (~> 13.1, >= 13.1.0) + sys-uname (~> 1.2, >= 1.2.1) + cucumber-cucumber-expressions (10.3.0) + cucumber-gherkin (15.0.2) + cucumber-messages (~> 13.0, >= 13.0.1) + cucumber-html-formatter (9.0.0) + cucumber-messages (~> 13.0, >= 13.0.1) + cucumber-messages (13.2.1) + protobuf-cucumber (~> 3.10, >= 3.10.8) + cucumber-rails (2.2.0) + capybara (>= 2.18, < 4) + cucumber (>= 3.0.2, < 6) + mime-types (~> 3.2) + nokogiri (~> 1.8) + rails (>= 5.0, < 7) + cucumber-tag-expressions (2.0.4) + cucumber-wire (4.0.1) + cucumber-core (~> 8.0, >= 8.0.1) + cucumber-cucumber-expressions (~> 10.3, >= 10.3.0) + cucumber-messages (~> 13.0, >= 13.0.1) dalli (2.7.11) + database_cleaner (1.8.5) debug_inspector (1.1.0) deep_merge (1.2.1) delayed_job (4.1.9) @@ -146,6 +184,7 @@ GEM delayed_job_active_record (4.1.6) activerecord (>= 3.0, < 6.2) delayed_job (>= 3.0, < 5) + diff-lcs (1.4.4) docile (1.3.5) dry-configurable (0.12.1) concurrent-ruby (~> 1.0) @@ -209,6 +248,8 @@ GEM ffi (1.15.0) ffi-libarchive (1.0.17) ffi (~> 1.0) + friendly_id (5.4.2) + activerecord (>= 4.0.0) fspath (3.1.2) gd2-ffij (0.4.0) ffi (>= 1.0.0) @@ -262,12 +303,17 @@ GEM marcel (1.0.1) maxminddb (0.1.22) method_source (1.0.0) + middleware (0.1.0) + mime-types (3.3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2020.1104) mini_magick (4.11.0) mini_mime (1.1.0) mini_portile2 (2.5.1) minitest (5.14.4) msgpack (1.4.2) multi_json (1.15.0) + multi_test (0.1.2) multi_xml (0.6.0) multipart-post (2.1.1) nio4r (2.5.7) @@ -327,6 +373,11 @@ GEM pg (1.2.3) popper_js (1.16.0) progress (3.6.0) + protobuf-cucumber (3.10.8) + activesupport (>= 3.2) + middleware + thor + thread_safe public_suffix (4.0.6) puma (5.3.0) nio4r (~> 2.0) @@ -445,6 +496,8 @@ GEM sprockets (>= 3.0.0) strong_migrations (0.7.6) activerecord (>= 5) + sys-uname (1.2.2) + ffi (~> 1.1) thor (1.1.0) thread_safe (0.3.6) tilt (2.0.10) @@ -453,6 +506,9 @@ GEM uglifier (4.2.0) execjs (>= 0.3.0, < 3) unicode-display_width (2.0.0) + validate_url (1.0.13) + activemodel (>= 3.0.0) + public_suffix validates_email_format_of (1.6.3) i18n vendorer (0.2.0) @@ -491,13 +547,16 @@ DEPENDENCIES capybara (>= 2.15) composite_primary_keys (~> 12.0.0) config + cucumber-rails dalli + database_cleaner debug_inspector delayed_job_active_record erb_lint factory_bot_rails faraday ffi-libarchive + friendly_id gd2-ffij (>= 0.4.0) htmlentities http_accept_language (~> 2.1.1) @@ -548,6 +607,7 @@ DEPENDENCIES simplecov-lcov strong_migrations uglifier (>= 1.3.0) + validate_url validates_email_format_of (>= 1.5.1) vendorer webmock diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb index 9c832b43ac..32a6fe3bb6 100644 --- a/app/abilities/ability.rb +++ b/app/abilities/ability.rb @@ -31,6 +31,8 @@ def initialize(user) can [:history, :version], OldNode can [:history, :version], OldWay can [:history, :version], OldRelation + can [:index, :show, :show_events, :show_members], Microcosm + can [:show, :index], Event end if user @@ -47,6 +49,34 @@ def initialize(user) can [:mine, :new, :create, :edit, :update, :destroy], Trace can [:account, :go_public], User + # This is a cancancan rule condition, effectively the same thing as + # microcosm.organizer?(user), as used in a block, but in declarative form. + user_is_microcosm_organizer = { + :microcosm_members => { + :user_id => user.id, + :role => MicrocosmMember::Roles::ORGANIZER + } + } + user_is_microcosm_member = { + :microcosm_members => { + :user_id => user.id, + :role => MicrocosmMember::Roles::MEMBER + } + } + + can [:create], EventAttendance, :event => { + :microcosm => user_is_microcosm_member + } + # TODO: There's attribute level rules now. We can use this for setting the intention. + can [:update], EventAttendance, :user_id => user.id + can [:new, :create, :step_up], Microcosm + can [:edit, :update], Microcosm, user_is_microcosm_organizer + can [:create], MicrocosmMember + # TODO: There's attribute level rules now. We can use this for setting the role. + can [:destroy], MicrocosmMember, :user_id => user.id + can [:destroy, :edit, :update], MicrocosmMember, :microcosm => user_is_microcosm_organizer + can [:create, :update], Event, :microcosm => user_is_microcosm_organizer + if user.moderator? can [:hide, :hidecomment], DiaryEntry can [:index, :show, :resolve, :ignore, :reopen], Issue diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 8d66a7fef7..c9705ca0b7 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,5 +1,6 @@ //= link browserconfig.xml //= link manifest.json +//= link html5shiv.js //= link_tree ../favicons //= link_tree ../images diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index cbb69119ce..641fb20d55 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -143,3 +143,77 @@ $(document).ready(function () { OSM.location = application_data.location; } }); + +window.showMap = function (id) { + var params = $("#" + id).data(); + var map = L.map(id, { + attributionControl: false, + zoomControl: false + }); + map.addLayer(new L.OSM.Mapnik()); + var show_marker = true; + if (!params.lon || !params.lat) { + params.lon = 0; + params.lat = 0; + params.zoom = 1; + show_marker = false; + } + map.setView([params.lat, params.lon], params.zoom); + if (show_marker) { + L.marker([params.lat, params.lon], { icon: OSM.getUserIcon() }).addTo(map); + } +}; + +window.formMapInput = function (id, type) { + var map = L.map(id, { + attributionControl: false + }); + map.addLayer(new L.OSM.Mapnik()); + + var lat_field = document.getElementById(type + "_latitude"); + var lon_field = document.getElementById(type + "_longitude"); + + if (lat_field.value) { + map.setView([lat_field.value, lon_field.value], 12); + } else { + map.setView([0, 0], 0); + } + + L.Control.Watermark = L.Control.extend({ + onAdd: function () { + var container = map.getContainer(); + var img = L.DomUtil.create("img"); + img.src = "/assets/marker-blue.png"; // 25x41 px + // img.style.width = '200px'; + img.style["margin-left"] = ((container.offsetWidth / 2) - 12) + "px"; + img.style["margin-bottom"] = ((container.offsetHeight / 2) - 20) + "px"; + return img; + }, + onRemove: function () { + // Nothing to do here + } + }); + L.control.watermark = function (opts) { + return new L.Control.Watermark(opts); + }; + L.control.watermark({ position: "bottomleft" }).addTo(map); + + function normalizeLongitude(longitude) { + // Convert any given longitude to this interval [-180, 180). + // We add 540 because JS mods of negative numbers are negative. + return (((longitude % 360) + 360 + 180) % 360) - 180; + } + + map.on("move", function () { + var center = map.getCenter(); + $("#" + type + "_latitude").val(center.lat); + $("#" + type + "_longitude").val(normalizeLongitude(center.lng)); + if ($("#" + type + "_min_lat")) { + var bounds = map.getBounds(); + $("#" + type + "_min_lat").val(bounds._southWest.lat); + $("#" + type + "_max_lat").val(bounds._northEast.lat); + $("#" + type + "_min_lon").val(normalizeLongitude(bounds._southWest.lng)); + $("#" + type + "_max_lon").val(normalizeLongitude(bounds._northEast.lng)); + } + }); +}; diff --git a/app/assets/javascripts/event.js b/app/assets/javascripts/event.js new file mode 100644 index 0000000000..27ed864489 --- /dev/null +++ b/app/assets/javascripts/event.js @@ -0,0 +1,17 @@ +/*global showMap,formMapInput*/ + +$(document).ready(function () { + function init_event_form() { + formMapInput("event_map_form", "event"); + } + + function init_event_show() { + showMap("event_map_show"); + } + + if ($("#event_map_form").length) { + init_event_form(); + } else if ($("#event_map_show").length) { + init_event_show(); + } +}); diff --git a/app/assets/javascripts/microcosms.js b/app/assets/javascripts/microcosms.js new file mode 100644 index 0000000000..3d2b0cbb1e --- /dev/null +++ b/app/assets/javascripts/microcosms.js @@ -0,0 +1,17 @@ +/*global showMap,formMapInput*/ + +$(document).ready(function () { + function init_microcosm_form() { + formMapInput("microcosm_map_form", "microcosm"); + } + + function init_microcosm_show() { + showMap("microcosm_map"); + } + + if ($("#microcosm_map_form").length) { + init_microcosm_form(); + } else if ($("#microcosm_map").length) { + init_microcosm_show(); + } +}); diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 411e6167bf..74448484ea 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -1539,6 +1539,32 @@ img.user_thumbnail_tiny { border: 1px solid $grey; } +.user_card { + /* semantic markup here, implement with bootstrap */ + @extend .card; + width: 100px; + display: inline-block; + > img { + @extend .card-img-top; + } + > div { + @extend .card-body; + /* + Some display_names are long and break the uniform display of user cards. + For now, hide the overflow. This does make some user names ambiguous. + TODO: Fix this. + */ + overflow: hidden; + white-space: nowrap; + padding: 0.5rem; /* override bootstrap's 1.25rem */ + > h5 { + @extend .card-title; + font-size: 0.9rem; /* override bootstrap's 1.25 rem */ + } + } +} + + /* Rules for geo microformats */ abbr.geo { diff --git a/app/assets/stylesheets/events.scss b/app/assets/stylesheets/events.scss new file mode 100644 index 0000000000..3a240b3823 --- /dev/null +++ b/app/assets/stylesheets/events.scss @@ -0,0 +1,14 @@ +// Place all the styles related to the Events controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: https://sass-lang.com/ + +.event_details { + .when { + color: #777; font-size: 120%; + } +} + + +#event_map_form, #event_map_show { + height: 400px; +} diff --git a/app/assets/stylesheets/microcosms.scss b/app/assets/stylesheets/microcosms.scss new file mode 100644 index 0000000000..571bdc065b --- /dev/null +++ b/app/assets/stylesheets/microcosms.scss @@ -0,0 +1,36 @@ +// Place all the styles related to the Microcosms controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: https://sass-lang.com/ + +.mic_details { + h1 { + margin-top: 0; + } + label { + font-weight: bold; + } + ul { + display: inline-block; + } + ul > li { + display: inline-block; + } +} + +.microcosms { + form { + display: inline; + } + img { + float: none; + } +} + +#microcosm_map, #microcosm_map_form { + height: 400px; +} + +/* show_members page */ +.organizers ul, .members ul { + list-style: none; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d571535d35..18a3ef67a0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -381,6 +381,11 @@ def get_auth_data # override to stop oauth plugin sending errors def invalid_oauth_response; end + # Convert all blanks in a hash (e.g. params) to nil. + def nilify(params) + params.each { |k, v| params[k] = v.presence } + end + # clean any referer parameter def safe_referer(referer) referer = URI.parse(referer) diff --git a/app/controllers/event_attendances_controller.rb b/app/controllers/event_attendances_controller.rb new file mode 100644 index 0000000000..a198c73339 --- /dev/null +++ b/app/controllers/event_attendances_controller.rb @@ -0,0 +1,44 @@ +class EventAttendancesController < ApplicationController + layout "site" + before_action :authorize_web + before_action :set_event_attendance, :only => [:update] + + load_and_authorize_resource + + def create + attendance = EventAttendance.new(create_params) + if attendance.save + redirect_to event_path(attendance.event), :notice => t(".success") + else + # flash[:alert] = t(".failure") + # # render event_path(attendance.event) + # @event = attendance.event + # render "events/show" + redirect_to event_path(attendance.event), :alert => t(".failure") + end + end + + def update + if @event_attendance.update(update_params) + redirect_to @event_attendance.event, :notice => t(".success") + else + flash[:alert] = t(".failure") + render event_path(@event_attendance.event) + end + end + + private + + def set_event_attendance + @event_attendance = EventAttendance.find(params[:id]) + end + + def create_params + params.require(:event_attendance).permit(:event_id, :user_id, :intention) + end + + # Only update is permitted to be updated. + def update_params + params.require(:event_attendance).permit(:intention) + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 0000000000..a1b5718d64 --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,85 @@ +class EventsController < ApplicationController + layout "site" + before_action :authorize_web + before_action :set_event, :only => [:edit, :show, :update] + # This needs to be one before load_and_authorize_resource, so cancancan will be handed + # an event that contains a microcosm, based on the input parameter microcosm_id. + before_action :set_params_for_new, :only => [:new] + + load_and_authorize_resource + + # GET /events + # GET /events.json + def index + @events = Event.all + end + + # GET /events/new + def new + @title = t "events.new.title" + @event = Event.new(event_params_new) + end + + # POST /events + # POST /events.json + def create + @event = Event.new(event_params) + @event_organizer = EventOrganizer.new(:event => @event, :user => current_user) + + if @event.save && @event_organizer.save + warn_if_event_in_past + redirect_to @event, :notice => t(".success") + else + flash[:alert] = t(".failure") + render :new + end + end + + # GET /events/1/edit + def edit; end + + def update + if @event.update(event_params) + warn_if_event_in_past + redirect_to @event, :notice => t(".success") + else + flash[:alert] = t(".failure") + render :edit + end + end + + # GET /events/1 + # GET /events/1.json + def show + @my_attendance = EventAttendance.find_or_initialize_by(:event_id => @event.id, :user_id => current_user&.id) + @yes_check = @my_attendance.intention == EventAttendance::Intentions::YES ? "✓" : "" + @no_check = @my_attendance.intention == EventAttendance::Intentions::NO ? "✓" : "" + @maybe_check = @my_attendance.intention == EventAttendance::Intentions::MAYBE ? "✓" : "" + @yes_disabled = @my_attendance.intention == EventAttendance::Intentions::YES + @no_disabled = @my_attendance.intention == EventAttendance::Intentions::NO + @maybe_disabled = @my_attendance.intention == EventAttendance::Intentions::MAYBE + end + + private + + def warn_if_event_in_past + flash[:warning] = t "events.show.past" if @event.past? + end + + def set_event + @event = Event.find(params[:id]) + end + + def set_params_for_new + @params = event_params_new + end + + def event_params + nilify(params.require(:event).permit(:title, :moment, :location, :location_url, + :description, :latitude, :longitude, :microcosm_id)) + end + + def event_params_new + params.require(:event).permit(:microcosm_id) + end +end diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb index cd3584f027..4ee2f4c50b 100644 --- a/app/controllers/issues_controller.rb +++ b/app/controllers/issues_controller.rb @@ -12,7 +12,7 @@ def index @title = t ".title" @issue_types = [] - @issue_types.concat %w[Note] if current_user.moderator? + @issue_types.concat %w[Microcosm Note] if current_user.moderator? @issue_types.concat %w[DiaryEntry DiaryComment User] if current_user.administrator? @users = User.joins(:roles).where(:user_roles => { :role => current_user.roles.map(&:role) }).distinct diff --git a/app/controllers/microcosm_members_controller.rb b/app/controllers/microcosm_members_controller.rb new file mode 100644 index 0000000000..1432238b18 --- /dev/null +++ b/app/controllers/microcosm_members_controller.rb @@ -0,0 +1,53 @@ +class MicrocosmMembersController < ApplicationController + layout "site" + before_action :authorize_web + before_action :set_microcosm_member, :only => [:destroy, :edit, :update] + load_and_authorize_resource + + def create + # If there's no given user, default to the current_user. + membership = MicrocosmMember.new(create_params.reverse_merge!(:user => current_user)) + membership.role = MicrocosmMember::Roles::MEMBER + if membership.save + redirect_to microcosm_path(membership.microcosm), :notice => t(".success") + else + redirect_to microcosm_path(membership.microcosm), :alert => t(".failure") + end + end + + def edit; end + + def update + if @microcosm_member.update(update_params) + redirect_to @microcosm_member.microcosm, :notice => t(".success") + else + flash[:alert] = t(".failure") + render :edit + end + end + + def destroy + issues = @microcosm_member.can_be_deleted + if issues.empty? && @microcosm_member.destroy + redirect_to @microcosm_member.microcosm, :notice => t(".success") + else + issues = issues.map { |i| t("activerecord.errors.models.microcosm_member.#{i}") } + issues = issues.to_sentence.capitalize + redirect_to @microcosm_member.microcosm, :error => "#{t('.failure')} #{issues}." + end + end + + private + + def set_microcosm_member + @microcosm_member = MicrocosmMember.find(params[:id]) + end + + def create_params + params.require(:microcosm_member).permit(:microcosm_id, :user_id, :role) + end + + def update_params + params.require(:microcosm_member).permit(:role) + end +end diff --git a/app/controllers/microcosms_controller.rb b/app/controllers/microcosms_controller.rb new file mode 100644 index 0000000000..1de4790f0d --- /dev/null +++ b/app/controllers/microcosms_controller.rb @@ -0,0 +1,127 @@ +class MicrocosmsController < ApplicationController + layout "site" + before_action :authorize_web + + before_action :set_microcosm, :only => [:edit, :show, :show_events, :show_members, :step_up, :update] + + helper_method :recent_changesets + + load_and_authorize_resource :except => [:create, :new] + authorize_resource + + def index + # TODO: OMG is the math right here? + minute_of_day = "(60 * EXTRACT(HOUR FROM current_timestamp) + EXTRACT(MINUTE FROM current_timestamp))" + morning = "(60 * 6)" # 6 AM + long_facing_sun = "(#{minute_of_day} + #{morning}) / 4" + # Using Arel.sql here due to warning about non-attributes arguments will be disallowed in Rails 6.1. + # Only list out microcosms that have at least 2 members in order to mitigate spam. In order to get + # a microcosm listed, the organizer must find 2 members and give them the link to the page manually. + @microcosms = Microcosm + .joins(:microcosm_members) + .group("microcosms.id") + .having("COUNT(microcosms.id) >= #{Settings.microcosm_critical_mass}") + .order(Arel.sql("longitude + 180 + #{long_facing_sun} DESC")) + + @my_microcosms = current_user ? current_user.microcosms : [] + @not_my_microcosms = @microcosms - @my_microcosms + end + + # GET /microcosms/mycity + # GET /microcosms/mycity.json + def show + @my_membership = MicrocosmMember.find_or_initialize_by(:microcosm_id => @microcosm.id, :user_id => current_user&.id) + end + + def show_members + # Could use pluralize, but we don't need that at this time. + @roles = MicrocosmMember::Roles::ALL_ROLES.map { |r| "#{r}s" } + end + + def show_events; end + + def edit; end + + def update + if @microcosm.update(microcosm_params) + redirect_to @microcosm, :notice => t(".success") + else + flash[:alert] = t(".failure") + render :edit + end + end + + def new + @title = t "microcosms.new.title" + @microcosm = Microcosm.new + end + + def create + @microcosm = Microcosm.new(microcosm_params) + if @microcosm.save && add_first_organizer + redirect_to @microcosm, :notice => t(".success") + else + flash[:alert] = t(".failure") + render "new" + end + end + + def add_first_organizer + membership = MicrocosmMember.new( + { + :microcosm_id => @microcosm.id, + :user_id => current_user.id, + :role => MicrocosmMember::Roles::ORGANIZER + } + ) + membership.save + end + + def recent_changesets + bbox = BoundingBox.new(@microcosm.min_lon, @microcosm.min_lat, @microcosm.max_lon, @microcosm.max_lat).to_scaled + Changeset + .where("min_lon < ? and max_lon > ? and min_lat < ? and max_lat > ?", + bbox.max_lon.to_i, + bbox.min_lon.to_i, + bbox.max_lat.to_i, + bbox.min_lat.to_i) + .order("changesets.id DESC").limit(20).preload(:user, :changeset_tags, :comments) + end + + def step_up + message = nil + if @microcosm.organizers.empty? + if @microcosm.member?(current_user) + message = t ".you_have_stepped_up" + add_first_organizer + else + message = t ".only_members_can_step_up" + end + else + message = t ".already_has_organizer" + end + # render :show + redirect_to @microcosm, :notice => message + end + + private + + def set_microcosm + @microcosm = Microcosm.friendly.find(params[:id]) + end + + def microcosm_params + normalize_longitude(params[:microcosm]) + params.require(:microcosm).permit( + :name, :location, :latitude, :longitude, + :min_lat, :max_lat, :min_lon, :max_lon, + :description + ) + end + + def normalize_longitude(microcosm_params) + longitude = microcosm_params[:longitude].to_f + longitude = (longitude + 180) % 360 - 180 + microcosm_params[:longitude] = longitude.to_s + end +end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index a04fab5b9a..86bc6a835d 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -52,7 +52,7 @@ def issue_params def default_assigned_role case issue_params[:reportable_type] - when "Note" + when "Microcosm", "Note" "moderator" when "User" case report_params[:category] diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb new file mode 100644 index 0000000000..c059e59068 --- /dev/null +++ b/app/helpers/event_helper.rb @@ -0,0 +1,9 @@ +module EventHelper + def event_location(event) + if event.location_url.present? + link_to event.location, event.location_url + else + event.location + end + end +end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index e6c1adb164..ebdaca52d5 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -7,6 +7,8 @@ def reportable_url(reportable) user_url(reportable) when DiaryComment diary_entry_url(reportable.diary_entry.user, reportable.diary_entry, :anchor => "comment#{reportable.id}") + when Microcosm + microcosm_url(reportable) when Note url_for(:controller => :browse, :action => :note, :id => reportable.id) end @@ -20,6 +22,8 @@ def reportable_title(reportable) reportable.display_name when DiaryComment I18n.t("issues.helper.reportable_title.diary_comment", :entry_title => reportable.diary_entry.title, :comment_id => reportable.id) + when Microcosm + reportable.name when Note I18n.t("issues.helper.reportable_title.note", :note_id => reportable.id) end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb index e9e8f6bfb1..0e2fadfbd9 100644 --- a/app/helpers/user_helper.rb +++ b/app/helpers/user_helper.rb @@ -50,6 +50,19 @@ def user_image_url(user, options = {}) end end + def user_card(user) + user_link = link_to user.display_name, user_path(user) + tag.div :class => "user_card" do + img = user_image(user) + div = tag.div do + tag.h5 do + user_link + end + end + img + div + end + end + # External authentication support def openid_logo diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000000..8dfd41709d --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,68 @@ +# == Schema Information +# +# Table name: events +# +# id :bigint(8) not null, primary key +# title :string not null +# moment :datetime +# location :string +# location_url :string +# latitude :float +# longitude :float +# description :text +# microcosm_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Event < ApplicationRecord + belongs_to :microcosm + has_many :event_attendances + has_many :event_organizers + + scope :future, -> { where("moment >= ?", Time.now) } + scope :past, -> { where("moment < ?", Time.now) } + + has_many :yes_attandances, -> { where :intention => EventAttendance::Intentions::YES }, :class_name => "EventAttendance" + has_many :yes_attendees, :through => :yes_attandances, :source => :user + + validates :moment, :datetime_format => true + validates :location, :length => 1..255, :characters => true, :if => :location? + validates :location_url, :length => 1..255, :if => :location_url? + validates :location_url, :url => { :allow_blank => false }, :if => :location_url? + validates :latitude, :numericality => true, :allow_nil => true, :inclusion => { :in => -90..90 } + validates :longitude, :numericality => true, :allow_nil => true, :inclusion => { :in => -180..180 } + validates :microcosm, :presence => true + + def location? + !location.nil? + end + + def location_url? + !location_url.nil? + end + + def attendees(intention) + EventAttendance.where(:event_id => id, :intention => intention) + end + + def yes_attendees + attendees(EventAttendance::Intentions::YES) + end + + def no_attendees + attendees(EventAttendance::Intentions::NO) + end + + def maybe_attendees + attendees(EventAttendance::Intentions::MAYBE) + end + + def organizers + EventOrganizer.where(:event_id => id) + end + + def past? + moment < Time.now + end +end diff --git a/app/models/event_attendance.rb b/app/models/event_attendance.rb new file mode 100644 index 0000000000..c06bc582a8 --- /dev/null +++ b/app/models/event_attendance.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: event_attendances +# +# id :bigint(8) not null, primary key +# user_id :integer not null +# event_id :integer not null +# intention :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_event_attendances_on_event_id (event_id) +# index_event_attendances_on_user_id (user_id) +# + +class EventAttendance < ApplicationRecord + module Intentions + YES = "yes".freeze + NO = "no".freeze + MAYBE = "maybe".freeze + ALL_INTENTIONS = [YES, NO, MAYBE].freeze + end + validates :intention, :inclusion => { :in => Intentions::ALL_INTENTIONS } + + belongs_to :event + belongs_to :user +end diff --git a/app/models/event_organizer.rb b/app/models/event_organizer.rb new file mode 100644 index 0000000000..0982c1fb4c --- /dev/null +++ b/app/models/event_organizer.rb @@ -0,0 +1,4 @@ +class EventOrganizer < ApplicationRecord + belongs_to :event + belongs_to :user +end diff --git a/app/models/issue.rb b/app/models/issue.rb index c94fe56a7d..22336ccc0f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -86,6 +86,8 @@ def set_reported_user self.reported_user = case reportable.class.name when "User" reportable + when "Microcosm" + reportable.organizers[0].user when "Note" reportable.author else diff --git a/app/models/microcosm.rb b/app/models/microcosm.rb new file mode 100644 index 0000000000..9af96258ca --- /dev/null +++ b/app/models/microcosm.rb @@ -0,0 +1,65 @@ +# == Schema Information +# +# Table name: microcosms +# +# id :bigint(8) not null, primary key +# name :string not null +# description :text not null +# created_at :datetime not null +# updated_at :datetime not null +# slug :string not null +# location :string not null +# latitude :float not null +# longitude :float not null +# min_lat :float not null +# max_lat :float not null +# min_lon :float not null +# max_lon :float not null +# +# At this time a microcosm has at least one organizer. The first organizer is +# the user that created the microcosm. There is no way to stop being an +# organizer. That's a feature of microcosms 2.0. + +class Microcosm < ApplicationRecord + extend FriendlyId + friendly_id :name, :use => :slugged + self.ignored_columns = ["key"] + + has_many :microcosm_members, -> { order(:user_id) } + has_many :users, :through => :microcosm_members # TODO: counter_cache + has_many :microcosm_links + has_many :events, -> { order(:moment) } + has_many :future_attendees, -> { where("events.moment >= ?", Time.now) }, :through => :events, :source => :yes_attendees + + validates :name, :presence => true, :length => 1..255, :characters => true + validates :description, :presence => true, :length => 1..1023, :characters => true + validates :location, :presence => true, :length => 1..255, :characters => true + validates :latitude, :numericality => true, :inclusion => { :in => -90..90 } + validates :longitude, :numericality => true, :inclusion => { :in => -180..180 } + validates :min_lat, :numericality => true, :inclusion => { :in => -90..90 } + validates :max_lat, :numericality => true, :inclusion => { :in => -90..90 } + validates :min_lon, :numericality => true, :inclusion => { :in => -180..180 } + validates :max_lon, :numericality => true, :inclusion => { :in => -180..180 } + + def set_link(site, url) + link = MicrocosmLink.find_or_initialize_by(:microcosm_id => id, :site => site) + link.url = url + link.save! + end + + def member?(user) + microcosm_members.where(:user_id => user.id).count.positive? + end + + def organizer?(user) + microcosm_members.where(:user_id => user.id, :role => MicrocosmMember::Roles::ORGANIZER).count.positive? + end + + def organizers + microcosm_members.where(:role => MicrocosmMember::Roles::ORGANIZER) + end + + def bbox + BoundingBox.new(min_lon, min_lat, max_lon, max_lat) + end +end diff --git a/app/models/microcosm_link.rb b/app/models/microcosm_link.rb new file mode 100644 index 0000000000..c2d403256d --- /dev/null +++ b/app/models/microcosm_link.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: microcosm_links +# +# id :bigint(8) not null, primary key +# microcosm_id :integer not null +# site :string not null +# url :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_microcosm_links_on_microcosm_id (microcosm_id) +# + +class MicrocosmLink < ApplicationRecord + belongs_to :microcosm + validates :microcosm, :presence => true + validates :site, :presence => true, :length => 1..255, :characters => true + validates :url, :presence => true, :length => 1..255, :url => true +end diff --git a/app/models/microcosm_member.rb b/app/models/microcosm_member.rb new file mode 100644 index 0000000000..3bdccf70c4 --- /dev/null +++ b/app/models/microcosm_member.rb @@ -0,0 +1,47 @@ +# == Schema Information +# +# Table name: microcosm_members +# +# id :bigint(8) not null, primary key +# microcosm_id :integer not null +# user_id :integer not null +# role :string(64) not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_microcosm_members_on_microcosm_id (microcosm_id) +# index_microcosm_members_on_microcosm_id_and_user_id_and_role (microcosm_id,user_id,role) UNIQUE +# index_microcosm_members_on_user_id (user_id) +# + +class MicrocosmMember < ApplicationRecord + module Roles + ORGANIZER = "organizer".freeze + MEMBER = "member".freeze + ALL_ROLES = [ORGANIZER, MEMBER].freeze + end + + belongs_to :microcosm + belongs_to :user + + scope :organizers, -> { where(:role => Roles::ORGANIZER) } + scope :members, -> { where(:role => Roles::MEMBER) } + + validates :microcosm, :presence => true, :associated => true + validates :user, :presence => true, :associated => true + validates :role, :inclusion => { :in => Roles::ALL_ROLES } + + # We assume this user already belongs to this microcosm. + def can_be_deleted + issues = [] + # The user may also be an organizer under a separate membership. + issues.append(:is_organizer) if MicrocosmMember.exists?(:microcosm_id => microcosm_id, :user_id => user_id, :role => Roles::ORGANIZER) + + # check if attending events + issues.append(:is_attending_future_events) if microcosm.future_attendees.exists?(:id => user_id) + + issues + end +end diff --git a/app/models/report.rb b/app/models/report.rb index 346c5eea95..79a31234b6 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -33,6 +33,7 @@ class Report < ApplicationRecord def self.categories_for(reportable) case reportable.class.name when "DiaryEntry", "DiaryComment" then %w[spam offensive threat other] + when "Microcosm" then %w[spam offensive other] when "User" then %w[spam offensive threat vandal other] when "Note" then %w[spam personal abusive other] else %w[other] diff --git a/app/models/user.rb b/app/models/user.rb index 26a9f33e15..7fb5d03d18 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -79,6 +79,9 @@ class User < ApplicationRecord has_many :reports + has_many :microcosm_members + has_many :microcosms, :through => :microcosm_members + scope :visible, -> { where(:status => %w[pending active confirmed]) } scope :active, -> { where(:status => %w[active confirmed]) } scope :identifiable, -> { where(:data_public => true) } diff --git a/app/validators/datetime_format_validator.rb b/app/validators/datetime_format_validator.rb new file mode 100644 index 0000000000..1da6e30655 --- /dev/null +++ b/app/validators/datetime_format_validator.rb @@ -0,0 +1,20 @@ +require "date" + +class DatetimeFormatValidator < ActiveModel::EachValidator + # Just basic format yyyy-mm-ddThh:mm + # FORMAT = "\d\d\d\d-\d\d-\d\dT\d\d:\d\d".freeze + + def validate_each(record, attribute, value) + return if value.instance_of?(ActiveSupport::TimeWithZone) + + # Validate the format. + # Not sure if this is worth doing. It's probably faster, but unlikely to happen. + # record.errors[attribute] << (options[:message] || I18n.t("validations.invalid_datetime_format")) if value !~ /#{FORMAT}/ + + # Validate the range. + before_value = record.read_attribute_before_type_cast(attribute) + Date.iso8601(before_value) + rescue ArgumentError + record.errors[attribute] << (options[:message] || I18n.t("validations.invalid_datetime_range")) + end +end diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb new file mode 100644 index 0000000000..7f730d7f51 --- /dev/null +++ b/app/views/events/_form.html.erb @@ -0,0 +1,64 @@ +<%= stylesheet_link_tag "events" %> +<%= javascript_include_tag "event" %> + +<%= form_for @event do |form| %> +
+ <% if event.errors.any? %> +
+

<%= pluralize(event.errors.count, "error") %> prohibited this event from being saved:

+ +
+ <% end %> +
+ <%= form.label :title, :class => "standard-label" %> + <%= form.text_field :title, :id => :event_title %> +
+
+ <%= form.label :moment, :class => "standard-label" %> + <%= form.datetime_field :moment, :pattern => "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}" %> + If your browser doesn't provide a + widget here, enter the date/time in this format yyyy-mm-ddThh:mm. +
+
+ <%= form.label :location, :class => "standard-label" %> + <%= form.text_field :location, :id => :event_location %> +
+
+ <%= form.label :location_url, :class => "standard-label" %> + <%= form.text_field :location_url, :id => :event_location_url %> +
+
+ <%= form.label :description, :class => "standard-label" %> + <%= form.text_area :description, :id => :event_description %> +
+
+
+ <%= form.label :latitude, :class => "standard-label" %> + <%= form.text_field :latitude, :id => "event_latitude" %> +
+
+ <%= form.label :longitude, :class => "standard-label" %> + <%= form.text_field :longitude, :id => "event_longitude" %> +
+
+
+
+
+
+ <% if @event&.microcosm_id %> + <%= form.hidden_field(:microcosm_id, :value => @event.microcosm_id) %> + <% else %> +
+ <%= form.label :microcosm_id, :class => "standard-label" %> + <%= collection_select(:event, :microcosm_id, Microcosm.all, :id, :name, :prompt => true) %> +
+ <% end %> +
+ <%= form.submit %> +
+
+<% end %> diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb new file mode 100644 index 0000000000..82a4785900 --- /dev/null +++ b/app/views/events/edit.html.erb @@ -0,0 +1,3 @@ +

<%= t(".edit_event") %>

+ +<%= render "form", :event => @event %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb new file mode 100644 index 0000000000..d9435ac817 --- /dev/null +++ b/app/views/events/index.html.erb @@ -0,0 +1,30 @@ +<% content_for :heading do %> +

+ <%= t(".heading") %> +

+<% end %> + +

<%= notice %>

+ + + + + + + + + + + + + <% @events.future.each do |event| %> + + + + + + + + <% end %> + +
<%= t(".title") %><%= t(".moment") %><%= t(".location") %><%= t(".description") %><%= t(".microcosm") %>
<%= link_to event.title, event %><%= event.moment %><%= event.location %><%= event.description %><%= link_to event.microcosm.name, microcosm_path(event.microcosm) %>
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb new file mode 100644 index 0000000000..3450e259ff --- /dev/null +++ b/app/views/events/new.html.erb @@ -0,0 +1,5 @@ +<% content_for :heading do %> +

<%= @title %>

+<% end %> + +<%= render "form", :event => @event %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb new file mode 100644 index 0000000000..f29c0eb7fb --- /dev/null +++ b/app/views/events/show.html.erb @@ -0,0 +1,80 @@ +<%= stylesheet_link_tag "events" %> +<%= javascript_include_tag "event" %> + +<% content_for :heading do %> +

+ <%= t(".header_title") + ": #{@event.title}" %> +

+ <% if current_user && @event.microcosm.organizer?(current_user) %> + <%= link_to t(".edit"), edit_event_path %> + <% end %> +<% end %> + +
+
+
+

+ <%= t(".when") %>: <%= l @event.moment, :format => :friendly %> +

+

+ <%= t(".hosted_by") %>: <%= link_to @event.microcosm.name, microcosm_path(@event.microcosm) %> +

+

+ <%= t(".organized_by") %>: <% @event.organizers.each do |organizer| %> + <%= link_to organizer.user.display_name, user_path(organizer.user) %> + <% end %> +

+
+
+

<%= t(".people_are_going", :count => @event.yes_attendees.size) %>

+

<%= t(".are_you_going") %>

+ <% if current_user %> + <%= form_with :model => @my_attendance, :local => true do |form| %> + <%= form.hidden_field(:event_id, :value => @event.id) %> + <%= form.hidden_field(:user_id, :value => current_user&.id) %> + <%= form.submit :name => "event_attendance[intention]", :value => @yes_check + t(".going_yes"), :disabled => @yes_disabled %> + <%= form.submit :name => "event_attendance[intention]", :value => @no_check + t(".going_no"), :disabled => @no_disabled %> + <%= form.submit :name => "event_attendance[intention]", :value => @maybe_check + t(".going_maybe"), :disabled => @maybe_disabled %> + <% end %> + <% else %> +

+ <%= t(".login_to_rsvp") %> +

+ <% end %> +
+
+
+
+
+

+ <%= t(".description") %>: + <%= @event.description %> +

+ <%= t(".who_yes") %> +
+ <% @event.yes_attendees.each do |attendance| %> + <%= user_card(attendance.user) %> + <% end %> +
+ <%= t(".who_maybe") %> +
+ <% @event.maybe_attendees.each do |attendance| %> + <%= user_card(attendance.user) %> + <% end %> +
+ <%= t(".who_no") %> +
+ <% @event.no_attendees.each do |attendance| %> + <%= user_card(attendance.user) %> + <% end %> +
+
+
+

+ <%= t(".location") %>: <%= event_location(@event) %> +

+ <%# TODO: replace these attributes @map_coords %> + <%= tag.div :id => "event_map_show", :data => { :lat => @event.latitude, :lon => @event.longitude, :zoom => 11 } %> +
+
+
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 06823e18f8..e24376a886 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -1,3 +1,8 @@ +
+

+This development version of OpenStreetMap is for Microcosms. Register your own account on this website. Send feedback to the issue tracker or Brian DeRocher. +

+

@@ -44,6 +49,12 @@ <% end -%> <% end %> + + @@ -70,6 +81,8 @@ <% end -%> <% end %> +
  • <%= link_to t("layouts.microcosms"), microcosms_path, :class => "dropdown-item" %>
  • +
  • <%= link_to t("layouts.events"), events_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.gps_traces"), traces_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.user_diaries"), diary_entries_path, :class => "dropdown-item" %>
  • <%= link_to t("layouts.copyright"), copyright_path, :class => "dropdown-item" %>
  • diff --git a/app/views/layouts/site.html.erb b/app/views/layouts/site.html.erb index ca59f5763e..5d6d75ff1b 100644 --- a/app/views/layouts/site.html.erb +++ b/app/views/layouts/site.html.erb @@ -7,5 +7,6 @@ <% if defined?(PIWIK) -%> <% end -%> +
    <%# Only needed to add space for the microcosm footer. TODO: Remove when no longer needed. %> diff --git a/app/views/microcosm_members/_form.html.erb b/app/views/microcosm_members/_form.html.erb new file mode 100644 index 0000000000..058622d3aa --- /dev/null +++ b/app/views/microcosm_members/_form.html.erb @@ -0,0 +1,19 @@ +<%= form_for @microcosm_member do |form| %> +
    +
    + <%= form.label :microcosm_id, :class => "standard-label" %> + <%= @microcosm_member.microcosm.name %> +
    +
    + <%= form.label :user_id, :class => "standard-label" %> + <%= @microcosm_member.user.display_name %> +
    +
    + <%= form.label :role, :class => "standard-label" %> + <%= form.select(:role, MicrocosmMember::Roles::ALL_ROLES.map { |role| [role.titleize, role] }) %> +
    +
    + <%= form.submit %> +
    +
    +<% end %> diff --git a/app/views/microcosm_members/edit.html.erb b/app/views/microcosm_members/edit.html.erb new file mode 100644 index 0000000000..bb5e2dcea2 --- /dev/null +++ b/app/views/microcosm_members/edit.html.erb @@ -0,0 +1,3 @@ +

    <%= t(".edit_member") %>

    + +<%= render "form", :microcosm_member => @microcosm_member %> diff --git a/app/views/microcosms/_empty_list.html.erb b/app/views/microcosms/_empty_list.html.erb new file mode 100644 index 0000000000..e97022b2db --- /dev/null +++ b/app/views/microcosms/_empty_list.html.erb @@ -0,0 +1 @@ +There are no items in this list. diff --git a/app/views/microcosms/_form.html.erb b/app/views/microcosms/_form.html.erb new file mode 100644 index 0000000000..493fda8f76 --- /dev/null +++ b/app/views/microcosms/_form.html.erb @@ -0,0 +1,57 @@ +<%= stylesheet_link_tag "microcosms" %> +<%= javascript_include_tag "microcosms" %> + +

    + All fields are required. +

    + +<%= form_for @microcosm do |form| %> +
    +
    +
    + <%= form.label :name, :class => "standard-label" %> + <%= form.text_field :name, :id => :microcosm_name %> +
    +
    + <%= form.label :location, :class => "standard-label" %> + <%= form.text_field :location, :id => :microcosm_location %> (for example city name and country) +
    +
    + <%= form.label :latitude, :class => "standard-label" %> + <%= form.text_field :latitude, :id => :microcosm_latitude %> (decimal) +
    +
    + <%= form.label :longitude, :class => "standard-label" %> + <%= form.text_field :longitude, :id => :microcosm_longitude %> (decimal) +
    +
    +
    +
    +
    + +
    + <%= form.label :min_lat, :class => "standard-label" %> + <%= form.text_field :min_lat, :id => :microcosm_min_lat %> (decimal) +
    +
    + <%= form.label :max_lat, :class => "standard-label" %> + <%= form.text_field :max_lat, :id => :microcosm_max_lat %> (decimal) +
    +
    + <%= form.label :min_lon, :class => "standard-label" %> + <%= form.text_field :min_lon, :id => :microcosm_min_lon %> (decimal) +
    +
    + <%= form.label :max_lon, :class => "standard-label" %> + <%= form.text_field :max_lon, :id => :microcosm_max_lon %> (decimal) +
    +
    + <%= form.label :description, :class => "standard-label" %> + <%= form.text_area :description, :id => :microcosm_description %> +
    +
    + <%= form.submit %> +
    +
    +
    +<% end %> diff --git a/app/views/microcosms/_index_list.html.erb b/app/views/microcosms/_index_list.html.erb new file mode 100644 index 0000000000..818c808fda --- /dev/null +++ b/app/views/microcosms/_index_list.html.erb @@ -0,0 +1,25 @@ +<% if !microcosms.empty? %> +

    <%= t(header) %>

    + + + <% microcosms.each do |microcosm| %> + + + + <% end %> + +
    +

    <%= link_to microcosm.name, microcosm %>

    +
    +
    + <%= microcosm.location %> +
    +
    + (<%= number_with_precision(microcosm.latitude) %>°, <%= number_with_precision(microcosm.longitude) %>°) +
    +
    +

    + <%= microcosm.description %> +

    +
    +<% end %> diff --git a/app/views/microcosms/edit.html.erb b/app/views/microcosms/edit.html.erb new file mode 100644 index 0000000000..43953e43b5 --- /dev/null +++ b/app/views/microcosms/edit.html.erb @@ -0,0 +1,3 @@ +

    <%= t(".edit_microcosm") %>

    + +<%= render "form", :microcosm => @microcosm %> diff --git a/app/views/microcosms/index.html.erb b/app/views/microcosms/index.html.erb new file mode 100644 index 0000000000..cc20dbe72b --- /dev/null +++ b/app/views/microcosms/index.html.erb @@ -0,0 +1,15 @@ +<% content_for :heading do %> +

    <%= t(".title") %>

    +
      +
    • <%= link_to image_tag("new.png", :class => "small_icon", :border => 0) + t(".new"), new_microcosm_path, :title => t(".new_title") %>
    • +
    +<% end %> + +<%= render :partial => "index_list", :locals => { :microcosms => @my_microcosms, :header => ".my_microcosms" } %> + +

    + <%= t(".sorted_by") %> + <%= t(".critical_mass", :n => Settings.microcosm_critical_mass) %> +

    + +<%= render :partial => "index_list", :locals => { :microcosms => @not_my_microcosms, :header => ".all" } %> diff --git a/app/views/microcosms/new.html.erb b/app/views/microcosms/new.html.erb new file mode 100644 index 0000000000..5469d3b8a6 --- /dev/null +++ b/app/views/microcosms/new.html.erb @@ -0,0 +1,5 @@ +<% content_for :heading do %> +

    <%= @title %>

    +<% end %> + +<%= render "form", :microcosm => @microcosm %> diff --git a/app/views/microcosms/show.html.erb b/app/views/microcosms/show.html.erb new file mode 100644 index 0000000000..1c60561525 --- /dev/null +++ b/app/views/microcosms/show.html.erb @@ -0,0 +1,127 @@ +<%= stylesheet_link_tag "microcosms" %> +<%= javascript_include_tag "microcosms" %> + +<% content_for :heading do %> +

    + <%= "#{@microcosm.name} " + t(".header_title") %> +

    + <% if current_user %> + <%= report_link(t(".report"), @microcosm) %> + <% if @microcosm.organizer?(current_user) %> + <%= link_to t(".edit"), edit_microcosm_path %> + <% end %> + <% end %> +<% end %> + +
    +
    +
    + <%# TODO: replace these attributes @map_coords %> + <%= tag.div :id => "microcosm_map", :data => { :lat => @microcosm.latitude, :lon => @microcosm.longitude, :zoom => 11 } %> +
    +
    +

    + <%= link_to @microcosm.name, @microcosm %> +

    +

    + <%= @microcosm.location %> (<%= number_with_precision(@microcosm.latitude) %>°, <%= number_with_precision(@microcosm.longitude) %>°) +

    +

    + <%= auto_link @microcosm.description %> +

    +
    +
    + +
      + <% @microcosm.organizers.each do |membership| %> +
    • + <%= link_to membership.user.display_name, user_path(membership.user) %> +
    • + <% end %> +
    + <% if @microcosm.organizers.empty? %> + <%= link_to "Step up", step_up_path, :method => :post %> + <% end %> +
    + + <% if ! current_user %> +
    + <%# Just a link here. application_controller will redirect to login. That will be posted. %> + <%= link_to t(".join.action"), login_to_join_path(:microcosm_member => { :microcosm_id => @microcosm.id }), :class => "btn btn-primary btn-sm" %> +
    + <% elsif ! @microcosm.member?(current_user) %> + <%= form_with :scope => :microcosm_member, :url => microcosm_members_path, :local => true do |form| %> + <%= form.hidden_field(:microcosm_id, :value => @microcosm.id) %> + <%= form.hidden_field(:user_id, :value => current_user.id) %> + <%= form.submit :value => t(".join.action"), :class => "btn btn-primary btn-sm" %> + <% end %> + <% else %> + <%= form_with :model => @my_membership, :local => true, :method => :delete do |form| %> + <%= form.submit :value => t(".leave.action"), :class => "btn btn-primary btn-sm", :data => { :confirm => t(".leave.confirm", :name => @microcosm.name) } %> + <% end %> + <% end %> +
    +
    +
    +
    +

    + <%= link_to t(".events"), events_of_microcosm_path(@microcosm) %> +

    +
      + <% @microcosm.events.future.each do |event| %> +
    • + <%= l(event.moment, :format => "%e %B") %> - <%= link_to(event.title, event_path(event)) %> +
    • + <% end %> +
    +

    + <%= t(".recent_changes") %> +

    +
      + <% recent_changesets.each do |changeset| %> +
    • + <%= changeset_details(changeset) %> + · + <%= changeset.tags["comment"].to_s.presence || t("browse.no_comment") %> + · + <%= link_to("##{changeset.id}", changeset_path(changeset)) %> + <% if changeset.tags["review_requested"] == "yes" %> + <%= t(".review_requested") %> + <% end %> +
    • + <% end %> +
    +

    + <%= t(".diary_entries") %> +

    +
      + <% @microcosm.users.each do |user| %> + <% user.diary_entries.each do |entry| %> +
    • + <%= l(entry.created_at, :format => :blog) %> <%= link_to user.display_name, user_path(user) %> <%= link_to(entry.title, diary_entry_path(entry.user, entry)) %> +
    • + <% end %> + <% end %> +
    +
    +
    +

    + <%= link_to t(".members"), members_of_microcosm_path(@microcosm) %> +

    +
    + <% @microcosm.microcosm_members.each do |membership| %> + <%= user_card(membership.user) %> + <% end %> +
    +
    +
    +
    diff --git a/app/views/microcosms/show_events.html.erb b/app/views/microcosms/show_events.html.erb new file mode 100644 index 0000000000..e822c6df87 --- /dev/null +++ b/app/views/microcosms/show_events.html.erb @@ -0,0 +1,54 @@ +<% content_for :heading do %> +

    + <%= "#{@microcosm.name} " + t(".events") %> +

    + +<% end %> +<%# TODO: Refactor this DRY. %> +

    Upcoming

    + + + + + + + + + + + <% @microcosm.events.future.each do |event| %> + + + + + + + <% end %> + +
    <%= t(".when") %><%= t(".title") %><%= t(".location") %>
    <%= l(event.moment, :format => :blog) %><%= link_to event.title, event %><%= event.location %><% if current_user && @microcosm.organizer?(current_user) %> <%= link_to t(".edit"), edit_event_path(event) %><% end %>
    +<%# TODO: Refactor this DRY. %> +

    Past

    + + + + + + + + + + + <% @microcosm.events.past.each do |event| %> + + + + + + + <% end %> + +
    <%= t(".when") %><%= t(".title") %><%= t(".location") %>
    <%= l(event.moment, :format => :blog) %><%= link_to event.title, event %><%= event.location %><% if current_user && @microcosm.organizer?(current_user) %> <%= link_to t(".edit"), edit_event_path(event) %><% end %>
    diff --git a/app/views/microcosms/show_members.html.erb b/app/views/microcosms/show_members.html.erb new file mode 100644 index 0000000000..3cedecb77d --- /dev/null +++ b/app/views/microcosms/show_members.html.erb @@ -0,0 +1,27 @@ +<%= stylesheet_link_tag "microcosms" %> + +<% content_for :heading do %> +

    + <%= "#{@microcosm.name} " + t(".members") %> +

    +<% end %> + +<% @roles.each do |role| %> +

    + <%= t(".#{role}") %> +

    +
    + +
    +<% end %> diff --git a/config.ru b/config.ru index 441e6ff0c3..3c4871c3bd 100644 --- a/config.ru +++ b/config.ru @@ -2,4 +2,7 @@ require_relative "config/environment" -run Rails.application +# TODO: Confirm that this is truly needed to fix rails routing for websites at a sub-path. +map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do + run Rails.application +end diff --git a/config/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 0000000000..3d387f900a --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,46 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "ca63b4996adb0cb57343feabf1f3b0ac6d666c398ea1e2af1ebc61f5caeedd68", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/microcosm_members_controller.rb", + "line": 44, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:microcosm_member).permit(:microcosm_id, :user_id, :role)", + "render_path": null, + "location": { + "type": "method", + "class": "MicrocosmMembersController", + "method": "create_params" + }, + "user_input": ":role", + "confidence": "Medium", + "note": "Lukcily '@role = params[:role]' in user_roles_controller skirts past this checker." + }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "e6bb64ce115e9326e57fa5be5a03191adf562246e635884cfa6b97d9b6f4a502", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/microcosm_members_controller.rb", + "line": 48, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:microcosm_member).permit(:role)", + "render_path": null, + "location": { + "type": "method", + "class": "MicrocosmMembersController", + "method": "update_params" + }, + "user_input": ":role", + "confidence": "Medium", + "note": "" + } + ], + "updated": "2020-07-25 13:27:31 -0400", + "brakeman_version": "4.8.2" +} diff --git a/config/cucumber.yml b/config/cucumber.yml new file mode 100644 index 0000000000..5aa9c13b42 --- /dev/null +++ b/config/cucumber.yml @@ -0,0 +1,9 @@ +<% +rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : "" +rerun = rerun.strip.gsub /\s/, ' ' +rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}" +std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} --strict --tags 'not @wip'" +%> +default: <%= std_opts %> features +wip: --tags @wip:3 --wip features +rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip' diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb new file mode 100644 index 0000000000..2966f4cca0 --- /dev/null +++ b/config/initializers/friendly_id.rb @@ -0,0 +1,107 @@ +# FriendlyId Global Configuration +# +# Use this to set up shared configuration options for your entire application. +# Any of the configuration options shown here can also be applied to single +# models by passing arguments to the `friendly_id` class method or defining +# methods in your model. +# +# To learn more, check out the guide: +# +# http://norman.github.io/friendly_id/file.Guide.html + +FriendlyId.defaults do |config| + # ## Reserved Words + # + # Some words could conflict with Rails's routes when used as slugs, or are + # undesirable to allow as slugs. Edit this list as needed for your app. + config.use :reserved + + config.reserved_words = %w[new edit index session login logout users admin + stylesheets assets javascripts images] + + # This adds an option to treat reserved words as conflicts rather than exceptions. + # When there is no good candidate, a UUID will be appended, matching the existing + # conflict behavior. + + # config.treat_reserved_as_conflict = true + + # ## Friendly Finders + # + # Uncomment this to use friendly finders in all models. By default, if + # you wish to find a record by its friendly id, you must do: + # + # MyModel.friendly.find('foo') + # + # If you uncomment this, you can do: + # + # MyModel.find('foo') + # + # This is significantly more convenient but may not be appropriate for + # all applications, so you must explicity opt-in to this behavior. You can + # always also configure it on a per-model basis if you prefer. + # + # Something else to consider is that using the :finders addon boosts + # performance because it will avoid Rails-internal code that makes runtime + # calls to `Module.extend`. + # + # config.use :finders + # + # ## Slugs + # + # Most applications will use the :slugged module everywhere. If you wish + # to do so, uncomment the following line. + # + # config.use :slugged + # + # By default, FriendlyId's :slugged addon expects the slug column to be named + # 'slug', but you can change it if you wish. + # + # config.slug_column = 'slug' + # + # By default, slug has no size limit, but you can change it if you wish. + # + # config.slug_limit = 255 + # + # When FriendlyId can not generate a unique ID from your base method, it appends + # a UUID, separated by a single dash. You can configure the character used as the + # separator. If you're upgrading from FriendlyId 4, you may wish to replace this + # with two dashes. + # + # config.sequence_separator = '-' + # + # Note that you must use the :slugged addon **prior** to the line which + # configures the sequence separator, or else FriendlyId will raise an undefined + # method error. + # + # ## Tips and Tricks + # + # ### Controlling when slugs are generated + # + # As of FriendlyId 5.0, new slugs are generated only when the slug field is + # nil, but if you're using a column as your base method can change this + # behavior by overriding the `should_generate_new_friendly_id?` method that + # FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave + # more like 4.0. + # Note: Use(include) Slugged module in the config if using the anonymous module. + # If you have `friendly_id :name, use: slugged` in the model, Slugged module + # is included after the anonymous module defined in the initializer, so it + # overrides the `should_generate_new_friendly_id?` method from the anonymous module. + # + # config.use :slugged + # config.use Module.new { + # def should_generate_new_friendly_id? + # slug.blank? || _changed? + # end + # } + # + # FriendlyId uses Rails's `parameterize` method to generate slugs, but for + # languages that don't use the Roman alphabet, that's not usually sufficient. + # Here we use the Babosa library to transliterate Russian Cyrillic slugs to + # ASCII. If you use this, don't forget to add "babosa" to your Gemfile. + # + # config.use Module.new { + # def normalize_friendly_id(text) + # text.to_slug.normalize! :transliterations => [:russian, :latin] + # end + # } +end diff --git a/config/locales/en.yml b/config/locales/en.yml index cfe717fe26..971011d7e7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,16 +8,50 @@ en: helpers: file: prompt: Choose file + label: + event: + description: "Description" + location: "Location" + location_url: "Location URL" + microcosm_id: "Microcosm" + moment: "When" + title: "Title" + microcosm: + description: "Description" + latitude: "Latitude" + location: "Location" + longitude: "Longitude" + max_lat: "Maximum Latitude" + max_lon: "Maximum Longitude" + min_lat: "Minimum Latitude" + min_lon: "Minimum Longitude" + name: "Name" + microcosm_member: + microcosm_id: "Microcosm" + user_id: "User" + role: "Role" submit: + client_application: + create: Register + update: Edit diary_comment: create: Save diary_entry: create: "Publish" update: "Update" + event: + create: "Create Event" + update: "Update Event" issue_comment: create: Add Comment message: create: Send + microcosm: + create: "Create Microcosm" + update: "Update Microcosm" + microcosm_member: + create: "Add Membership" + update: "Update Membership" client_application: create: Register update: Update @@ -35,6 +69,10 @@ en: messages: invalid_email_address: does not appear to be a valid e-mail address email_address_not_routable: is not routable + models: + microcosm_member: + is_organizer: "you are an organizer of this microcosm" + is_attending_future_events: "you are attending future events" # Translates all the model names, which is used in error handling on the web site models: acl: "Access Control List" @@ -47,6 +85,7 @@ en: issue: "Issue" language: "Language" message: "Message" + microcosm: "Microcosm" node: "Node" node_tag: "Node Tag" notifier: "Notifier" @@ -480,6 +519,57 @@ en: comment: Comment newer_comments: "Newer Comments" older_comments: "Older Comments" + events: + create: + success: "Event was successfully created." + failure: "Event was not created." + destroy: + failure: "Event was not created." + edit: + edit_event: "Edit Event" + index: + description: "Description" + heading: "Upcoming Events" + location: "Location" + microcosm: "Microcosm" + moment: "When" + title: "Title" + new: + title: "New Event" + show: + are_you_going: "Are you going?" + description: "Description" + directions_to: "Directions to this location." + edit: "Edit" + going_maybe: "maybe" + going_no: "no" + going_yes: "yes" + header_title: "Event" + hosted_by: "Hosted by" + location: "Location" + login_to_rsvp: "Please login to RSVP." + organized_by: "Organized by" + past: "Event is in the past." + people_are_going: + zero: "" + one: "One person is going." + other: "%{count} people are going" + when: "When" + who_yes: "Going" + who_no: "Not Going" + who_maybe: "Might Go" + update: + success: "The event was successfully updated." + failure: "The event was not updated." + event_attendances: + create: + failure: "Attendance was not saved." + success: "Attendance was successfully saved." + filter: + not_an_intention: "The string `%{intention}' is not a valid intention." + update: + failure: "Attendance was not updated." + success: "Attendance was successfully updated." friendships: make_friend: heading: "Add %{user} as a friend?" @@ -1358,6 +1448,10 @@ en: offensive_label: This diary comment is obscene/offensive threat_label: This diary comment contains a threat other_label: Other + microcosm: + spam_label: The microcosm is/contains spam + offensive_label: This microcosm entry is obscene/offensive + other_label: Other user: spam_label: This user profile is/contains spam offensive_label: This user profile is obscene/offensive @@ -1384,6 +1478,8 @@ en: logout: Log Out log_in: Log In log_in_tooltip: Log in with an existing account + microcosms: Microcosms + events: Events sign_up: Sign Up start_mapping: Start Mapping sign_up_tooltip: Create an account for editing @@ -1423,6 +1519,78 @@ en: text: Make a Donation learn_more: "Learn More" more: More + microcosms: + create: + failure: "Microcosm was not saved." + success: "Microcosm was successfully created." + edit: + edit_microcosm: "Edit Microcosm" + index: + critical_mass: "Only microcosms wth at least %{n} members are displayed." + description: "Description" + location: "Location" + latitude: "Latitude" + longitude: "Longitude" + name: "Name" + new: "New" + new_title: "Create a new microcosm" + sorted_by: "Sorted by longitude and time of day." + title: "Microcosms" + index_list: + all: "All Microcosms" + my_microcosms: "My Microcosms" + new: + title: "New Microcosm" + show: + diary_entries: "Diary Entries of Members" + edit: "Edit" + events: "Upcoming Events" + header_title: "Microcosm" + leave: + action: "Leave" + confirm: "Are you sure you want to leave %{name}?" + login_to_join: "Please login to join the microcosm." + members: "Members" + organizers: "Organizers" + report: "Report" + review_requested: "review requested" + share: "Share" + join: + action: "Join" + confirm: "Join %{name}?" + recent_changes: "Recent Changes" + show_events: + edit: "Edit" + events: "Events" + location: "Location" + new_title: "new event" + title: "Title" + when: "When" + show_members: + edit: "edit" + members: "Members" + organizers: "Organizers" + remove: "remove" + step_up: + already_has_organizer: "This microcosm already has an organizer." + only_members_can_step_up: "Only members can step up." + you_have_stepped_up: "You have stepped up." + update: + success: "The microcosm was successfully updated." + failure: "The microcosm did not update successfully." + microcosm_members: + create: + failure: "Membership was not saved." + success: "Membership was successfully created." + destroy: + failure: "Member was not removed." + success: "Member was removed." + edit: + edit_member: "Edit Membership" + update: + failure: "Microcosm Member was not updated." + success: "Microcosm Member was successfully updated." + notifier: user_mailer: diary_comment_notification: subject: "[OpenStreetMap] %{user} commented on a diary entry" @@ -2863,4 +3031,6 @@ en: leading_whitespace: "has leading whitespace" trailing_whitespace: "has trailing whitespace" invalid_characters: "contains invalid characters" + invalid_datetime_format: "contains invalid datetime format, use yyyy-mm-ddThh:mm" + invalid_datetime_range: "contains invalid datetime range" url_characters: "contains special URL characters (%{characters})" diff --git a/config/routes.rb b/config/routes.rb index da3921e4af..7f85d038e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,16 @@ # redactions resources :redactions + # microcosms + resources :microcosms + resources :microcosm_members, :only => [:create, :destroy, :edit, :new, :update] + get "/microcosm_members" => "microcosm_members#create", :as => "login_to_join" + resources :events + resources :event_attendances + get "microcosms/:id/members", :to => "microcosms#show_members", :as => :members_of_microcosm + get "microcosms/:id/events", :to => "microcosms#show_events", :as => :events_of_microcosm + post "microcosms/:id/step_up", :to => "microcosms#step_up", :as => :step_up + # errors match "/403", :to => "errors#forbidden", :via => :all match "/404", :to => "errors#not_found", :via => :all diff --git a/config/settings.yml b/config/settings.yml index 3dd7329722..8907a31840 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -134,3 +134,5 @@ smtp_enable_starttls_auto: false smtp_authentication: null smtp_user_name: null smtp_password: null +# Number of members a microcosm must have before being displayed on the list page. Spam prevention. +microcosm_critical_mass: 0 diff --git a/db/migrate/20190826032448_create_microcosms.rb b/db/migrate/20190826032448_create_microcosms.rb new file mode 100644 index 0000000000..044460d3ad --- /dev/null +++ b/db/migrate/20190826032448_create_microcosms.rb @@ -0,0 +1,10 @@ +class CreateMicrocosms < ActiveRecord::Migration[5.2] + def change + create_table :microcosms do |t| + t.string :name, :null => false + t.string :key, :null => false + t.text :description, :null => false + t.timestamps + end + end +end diff --git a/db/migrate/20190831122812_create_microcosm_members.rb b/db/migrate/20190831122812_create_microcosm_members.rb new file mode 100644 index 0000000000..491d61edcb --- /dev/null +++ b/db/migrate/20190831122812_create_microcosm_members.rb @@ -0,0 +1,10 @@ +class CreateMicrocosmMembers < ActiveRecord::Migration[5.2] + def change + create_table :microcosm_members do |t| + t.references :microcosm, :foreign_key => true, :null => false + t.references :user, :foreign_key => true, :null => false + t.string :role, :limit => 64, :null => false + t.timestamps + end + end +end diff --git a/db/migrate/20190901143302_create_friendly_id_slugs.rb b/db/migrate/20190901143302_create_friendly_id_slugs.rb new file mode 100644 index 0000000000..362a617402 --- /dev/null +++ b/db/migrate/20190901143302_create_friendly_id_slugs.rb @@ -0,0 +1,14 @@ +class CreateFriendlyIdSlugs < ActiveRecord::Migration[5.2] + def change + create_table :friendly_id_slugs do |t| + t.string :slug, :null => false + t.integer :sluggable_id, :null => false + t.string :sluggable_type, :limit => 50 + t.string :scope + t.datetime :created_at + end + add_index :friendly_id_slugs, [:sluggable_type, :sluggable_id] + add_index :friendly_id_slugs, [:slug, :sluggable_type], :length => { :slug => 140, :sluggable_type => 50 } + add_index :friendly_id_slugs, [:slug, :sluggable_type, :scope], :length => { :slug => 70, :sluggable_type => 50, :scope => 70 }, :unique => true + end +end diff --git a/db/migrate/20190901151436_add_slug_to_microcosms.rb b/db/migrate/20190901151436_add_slug_to_microcosms.rb new file mode 100644 index 0000000000..a1eec62e2b --- /dev/null +++ b/db/migrate/20190901151436_add_slug_to_microcosms.rb @@ -0,0 +1,35 @@ +# +# Al of this junk will be reduced when I collapse the microcosm migrations. +# + +class AddSlugToMicrocosms < ActiveRecord::Migration[5.2] + def up + add_column :microcosms, :slug, :string + Microcosm.update_all ["slug = key"] + # change_column_null :microcosms, :slug, false + safety_assured do + execute 'ALTER TABLE "microcosms" ADD CONSTRAINT "microcosms_slug_null" CHECK ("slug" is NOT NULL) NOT VALID' + end + end + + def down + Microcosm.update_all ["key = slug"] + remove_column :microcosms, :slug + end +end + +class ValidateAddSlugToMicrocosms < ActiveRecord::Migration[5.2] + def up + safety_assured do + execute 'ALTER TABLE "microcosms" VALIDATE CONSTRAINT "microcosms_slug_null"' + end + end +end + +class AddIndexToMicrocosmSlug < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :microcosms, :slug, :unique => true, :algorithm => :concurrently + end +end diff --git a/db/migrate/20190901163613_remove_key_from_microsoms.rb b/db/migrate/20190901163613_remove_key_from_microsoms.rb new file mode 100644 index 0000000000..ef3f0e2804 --- /dev/null +++ b/db/migrate/20190901163613_remove_key_from_microsoms.rb @@ -0,0 +1,5 @@ +class RemoveKeyFromMicrosoms < ActiveRecord::Migration[5.2] + def change + safety_assured { remove_column :microcosms, :key } # rubocop:disable Rails/ReversibleMigration + end +end diff --git a/db/migrate/20190902200639_create_microcosm_links.rb b/db/migrate/20190902200639_create_microcosm_links.rb new file mode 100644 index 0000000000..7220bdb87d --- /dev/null +++ b/db/migrate/20190902200639_create_microcosm_links.rb @@ -0,0 +1,10 @@ +class CreateMicrocosmLinks < ActiveRecord::Migration[5.2] + def change + create_table :microcosm_links do |t| + t.references :microcosm, :foreign_key => true, :null => false + t.string :site, :null => false + t.string :url, :null => false + t.timestamps + end + end +end diff --git a/db/migrate/20190903023243_role_in_microcosm_should_be_unique.rb b/db/migrate/20190903023243_role_in_microcosm_should_be_unique.rb new file mode 100644 index 0000000000..4d002e446a --- /dev/null +++ b/db/migrate/20190903023243_role_in_microcosm_should_be_unique.rb @@ -0,0 +1,7 @@ +class RoleInMicrocosmShouldBeUnique < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :microcosm_members, [:microcosm_id, :user_id, :role], :unique => true, :algorithm => :concurrently + end +end diff --git a/db/migrate/20190903030453_add_location_to_microcosms.rb b/db/migrate/20190903030453_add_location_to_microcosms.rb new file mode 100644 index 0000000000..e602663e4a --- /dev/null +++ b/db/migrate/20190903030453_add_location_to_microcosms.rb @@ -0,0 +1,16 @@ +class AddLocationToMicrocosms < ActiveRecord::Migration[5.2] + def change + # This group of migrations for microcosms will be run together. + safety_assured do + change_table "microcosms", :bulk => true do |t| + t.string "location", :null => false + t.float "latitude", :null => false + t.float "longitude", :null => false + t.float "min_lat", :null => false + t.float "max_lat", :null => false + t.float "min_lon", :null => false + t.float "max_lon", :null => false + end + end + end +end diff --git a/db/migrate/20190905160802_create_events.rb b/db/migrate/20190905160802_create_events.rb new file mode 100644 index 0000000000..150fb13752 --- /dev/null +++ b/db/migrate/20190905160802_create_events.rb @@ -0,0 +1,12 @@ +class CreateEvents < ActiveRecord::Migration[5.2] + def change + create_table :events do |t| + t.string :title, :null => false + t.datetime :moment + t.string :location + t.text :description + t.references :microcosm, :foreign_key => true, :null => false + t.timestamps + end + end +end diff --git a/db/migrate/20190905224243_create_event_attendances.rb b/db/migrate/20190905224243_create_event_attendances.rb new file mode 100644 index 0000000000..3d7a214446 --- /dev/null +++ b/db/migrate/20190905224243_create_event_attendances.rb @@ -0,0 +1,10 @@ +class CreateEventAttendances < ActiveRecord::Migration[5.2] + def change + create_table :event_attendances do |t| + t.references :user, :foreign_key => true, :null => false + t.references :event, :foreign_key => true, :null => false + t.string :intention, :null => false + t.timestamps + end + end +end diff --git a/db/migrate/20200127033234_create_event_organizers.rb b/db/migrate/20200127033234_create_event_organizers.rb new file mode 100644 index 0000000000..5ed4511b1b --- /dev/null +++ b/db/migrate/20200127033234_create_event_organizers.rb @@ -0,0 +1,9 @@ +class CreateEventOrganizers < ActiveRecord::Migration[6.0] + def change + create_table :event_organizers do |t| + t.references :event, :foreign_key => true + t.references :user, :foreign_key => true + t.timestamps + end + end +end diff --git a/db/migrate/20200214135750_add_coords_to_event.rb b/db/migrate/20200214135750_add_coords_to_event.rb new file mode 100644 index 0000000000..f1ce41678a --- /dev/null +++ b/db/migrate/20200214135750_add_coords_to_event.rb @@ -0,0 +1,11 @@ +class AddCoordsToEvent < ActiveRecord::Migration[6.0] + def change + safety_assured do + change_table :events, :bulk => true do |t| + t.float :latitude + t.float :longitude + t.string :location_url + end + end + end +end diff --git a/db/migrate/20200727014256_attendance_to_event_should_be_unique.rb b/db/migrate/20200727014256_attendance_to_event_should_be_unique.rb new file mode 100644 index 0000000000..4e2e11b737 --- /dev/null +++ b/db/migrate/20200727014256_attendance_to_event_should_be_unique.rb @@ -0,0 +1,7 @@ +class AttendanceToEventShouldBeUnique < ActiveRecord::Migration[6.0] + disable_ddl_transaction! + + def change + add_index :event_attendances, [:user_id, :event_id], :unique => true, :algorithm => :concurrently + end +end diff --git a/db/migrate/20210425164111_convert_event_attendance_intention_to_enum.rb b/db/migrate/20210425164111_convert_event_attendance_intention_to_enum.rb new file mode 100644 index 0000000000..5d05f845a5 --- /dev/null +++ b/db/migrate/20210425164111_convert_event_attendance_intention_to_enum.rb @@ -0,0 +1,26 @@ +class ConvertEventAttendanceIntentionToEnum < ActiveRecord::Migration[6.0] + def up + # This is safe because the + safety_assured do + rename_column(:event_attendances, :intention, :intention_orig) + create_enumeration :event_attendance_intention_enum, %w[maybe no yes] + # Need to create this with a default to ensure not null. + add_column(:event_attendances, :intention, :event_attendance_intention_enum, :default => "maybe") + # Then clobber the value + EventAttendance.update_all("intention = intention_orig::event_attendance_intention_enum") + change_column_default(:event_attendances, :intention, nil) + remove_column(:event_attendances, :intention_orig) + end + end + + def down + safety_assured do + add_column(:event_attendances, :intention_orig, :string, :default => "maybe", :null => false) + EventAttendance.update_all("intention_orig = intention") + change_column_default(:event_attendances, :intention_orig, nil) + remove_column(:event_attendances, :intention) + drop_enumeration :event_attendance_intention_enum + rename_column(:event_attendances, :intention_orig, :intention) + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 89a62626ee..cd01c1fbb4 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -36,6 +36,17 @@ CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public; COMMENT ON EXTENSION btree_gist IS 'support for indexing common datatypes in GiST'; +-- +-- Name: event_attendance_intention_enum; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.event_attendance_intention_enum AS ENUM ( + 'maybe', + 'no', + 'yes' +); + + -- -- Name: format_enum; Type: TYPE; Schema: public; Owner: - -- @@ -713,6 +724,142 @@ CREATE TABLE public.diary_entry_subscriptions ( ); +-- +-- Name: event_attendances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.event_attendances ( + id bigint NOT NULL, + user_id bigint NOT NULL, + event_id bigint NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + intention public.event_attendance_intention_enum +); + + +-- +-- Name: event_attendances_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.event_attendances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: event_attendances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.event_attendances_id_seq OWNED BY public.event_attendances.id; + + +-- +-- Name: event_organizers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.event_organizers ( + id bigint NOT NULL, + event_id bigint, + user_id bigint, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: event_organizers_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.event_organizers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: event_organizers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.event_organizers_id_seq OWNED BY public.event_organizers.id; + + +-- +-- Name: events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.events ( + id bigint NOT NULL, + title character varying NOT NULL, + moment timestamp without time zone, + location character varying, + description text, + microcosm_id bigint NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + latitude double precision, + longitude double precision, + location_url character varying +); + + +-- +-- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id; + + +-- +-- Name: friendly_id_slugs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.friendly_id_slugs ( + id bigint NOT NULL, + slug character varying NOT NULL, + sluggable_id integer NOT NULL, + sluggable_type character varying(50), + scope character varying, + created_at timestamp without time zone +); + + +-- +-- Name: friendly_id_slugs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.friendly_id_slugs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: friendly_id_slugs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.friendly_id_slugs_id_seq OWNED BY public.friendly_id_slugs.id; + + -- -- Name: friends; Type: TABLE; Schema: public; Owner: - -- @@ -946,6 +1093,112 @@ CREATE SEQUENCE public.messages_id_seq ALTER SEQUENCE public.messages_id_seq OWNED BY public.messages.id; +-- +-- Name: microcosm_links; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.microcosm_links ( + id bigint NOT NULL, + microcosm_id bigint NOT NULL, + site character varying NOT NULL, + url character varying NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: microcosm_links_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.microcosm_links_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: microcosm_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.microcosm_links_id_seq OWNED BY public.microcosm_links.id; + + +-- +-- Name: microcosm_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.microcosm_members ( + id bigint NOT NULL, + microcosm_id bigint NOT NULL, + user_id bigint NOT NULL, + role character varying(64) NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: microcosm_members_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.microcosm_members_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: microcosm_members_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.microcosm_members_id_seq OWNED BY public.microcosm_members.id; + + +-- +-- Name: microcosms; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.microcosms ( + id bigint NOT NULL, + name character varying NOT NULL, + description text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + slug character varying, + location character varying NOT NULL, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + min_lat double precision NOT NULL, + max_lat double precision NOT NULL, + min_lon double precision NOT NULL, + max_lon double precision NOT NULL +); + + +-- +-- Name: microcosms_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.microcosms_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: microcosms_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.microcosms_id_seq OWNED BY public.microcosms.id; + + -- -- Name: node_tags; Type: TABLE; Schema: public; Owner: - -- @@ -1533,6 +1786,34 @@ ALTER TABLE ONLY public.diary_comments ALTER COLUMN id SET DEFAULT nextval('publ ALTER TABLE ONLY public.diary_entries ALTER COLUMN id SET DEFAULT nextval('public.diary_entries_id_seq'::regclass); +-- +-- Name: event_attendances id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances ALTER COLUMN id SET DEFAULT nextval('public.event_attendances_id_seq'::regclass); + + +-- +-- Name: event_organizers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_organizers ALTER COLUMN id SET DEFAULT nextval('public.event_organizers_id_seq'::regclass); + + +-- +-- Name: events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass); + + +-- +-- Name: friendly_id_slugs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friendly_id_slugs ALTER COLUMN id SET DEFAULT nextval('public.friendly_id_slugs_id_seq'::regclass); + + -- -- Name: friends id; Type: DEFAULT; Schema: public; Owner: - -- @@ -1575,6 +1856,27 @@ ALTER TABLE ONLY public.issues ALTER COLUMN id SET DEFAULT nextval('public.issue ALTER TABLE ONLY public.messages ALTER COLUMN id SET DEFAULT nextval('public.messages_id_seq'::regclass); +-- +-- Name: microcosm_links id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_links ALTER COLUMN id SET DEFAULT nextval('public.microcosm_links_id_seq'::regclass); + + +-- +-- Name: microcosm_members id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_members ALTER COLUMN id SET DEFAULT nextval('public.microcosm_members_id_seq'::regclass); + + +-- +-- Name: microcosms id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosms ALTER COLUMN id SET DEFAULT nextval('public.microcosms_id_seq'::regclass); + + -- -- Name: note_comments id; Type: DEFAULT; Schema: public; Owner: - -- @@ -1797,6 +2099,38 @@ ALTER TABLE ONLY public.diary_entry_subscriptions ADD CONSTRAINT diary_entry_subscriptions_pkey PRIMARY KEY (user_id, diary_entry_id); +-- +-- Name: event_attendances event_attendances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT event_attendances_pkey PRIMARY KEY (id); + + +-- +-- Name: event_organizers event_organizers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_organizers + ADD CONSTRAINT event_organizers_pkey PRIMARY KEY (id); + + +-- +-- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events + ADD CONSTRAINT events_pkey PRIMARY KEY (id); + + +-- +-- Name: friendly_id_slugs friendly_id_slugs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friendly_id_slugs + ADD CONSTRAINT friendly_id_slugs_pkey PRIMARY KEY (id); + + -- -- Name: friends friends_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1853,6 +2187,38 @@ ALTER TABLE ONLY public.messages ADD CONSTRAINT messages_pkey PRIMARY KEY (id); +-- +-- Name: microcosm_links microcosm_links_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_links + ADD CONSTRAINT microcosm_links_pkey PRIMARY KEY (id); + + +-- +-- Name: microcosm_members microcosm_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_members + ADD CONSTRAINT microcosm_members_pkey PRIMARY KEY (id); + + +-- +-- Name: microcosms microcosms_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosms + ADD CONSTRAINT microcosms_pkey PRIMARY KEY (id); + + +-- +-- Name: microcosms microcosms_slug_null; Type: CHECK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE public.microcosms + ADD CONSTRAINT microcosms_slug_null CHECK ((slug IS NOT NULL)) NOT VALID; + + -- -- Name: node_tags node_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2279,6 +2645,69 @@ CREATE INDEX index_client_applications_on_user_id ON public.client_applications CREATE INDEX index_diary_entry_subscriptions_on_diary_entry_id ON public.diary_entry_subscriptions USING btree (diary_entry_id); +-- +-- Name: index_event_attendances_on_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_attendances_on_event_id ON public.event_attendances USING btree (event_id); + + +-- +-- Name: index_event_attendances_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_attendances_on_user_id ON public.event_attendances USING btree (user_id); + + +-- +-- Name: index_event_attendances_on_user_id_and_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_event_attendances_on_user_id_and_event_id ON public.event_attendances USING btree (user_id, event_id); + + +-- +-- Name: index_event_organizers_on_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_organizers_on_event_id ON public.event_organizers USING btree (event_id); + + +-- +-- Name: index_event_organizers_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_event_organizers_on_user_id ON public.event_organizers USING btree (user_id); + + +-- +-- Name: index_events_on_microcosm_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_microcosm_id ON public.events USING btree (microcosm_id); + + +-- +-- Name: index_friendly_id_slugs_on_slug_and_sluggable_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_friendly_id_slugs_on_slug_and_sluggable_type ON public.friendly_id_slugs USING btree (slug, sluggable_type); + + +-- +-- Name: index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope ON public.friendly_id_slugs USING btree (slug, sluggable_type, scope); + + +-- +-- Name: index_friendly_id_slugs_on_sluggable_type_and_sluggable_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_friendly_id_slugs_on_sluggable_type_and_sluggable_id ON public.friendly_id_slugs USING btree (sluggable_type, sluggable_id); + + -- -- Name: index_issue_comments_on_issue_id; Type: INDEX; Schema: public; Owner: - -- @@ -2328,6 +2757,34 @@ CREATE INDEX index_issues_on_status ON public.issues USING btree (status); CREATE INDEX index_issues_on_updated_by ON public.issues USING btree (updated_by); +-- +-- Name: index_microcosm_links_on_microcosm_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_microcosm_links_on_microcosm_id ON public.microcosm_links USING btree (microcosm_id); + + +-- +-- Name: index_microcosm_members_on_microcosm_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_microcosm_members_on_microcosm_id ON public.microcosm_members USING btree (microcosm_id); + + +-- +-- Name: index_microcosm_members_on_microcosm_id_and_user_id_and_role; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_microcosm_members_on_microcosm_id_and_user_id_and_role ON public.microcosm_members USING btree (microcosm_id, user_id, role); + + +-- +-- Name: index_microcosm_members_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_microcosm_members_on_user_id ON public.microcosm_members USING btree (user_id); + + -- -- Name: index_note_comments_on_body; Type: INDEX; Schema: public; Owner: - -- @@ -2749,6 +3206,54 @@ ALTER TABLE ONLY public.diary_entry_subscriptions ADD CONSTRAINT diary_entry_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); +-- +-- Name: microcosm_members fk_rails_29dbef698e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_members + ADD CONSTRAINT fk_rails_29dbef698e FOREIGN KEY (microcosm_id) REFERENCES public.microcosms(id); + + +-- +-- Name: microcosm_links fk_rails_4e3be5d646; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_links + ADD CONSTRAINT fk_rails_4e3be5d646 FOREIGN KEY (microcosm_id) REFERENCES public.microcosms(id); + + +-- +-- Name: event_attendances fk_rails_64ad6920ae; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT fk_rails_64ad6920ae FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: microcosm_members fk_rails_850250d78a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.microcosm_members + ADD CONSTRAINT fk_rails_850250d78a FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: event_organizers fk_rails_b1c2c61554; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_organizers + ADD CONSTRAINT fk_rails_b1c2c61554 FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: event_organizers fk_rails_c1e082c91e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_organizers + ADD CONSTRAINT fk_rails_c1e082c91e FOREIGN KEY (event_id) REFERENCES public.events(id); + + -- -- Name: active_storage_attachments fk_rails_c3b3935057; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -2757,6 +3262,22 @@ ALTER TABLE ONLY public.active_storage_attachments ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); +-- +-- Name: event_attendances fk_rails_d082d0d206; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.event_attendances + ADD CONSTRAINT fk_rails_d082d0d206 FOREIGN KEY (event_id) REFERENCES public.events(id); + + +-- +-- Name: events fk_rails_daa1b5e200; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events + ADD CONSTRAINT fk_rails_daa1b5e200 FOREIGN KEY (microcosm_id) REFERENCES public.microcosms(id); + + -- -- Name: friends friends_friend_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3119,10 +3640,24 @@ INSERT INTO "schema_migrations" (version) VALUES ('20190623093642'), ('20190702193519'), ('20190716173946'), +('20190826032448'), +('20190831122812'), +('20190901143302'), +('20190901151436'), +('20190901163613'), +('20190902200639'), +('20190903023243'), +('20190903030453'), +('20190905160802'), +('20190905224243'), ('20191120140058'), +('20200127033234'), +('20200214135750'), +('20200727014256'), ('20201006213836'), ('20201006220807'), ('20201214144017'), +('20210425164111'), ('21'), ('22'), ('23'), diff --git a/features/member.feature b/features/member.feature new file mode 100644 index 0000000000..96fd7edacd --- /dev/null +++ b/features/member.feature @@ -0,0 +1,62 @@ +Feature: Interact with the Microcosm + In order to interact with a microcosm + as a member + I want to perform member actions + + Background: + Given there is this microcosm: + | name | MappingDC | + | location | Washington, DC, USA | + | latitude | 38.9 | + | longitude | -77.03 | + | min_lat | 38.516 | + | max_lat | 39.472 | + | min_lon | -77.671 | + | max_lon | -76.349 | + And the microcosm has description "MappingDC strives to improve OSM in the DC area" + And the microcosm has the "Facebook" page "https://facebook.com/groups/mappingdc" + And the microcosm has the "Twitter" page "https://twitter.com/mappingdc" + And the microcosm has the "Website" page "https://mappingdc.org" + And I am on the microcosm "MappingDC" page + + Scenario: A user may leave a microcosm + Given there is a user "abe@example.com" with name "Abraham" + And this user is an "member" of this microcosm + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I should see a "Leave" button + And I press "Leave" + Then I should see a "Join" button + + Scenario: RSVP for an event + Given there is an event for this microcosm + And there is a user "will_attend@example.com" with name "Will" + And this user is an "member" of this microcosm + And user "will_attend@example.com" logs in + And I am on this event page + Then I should not see "people are going." + And I should not see "person is going." + Then I should see "Are you going?" + And I press "yes" + And I am on this event page + Then I should see "One person is going." + And I press "no" + And I am on this event page + Then I should not see "people are going." + And I should not see "person is going." + + Scenario: Members should not see join button + Given there is a user "will_attend@example.com" with name "Will" + And this user is an "member" of this microcosm + And user "will_attend@example.com" logs in + And I am on the microcosm "MappingDC" page + Then I should not see a "Join" button + + Scenario: Step up + Given there is a user "abe@example.com" with name "Abe" + And this user is an "member" of this microcosm + Given this microcosm has no organizers + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Step up" + Then I should see "Organizers Abe" diff --git a/features/organizer.feature b/features/organizer.feature new file mode 100644 index 0000000000..4e5fc96870 --- /dev/null +++ b/features/organizer.feature @@ -0,0 +1,94 @@ +Feature: Manage a Microcosm + In order to manage microcosms + as an organizer + I want to manage the microcosm + + Background: + Given there is this microcosm: + | name | MappingDC | + | location | Washington, DC, USA | + | latitude | 38.9 | + | longitude | -77.03 | + | min_lat | 38.516 | + | max_lat | 39.472 | + | min_lon | -77.671 | + | max_lon | -76.349 | + And the microcosm has description "MappingDC strives to improve OSM in the DC area" + And the microcosm has the "Facebook" page "https://facebook.com/groups/mappingdc" + And the microcosm has the "Twitter" page "https://twitter.com/mappingdc" + And the microcosm has the "Website" page "https://mappingdc.org" + And I am on the microcosm "MappingDC" page + + + Scenario: Edit a microcosm + Given there is a user "abe@example.com" with name "Abe" + And this user is an "organizer" of this microcosm + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click the link to "/microcosms/mappingdc/edit" + And I set the microcosm in ".edit_microcosm" to "Baltimore", "40", "-76" + And I submit the form + Then I should not see "Washington, DC, USA" + Then I should see "Baltimore" + + Scenario: Promote a user to organizer + Given there is a user "organizer@example.com" with name "Organizer" + And this user is an "organizer" of this microcosm + Given there is a user "promotee@example.com" with name "Promotee" + And this user is a "member" of this microcosm + When user "organizer@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Members" + And Within ".members" I click the "1st" "edit" + And I set the user to "Organizer" + And I submit the form + Then I should see "Organizers Organizer Promotee" + + Scenario: Create an event + Given there is a user "abe@example.com" with name "Abe" + And this user is an "organizer" of this microcosm + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Upcoming Events" + And I click "new event" + And I set the event to "Update DC Bike Lanes", "2030-01-20T12:34", "DC Library", "We will update the dc bike lane data in OSM." + And I submit the form + And I am on the microcosm "MappingDC" page + Then I should see "Update DC Bike Lanes" + And I click "Update DC Bike Lanes" + Then I should see "Update DC Bike Lanes" + And I should see "Location: DC Library" + And I should see "Description: We will update the dc bike lane data in OSM." + And I should see "Organized by: Abe" + And I should see "20 January 2030 at 12:34" + + Scenario: Edit an event + Given there is an event for this microcosm + And there is a user "abe@example.com" with name "Abe" + And this user is an "organizer" of this microcosm + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Upcoming Events" + And I click "Edit" + And I set the event to "DC Bike Trails", "2030-02-20T12:34", "DC Park", "Hiking." + And I submit the form + And I am on the microcosm "MappingDC" page + Then I should see "DC Bike Trails" + And I click "DC Bike Trails" + Then I should see "DC Bike Trails" + And I should see "Location: DC Park" + And I should see "Description: Hiking." + And I should see "20 February 2030 at 12:34" + + Scenario: Remove a member + Given there is a user "organizer@example.com" with name "Organizer" + And this user is an "organizer" of this microcosm + And there is a user "abe@example.com" with name "Nicolas" + And this user is a "member" of this microcosm + When user "organizer@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Members" + And Within ".members" I click the "1st" "remove" + And I am on the microcosm "MappingDC" page + And I click "Members" + Then I should not see "Nicolas" diff --git a/features/step_definitions/.gitkeep b/features/step_definitions/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/step_definitions/about_spec.rb b/features/step_definitions/about_spec.rb new file mode 100644 index 0000000000..e99d9832bf --- /dev/null +++ b/features/step_definitions/about_spec.rb @@ -0,0 +1,212 @@ +Given("there is this microcosm:") do |table| + attribs = table.rows_hash + attribs["description"] = "Some description" + attribs["latitude"] = Float(attribs["latitude"]) + attribs["longitude"] = Float(attribs["longitude"]) + @the_microcosm = Microcosm.create!(attribs) +end + +Given("there is a changeset by {string} at {string}, {string}, {string}, {string} with comment {string}") do |author, min_lat, max_lat, min_lon, max_lon, comment| + ch = Changeset.create!( + :user => User.find_by(:display_name => author), + :created_at => Time.now.utc, + :closed_at => Time.now.utc + 1.day, + :min_lat => (min_lat.to_i * GeoRecord::SCALE), + :max_lat => (max_lat.to_i * GeoRecord::SCALE), + :min_lon => (min_lon.to_i * GeoRecord::SCALE), + :max_lon => (max_lon.to_i * GeoRecord::SCALE), + :num_changes => 0 + ) + ChangesetTag.create!( + :changeset => ch, + :k => "comment", + :v => comment + ) +end + +Given("I am on the microcosms page") do + visit microcosms_path +end + +Given("I am on the microcosm {string} page") do |name| + visit microcosm_path(Microcosm.find_by(:name => name)) +end + +Given("I am on the microcosm page by id") do + visit microcosm_path(@the_microcosm) +end + +Then("I should see a map of the microcosm centered at their AOI") do + assert page.has_css? "#microcosm_map" + assert page.has_css? ".leaflet-container" + coords = page.evaluate_script("window.map.getCenter()") + assert coords["lat"] == @the_microcosm.lat + assert coords["lng"] == @the_microcosm.lon +end + +Given("I am on the microcosm edit page") do + visit edit_microcosm_path(@the_microcosm) +end + +Given("there is an event for this microcosm") do + @the_event = Event.create!( + :title => "Some Event", + :moment => DateTime.now, + :location => "Some Location", + :location_url => "https://en.wikipedia.org/wiki/Washington_Monument", + :latitude => 12.34, + :longitude => 56.78, + :description => "Some description", + :microcosm_id => @the_microcosm.id + ) +end + +Given("I am on this event page") do + visit event_path(@the_event) +end + +Given("I am on the all events page") do + visit events_path +end + +# The lines like "The microcosm HAS..." are not behavior driven because it's using @varibles. + +Given("the microcosm has the {string} page {string}") do |site, url| + @the_microcosm.set_link(site, url) + @the_microcosm.save +end + +Given("the microcosm has description {string}") do |desc| + @the_microcosm.description = desc + @the_microcosm.save +end + +Given("this user is a(n) {string} of this microcosm") do |role| + @the_microcosm.microcosm_members.create!(:user_id => @the_user.id, :role => role) +end + +Given("this microcosm has no organizers") do + @the_microcosm.organizers.map(&:destroy) +end + +Then("I should see the microcosm {string} name") do |name| + within(".content-heading") do + page.assert_text name + end +end + +And("I set the microcosm in {string} to {string}, {string}, {string}") do |scope, name, lat, lon| + within(scope) do + fill_in "Name", :with => name + fill_in "Location", :with => name + fill_in "Latitude", :with => lat + fill_in "Longitude", :with => lon + fill_in "Minimum Latitude", :with => lat # TODO: Parameterize this. + fill_in "Maximum Latitude", :with => lat + fill_in "Minimum Longitude", :with => lon + fill_in "Maximum Longitude", :with => lon + fill_in "Description", :with => name + end +end + +And("I set the event to {string}, {string}, {string}, {string}") do |title, moment, location, description| + within("#content") do + fill_in "Title", :with => title + fill_in "When", :with => moment + fill_in "Location", :with => location + fill_in "Description", :with => description + end +end + +And("I set the user to {string}") do |role| + within("#content") do + select role, :from => "Role" + end +end + +And("I submit the form") do + within("#content") do + find('form input[type="submit"]').click + end +end + +# Not microcosm specific. + +Given("{string} is an administrator") do |email| + user = User.find_by(:email => email) + user.roles.create(:role => "administrator", :granter => user) + user.save +end + +When("print body") do + print body +end + +Then("I should see the {string} link to {string}") do |title, href| + assert page.has_link? title, :href => href +end + +Then("I should see {string}") do |msg| + page.assert_text msg, :normalize_ws => true +end + +Then("I should not see {string}") do |msg| + page.assert_no_text msg +end + +Then("I should see a {string} button") do |title| + assert page.has_button? title +end + +Then("I should not see a {string} button") do |title| + assert !page.has_button?(title) +end + +Then("I should be forbidden") do + assert page.status_code == 403 +end + +And("I click {string}") do |title| + within("#content") do + click_link(title) + end +end + +And("Within {string} I click the {string} {string}") do |scope, nth, locator| + within(scope) do + # all(:link_or_button).each do |el| + # puts el.inspect + # puts el.value + # end + all(:link_or_button, locator)[unordinalize(nth)].click + end +end + +And("I click the link to {string}") do |url| + find("a[href='#{url}']").click +end + +And("I press {string}") do |title| + click_button title +end + +When("user {string} logs in") do |username| + visit login_path + within("#login_form") do + fill_in "username", :with => username + fill_in "password", :with => "test" + click_button "Login" + end +end + +Given("there is a user {string} with name {string}") do |username, name| + @the_user = create(:user, :email => username, :display_name => name) +end + +When("I logout") do + visit logout_path +end + +def unordinalize(ordinal) + ordinal.scan(/^\d+/).first.to_i - 1 +end diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 0000000000..5b8e0485d2 --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,69 @@ +# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril. +# It is recommended to regenerate this file in the future when you upgrade to a +# newer version of cucumber-rails. Consider adding your own code to a new file +# instead of editing this one. Cucumber will automatically load all features/**/*.rb +# fi les. + +require "simplecov" +require "simplecov-lcov" + +# TODO: Might need to handle incompatibility. +# See https://github.com/openstreetmap/openstreetmap-website/commit/4a9f8a5e696b55840ad0ffcaf8941f0454dbf405#diff-d09ea66f8227784ff4393d88a19836f321c915ae10031d16c93d67e6283ab55f + +SimpleCov.start("rails") + +require "cucumber/rails" + +# frozen_string_literal: true + +# Capybara defaults to CSS3 selectors rather than XPath. +# If you'd prefer to use XPath, just uncomment this line and adjust any +# selectors in your step definitions to use the XPath syntax. +# Capybara.default_selector = :xpath + +# By default, any exception happening in your Rails application will bubble up +# to Cucumber so that your scenario will fail. This is a different from how +# your application behaves in the production environment, where an error page will +# be rendered instead. +# +# Sometimes we want to override this default behaviour and allow Rails to rescue +# exceptions and display an error page (just like when the app is running in production). +# Typical scenarios where you want to do this is when you test your error pages. +# There are two ways to allow Rails to rescue exceptions: +# +# 1) Tag your scenario (or feature) with @allow-rescue +# +# 2) Set the value below to true. Beware that doing this globally is not +# recommended as it will mask a lot of errors for you! +# +ActionController::Base.allow_rescue = false + +# Remove/comment out the lines below if your app doesn't have a database. +# For some databases (like MongoDB and CouchDB) you may need to use :truncation instead. +begin + DatabaseCleaner.strategy = :transaction +rescue NameError + raise "You need to add database_cleaner to your Gemfile (in the :test group) if you wish to use it." +end + +# You may also want to configure DatabaseCleaner to use different strategies for certain features and scenarios. +# See the DatabaseCleaner documentation for details. Example: +# +# Before('@no-txn,@selenium,@culerity,@celerity,@javascript') do +# # { except: [:widgets] } may not do what you expect here +# # as Cucumber::Rails::Database.javascript_strategy overrides +# # this setting. +# DatabaseCleaner.strategy = :truncation +# end +# +# Before('not @no-txn', 'not @selenium', 'not @culerity', 'not @celerity', 'not @javascript') do +# DatabaseCleaner.strategy = :transaction +# end +# + +# Possible values are :truncation and :transaction +# The :transaction strategy is faster, but might give you threading problems. +# See https://github.com/cucumber/cucumber-rails/blob/master/features/choose_javascript_database_strategy.feature +Cucumber::Rails::Database.javascript_strategy = :truncation + +World(FactoryBot::Syntax::Methods) diff --git a/features/user.feature b/features/user.feature new file mode 100644 index 0000000000..d6beb200ee --- /dev/null +++ b/features/user.feature @@ -0,0 +1,37 @@ +Feature: User associated operations for a Microcosm + In order to use microcosms + as a user + I want to create the microcosm + + Background: + Given there is this microcosm: + | name | MappingDC | + | location | Washington, DC, USA | + | latitude | 38.9 | + | longitude | -77.03 | + | min_lat | 38.516 | + | max_lat | 39.472 | + | min_lon | -77.671 | + | max_lon | -76.349 | + And the microcosm has description "MappingDC strives to improve OSM in the DC area" + And the microcosm has the "Facebook" page "https://facebook.com/groups/mappingdc" + And the microcosm has the "Twitter" page "https://twitter.com/mappingdc" + And the microcosm has the "Website" page "https://mappingdc.org" + And I am on the microcosm "MappingDC" page + + Scenario: Create a microcosm + Given there is a user "abe@example.com" with name "Abe" + When user "abe@example.com" logs in + And I am on the microcosms page + And I click the link to "/microcosms/new" + And I set the microcosm in "#new_microcosm" to "Baltimore", "38", "-77" + And I submit the form + Then I should see "Baltimore" + + Scenario: Step up + Given there is a user "abe@example.com" with name "Abe" + Given this microcosm has no organizers + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Step up" + Then I should not see "Organizers Abe" diff --git a/features/visitor_about.feature b/features/visitor_about.feature new file mode 100644 index 0000000000..1352916e0d --- /dev/null +++ b/features/visitor_about.feature @@ -0,0 +1,100 @@ +Feature: Learn about the Microcosm + In order to learn about this microcosm + as a visitor + I want to read their webpage + + Background: + Given there is this microcosm: + | name | MappingDC | + | location | Washington, DC, USA | + | latitude | 38.9 | + | longitude | -77.03 | + | min_lat | 38.516 | + | max_lat | 39.472 | + | min_lon | -77.671 | + | max_lon | -76.349 | + And the microcosm has description "MappingDC strives to improve OSM in the DC area" + And the microcosm has the "Facebook" page "https://facebook.com/groups/mappingdc" + And the microcosm has the "Twitter" page "https://twitter.com/mappingdc" + And the microcosm has the "Website" page "https://mappingdc.org" + Given there is a user "aaa@example.com" with name "A User" + And this user is an "member" of this microcosm + Given there is a user "bbb@example.com" with name "B User" + And this user is an "member" of this microcosm + Given there is a user "ccc@example.com" with name "C User" + And this user is an "member" of this microcosm + And I am on the microcosm "MappingDC" page + + + Scenario: The microcosm should be listed + When I am on the microcosms page + Then I should see "MappingDC" + + + Scenario: Describe the microcosm + Then I should see the microcosm "MappingDC" name + Then I should see "Washington, DC, USA" + Then I should see the "Facebook" link to "https://facebook.com/groups/mappingdc" + Then I should see the "Twitter" link to "https://twitter.com/mappingdc" + Then I should see the "Website" link to "https://mappingdc.org" + Then I should see "MappingDC strives to improve OSM in the DC area" + + + Scenario: Can load by id + Then I am on the microcosm page by id + Then I should see "MappingDC strives to improve OSM in the DC area" + + +# @javascript +# Scenario: Can see a map of the microcosm area +# When I am on the microcosm page by id +# Then I should see a map of the microcosm centered at their AOI + + + Scenario: Regular user cannot edit the microcosm + Given there is a user "abe@example.com" with name "Abe" + When user "abe@example.com" logs in + When I am on the microcosm edit page + Then I should be forbidden + + + Scenario: Logged out user sees message to join microcosm + Given there is a user "abe@example.com" with name "Abe" + When I am on the microcosm "MappingDC" page + And I click "Join" + Then I should see "Login" + + + Scenario: A user may join a microcosm + Given there is a user "abe@example.com" with name "Abraham" + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I should see a "Join" button + And I press "Join" + Then I should see "Abraham" + + + Scenario: See upcoming events + Given there is a user "abe@example.com" with name "Abe" + And this user is an "organizer" of this microcosm + When user "abe@example.com" logs in + And I am on the microcosm "MappingDC" page + And I click "Upcoming Events" + And I click "new event" + And I set the event to "Update DC Bike Lanes", "2030-01-20T12:34", "DC Library", "We will update the dc bike lane data in OSM." + And I submit the form + And I logout + And I am on the microcosm "MappingDC" page + And I click "Upcoming Events" + Then I should see "Update DC Bike Lanes" + When I am on the all events page + Then I should see "Update DC Bike Lanes" + + + Scenario: See recent changesets + Given there is a user "abe@example.com" with name "Abe" + And there is a changeset by "Abe" at "38.8", "39.1", "-77.1", "-76.8" with comment "Add public bookcase" + And there is a changeset by "Abe" at "138.8", "139.1", "-7.1", "-6.8" with comment "Add library" + When I am on the microcosm "MappingDC" page + Then I should see "Add public bookcase" + And I should not see "Add library" diff --git a/lib/tasks/cucumber.rake b/lib/tasks/cucumber.rake new file mode 100644 index 0000000000..0e69cf670c --- /dev/null +++ b/lib/tasks/cucumber.rake @@ -0,0 +1,74 @@ +# IMPORTANT: This file is generated by cucumber-rails - edit at your own peril. +# It is recommended to regenerate this file in the future when you upgrade to a +# newer version of cucumber-rails. Consider adding your own code to a new file +# instead of editing this one. Cucumber will automatically load all features/**/*.rb +# files. + +unless ARGV.any? { |a| a.starts_with? "gems" } # Don't load anything when running the gems:* tasks + +vendored_cucumber_bin = Dir[Rails.root.join("vendor/{gems,plugins}/cucumber*/bin/cucumber")].first # rubocop:disable Layout/IndentationWidth +$LOAD_PATH.unshift("#{File.dirname(vendored_cucumber_bin)}/../lib") unless vendored_cucumber_bin.nil? + +begin + require "cucumber/rake/task" + + namespace :cucumber do + Cucumber::Rake::Task.new({ :ok => "test:prepare" }, "Run features that should pass") do |t| + t.binary = vendored_cucumber_bin # If nil, the gem's binary is used. + t.fork = true # You may get faster startup if you set this to false + t.profile = "default" + end + + Cucumber::Rake::Task.new({ :wip => "test:prepare" }, "Run features that are being worked on") do |t| + t.binary = vendored_cucumber_bin + t.fork = true # You may get faster startup if you set this to false + t.profile = "wip" + end + + Cucumber::Rake::Task.new({ :rerun => "test:prepare" }, "Record failing features and run only them if any exist") do |t| + t.binary = vendored_cucumber_bin + t.fork = true # You may get faster startup if you set this to false + t.profile = "rerun" + end + + desc "Run all features" + task :all => [:ok, :wip] + + task :statsetup => :environment do + require "rails/code_statistics" + ::STATS_DIRECTORIES << %w[Cucumber\ features features] if File.exist?("features") + ::CodeStatistics::TEST_TYPES << "Cucumber features" if File.exist?("features") + end + + task :annotations_setup => :environment do + Rails.application.configure do + if config.respond_to?(:annotations) + config.annotations.directories << "features" + config.annotations.register_extensions("feature") { |tag| /#\s*(#{tag}):?\s*(.*)$/ } + end + end + end + end + desc "Alias for cucumber:ok" + task :cucumber => "cucumber:ok" + + task :default => :cucumber + + task :features => :cucumber do + warn "*** The 'features' task is deprecated. See rake -T cucumber ***" + end + + # In case we don't have the generic Rails test:prepare hook, append a no-op task that we can depend upon. + task "test:prepare" => :environment + + task :stats => "cucumber:statsetup" + + task :notes => "cucumber:annotations_setup" +rescue LoadError + desc "cucumber rake task not available (cucumber not installed)" + task :cucumber => :environment do + abort "Cucumber rake task is not available. Be sure to install cucumber as a gem or plugin" + end +end + +end diff --git a/script/cucumber b/script/cucumber new file mode 100755 index 0000000000..3d7910bdf6 --- /dev/null +++ b/script/cucumber @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +vendored_cucumber_bin = Dir["#{File.dirname(__FILE__)}/../vendor/{gems,plugins}/cucumber*/bin/cucumber"].first +if vendored_cucumber_bin + load File.expand_path(vendored_cucumber_bin) +else + require "rubygems" unless ENV["NO_RUBYGEMS"] + require "cucumber" + load Cucumber::BINARY +end diff --git a/test/controllers/event_attendances_controller_test.rb b/test/controllers/event_attendances_controller_test.rb new file mode 100644 index 0000000000..a48bb622c3 --- /dev/null +++ b/test/controllers/event_attendances_controller_test.rb @@ -0,0 +1,163 @@ +require "test_helper" +require "minitest/mock" + +class EventAttendancesControllerTest < ActionDispatch::IntegrationTest + ## + # test all routes which lead to this controller + # Following guidance from Ruby on Rails Guide + # https://guides.rubyonrails.org/testing.html#functional-tests-for-your-controllers + # + def test_routes + assert_routing( + { :path => "/event_attendances/1", :method => :put }, + { :controller => "event_attendances", :action => "update", :id => "1" } + ) + assert_routing( + { :path => "/event_attendances", :method => :post }, + { :controller => "event_attendances", :action => "create" } + ) + end + + def test_create_when_save_works + # arrange + mm = create(:microcosm_member) + ev = create(:event, :microcosm => mm.microcosm) + ea_orig = build(:event_attendance, :user => mm.user, :event => ev) + session_for(mm.user) + + # act + assert_difference "EventAttendance.count", 1 do + post event_attendances_url, :params => { :event_attendance => ea_orig.as_json }, :xhr => true + end + + # assert + assert_redirected_to event_path(ev) + ea_new_id = EventAttendance.maximum(:id) + assert_equal I18n.t("event_attendances.create.success"), flash[:notice] + ea_new = EventAttendance.find(ea_new_id) + # Assign the new id to the original object, so we can do an equality test easily. + ea_orig.id = ea_new.id + assert_equal(ea_orig, ea_new) + end + + def test_create_when_non_member + # arrange + m = create(:microcosm) + u = create(:user) + ev = create(:event, :microcosm => m) + ea_orig = build(:event_attendance, :user => u, :event => ev) + session_for(u) + + # act + assert_difference "EventAttendance.count", 0 do + post event_attendances_url, :params => { :event_attendance => ea_orig.as_json }, :xhr => true + end + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_create_when_save_fails + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + + e = create(:event, :microcosm => mm.microcosm) + ea = create(:event_attendance, :event => e) + # Customize this instance. + def ea.save + false + end + + controller_mock = EventAttendancesController.new + def controller_mock.render(_partial) + # TODO: Would be nice to verify :new was rendered. + end + + # act + EventAttendancesController.stub :new, controller_mock do + EventAttendance.stub :new, ea do + assert_difference "EventAttendance.count", 0 do + post event_attendances_url, :params => { :event_attendance => ea.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("event_attendances.create.failure"), flash[:alert] + end + + def test_update_success + # arrange + mm = create(:microcosm_member) + ev = create(:event, :microcosm => mm.microcosm) + ea1 = create(:event_attendance, :user => mm.user, :event => ev, :intention => "yes") # original object + ea2 = build(:event_attendance, :user => mm.user, :event => ev, :intention => "no") # new data + session_for(mm.user) + + # act + # Update m1 with the values from m2. + put event_attendance_url(ea1), :params => { :event_attendance => ea2.as_json }, :xhr => true + + # assert + assert_redirected_to event_path(ev) + assert_equal I18n.t("event_attendances.update.success"), flash[:notice] + ea1.reload + # Assign the id of object 1 to object 2, so we can do an equality test easily. + ea2.id = ea1.id + assert_equal(ea2, ea1) + end + + def test_update_as_wrong_user + # arrange + mm = create(:microcosm_member) + ev = create(:event, :microcosm => mm.microcosm) + ea1 = create(:event_attendance, :user => mm.user, :event => ev) # original object + u2 = create(:user) + ea2 = build(:event_attendance, :user => u2, :event => ev) # new data + session_for(u2) + + # act + # Update m1 with the values from m2. + put event_attendance_url(ea1), :params => { :event_attendance => ea2.as_json }, :xhr => true + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_update_when_save_fails + # arrange + mm = create(:microcosm_member) + ev = create(:event, :microcosm => mm.microcosm) + session_for(mm.user) + + ea = create(:event_attendance, :user => mm.user, :event => ev) # original object + def ea.update(_params) + false + end + + controller_mock = EventAttendancesController.new + def controller_mock.set_event_attendance + @event_attendance = EventAttendance.new + end + + def controller_mock.render(_partial) + # Can't do assert_equal here. + # assert_equal :edit, partial + end + + # act + EventAttendancesController.stub :new, controller_mock do + EventAttendance.stub :new, ea do + assert_difference "EventAttendance.count", 0 do + put event_attendance_url(ea), :params => { :event_attendance => ea.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("event_attendances.update.failure"), flash[:alert] + end +end diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb new file mode 100644 index 0000000000..7ca7ff376a --- /dev/null +++ b/test/controllers/events_controller_test.rb @@ -0,0 +1,267 @@ +require "test_helper" +require "minitest/mock" + +class EventsControllerTest < ActionDispatch::IntegrationTest + def test_routes + assert_routing( + { :path => "/events", :method => :get }, + { :controller => "events", :action => "index" } + ) + assert_routing( + { :path => "/events/new", :method => :get }, + { :controller => "events", :action => "new" } + ) + assert_routing( + { :path => "/events", :method => :post }, + { :controller => "events", :action => "create" } + ) + assert_routing( + { :path => "/events/1", :method => :get }, + { :controller => "events", :action => "show", :id => "1" } + ) + assert_routing( + { :path => "/events/1/edit", :method => :get }, + { :controller => "events", :action => "edit", :id => "1" } + ) + assert_routing( + { :path => "/events/1", :method => :patch }, + { :controller => "events", :action => "update", :id => "1" } + ) + # No ability in cancancan yet. + # assert_routing( + # { :path => "/events/1", :method => :delete }, + # { :controller => "events", :action => "destroy", :id => "1" } + # ) + end + + def test_index_get_future + # arrange + e = create(:event) + # act + get events_path + # assert + check_page_basics + assert_template "index" + assert_match e.title, response.body + end + + def test_index_get_past + # arrange + e = create(:event, :moment => Time.now - 1000) + # act + get events_path + # assert + check_page_basics + assert_template "index" + assert_no_match e.title, response.body + end + + def test_show_get + # arrange + e = create(:event) + # act + get event_path(e) + # assert + check_page_basics + # assert_template("show") + assert_match e.title, response.body + assert_match e.description, response.body + assert_match e.location, response.body + end + + def test_new_no_login + # Make sure that you are redirected to the login page when you + # are not logged in + # act + # There must be microcosm to build an event against. + m = create(:microcosm) + params = { :event => { :microcosm_id => m.id } } + get new_event_path(params) + # assert + assert_response :redirect + assert_redirected_to login_path(:referer => new_event_path(:params => params)) + end + + def test_new_form + # Now try again when logged in. + # There must be microcosm to build an event against. + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + # act + get new_event_path(:event => { :microcosm_id => mm.microcosm_id }) + # assert + check_page_basics + assert_select "title", :text => /New Event/, :count => 1 + assert_select "div.content-heading", :count => 1 do + assert_select "h1", :text => /New Event/, :count => 1 + end + assert_select "div#content", :count => 1 do + assert_select "form[action='/events'][method=post]", :count => 1 do + assert_select "input#event_title[name='event[title]']", :count => 1 + assert_select "input#event_moment[name='event[moment]']", :count => 1 + assert_select "input#event_location[name='event[location]']", :count => 1 + assert_select "input#event_location_url[name='event[location_url]']", :count => 1 + assert_select "textarea#event_description[name='event[description]']", :count => 1 + assert_select "input#event_latitude[name='event[latitude]']", :count => 1 + assert_select "input#event_longitude[name='event[longitude]']", :count => 1 + assert_select "input", :count => 9 + end + end + end + + def test_new_form_non_organizer + # Now try again when logged in. There must be microcosm to build an event against. + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + # act + get new_event_path(:event => { :microcosm_id => mm.microcosm_id }) + # assert + follow_redirect! + assert_response :forbidden + end + + # also tests application_controller::nilify + def test_create_when_save_works + # arrange + mm = create(:microcosm_member, :organizer) + e_orig = build(:event, :microcosm => mm.microcosm) + session_for(mm.user) + + # act + e_new_id = nil + assert_difference "Event.count", 1 do + post events_url, :params => { :event => e_orig.as_json }, :xhr => true + e_new_id = @response.headers["Location"].split("/")[-1] + end + + # assert + e_new = Event.find(e_new_id) + # Assign the id e_new to e_orig, so we can do an equality test easily. + e_orig.id = e_new.id + assert_equal(e_orig, e_new) + end + + def test_create_as_non_organizer + # arrange + mm = create(:microcosm_member) + ev = build(:event, :microcosm => mm.microcosm) + session_for(mm.user) + + # act + assert_difference "Event.count", 0 do + post events_url, :params => { :event => ev.as_json }, :xhr => true + end + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_create_when_save_fails + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + + ev = create(:event, :microcosm => mm.microcosm) + # Customize this instance. + def ev.save + false + end + + controller_mock = EventsController.new + def controller_mock.render(_partial) + # TODO: Would be nice to verify :new was rendered. + end + + # act + EventsController.stub :new, controller_mock do + Event.stub :new, ev do + assert_difference "Event.count", 0 do + post events_url, :params => { :event => ev.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("events.create.failure"), flash[:alert] + end + + def test_update_put_organizer + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + e1 = create(:event, :microcosm => mm.microcosm) # original object + e2 = build(:event, :microcosm => mm.microcosm) # new data + # act + put event_url(e1), :params => { :event => e2.as_json }, :xhr => true + # assert + assert_redirected_to event_path(e1) + # TODO: Is it better to use t() to translate? + assert_equal "The event was successfully updated.", flash[:notice] + e1.reload + # Assign the id of e1 to e2, so we can do an equality test easily. + e2.id = e1.id + assert_equal(e2, e1) + end + + def test_update_put_non_organizer + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + e1 = create(:event, :microcosm => mm.microcosm) # original object + e2 = build(:event, :microcosm => mm.microcosm) # new data + # act + put event_url(e1), :params => { :event => e2.as_json }, :xhr => true + # assert + follow_redirect! + assert_response :forbidden + end + + def test_update_put_failure + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + ev = create(:event, :microcosm => mm.microcosm) + def ev.update(_params) + false + end + + controller_mock = EventsController.new + def controller_mock.set_event + @event = Event.new + end + + def controller_mock.render(_partial) + # Can't do assert_equal here. + # assert_equal :edit, partial + end + + # act + EventsController.stub :new, controller_mock do + Event.stub :new, ev do + assert_difference "Event.count", 0 do + put event_url(ev), :params => { :event => ev.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("events.update.failure"), flash[:alert] + end + + def test_in_past_warns + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + ev = create(:event, :microcosm => mm.microcosm) + ev.moment = Time.new - 1000 + + # act + post events_url, :params => { :event => ev.attributes } + + # assert + assert_equal I18n.t("events.show.past"), flash[:warning] + end +end diff --git a/test/controllers/microcosm_members_controller_test.rb b/test/controllers/microcosm_members_controller_test.rb new file mode 100644 index 0000000000..5fc6c02b0f --- /dev/null +++ b/test/controllers/microcosm_members_controller_test.rb @@ -0,0 +1,179 @@ +require "test_helper" +require "minitest/mock" + +class MicrocosmMemberControllerTest < ActionDispatch::IntegrationTest + test "test routes" do + assert_routing( + { :path => "/microcosm_members", :method => :post }, + { :controller => "microcosm_members", :action => "create" } + ) + assert_routing( + { :path => "/microcosm_members/1/edit", :method => :get }, + { :controller => "microcosm_members", :action => "edit", :id => "1" } + ) + assert_routing( + { :path => "/microcosm_members/1", :method => :put }, + { :controller => "microcosm_members", :action => "update", :id => "1" } + ) + assert_routing( + { :path => "/microcosm_members/new", :method => :get }, + { :controller => "microcosm_members", :action => "new" } + ) + end + + def test_create_when_save_works + # arrange + u = create(:user) + session_for(u) + m = create(:microcosm) + mm_orig = build(:microcosm_member, :microcosm => m, :user => u) + + # act + assert_difference "MicrocosmMember.count", 1 do + post microcosm_members_url, :params => { :microcosm_member => mm_orig.as_json }, :xhr => true + end + + # assert + # Redirect goes to microcosm, not microcosm_member. + assert_redirected_to microcosm_path(m) + # The URL doesn't have the id of the object created, so do this. + mm_new_id = MicrocosmMember.maximum(:id) + + follow_redirect! + assert_equal I18n.t("microcosm_members.create.success"), flash[:notice] + mm_new = MicrocosmMember.find(mm_new_id) + # Assign the id m_new to m_orig, so we can do an equality test easily. + mm_orig.id = mm_new.id + assert_equal(mm_orig, mm_new) + assert_equal mm_new.user, u + end + + # This test could also mock controller.save like the other tests. + def test_create_when_save_fails + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + + mm2 = create(:microcosm_member, :microcosm => mm.microcosm) + # Customize this instance. + def mm2.save + false + end + + # act + MicrocosmMember.stub :new, mm2 do + assert_difference "MicrocosmMember.count", 0 do + post microcosm_members_url, :params => { :microcosm_member => mm.as_json }, :xhr => true + end + end + + # assert + # Redirect goes to microcosm, not microcosm_member. + assert_redirected_to microcosm_path(mm.microcosm) + follow_redirect! + assert_equal I18n.t("microcosm_members.create.failure"), flash[:alert] + end + + def test_update_as_a_different_user + # arrange + mm = create(:microcosm_member) # N.b. not an organizer + session_for(create(:user)) + + # act + put microcosm_member_url(mm), :params => { :microcosm_member => mm.as_json }, :xhr => true + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_update_when_save_works + # arrange + mm1 = create(:microcosm_member, :organizer) # original + session_for(mm1.user) + mm2 = build(:microcosm_member) # new data + + # act + # Update mm1 with the values from mm2. + put microcosm_member_url(mm1), :params => { :microcosm_member => mm2.as_json }, :xhr => true + + # assert + mm1.reload + # Assign the id of m1 to m2, so we can do an equality test easily. + mm2.id = mm1.id + assert_equal(mm2, mm1) + end + + def test_update_when_save_fails + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + mm.role = "asdf" # assume does not exist + + # act + put microcosm_member_url(mm), :params => { :microcosm_member => mm.as_json }, :xhr => true + + # assert + assert_response :success + assert_template "microcosm_members/edit" + end + + def test_delete_as_a_different_user + # arrange + mm = create(:microcosm_member) # N.b. not an organizer + session_for(create(:user)) + + # act + delete microcosm_member_url(mm), :xhr => true + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_delete_when_destroy_works + # arrange + mm1 = create(:microcosm_member, :organizer) # original + session_for(mm1.user) + mm2 = create(:microcosm_member, :microcosm => mm1.microcosm) + + # act + delete microcosm_member_url(mm2), :xhr => true + + # assert + mm1.reload + assert_not mm1.microcosm.member?(mm2.user) + end + + def test_delete_when_destroy_fails + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + # Customize this instance so delete returns false. + def mm.destroy + false + end + + controller_mock = MicrocosmMembersController.new + def controller_mock.set_microcosm_member + @microcosm_member = MicrocosmMember.new + end + + def controller_mock.render(_partial) + # Can't do assert_equal here. + # assert_equal :edit, partial + end + + # act + MicrocosmMembersController.stub :new, controller_mock do + MicrocosmMember.stub :new, mm do + assert_difference "MicrocosmMember.count", 0 do + delete microcosm_member_url(mm), :xhr => true + end + end + end + + # assert + assert_match(/#{I18n.t("microcosm_members.destroy.failure")}/, flash[:error]) + end +end diff --git a/test/controllers/microcosms_controller_test.rb b/test/controllers/microcosms_controller_test.rb new file mode 100644 index 0000000000..0abca4a490 --- /dev/null +++ b/test/controllers/microcosms_controller_test.rb @@ -0,0 +1,390 @@ +require "test_helper" +require "minitest/mock" + +class MicrocosmsControllerTest < ActionDispatch::IntegrationTest + ## + # test all routes which lead to this controller + # Following guidance from Ruby on Rails Guide + # https://guides.rubyonrails.org/testing.html#functional-tests-for-your-controllers + # + def test_routes + assert_routing( + { :path => "/microcosms", :method => :get }, + { :controller => "microcosms", :action => "index" } + ) + assert_routing( + { :path => "/microcosms/1", :method => :get }, + { :controller => "microcosms", :action => "show", :id => "1" } + ) + assert_routing( + { :path => "/microcosms/mdc", :method => :get }, + { :controller => "microcosms", :action => "show", :id => "mdc" } + ) + assert_routing( + { :path => "/microcosms/mdc/members", :method => :get }, + { :controller => "microcosms", :action => "show_members", :id => "mdc" } + ) + assert_routing( + { :path => "/microcosms/mdc/events", :method => :get }, + { :controller => "microcosms", :action => "show_events", :id => "mdc" } + ) + assert_routing( + { :path => "/microcosms/mdc/edit", :method => :get }, + { :controller => "microcosms", :action => "edit", :id => "mdc" } + ) + assert_routing( + { :path => "/microcosms/mdc", :method => :put }, + { :controller => "microcosms", :action => "update", :id => "mdc" } + ) + assert_routing( + { :path => "/microcosms/new", :method => :get }, + { :controller => "microcosms", :action => "new" } + ) + assert_routing( + { :path => "/microcosms", :method => :post }, + { :controller => "microcosms", :action => "create" } + ) + end + + def test_index_get_too_few_members + # arrange + m = create(:microcosm) + # act + get microcosms_path + # assert + check_page_basics + assert_template "index" + assert_no_match m.name, response.body + end + + def test_index_get + # arrange + m = create(:microcosm) + create(:microcosm_member, :microcosm => m) + create(:microcosm_member, :microcosm => m) + create(:microcosm_member, :microcosm => m) + # act + get microcosms_path + # assert + check_page_basics + assert_template "index" + assert_match m.name, response.body + end + + def test_show_get + # arrange + m = create(:microcosm) + # act + get microcosm_path(m) + # assert + check_page_basics + # assert_template("show") + assert_match m.name, response.body + assert_match m.description, response.body + end + + def test_show_members_get + # arrange + mm = create(:microcosm_member) + # act + get members_of_microcosm_path(mm.microcosm) + # assert + check_page_basics + assert_match mm.user.display_name, response.body + end + + def test_show_events_get + # arrange + e = create(:event) + # act + get events_of_microcosm_path(e.microcosm) + # assert + check_page_basics + assert_match e.title, response.body + end + + def test_edit_get_no_session + # arrange + m = create(:microcosm) + # act + get edit_microcosm_path(m) + # assert + assert_response :redirect + assert_redirected_to login_path(:referer => edit_microcosm_path(m)) + end + + def test_edit_get_is_not_member_is_not_organizer + # arrange + m = create(:microcosm) + user = create(:user) + session_for(user) + # act + get edit_microcosm_path(m) + # assert + follow_redirect! + assert_response :forbidden + end + + def test_edit_get_is_member_not_organizer + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + # act + get edit_microcosm_path(mm.microcosm) + # assert + follow_redirect! + assert_response :forbidden + end + + def test_edit_get_is_organizer + # arrange + mm = create(:microcosm_member, :organizer) + # We need to reload the object from PG because the floats in Ruby translate + # to double precision in PG and will actually loose 1 digit of precision. PG + # says 15, but it doesn't get that. Reload so values below are correct. + mm.reload + session_for(mm.user) + # act + get edit_microcosm_path(mm.microcosm) + # assert + check_page_basics + assert_select "div#content", :count => 1 do + assert_select "form[action='/microcosms/#{mm.microcosm.slug}'][method=post]", :count => 1 do + assert_select "input#microcosm_location[name='microcosm[location]'][value='#{mm.microcosm.location}']", :count => 1 + assert_select "input#microcosm_latitude[name='microcosm[latitude]'][value='#{mm.microcosm.latitude}']", :count => 1 + assert_select "input#microcosm_longitude[name='microcosm[longitude]'][value='#{mm.microcosm.longitude}']", :count => 1 + assert_select "input#microcosm_min_lat[name='microcosm[min_lat]'][value='#{mm.microcosm.min_lat}']", :count => 1 + assert_select "input#microcosm_max_lat[name='microcosm[max_lat]'][value='#{mm.microcosm.max_lat}']", :count => 1 + assert_select "input#microcosm_min_lon[name='microcosm[min_lon]'][value='#{mm.microcosm.min_lon}']", :count => 1 + assert_select "input#microcosm_max_lon[name='microcosm[max_lon]'][value='#{mm.microcosm.max_lon}']", :count => 1 + assert_select "textarea#microcosm_description[name='microcosm[description]']", :text => mm.microcosm.description, :count => 1 + assert_select "input", :count => 11 + end + end + end + + def test_update_success + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + m1 = mm.microcosm # original object + m2 = build(:microcosm) # new data + + # act + # Update m1 with the values from m2. + put microcosm_url(m1), :params => { :microcosm => m2.as_json }, :xhr => true + + # assert + assert_redirected_to microcosm_path(m1) + assert_equal I18n.t("microcosms.update.success"), flash[:notice] + m1.reload + # Assign the id of m1 to m2, so we can do an equality test easily. + m2.id = m1.id + assert_equal(m2, m1) + end + + # TODO: Really we should test abilities separately + # https://github.com/CanCanCommunity/cancancan/wiki/Testing-Abilities + def test_update_success_as_non_organizer + # arrange + mm = create(:microcosm_member) + # mm = create(:microcosm_member, :user => mm.user) + session_for(mm.user) + m1 = mm.microcosm # original object + m2 = build(:microcosm) # new data + + # act + # Update m1 with the values from m2. + put microcosm_url(m1), :params => { :microcosm => m2.as_json }, :xhr => true + + # assert + follow_redirect! + assert_response :forbidden + end + + def test_update_failure + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + m1 = mm.microcosm # original object + def m1.update(_params) + false + end + + controller_mock = MicrocosmsController.new + def controller_mock.set_microcosm + @microcosm = Microcosm.new + end + + def controller_mock.render(_partial) + # Can't do assert_equal here. + # assert_equal :edit, partial + end + + # act + MicrocosmsController.stub :new, controller_mock do + Microcosm.stub :new, m1 do + assert_difference "Microcosm.count", 0 do + put microcosm_url(m1), :params => { :microcosm => m1.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("microcosms.update.failure"), flash[:alert] + end + + def test_new_no_login + # Make sure that you are redirected to the login page when you + # are not logged in + # act + get new_microcosm_path + # assert + assert_response :redirect + assert_redirected_to login_path(:referer => new_microcosm_path) + end + + def test_new_form + # Now try again when logged in + # arrange + session_for(create(:user)) + # act + get new_microcosm_path + # assert + check_page_basics + assert_select "title", :text => /New Microcosm/, :count => 1 + assert_select "div.content-heading", :count => 1 do + assert_select "h1", :text => /New Microcosm/, :count => 1 + end + assert_select "div#content", :count => 1 do + assert_select "form[action='/microcosms'][method=post]", :count => 1 do + assert_select "input#microcosm_location[name='microcosm[location]']", :count => 1 + assert_select "input#microcosm_latitude[name='microcosm[latitude]']", :count => 1 + assert_select "input#microcosm_longitude[name='microcosm[longitude]']", :count => 1 + assert_select "input#microcosm_min_lat[name='microcosm[min_lat]']", :count => 1 + assert_select "input#microcosm_max_lat[name='microcosm[max_lat]']", :count => 1 + assert_select "input#microcosm_min_lon[name='microcosm[min_lon]']", :count => 1 + assert_select "input#microcosm_max_lon[name='microcosm[max_lon]']", :count => 1 + assert_select "textarea#microcosm_description[name='microcosm[description]']", :count => 1 + assert_select "input", :count => 10 + end + end + end + + # also tests add_first_organizer + def test_create_when_save_works + # arrange + u = create(:user) + session_for(u) + m_orig = create(:microcosm) + + # act + m_new_slug = nil + assert_difference "Microcosm.count", 1 do + post microcosms_url, :params => { :microcosm => m_orig.as_json }, :xhr => true + m_new_slug = @response.headers["Location"].split("/")[-1] + end + + # assert + assert_equal I18n.t("microcosms.create.success"), flash[:notice] + m_new = Microcosm.find_by(:slug => m_new_slug) + # Assign the id m_new to m_orig, so we can do an equality test easily. + m_orig.id = m_new.id + assert_equal(m_orig, m_new) + assert_equal m_new.organizers[0].user, u + end + + def test_create_when_save_fails + # arrange + u = create(:user) + session_for(u) + m = create(:microcosm) + + # Can't stub :save on Microcosm because save is not a method that Microcosm + # will respond_to? Only an instance of Microcosm will respond_to :save. + + mic_mock = Minitest::Mock.new + mic_mock.expect :save, false + + # Not a true Mock, because I needs the rest of the controller's methods there. + # num_calls = 0 + controller_mock = MicrocosmsController.new + def controller_mock.render(_partial) + # Evidently it's not even called, but if it's not overridden, rendering + # will happen and dive into new.html.erb and _form.html.erb. That + # necessitates mocking more methods. + # + # assert_equal "new", partial + # num_calls += 1 + end + # assert_equal 1, num_calls # TODO: It would be nice if we could do this. + + # act + MicrocosmsController.stub :new, controller_mock do + Microcosm.stub :new, mic_mock do + assert_difference "Microcosm.count", 0 do + post microcosms_url, :params => { :microcosm => m.as_json }, :xhr => true + end + end + end + + # assert + assert_equal I18n.t("microcosms.create.failure"), flash[:alert] + end + + def test_step_up_non_member + # arrange + u = create(:user) + session_for(u) + m = create(:microcosm) + # act + post step_up_url(m) + follow_redirect! + # assert + assert_equal "Only members can step up.", flash[:notice] + end + + def test_step_up_member + # arrange + mm = create(:microcosm_member) + session_for(mm.user) + # act + post step_up_url(mm.microcosm) + follow_redirect! + # assert + assert_equal "You have stepped up.", flash[:notice] + end + + def test_step_up_already_has_organizer + # arrange + mm = create(:microcosm_member, :organizer) + session_for(mm.user) + # act + post step_up_url(mm.microcosm) + follow_redirect! + # assert + assert_equal "This microcosm already has an organizer.", flash[:notice] + end + + def test_create_with_coords_out_of_range + # arrange + u = create(:user) + session_for(u) + m_orig = create(:microcosm) + m_orig.longitude = -200 + + # act + m_new_slug = nil + assert_difference "Microcosm.count", 1 do + post microcosms_url, :params => { :microcosm => m_orig.as_json }, :xhr => true + m_new_slug = @response.headers["Location"].split("/")[-1] + end + + # assert + assert_equal I18n.t("microcosms.create.success"), flash[:notice] + m_new = Microcosm.find_by(:slug => m_new_slug) + # Assign the id m_new to m_orig, so we can do an equality test easily. + m_orig.id = m_new.id + assert_equal 160, m_new.longitude + end +end diff --git a/test/factories/event_attendances.rb b/test/factories/event_attendances.rb new file mode 100644 index 0000000000..bc19c9bdd8 --- /dev/null +++ b/test/factories/event_attendances.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :event_attendance do + user + event + intention { "yes" } + + trait :no do + intention { "no" } + end + end +end diff --git a/test/factories/event_organizers.rb b/test/factories/event_organizers.rb new file mode 100644 index 0000000000..175c60eaf5 --- /dev/null +++ b/test/factories/event_organizers.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :event_organizer do + event + user + end +end diff --git a/test/factories/events.rb b/test/factories/events.rb new file mode 100644 index 0000000000..8c02840b1d --- /dev/null +++ b/test/factories/events.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :event do + sequence(:title) { |n| "Title #{n}" } + moment { Time.now + 1000 } + sequence(:location) { |n| "Location #{n}" } + sequence(:location_url) { |n| "http://example.com/app/#{n}" } + sequence(:description) { |n| "Description #{n}" } + microcosm + latitude { rand(-90.0...90.0) } + longitude { rand(-180.0...180.0) } + + # TODO: trait for event with attendees + end +end diff --git a/test/factories/microcosm_links.rb b/test/factories/microcosm_links.rb new file mode 100644 index 0000000000..52b9dbcd5a --- /dev/null +++ b/test/factories/microcosm_links.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :microcosm_link do + microcosm + site { "website" } + url { "http://example.com" } + end +end diff --git a/test/factories/microcosm_members.rb b/test/factories/microcosm_members.rb new file mode 100644 index 0000000000..1a50794c40 --- /dev/null +++ b/test/factories/microcosm_members.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :microcosm_member do + microcosm + user + role { MicrocosmMember::Roles::MEMBER } + + trait :organizer do + role { MicrocosmMember::Roles::ORGANIZER } + end + end +end diff --git a/test/factories/microcosms.rb b/test/factories/microcosms.rb new file mode 100644 index 0000000000..942a10c082 --- /dev/null +++ b/test/factories/microcosms.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :microcosm do + sequence(:name) { |n| "Microcosm #{n}" } + sequence(:description) { |n| "This is description #{n}" } + sequence(:location) { |n| "This is location #{n}" } + latitude { rand(-90.0...90.0) } + longitude { rand(-180.0...180.0) } + min_lat { rand(-90.0...90.0) } + max_lat { rand(-90.0...90.0) } + min_lon { rand(-180.0...180.0) } + max_lon { rand(-180.0...180.0) } + end +end diff --git a/test/helpers/event_helper_test.rb b/test/helpers/event_helper_test.rb new file mode 100644 index 0000000000..c2846ba0e9 --- /dev/null +++ b/test/helpers/event_helper_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class EventHelperTest < ActionView::TestCase + def test_event_location_url + event = create(:event) + location = event_location(event) + assert_match %r{^#{event.location}$}, location + end + + def test_event_location_no_url + event = create(:event, :location_url => nil) + location = event_location(event) + assert_match(/^#{event.location}$/, location) + end +end diff --git a/test/models/event_attendance_test.rb b/test/models/event_attendance_test.rb new file mode 100644 index 0000000000..dd8c492497 --- /dev/null +++ b/test/models/event_attendance_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EventAttendanceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/event_organizer_test.rb b/test/models/event_organizer_test.rb new file mode 100644 index 0000000000..0cd6db89b7 --- /dev/null +++ b/test/models/event_organizer_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class EventOrganizerTest < ActiveSupport::TestCase + def test_eventorganizer_validations + validate({}, true) + end +end diff --git a/test/models/event_test.rb b/test/models/event_test.rb new file mode 100644 index 0000000000..8ddc87b9c3 --- /dev/null +++ b/test/models/event_test.rb @@ -0,0 +1,99 @@ +require "test_helper" + +class EventTest < ActiveSupport::TestCase + def test_validations + validate({}, true) + + validate({ :moment => nil }, false) + validate({ :moment => "" }, false) + validate({ :moment => "not a timestamp" }, false) + validate({ :moment => "3030-30-30T30:30" }, false) + + validate({ :location => nil }, true) + validate({ :location => "" }, false) + + validate({ :location => "a" * 255 }, true) + validate({ :location => "a" * 256 }, false) + + validate({ :location_url => nil }, true) + validate({ :location_url => "" }, false) + + validate({ :location_url => "foo" }, false) + scheme = "https://" + validate({ :location_url => scheme + "a" * (255 - scheme.length) }, true) + validate({ :location_url => scheme + "a" * (256 - scheme.length) }, false) + + validate({ :latitude => 90 }, true) + validate({ :latitude => 90.00001 }, false) + validate({ :latitude => -90 }, true) + validate({ :latitude => -90.00001 }, false) + + validate({ :longitude => 180 }, true) + validate({ :longitude => 180.00001 }, false) + validate({ :longitude => -180 }, true) + validate({ :longitude => -180.00001 }, false) + end + + def test_location + event = build(:event) + assert event.location? + event.location = nil + assert_not event.location? + end + + def test_location_url + event = build(:event) + assert event.location_url? + event.location_url = nil + assert_not event.location_url? + end + + def test_attendees + # Zero + event = create(:event) + assert_equal(0, event.yes_attendees.length) + + # 1 Yes + ea = create(:event_attendance) + assert_equal(1, ea.event.yes_attendees.length) + assert_equal(ea.user.display_name, ea.event.yes_attendees[0].user.display_name) + + # 1 No + ea = create(:event_attendance, :no) + assert_equal(0, ea.event.yes_attendees.length) + + # 2 Yes + event = create(:event) + u1 = create(:user) + ea1 = EventAttendance.new(:event => event, :user => u1, :intention => EventAttendance::Intentions::YES) + ea1.save + u2 = create(:user) + ea2 = EventAttendance.new(:event => event, :user => u2, :intention => EventAttendance::Intentions::YES) + ea2.save + assert_equal(2, event.yes_attendees.length) + assert_equal(u1.display_name, event.yes_attendees[0].user.display_name) + assert_equal(u2.display_name, event.yes_attendees[1].user.display_name) + + # 1 Yes and 1 No + event = create(:event) + u1 = create(:user) + ea1 = EventAttendance.new(:event => event, :user => u1, :intention => EventAttendance::Intentions::NO) + ea1.save + u2 = create(:user) + ea2 = EventAttendance.new(:event => event, :user => u2, :intention => EventAttendance::Intentions::YES) + ea2.save + assert_equal(1, event.yes_attendees.length) + assert_equal(u2.display_name, event.yes_attendees[0].user.display_name) + end + + def test_organizers + # Zero + event = create(:event) + assert_equal(0, event.organizers.length) + + # One + eo = create(:event_organizer) + assert_equal(1, eo.event.organizers.length) + assert_equal(eo.user.display_name, eo.event.organizers[0].user.display_name) + end +end diff --git a/test/models/issue_test.rb b/test/models/issue_test.rb index 073dc26447..d78e227a32 100644 --- a/test/models/issue_test.rb +++ b/test/models/issue_test.rb @@ -12,6 +12,7 @@ def test_assigned_role def test_reported_user create(:language, :code => "en") user = create(:user) + microcosm_organizer = create(:microcosm_member, :organizer) note = create(:note_comment, :author => create(:user)).note anonymous_note = create(:note_comment, :author => nil).note diary_entry = create(:diary_entry) @@ -21,6 +22,10 @@ def test_reported_user issue.save! assert_equal issue.reported_user, user + issue = Issue.new(:reportable => microcosm_organizer.microcosm, :assigned_role => "administrator") + issue.save! + assert_equal issue.reported_user, microcosm_organizer.user + issue = Issue.new(:reportable => note, :assigned_role => "administrator") issue.save! assert_equal issue.reported_user, note.author diff --git a/test/models/microcosm_link_test.rb b/test/models/microcosm_link_test.rb new file mode 100644 index 0000000000..c8f15ad71c --- /dev/null +++ b/test/models/microcosm_link_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class MicrocosmLinkTest < ActiveSupport::TestCase + def test_microcosm_link_validations + validate({}, true) + + validate({ :microcosm_id => nil }, false) + validate({ :microcosm_id => "" }, false) + + validate({ :site => nil }, false) + validate({ :site => "" }, false) + + validate({ :url => nil }, false) + validate({ :url => "" }, false) + + validate({ :url => "foo" }, false) + scheme = "https://" + validate({ :url => scheme + "a" * (255 - scheme.length) }, true) + validate({ :url => scheme + "a" * (256 - scheme.length) }, false) + end +end diff --git a/test/models/microcosm_member_test.rb b/test/models/microcosm_member_test.rb new file mode 100644 index 0000000000..6eb0822c30 --- /dev/null +++ b/test/models/microcosm_member_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class MicrocosmMemberTest < ActiveSupport::TestCase + def test_microcosm_validations + validate({}, true) + + validate({ :microcosm_id => nil }, false) + validate({ :microcosm_id => "" }, false) + + validate({ :user_id => nil }, false) + validate({ :user_id => "" }, false) + + validate({ :role => "overlord" }, false) + end +end diff --git a/test/models/microcosm_test.rb b/test/models/microcosm_test.rb new file mode 100644 index 0000000000..00ffcb2a03 --- /dev/null +++ b/test/models/microcosm_test.rb @@ -0,0 +1,158 @@ +require "test_helper" + +class MicrocosmTest < ActiveSupport::TestCase + def test_microcosm_validations + validate({}, true) + + validate({ :name => nil }, false) + validate({ :name => "" }, false) + validate({ :name => "a" * 255 }, true) + validate({ :name => "a" * 256 }, false) + + validate({ :description => nil }, false) + validate({ :description => "" }, false) + validate({ :description => "a" * 1023 }, true) + validate({ :description => "a" * 1024 }, false) + + validate({ :location => nil }, false) + validate({ :location => "" }, false) + validate({ :location => "a" * 255 }, true) + validate({ :location => "a" * 256 }, false) + + validate({ :latitude => 90 }, true) + validate({ :latitude => 90.00001 }, false) + validate({ :latitude => -90 }, true) + validate({ :latitude => -90.00001 }, false) + + validate({ :longitude => 180 }, true) + validate({ :longitude => 180.00001 }, false) + validate({ :longitude => -180 }, true) + validate({ :longitude => -180.00001 }, false) + + [:min, :max].each do |extremum| + [:lat, :lon].each do |coord| # rubocop:disable Performance/CollectionLiteralInLoop + attr = "#{extremum}_#{coord}" + validate({ attr => nil }, false) + validate({ attr => -200 }, false) + validate({ attr => 200 }, false) + end + end + end + + def test_set_link_that_does_not_exist + # arrange + site_name = "site_name" + site_url = "http://example.com" + m = create(:microcosm) + # act + m.set_link(site_name, site_url) + # assert + ml = MicrocosmLink.find_by(:microcosm_id => m.id, :site => site_name) + assert_equal ml.url, site_url + end + + def test_set_link_that_does_exist + # arrange + m = create(:microcosm) + site_name = "site_name" + site_url_old = "http://example1.com" + MicrocosmLink.new(:microcosm => m, :site => site_name, :url => site_url_old) + site_url_new = "http://example2.com" + # act + m.set_link(site_name, site_url_new) + # assert + ml = MicrocosmLink.find_by(:microcosm_id => m.id, :site => site_name) + assert_equal ml.url, site_url_new + end + + def test_member_that_does_exist + # arrange + mm = create(:microcosm_member) + # act + result = mm.microcosm.member?(mm.user) + # assert + assert result + end + + def test_member_that_does_not_exists + # arrange + m = create(:microcosm) + u = create(:user) + # act + result = m.member?(u) + # assert + assert_not result + end + + def test_organizer_that_does_exist + # arrange + mm = create(:microcosm_member, :organizer) + # act + result = mm.microcosm.organizer?(mm.user) + # assert + assert result + end + + def test_organizer_that_does_not_exists + # arrange + m = create(:microcosm) + u = create(:user) + # act + result = m.organizer?(u) + # assert + assert_not result + end + + def test_organizer_that_is_member + # arrange + mm = create(:microcosm_member) # not organizer + # act + result = mm.microcosm.organizer?(mm.user) + # assert + assert_not result + end + + def test_organizer_that_is_organizer_of_other_microcosm + # arrange + mm = create(:microcosm_member, :organizer) + m = create(:microcosm) + # act + result = m.organizer?(mm.user) + # assert + assert_not result + end + + def test_organizers_zero + # arrange + m = create(:microcosm) + # act + o = m.organizers + # assert + assert_empty o + end + + def test_organizers_not_zero + # arrange + mm = create(:microcosm_member, :organizer) + # act + o = mm.microcosm.organizers + # assert + assert_equal o, [mm] + end + + def test_bbox + # arrange + m = create(:microcosm) + m.min_lat = 10 + m.max_lat = 20 + m.min_lon = 30 + m.max_lon = 40 + # act + b = m.bbox + # assert + assert_equal 10, b.min_lat + assert_equal 20, b.max_lat + assert_equal 30, b.min_lon + assert_equal 40, b.max_lon + end +end diff --git a/test/system/report_microcosm_test.rb b/test/system/report_microcosm_test.rb new file mode 100644 index 0000000000..ee3286e17a --- /dev/null +++ b/test/system/report_microcosm_test.rb @@ -0,0 +1,34 @@ +require "application_system_test_case" + +class ReportMicrocosmTest < ApplicationSystemTestCase + def setup + create(:language, :code => "en") + mm = create(:microcosm_member, :organizer) + @microcosm = mm.microcosm + end + + def test_no_link_when_not_logged_in + visit microcosm_path(@microcosm) + assert page.has_content?(@microcosm.name) + + assert_not page.has_content?(I18n.t("microcosms.show.report")) + end + + def test_it_works + sign_in_as(create(:user)) + visit microcosm_path(@microcosm) + assert page.has_content? @microcosm.name + + click_on I18n.t("microcosms.show.report") + assert page.has_content? "Report" + assert page.has_content? I18n.t("reports.new.disclaimer.intro") + + choose I18n.t("reports.new.categories.microcosm.spam_label") + fill_in "report_details", :with => "This comment is spam" + assert_difference "Issue.count", 1 do + click_on "Create Report" + end + + assert page.has_content? "Your report has been registered successfully" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index a6147ef290..4fc6380f73 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -326,6 +326,19 @@ def xml_node_for_relation(relation) el end + def check_page_basics + assert_response :success + assert_no_missing_translations + end + + def validate(attrs, result) + object_class = self.class.name.dup.sub!(/Test$/, "").underscore + object = build(object_class, attrs) + valid = object.valid? + errors = object.errors.messages + assert_equal result, valid, "Expected #{attrs.inspect} to be #{result} but #{errors}" + end + class OMHelper extend ObjectMetadata end