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| %> +
<%= notice %>
+ +<%= 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) %> | +
+ <%= 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") %> ++ <%= 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 } %> ++This development version of OpenStreetMap is for Microcosms. Register your own account on this website. Send feedback to the issue tracker or Brian DeRocher. +
+