diff --git a/.rubocop.yml b/.rubocop.yml index a95cc61eb..51a0b7630 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -61,8 +61,15 @@ Rails/ActionOrder: Metrics/ClassLength: Exclude: - "test/**/*.rb" + - "app/models/partner.rb" +Metrics/CyclomaticComplexity: + Exclude: + - app/controllers/application_controller.rb +Metrics/PerceivedComplexity: + Exclude: + - app/controllers/application_controller.rb Rails/RootPathnameMethods: Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a24d1d674..858ddb12d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -372,6 +372,8 @@ Rails/SkipsModelValidations: Exclude: - 'lib/tasks/fixes/users.rake' - 'db/migrate/20240125175508_partner_hidden_attribute_set_value.rb' + - 'db/migrate/20240411200641_partner_can_be_assigned_events_set_value.rb' + - 'db/migrate/20240422092628_set_calendar_checksum_date.rb' # Offense count: 22 # This cop supports unsafe autocorrection (--autocorrect-all). diff --git a/README.md b/README.md index 677966ede..57289e998 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,24 @@ # PlaceCal -## Introduction / Warning +## Introduction -PlaceCal is a large and very complicated app for collating organisation and event information from a large variety of sources. In the words of one developer: "I'm not sure where the interface between the app and the real world is here". +PlaceCal is an online calendar which lists events and activities by and for members of local communities, curated around interests and locality. + +The codebase doesn't currently have enough seeds to create a dev environment suitable for developing from scratch. If you're interested in contributing to PlaceCal please get in touch at support@placecal.org To get an idea of the project and what we're about, check out [the handbook](https://handbook.placecal.org/). ## Requirements -To run PlaceCal locally you will need: - -- A Mac or a Linux machine (we don't support Windows at present) -- GNU Compiler Collection (gcc) (for compiling ruby and gems) -- Docker (optional) (for isolating and automating postgres management) -- Postgres relational database. We are currently using v14. - - Server - - either installed for your distribution or as a docker image (with the correct open port -- see below) - - Client - - you will still need the local developer libraries for postgres - - these are distribution specific so you need to find out what they are called to install them - - `libpq-dev` (debian) - - `postgresql-libs` (arch) - - `dev-db/postgresql` (gentoo) -- Ruby 3.1.2 - We recommend using a version manager for this such as `rvm` or `rbenv`. Current version we are using is in `.ruby-version`. - - [rvm](https://rvm.io/) - - [rbenv](https://github.com/rbenv/rbenv) - - [ruby-build](https://github.com/rbenv/ruby-build) - - [rbenv-gemset](https://github.com/jf/rbenv-gemset) (optional) -- Node.js 16.x. We recommend using a version manager for this such as `nvm` or `nodenv`. Current version we are using is in `.node-version`. - - [nvm](https://github.com/nvm-sh/nvm) - - [nodenv](https://github.com/nodenv/nodenv) -- Yarn 1.x - - [yarn](https://classic.yarnpkg.com/en/docs/install) -- ImageMagick for image manipulation +To run PlaceCal locally you will need to install the following dependencies: + +- [Docker](https://docs.docker.com/get-docker/) +- [Ruby 3.1.2](https://www.ruby-lang.org/en/news/2022/04/12/ruby-3-1-2-released/) +- [Node.js](https://nodejs.org/en/download) 16.x & (optional) [nvm](https://github.com/nvm-sh/nvm) to manage it +- [Yarn 1.x](https://classic.yarnpkg.com/lang/en/) +- [ImageMagick](https://imagemagick.org/index.php) for image manipulation - [Graphviz](https://voormedia.github.io/rails-erd/install.html) for documentation diagrams -- Chrome/Chromium for system tests along with a matching version of [Chromedriver](https://chromedriver.chromium.org/) +- [Chrome/Chromium](https://www.chromium.org/chromium-projects/) for system tests along with a matching version of [Chromedriver](https://chromedriver.chromium.org/) ## Quickstart with docker for GFSC devs @@ -50,6 +34,15 @@ Local site is now running at `lvh.me:3000` Some other useful commands for developing can be found in the makefile. +## Troubleshooting + +If the make command fails and you can't work out why, here are some suggestions: + +- Do you have an older version of the database hanging around? Try running `rails db:drop:all` +- Is the docker daemon running? +- Is the port in use by another application or docker container? Try stopping or removing any conflicting containers +- Are you using the correct node version? Try running `nvm use` + ## Quickstart for everyone else ### Set up Postgresql locally with docker @@ -74,61 +67,28 @@ Amongst other things, this will create an admin user for you: - The admin interface is at `admin.lvh.me:3000` - Access code docs through your local filesystem and update them with `bin/rails yard` -## Testing +## Testing, linting and formatting -PlaceCal tests are written in minitest. +PlaceCal tests are written in minitest. We use Rubocop to lint our Ruby code. -Before running the tests please make sure your development environment is up to date (you can run `bin/update` to quickly do that). +We use [Prettier](https://prettier.io/) to format everything it's able to parse. We run this automatically as part of a pre-commit hook. -You can run the tests with: +You can run the tests & rubocop with: ```sh -make # will run all unit and system tests then lint check all code -rails test # will run all unit tests -rails test:system # will run system tests +make test ``` -Note that the system tests can take a while to run and are quite resource-intensive. To perform more advanced usage like executing only a specific test or test file, see the [Rails documentation on testing](https://guides.rubyonrails.org/testing.html). - ## Documentation for Developers -The documentation for PlaceCal currently stored in notion and can be read [here](https://www.notion.so/gfsc/PlaceCal-developer-handbook-01649b69009340e3ae3035e9cf346f27). There is also a small amount of documentation sprinkled throughout the code itself and can be turned into HTML by running `rails yard`. If you are working with the code and are completely lost you can also try the GFSC discord server where you can prod a human for answers. Good Luck! +The documentation for PlaceCal is currently stored in notion and can be read [here](https://handbook.placecal.org/placecal-developer-handbook). There is also a small amount of documentation sprinkled throughout the code itself and can be turned into HTML by running `rails yard`. If you are working with the code and are completely lost you can also try the [GFSC discord server](http://discord.gfsc.studio) where you can prod a human for answers. Good Luck! ### Generating new components We use view_component to make components, and you can create a new one by running `rails g component ` +Previously this system used mountain view, and some of the components are still generated using this. More info here: https://viewcomponent.org/guide/generators.html -## Formatting - -We use [Prettier](https://prettier.io/) to format everything it's able to parse. It will run as a pre-commit hook and format your changes as you make commits so you shouldn't have to think about it much. - -If you do want to run it manually, you can: - -```sh -bin/yarn run format -``` - -It's also run for you by the test runner. - -Note that we use tabs over spaces because [tabs are more accessible to people using braille displays](https://twitter.com/Rich_Harris/status/1541761871585464323). - -## Linting - -We use Rubocop to lint our Ruby code. Because of the time it can take to run, this is a manual step: - -```sh -bin/bundle exec rubocop --autocorrect -``` - -It's also run for you by the test runner. - -## Contributing - -We welcome new contributors but strongly recommend you have a chat with us in [Geeks for Social Change's Discord server](http://discord.gfsc.studio) and say hi before you do. We will be happy to onboard you properly before you get stuck in. - ## Donations -If you'd like to support development, please consider sending us a one-off or regular donation on Ko-fi. - -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4M43THUM) +If you'd like to support development, please consider sending us a one-off or regular donation on Ko-fi. You can do this through the "support" button in GitHub at the top of this repo. diff --git a/app/assets/stylesheets/base/grid.scss b/app/assets/stylesheets/base/grid.scss index bbd40fa22..5123b4544 100644 --- a/app/assets/stylesheets/base/grid.scss +++ b/app/assets/stylesheets/base/grid.scss @@ -116,6 +116,24 @@ $column: 17.38317%; } } +.three-col { + -moz-column-count: 3; + -webkit-column-count: 3; + column-count: 3; + -webkit-column-width: 20rem; + -moz-column-width: 20rem; + column-width: 20rem; + margin-top: 1.35rem !important; + + li:first-of-type { + margin-top: -1.35rem; + } + + a { + display: inline-block; + } +} + @supports (display: grid) { .two-col { display: grid; @@ -133,6 +151,22 @@ $column: 17.38317%; } } + .three-col { + display: grid; + grid-template-columns: 1fr; + + @include for-tablet-landscape-up { + grid-template-columns: 1fr 1fr 1fr; + } + + grid-gap: 1rem; + align-items: start; + + li:first-of-type { + margin-top: 0; + } + } + .g--partner { display: grid; grid-template-columns: 1fr; diff --git a/app/components/address/address_component.rb b/app/components/address/address_component.rb index 5315e313c..7641d8257 100644 --- a/app/components/address/address_component.rb +++ b/app/components/address/address_component.rb @@ -12,6 +12,10 @@ def formatted_address return address_lines.join(',
').html_safe end + uri = URI.parse(raw_location) + "#{uri.hostname}".html_safe + + rescue URI::InvalidURIError raw_location end end diff --git a/app/components/event/_event.html.erb b/app/components/event/_event.html.erb index 8798b8484..8fd159d97 100644 --- a/app/components/event/_event.html.erb +++ b/app/components/event/_event.html.erb @@ -34,11 +34,11 @@ Online <% end %> - <% if place || first_address_line %> + <% if partner_at_location || first_address_line %>
- <% if place %> - <%= link_to place, partner_path(place) %> + <% if partner_at_location %> + <%= link_to partner_at_location, partner_path(partner_at_location) %> <% elsif first_address_line %> <%= first_address_line %> <% end %> diff --git a/app/components/event/event_component.rb b/app/components/event/event_component.rb index 833762fe5..e3148f00c 100644 --- a/app/components/event/event_component.rb +++ b/app/components/event/event_component.rb @@ -8,8 +8,7 @@ class EventComponent < MountainView::Presenter include ActionView::Helpers::DateHelper delegate :id, to: :event - - delegate :place, to: :event + delegate :partner_at_location, to: :event def time if event.dtend diff --git a/app/components/event_list/_event_list.html.erb b/app/components/event_list/_event_list.html.erb index 7a914539c..6327b5242 100644 --- a/app/components/event_list/_event_list.html.erb +++ b/app/components/event_list/_event_list.html.erb @@ -24,5 +24,5 @@ <% end %> <% else %> -

No events with this selection.

+

No events with this selection.<%= link_to 'skip to next date with events.', next_url(@next) if @next.present? %>

<% end %> diff --git a/app/components/paginator/_paginator.html.erb b/app/components/paginator/_paginator.html.erb index 1db5bed2a..6fa3a3956 100644 --- a/app/components/paginator/_paginator.html.erb +++ b/app/components/paginator/_paginator.html.erb @@ -23,13 +23,17 @@
- <%= radio_button_tag(:period, "day", period == 1.day) %> + <%= radio_button_tag(:period, "day", period == 'day') %> <%= label_tag(:period_day, "Daily view") %>
- <%= radio_button_tag(:period, "week", period == 1.week) %> + <%= radio_button_tag(:period, "week", period == 'week') %> <%= label_tag(:period_week, "Weekly view") %>
+
+ <%= radio_button_tag(:period, "future" , period == 'future') %> + <%= label_tag(:period_future, "Show all") %> +

@@ -54,13 +58,15 @@ <% end %>
-
    - <% paginator.each do |page| %> -
  1. - <%= link_to page[:text], page[:link] %> -
  2. - <% end %> -
+ <% if @period == 'day' || @period == 'week' %> +
    + <% paginator.each do |page| %> +
  1. + <%= link_to page[:text], page[:link] %> +
  2. + <% end %> +
+ <% end %>
diff --git a/app/components/paginator/paginator_component.rb b/app/components/paginator/paginator_component.rb index 8956262ba..7eb45973e 100644 --- a/app/components/paginator/paginator_component.rb +++ b/app/components/paginator/paginator_component.rb @@ -13,11 +13,11 @@ def paginator pages = [] # Create backward arrow link pages << { text: back_arrow, - link: create_event_url(pointer - period), + link: create_event_url(pointer - step), css: 'paginator__arrow paginator__arrow--back js-back' } # Create in-between links according to steps requested (0..steps).each do |i| - day = pointer + (period * i) + day = pointer + (step * i) css = active?(day) ? 'active js-button' : 'js-button' pages << { text: format_date(day), link: create_event_url(day), @@ -25,13 +25,13 @@ def paginator end # Create forwards arrow link pages << { text: forward_arrow, - link: create_event_url(pointer + period), + link: create_event_url(pointer + step), css: 'paginator__arrow paginator__arrow--forwards js-forwards' } end # Paginator title def title - if period <= 1.day + if step <= 1.day # Thursday 14 September, 2017 pointer.strftime('%A %e %B, %Y') else @@ -39,7 +39,7 @@ def title t = pointer.strftime('%A %e %B') t += ' - ' # FIXME: 1.day needs sorting when we add in month views - t + (pointer + period - 1.day).strftime('%A %e %B %Y') + t + (pointer + step - 1.day).strftime('%A %e %B %Y') end end @@ -49,7 +49,7 @@ def sort end # How far does each step take us? - def period + def step properties[:period] == 'week' ? 1.week : 1.day end @@ -67,7 +67,7 @@ def steps # Which day are we doing our calculations based on? def pointer - if period == 1.week + if step == 1.week # This should be set by the model, but just to be sure properties[:pointer].beginning_of_week else @@ -77,7 +77,7 @@ def pointer # Format date according to context def format_date(date) - if period <= 1.day + if step <= 1.day todayify(date) else weekify(date) @@ -101,7 +101,7 @@ def todayify(date) # Format the button for a week of events def weekify(date) today = Date.today - end_date = date + period - 1.day + end_date = date + step - 1.day date_fmt = if date.month == end_date.month "#{date.strftime('%e')} - #{end_date.strftime('%e %b')}" else @@ -124,7 +124,7 @@ def create_event_url(dt) # URL params to add back in def url_suffix str = [] - str << 'period=week' if period == 1.week + str << "period=#{period}" str << "sort=#{sort}" if sort str << "repeating=#{repeating}" if repeating "?#{str.join('&')}" if str.any? diff --git a/app/controllers/admin/calendars_controller.rb b/app/controllers/admin/calendars_controller.rb index 8fe60537d..9d6958902 100644 --- a/app/controllers/admin/calendars_controller.rb +++ b/app/controllers/admin/calendars_controller.rb @@ -42,8 +42,8 @@ def create authorize @calendar if @calendar.save - flash[:success] = 'Successfully created new calendar' redirect_to edit_admin_calendar_path(@calendar) + flash[:success] = 'New calendar created and queued for importing. Please check back in a few minutes.' else flash.now[:danger] = 'Calendar did not save' render 'new', status: :unprocessable_entity @@ -74,10 +74,8 @@ def destroy end def import - date = Time.zone.parse(params[:starting_from]) force_import = true - # CalendarImporterJob.perform_now @calendar.id, date, force_import - @calendar.queue_for_import! force_import, date + @calendar.queue_for_import! force_import flash[:success] = 'Calendar added to the import queue' redirect_to edit_admin_calendar_path(@calendar) @@ -109,7 +107,8 @@ def calendar_params :importer_mode, :public_contact_name, :public_contact_phone, - :public_contact_email + :public_contact_email, + :checksum_updated_at ) end end diff --git a/app/controllers/admin/partners_controller.rb b/app/controllers/admin/partners_controller.rb index ba9c6a691..7e89448da 100644 --- a/app/controllers/admin/partners_controller.rb +++ b/app/controllers/admin/partners_controller.rb @@ -46,7 +46,7 @@ def create if @partner.save format.html do flash[:success] = 'Partner was successfully created.' - redirect_to admin_partners_path + redirect_to edit_admin_partner_path(@partner) end format.json { render :show, status: :created, location: @partner } @@ -71,8 +71,6 @@ def update mutated_params = permitted_attributes(@partner) - before = @partner.hidden - @partner.accessed_by_user = current_user # prevent someone trying to add the same service_area twice by mistake and causing a crash @@ -147,22 +145,6 @@ def lookup_name render json: { name_available: found.nil? } end - def setup - @partner = Partner.new - authorize @partner - - render and return unless request.post? - - @partner.attributes = setup_params - @partner.accessed_by_user = current_user - - if @partner.valid? - redirect_to new_admin_partner_url(partner: setup_params) - else - render 'setup', status: :unprocessable_entity - end - end - private def set_partner_tags_controller diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 26ee433c7..28efb4929 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -52,18 +52,16 @@ def filter_events(period, **args) events = Event.all - if site - events = events.for_site(site) - events = events.with_tags(site.tags) if site.tags.any? - end - + events = events.for_site(site) if site events = events.in_place(place) if place events = events.by_partner(partner) if partner events = events.by_partner_or_place(partner_or_place) if partner_or_place events = events.one_off_events_only if repeating == 'off' events = events.one_off_events_first if repeating == 'last' events = - if period == 'week' + if period == 'future' + events.future(@today).includes(:place) + elsif period == 'week' events.find_by_week(@current_day).includes(:place) else events.find_by_day(@current_day).includes(:place) diff --git a/app/controllers/concerns/map_markers.rb b/app/controllers/concerns/map_markers.rb index d6b0bbef8..7d1ff00d4 100644 --- a/app/controllers/concerns/map_markers.rb +++ b/app/controllers/concerns/map_markers.rb @@ -19,7 +19,7 @@ def get_map_markers(locations, addresses_only = false) locations = locations.map do |loc| next loc unless loc.is_a?(Event) - loc.place || loc.address + loc.partner_at_location || loc.address end # Partners diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c193c9164..11f0d6502 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -14,15 +14,37 @@ class EventsController < ApplicationController # GET /events # GET /events.json def index - # Duration to view - default to day view - @period = params[:period].to_s || 'day' + # Duration to view - default to future view + @period = params[:period] || 'future' @repeating = params[:repeating] || 'on' @events = filter_events(@period, repeating: @repeating, site: current_site) + # Duration to view - default to day view if there are too many future events + if params[:period].to_s == '' && @events.count > 200 + @period = 'week' + @events = filter_events(@period, repeating: @repeating, site: current_site) + end @title = current_site.name # Sort criteria @events = sort_events(@events, @sort) @multiple_days = true + @next = if params[:year].present? + date = begin + Date.new(params[:year].to_i, + params[:month].to_i, + params[:day].to_i) + rescue Date::Error + Time.zone.today + end + Event.for_site(current_site).future( + date + ).first + else + Event.for_site(current_site).future( + Time.zone.today + ).first + end + respond_to do |format| format.html do if params[:simple].present? @@ -34,10 +56,7 @@ def index format.text format.ics do events = Event.all - if @site - events = events.for_site(@site) - events = events.with_tags(@site.tags) if @site.tags.any? - end + events = events.for_site(@site) if @site # TODO: Add caching maybe Rails.cache.fetch(:ics, expires_in: 1.hour)? ics_listing = events.ical_feed cal = create_calendar(ics_listing) @@ -52,8 +71,8 @@ def ical; end # GET /events/1 # GET /events/1.json def show - if @event.place - @map = get_map_markers([@event.place]) + if @event.partner_at_location + @map = get_map_markers([@event.partner_at_location]) elsif @event.address @map = get_map_markers([@event.address]) end diff --git a/app/controllers/partners_controller.rb b/app/controllers/partners_controller.rb index 862068b32..cd3f12780 100644 --- a/app/controllers/partners_controller.rb +++ b/app/controllers/partners_controller.rb @@ -30,13 +30,6 @@ def index @map = get_map_markers(@partners, true) if @partners.detect(&:address) end - # # GET /places - # # GET /places.json - # def places_index - # @places = Partner.event_hosts.for_site(current_site).order(:name) - # @map = get_map_markers(@places) if @places.detect(&:address) - # end - # GET /partners/1 # GET /partners/1.json def show diff --git a/app/controllers/users/auth_common.rb b/app/controllers/users/auth_common.rb index b43778996..7ed5569b5 100644 --- a/app/controllers/users/auth_common.rb +++ b/app/controllers/users/auth_common.rb @@ -1,5 +1,23 @@ # frozen_string_literal: true +class DeviseController + # monkey patching devise so it can handle the new default behaviour in rails 7 + # redirects + + original_redirect_to = instance_method(:redirect_to) + define_method(:redirect_to) do |options, response_options = {}| + if options.is_a?(Hash) + options[:allow_other_host] = true unless options.key?(:allow_other_host) + + elsif response_options.is_a?(Hash) + response_options[:allow_other_host] = true unless response_options.key?(:allow_other_host) + end + + original_redirect_to + .bind_call(self, options, response_options) + end +end + module Users module AuthCommon def self.included(klass) diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index 47668b919..68a8b3254 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -6,4 +6,11 @@ class Users::PasswordsController < Devise::PasswordsController include Users::AuthCommon before_action :set_site + + # POST /resource/password + def create + resource_class.send_reset_password_instructions(resource_params) + + redirect_to new_user_password_path, notice: 'If a PlaceCal account is associated with the submitted email address, password reset instructions have been sent.' + end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index d7b98fd33..a88f282da 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,24 +2,6 @@ require_relative 'auth_common' -class DeviseController - # monkey patching devise so it can handle the new default behaviour in rails 7 - # redirects - - original_redirect_to = instance_method(:redirect_to) - define_method(:redirect_to) do |options, response_options = {}| - if options.is_a?(Hash) - options[:allow_other_host] = true unless options.key?(:allow_other_host) - - elsif response_options.is_a?(Hash) - response_options[:allow_other_host] = true unless response_options.key?(:allow_other_host) - end - - original_redirect_to - .bind_call(self, options, response_options) - end -end - class Users::SessionsController < Devise::SessionsController include Users::AuthCommon diff --git a/app/datatables/calendar_datatable.rb b/app/datatables/calendar_datatable.rb index b41a7d458..329ed6641 100644 --- a/app/datatables/calendar_datatable.rb +++ b/app/datatables/calendar_datatable.rb @@ -18,7 +18,7 @@ def view_columns events: { source: 'Calendar.events', searchable: false, orderable: false }, state: { source: 'Calendar.calendar_state', searchable: false, orderable: false }, last_import_at: { source: 'Calendar.last_import_at', searchable: false, orderable: false }, - updated_at: { source: 'Calendar.updated_at', searchable: false, orderable: false } + checksum_updated_at: { source: 'Calendar.checksum_updated_at', searchable: false, orderable: false } } end @@ -32,7 +32,7 @@ def data events: record.events&.count&.to_s || 0, state: record.calendar_state, last_import_at: json_datetime(record.last_import_at), - updated_at: json_datetime(record.updated_at) + checksum_updated_at: json_datetime(record.checksum_updated_at) } end end diff --git a/app/graphql/types/event_type.rb b/app/graphql/types/event_type.rb index 704f535cc..965367d34 100644 --- a/app/graphql/types/event_type.rb +++ b/app/graphql/types/event_type.rb @@ -33,7 +33,6 @@ class EventType < Types::BaseObject description: 'The address where this event will take place' field :organizer, PartnerType, - method: :partner, description: 'The organising partner of this event' field :publisherUrl, String, @@ -54,5 +53,12 @@ def onlineEventUrl def onlineEventUrlType object&.online_address&.link_type end + + def organizer + partner = object&.partner + return if partner.blank? || partner.hidden + + partner + end end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 7f4dcd5fb..2509c78c8 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -22,15 +22,15 @@ def self.included(klass) end def partner(id:) - Partner.find(id) + Partner.visible.find(id) end def partner_connection(**_args) - Partner.all + Partner.visible.all end def partners_by_tag(tag_id:) - Partner.with_tags(tag_id).order(:name) + Partner.visible.with_tags(tag_id).order(:name) end end diff --git a/app/helpers/calendars_helper.rb b/app/helpers/calendars_helper.rb index 640be3b22..51b3addf2 100644 --- a/app/helpers/calendars_helper.rb +++ b/app/helpers/calendars_helper.rb @@ -45,27 +45,25 @@ def strategy_label(val) case val.second when 'event' 'Event: ' \ - 'Get the location of this event from the address field on the source event. ' \ - 'This is for area calendars, or organisations with no solid base.'.html_safe + 'Use the address from each event. '\ + 'If an address is invalid or it doesn\'t have one, the event will import with no location.'.html_safe when 'place' 'Default location: ' \ - 'Every event is in one location (set below). The address field on the source calendar is ignored.'.html_safe + 'Always use the calendar\'s default location.'.html_safe when 'room_number' - 'Room Number: ' \ - 'Every event is on one location (set below), and the address field is used ' \ - 'to store a room number.'.html_safe + 'Room number: ' \ + 'Get a room number from the event\'s address, '\ + 'and overwrite the rest of the address with the calendar\'s default location.'.html_safe when 'event_override' - 'Event Override: ' \ - 'Every event is in one location (set below), unless the address field ' \ - 'is set to another location'.html_safe + 'Event where possible: ' \ + 'Use the address from each event. '\ + 'If the address is invalid or it doesn\'t have one, use the calendar\'s default location.'.html_safe when 'no_location' - 'No Location: ' \ - 'Events imported will have no location information set in PlaceCal, even if it is present on ' \ - 'the remote feed'.html_safe + 'No location: ' \ + 'Discard any address information.'.html_safe when 'online_only' - 'Online Only: ' \ - 'Events imported will have no physical location information set in PlaceCal, even if it is present on ' \ - 'the remote feed, but will maintain the online information'.html_safe + 'Online only: ' \ + 'Discard any address information but retain web links.'.html_safe else val end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index af5b1c949..d79663fc3 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -26,7 +26,7 @@ def online_link online_address.url, class: 'btn btn-primary').html_safe elsif @event.publisher_url.blank? - link_to('Visit the webpage for this stream', + link_to('Visit the webpage for this event', online_address.url, class: 'btn btn-primary').html_safe end @@ -35,4 +35,18 @@ def online_link def html_to_plaintext(input) Nokogiri::HTML.fragment(input).text end + + def next_url(next_event) + opts = { + year: next_event.dtstart.year, + month: next_event.dtstart.month, + day: next_event.dtstart.day, + anchor: 'paginator', + period: @period, + sort: @sort, + repeating: @repeating + }.keep_if { |_key, value| value.present? } + + events_by_date_path(opts) + end end diff --git a/app/helpers/partners_helper.rb b/app/helpers/partners_helper.rb index 3f77d8073..89282ffef 100644 --- a/app/helpers/partners_helper.rb +++ b/app/helpers/partners_helper.rb @@ -17,18 +17,15 @@ def options_for_service_area_neighbourhoods(for_partner) end end - def options_for_partner_tags(partner = nil) - options = policy_scope(Tag) + def options_for_partner_partnerships + options = policy_scope(Partnership) .select(:name, :type, :id) .order(:name) - .map { |r| [r.name_with_type, r.id] } - return options unless partner - - (options + partner&.tags&.map { |r| [r.name_with_type, r.id] }).uniq + .map { |r| [r.name, r.id] } end def permitted_options_for_partner_tags - policy_scope(Tag).pluck(:id) + policy_scope(Partnership).pluck(:id) end def partner_service_area_text(partner) @@ -67,7 +64,7 @@ def site_links return if @sites.blank? @sites - .map { |site| link_to site.name, site.url } + .map { |site| link_to site.name, site.url, target: '_blank', rel: 'noopener' } .join(', ') .html_safe end diff --git a/app/jobs/calendar_importer/calendar_importer_task.rb b/app/jobs/calendar_importer/calendar_importer_task.rb index 89f61bb69..6c6fe8c79 100644 --- a/app/jobs/calendar_importer/calendar_importer_task.rb +++ b/app/jobs/calendar_importer/calendar_importer_task.rb @@ -27,7 +27,7 @@ def run purge_stale_events_from_calendar end - calendar.flag_complete_import_job! notices, calendar_source.checksum, parser::KEY + calendar.flag_complete_import_job! notices, parser::KEY end private @@ -53,7 +53,7 @@ def calendar_source end def event_data_from_parser - return [] if !force_import && calendar.last_checksum == calendar_source.checksum + return [] if !force_import && !calendar_source.checksum_changed calendar_source.events.map do |event_data| CalendarImporter::EventResolver.new(event_data, calendar, notices, from_date) @@ -67,7 +67,6 @@ def process_event(parsed_event) parsed_event.determine_online_location parsed_event.determine_location_for_strategy - # next if parsed_event.is_address_missing? active_event_uids << parsed_event.uid diff --git a/app/jobs/calendar_importer/event_resolver.rb b/app/jobs/calendar_importer/event_resolver.rb index 087eba764..4923ed49f 100644 --- a/app/jobs/calendar_importer/event_resolver.rb +++ b/app/jobs/calendar_importer/event_resolver.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true class CalendarImporter::EventResolver - WARNING1_MSG = 'Could not determine where this event is. Add an address to the location field of the source ' \ - 'calendar, or choose another import strategy with a default location' - WARNING2_MSG = 'Could not determine where this event is. A default location is set but the importer is set ' \ - 'to ignore this. Add an address to the location field of the source calendar, or choose ' \ - 'another import strategy with a default location.' - INFO1_MSG = 'This location was not recognised by PlaceCal, woulfd you like to add it?' - attr_reader :data, :uid, :notices, :calendar class Problem < StandardError; end @@ -28,10 +21,6 @@ def has_no_occurences? occurences.count.zero? end - def is_address_missing? - data.address_id.nil? - end - def occurences @occurences ||= data.occurrences_between(@from_date, Calendar.import_up_to) end @@ -59,7 +48,7 @@ def determine_location_for_strategy if strategies.key?(calendar.strategy) strategy = strategies[calendar.strategy] - place, address = method(strategy).call(calendar.place) + partner, address = method(strategy).call(calendar.place) else # this shouldn't happen and should be fatal to the entire job raise "Calendar import strategy unknown! (ID=#{calendar.id}, strategy=#{calendar.strategy})" @@ -67,124 +56,62 @@ def determine_location_for_strategy # NOTE: In this context, data is a calendar object. But this doesn't make sense because # determine_online_location sees a CalendarImporter::Events::IcsEvent object? - data.place_id = place.id if place + data.place_id = partner.id if partner data.address_id = address&.id data.partner_id = calendar.partner_id end - def event_strategy(place, address: nil) + def event_strategy(partner, address: nil) if data.has_location? - # place = 'attempt to match location' - # address = 'calendar.place.address || location' - - # try to find the place - place = Partner.fuzzy_find_by_location(event_location_components) - - # try to use that address - address = place&.address - - # if no place, try to find an address - # (This only executes if there is no place given) - address ||= Address.search(data.location, event_location_components, data.postcode) - - # if we have an address, try to use the place that address points to - # (Only runs if the fuzzy find and calendar.place are both nil) - place ||= address&.partners&.first - - # NOTE: What happens if address has zero partners and the earlier assignments fail? - # 'place' is nil in this instance - # NOTE: Address.search can also return Nil if there are no event_location_components, or - # if the address failed to save - # Both of these will cause the event to fail validation with "No place or address could be created/found (etc)" - - elsif place.present? - raise Problem, WARNING2_MSG - end # no location - # No longer an error if place is not present -- see #1198 - # Passthrough here - - [place, address] + address = Address.build_from_components(event_location_components, data.postcode) + partner = nil + end + [partner, address] end - def event_override_strategy(place, address: nil) + def event_override_strategy(partner, address: nil) if data.has_location? - # place = 'attempt to match location' - # address = 'calendar.place.address || location' - place = Partner.fuzzy_find_by_location(event_location_components) - address = place&.address - address ||= Address.search(data.location, event_location_components, data.postcode) - - raise Problem, INFO1_MSG if place.nil? && address.nil? - - # NOTE: Either one of 'place' or 'address' is unset here but not both - # NOTE: place is possibly unset here - fuzzy_find_by_location can be nil - # NOTE: address is possibly unset here - place might be nil or Address.search can return nil - # In either case we will just drop this event on the floor - elsif place.present? # no location - address = place.address - # place = 'calendar.place' - # address = 'calendar.place.address' - # place = calendar.place - - else # no place, no location - raise Problem, WARNING1_MSG + address = Address.build_from_components( + event_location_components, + data.postcode + ) end - - [place, address] + if address.nil? + address = partner&.address + else + partner = nil + end + [partner, address] end - def place_strategy(_place, _address: nil) - # Regardless of if the data has a location, we act the same - # We assign address to the place's address if possible, and otherwise we exit - - # This should theoretically never run ! :) (At least, it's not accounted for in Kim's table) - message = <<-TEXT - You have selected the "Default Location" strategy to set events on this calendar's locations, - but the "Default Location" field has not been set. - Please edit the calendar and set a Default Location, or choose another strategy. - TEXT - - raise Problem, message if calendar.place.nil? - + def place_strategy(_partner, _address: nil) [calendar.place, calendar.place.address] - # NOTE: calendar.place can be nil, in which case this event will be dropped on the floor # (Likely what is happening with Velociposse?) end - def room_number_strategy(place, address: nil) + def room_number_strategy(partner, address: nil) if data.has_location? - if place.present? - # place = 'calendar.place' - # address = '#{location}, place.address' - - # xx place = calendar.place.address - new_address = place.address.dup + if partner.present? + new_address = partner.address.dup address = new_address.prepend_room_number(data.location) address.save - - else # no place, yes location + else # no partner, yes location raise Problem, 'N/A' end - - elsif place.present? # no location - address = place.address - # place = 'calendar.place' - # address = 'calendar.place.address' - # xx place = calendar.place.address - + elsif partner.present? # no location + address = partner.address else # no place, no location raise Problem, 'N/A' end - - [place, address] + [partner, address] end - def no_location_strategy(_place, _address: nil) + def no_location_strategy(_partner, _address: nil) [nil, nil] end - def online_only_strategy(_place, _address: nil) + def online_only_strategy(_partner, _address: nil) [nil, nil] end @@ -218,10 +145,6 @@ def save_all_occurences unless event.update(attributes) notices << event.errors.full_messages.join(', ') end - - if event.address_id.blank? && calendar.strategy == 'event' - notices << "No place or address could be created or found for the event location: #{event.raw_location_from_source}" - end end end diff --git a/app/jobs/calendar_importer/events/ics_event.rb b/app/jobs/calendar_importer/events/ics_event.rb index a398148f6..685a94b7e 100644 --- a/app/jobs/calendar_importer/events/ics_event.rb +++ b/app/jobs/calendar_importer/events/ics_event.rb @@ -50,42 +50,50 @@ def occurrences_between(from, to) def online_event? # Either return the google conference value, or find the link in the description - link = @event.custom_properties.fetch 'x_google_conference', nil + link = @event.url + link ||= @event.custom_properties['x_google_conference'] + link ||= maybe_location_is_link link ||= find_event_link - return unless link + link = link.first if link.is_a?(Array) + link = link.to_s - # Then grab the first element of either the match object or the conference array - # (The match object returns ICal Text, not a String, so we have to cast) - # (We can't use .first here because the match object doesn't support it!) - # - online_address = OnlineAddress.find_or_create_by(url: link[0].to_s, - link_type: have_direct_url_to_stream?(link[0].to_s)) + return if link.blank? + + online_address = OnlineAddress.find_or_create_by(url: link, + link_type: have_direct_url_to_stream?(link)) online_address.id end private + def maybe_location_is_link + return if location.blank? + + URI.parse(location).to_s + + rescue URI::InvalidURIError + # no URL found + end + def have_direct_url_to_stream?(link) - # Oh my god why is ruby's iteration stuff so annoying - # also TODO: find a different name than "value" - domain = event_link_types.keys.find(proc {}) { |domain| link.include?(domain) } + domain = event_link_types.keys.find { |domain| link.include?(domain) } return event_link_types[domain][:type] if domain - # Because there is a type for each URL handled, this should never occur - # However, in the future, those URLs will be edited, so we should guard against this - raise MissingTypeForURL, "Type (direct/indirect) missing for URL #{link}" + 'indirect' end def find_event_link link_regexes = event_link_types.values.pluck(:regex) regex = Regexp.union link_regexes - regex.match description + regex.match(description).to_a end def event_link_types + # this only detects "direct" link types now and everything else is "indirect" + http = %r{(http(s)?://)?} # - https:// or http:// or nothing alphanum = /[A-Za-z0-9]+/ # - alphanumeric strings subdomain = /(#{alphanum}\.)?/ # - matches the www. or us04web in the zoom link @@ -104,8 +112,7 @@ def event_link_types { 'meet.jit.si' => { regex: %r{#{http}#{subdomain}meet.jit.si/#{suffix}}, type: 'direct' }, 'meet.google.com' => { regex: %r{#{http}#{subdomain}meet.google.com/#{suffix}}, type: 'direct' }, - 'zoom.us' => { regex: %r{#{http}#{subdomain}zoom.us/j/#{suffix}}, type: 'direct' }, - 'facebook.com' => { regex: %r{#{http}#{subdomain}facebook.com/events/#{suffix}}, type: 'indirect' } + 'zoom.us' => { regex: %r{#{http}#{subdomain}zoom.us/j/#{suffix}}, type: 'direct' } } end end diff --git a/app/jobs/calendar_importer/parsers/base.rb b/app/jobs/calendar_importer/parsers/base.rb index 0c0bc20ce..6d1f8a142 100644 --- a/app/jobs/calendar_importer/parsers/base.rb +++ b/app/jobs/calendar_importer/parsers/base.rb @@ -11,7 +11,7 @@ class Base PUBLIC = true NAME = '' KEY = '' - Output = Struct.new(:events, :checksum) + Output = Struct.new(:events, :checksum_changed) def self.handles_url?(calendar) calendar.source =~ allowlist_pattern @@ -31,10 +31,13 @@ def initialize(calendar, options = {}) def calendar_to_events data = download_calendar checksum = digest(data) + checksum_changed = @calendar.last_checksum != checksum + ## record if checksum has changed since last time we interacted with it. + ## if download_calendar fails it should raise an exception so this code won't run + @calendar.flag_checksum_change!(checksum) if checksum_changed + return Output.new([], checksum) if !@force_import && !checksum_changed - return Output.new([], checksum) if !@force_import && (@calendar.last_checksum == checksum) - - Output.new(import_events_from(data), checksum) + Output.new(import_events_from(data), checksum_changed) end # @abstract Subclass is expect to implmement #download_calendar diff --git a/app/jobs/calendar_importer/parsers/ics.rb b/app/jobs/calendar_importer/parsers/ics.rb index f4ef28b89..b945da902 100644 --- a/app/jobs/calendar_importer/parsers/ics.rb +++ b/app/jobs/calendar_importer/parsers/ics.rb @@ -25,7 +25,6 @@ def self.allowlist_pattern outlook: %r{^https?://outlook\.(office365|live)\.com/owa/calendar/.*}, webcal: %r{^webcal://}, mossley: %r{^https?://mossleycommunitycentre\.org\.uk}, - theproudtrust: %r{^https?://www\.theproudtrust\.org}, teamup: %r{^https?://ics\.teamup\.com/feed/.*}, consortium: %r{^https://www\.consortium\.lgbt/events/.*}, generic: %r{^https?://.*\.ics$} @@ -36,7 +35,9 @@ def self.allowlist_pattern def download_calendar # Why are we doing this? url = @url.gsub(%r{webcal://}, 'https://') # Remove the webcal:// and just use the part after it - Base.read_http_source url + res = Base.read_http_source url + # sanatize google calendar to prevent each requesting resetting the checksum + res.split("\n").reject { |l| l.include? 'DTSTAMP' }.join("\n") end def import_events_from(data) diff --git a/app/models/address.rb b/app/models/address.rb index 483cc82b6..812dc4ed3 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -105,34 +105,6 @@ def geocode_with_ward end class << self - # location - The raw location field - # components - Array containing parts of an event's location field, excluding the postcode. - def search(_location, components, postcode) - return nil if components.empty? - - # try by street name string match - address = Address.find_by('lower(street_address) IN (?)', components.map(&:downcase)) - return address if address - - ukpc = UKPostcode.parse(postcode.to_s) - - if ukpc.full_valid? - address = Address.find_by(postcode: ukpc.to_s) - return address if address - end - - begin - # now just create one - Address.build_from_components(components, postcode) - rescue ActiveRecord::RecordNotFound - # This 'solution' makes it so if an address is provided to us that - # does not map cleanly on to our neighbourhood table then we simply - # consider that a failed look up. In practice we should do more to - # normalize data both coming from Postcodes.io and our UK government - # ward dataset - end - end - def build_from_components(components, postcode) return if components.blank? @@ -142,7 +114,7 @@ def build_from_components(components, postcode) street_address3: components[2]&.strip, postcode: postcode ) - address if address.save + address.save ? address : nil end end end diff --git a/app/models/calendar.rb b/app/models/calendar.rb index 4022c7be8..8a7559355 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -22,8 +22,11 @@ class Calendar < ApplicationRecord validate :check_source_reachable + before_save :clear_status_on_source_change before_save :update_notice_count + after_create :automatically_queue_calendar + # Output the calendar's name when it's requested as a string alias_attribute :to_s, :name @@ -31,7 +34,7 @@ class Calendar < ApplicationRecord # @attr [Enumerable] :strategy enumerize( :strategy, - in: %i[event place room_number event_override no_location online_only], + in: %i[event_override event place room_number no_location online_only], default: :place, scope: true ) @@ -117,6 +120,10 @@ def update_notice_count self.notice_count = (notices || []).count if notices_changed? end + def automatically_queue_calendar + queue_for_import! false, DateTime.now + end + # # calendar state mutators # @@ -129,12 +136,12 @@ def update_notice_count # date to use as the start point # # @return nothing - def queue_for_import!(force_import, from_date) + def queue_for_import!(force_import, from_date = Time.now) transaction do return if is_busy? Calendar.record_timestamps = false - update! calendar_state: :in_queue + update! calendar_state: :in_queue, notices: nil CalendarImporterJob.perform_later id, from_date, force_import @@ -151,7 +158,7 @@ def flag_start_import_job! return unless calendar_state.in_queue? Calendar.record_timestamps = false - update! calendar_state: :in_worker + update! calendar_state: :in_worker, notices: nil ensure Calendar.record_timestamps = true @@ -167,7 +174,7 @@ def flag_start_import_job! # @param checksum [integer] # integer checksum of retrieved source payload # @return nothing - def flag_complete_import_job!(notices, checksum, importer_used) + def flag_complete_import_job!(notices, importer_used) transaction do return unless calendar_state.in_worker? @@ -176,7 +183,6 @@ def flag_complete_import_job!(notices, checksum, importer_used) update!( calendar_state: :idle, notices: notices, - last_checksum: checksum, last_import_at: DateTime.current, critical_error: nil, importer_used: importer_used @@ -187,6 +193,18 @@ def flag_complete_import_job!(notices, checksum, importer_used) end end + def flag_checksum_change!(checksum) + transaction do + Calendar.record_timestamps = false + update!( + last_checksum: checksum, + checksum_updated_at: DateTime.current + ) + ensure + Calendar.record_timestamps = true + end + end + def flag_bad_source!(problem) transaction do return unless calendar_state.in_worker? @@ -272,4 +290,12 @@ def check_source_reachable rescue CalendarImporter::Exceptions::UnsupportedFeed => e errors.add :source, 'Unable to autodetect calendar format, please pick an option from the list below' end + + def clear_status_on_source_change + return unless source_changed? + + self.critical_error = nil + self.notices = nil + self.last_import_at = nil + end end diff --git a/app/models/event.rb b/app/models/event.rb index b33b90083..f1d8f648e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -41,6 +41,13 @@ class Event < ApplicationRecord where('(DATE(dtstart) >= (?)) AND (DATE(dtstart) <= (?))', week_start, week_end) } + # Find by day onwards + scope :future, lambda { |day| + day_start = day.midnight # 2024-04-01 00:00:00 +0100 + where('dtstart >= ?', + day_start) + } + # For the API eventFilter find by neighbourhood scope :for_neighbourhoods, lambda { |neighbourhoods| neighbourhood_ids = neighbourhoods.map(&:id) @@ -54,23 +61,37 @@ class Event < ApplicationRecord } scope :with_tags, lambda { |tags| - tag_ids = tags.map(&:id) - joins(:partner) .joins('left outer join partner_tags on partners.id = partner_tags.partner_id') - .where('partner_tags.tag_id in (?)', tag_ids) + .where( + 'partner_tags.tag_id in (:tag_ids)', + tag_ids: tags.map(&:id) + ) } # Filter by Site scope :for_site, lambda { |site| - site_neighbourhood_ids = site.owned_neighbourhoods.map(&:id) - - joins('left join addresses on events.address_id = addresses.id') - .joins('left join partners on events.partner_id = partners.id') - .joins('left join service_areas on partners.id = service_areas.partner_id') - .where('(service_areas.neighbourhood_id in (?)) or (addresses.neighbourhood_id in (?))', - site_neighbourhood_ids, - site_neighbourhood_ids) + partners = Partner.for_site(site) + + if site&.tags&.any? + left_joins(:address) + .where( + 'partner_id in (:partner_ids) OR '\ + '(lower(addresses.street_address) in (:partner_names) AND '\ + 'lower(addresses.postcode) in (:partner_postcodes))', + partner_ids: partners.map(&:id), + partner_names: partners.map { |p| p.name.downcase }, + partner_postcodes: partners.map(&:address).keep_if(&:present?).map { |a| a.postcode.downcase } + ) + else + left_joins(:address) + .where( + 'partner_id in (:partner_ids) OR '\ + 'addresses.neighbourhood_id in (:neighbourhoods)', + neighbourhoods: site.owned_neighbourhoods.map(&:id), + partner_ids: partners.map(&:id) + ) + end } # Filter by Place @@ -139,15 +160,16 @@ def neighbourhood end def location - use_address = address || partner&.address - # (address if address.present?) || - # (partner.address if partner.present?) - + use_address = address || partner_at_location&.address || partner&.address return '' if use_address.nil? use_address.to_s end + def partner_at_location + @partner_at_location ||= place || Partner.find_from_event_address(address) + end + # TODO: plan this out on paper, currently half finished # Who to contact if the event is wrong def blame diff --git a/app/models/partner.rb b/app/models/partner.rb index bb40cd157..a5b5d62da 100644 --- a/app/models/partner.rb +++ b/app/models/partner.rb @@ -51,7 +51,17 @@ class Partner < ApplicationRecord accepts_nested_attributes_for :calendars, allow_destroy: true - accepts_nested_attributes_for :address, reject_if: ->(c) { c[:postcode].blank? && c[:street_address].blank? } + # If any of the address formfields are present we attempt to create an address + # this will trigger the validation + accepts_nested_attributes_for :address, reject_if: lambda { |c| + [c[:city], + c[:postcode], + c[:street_address], + c[:street_address2], + c[:street_address3]].all?(&:blank?) + } + + validates_associated :address accepts_nested_attributes_for :service_areas, allow_destroy: true @@ -92,8 +102,6 @@ class Partner < ApplicationRecord format: { with: EMAIL_REGEX, message: 'invalid email address' }, allow_blank: true - validates_associated :address, if: ->(p) { p.address.present? } - validate :check_neighbourhood_access validate :neighbourhood_admin_address_access, on: %i[create update] @@ -157,14 +165,19 @@ class Partner < ApplicationRecord return none if site_neighbourhood_ids.empty? query + .visible .left_joins(:address, :service_areas) .where( - 'NOT hidden AND (service_areas.neighbourhood_id in (:neighbourhood_ids) OR addresses.neighbourhood_id in (:neighbourhood_ids))', + '(service_areas.neighbourhood_id in (:neighbourhood_ids) OR addresses.neighbourhood_id in (:neighbourhood_ids))', neighbourhood_ids: site_neighbourhood_ids ) .distinct } + scope :visible, lambda { + where(hidden: false) + } + scope :for_site_with_tag, lambda { |site, tag| return none if tag.nil? @@ -207,17 +220,6 @@ class Partner < ApplicationRecord where.not(address_id: nil) } - # Get all Partners that have hosted an event in the last month or will host - # an event in the future - # - # TODO? This might be an incredibly inefficient query. If so, add a column - # to the Partner table, e.g. place_latest_dtstart, which can be updated on - # import. - scope :event_hosts, lambda { - joins('JOIN events ON events.place_id = partners.id') - .where('events.dtstart > ?', Date.today - 30).distinct - } - # Get all Partners that manage at least one other Partner. scope :managers, lambda { joins('JOIN organisation_relationships o_r on o_r.partner_subject_id = partners.id') @@ -372,8 +374,23 @@ def neighbourhood_name_for_site(badge_zoom_level) end end - def self.fuzzy_find_by_location(components) - Partner.find_by('lower(name) IN (?)', components.map(&:downcase)) + def self.find_from_event_address(address) + address_components = [ + address&.street_address || '', + address&.street_address2 || '', + address&.street_address3 || '' + ].reject(&:empty?) + + if address_components.any? && address&.postcode + Partner.left_joins(:address) + .find_by( + 'can_be_assigned_events AND '\ + 'lower(name) IN (:components) AND '\ + 'lower(addresses.postcode) = (:postcode)', + components: address_components.map(&:downcase), + postcode: address.postcode.downcase + ) + end end def self.neighbourhood_names_for_site(current_site, badge_zoom_level) @@ -484,9 +501,10 @@ def opening_times_is_json_or_nil end def three_or_less_category_tags - return if categories.count < 4 + # we can't just use categories.count here because of STI, on create they won't exist yet + return if category_ids.count < 4 - errors.add :base, 'Partner.tags can contain a maximum of 3 Category tags' + errors.add :categories, 'Partners can have a maximum of 3 Category tags' end def partnership_admins_must_add_tag diff --git a/app/models/partnership.rb b/app/models/partnership.rb index 7f814ad0b..3fb4bfd9d 100644 --- a/app/models/partnership.rb +++ b/app/models/partnership.rb @@ -1,4 +1,9 @@ # frozen_string_literal: true class Partnership < Tag + scope :users_partnerships, lambda { |user| + return Partnership.all if user.role == 'root' && !user.partnership_admin? + + user.partnerships + } end diff --git a/app/models/tag.rb b/app/models/tag.rb index e1748a06a..b11047e64 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -34,6 +34,8 @@ class Tag < ApplicationRecord } validate :check_editable_fields + # TECHDEBT + # we should look to work this out of the system as we now cover this with a scope on Partnership scope :users_tags, lambda { |user| return Tag.all if user.role == 'root' && !user.partnership_admin? diff --git a/app/models/user.rb b/app/models/user.rb index 0f1a44f55..0194b55d6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,6 +29,7 @@ class User < ApplicationRecord has_many :tags_users, dependent: :destroy has_many :tags, through: :tags_users + has_many :partnerships, through: :tags_users, source: :tag, class_name: 'Partnership' validates :email, presence: true, diff --git a/app/policies/partner_policy.rb b/app/policies/partner_policy.rb index 2b92bda54..46e23df06 100644 --- a/app/policies/partner_policy.rb +++ b/app/policies/partner_policy.rb @@ -50,16 +50,17 @@ def permitted_attributes :public_name, :public_email, :public_phone, :partner_name, :partner_email, :partner_phone, :address_id, :url, :facebook_link, :twitter_handle, :instagram_handle, - :opening_times, + :opening_times, :can_be_assigned_events, { calendars_attributes: %i[id name source strategy place_id partner_id _destroy], address_attributes: %i[id street_address street_address2 street_address3 city postcode], service_areas_attributes: %i[id neighbourhood_id _destroy], - tag_ids: [] }] + tag_ids: [], category_ids: [], facility_ids: [] }] attrs << :slug if user.root? attrs << :hidden if user.root? || user.neighbourhood_admin? || user.partnership_admin? attrs << :hidden_reason if user.root? || user.neighbourhood_admin? || user.partnership_admin? attrs << :hidden_blame_id if user.root? || user.neighbourhood_admin? || user.partnership_admin? + attrs << { partnership_ids: [] } if user.root? || user.partnership_admin? attrs end diff --git a/app/policies/partnership_policy.rb b/app/policies/partnership_policy.rb index 8b24fae63..c25ce16d1 100644 --- a/app/policies/partnership_policy.rb +++ b/app/policies/partnership_policy.rb @@ -1,4 +1,9 @@ # frozen_string_literal: true class PartnershipPolicy < TagPolicy + class Scope < Scope + def resolve + Partnership.users_partnerships(user) + end + end end diff --git a/app/views/admin/calendars/_form.html.erb b/app/views/admin/calendars/_form.html.erb index bd8443af6..cc06aeae4 100644 --- a/app/views/admin/calendars/_form.html.erb +++ b/app/views/admin/calendars/_form.html.erb @@ -1,14 +1,12 @@ -<% if @calendar.critical_error %> - -<% end %> - -

<%= link_to "View this calendar", - admin_calendar_path(@calendar.id), - class: "btn btn-sm btn-primary" unless @calendar.new_record? %> +

+ <%= link_to "View this calendar", + admin_calendar_path(@calendar.id), + class: "btn btn-sm btn-primary" unless @calendar.new_record? %>

+<%= render('importer_overview', calendar: @calendar) %> +
+
<%= simple_form_for [:admin, @calendar] do |f| %>
@@ -20,7 +18,7 @@ collection: options_for_organiser, selected: (@partner&.id || @calendar.partner_id).to_s, input_html: { class: 'form-control', data: {controller: "select2"} }, - hint: 'What group organises these events?' %> + hint: 'Which group organises these events?' %> <%= f.input :name, hint: 'A simple description to help rememember what this calendar is' %> <%= f.input :source, label: 'URL', placeholder: 'https://your-domain.com/events.ics', hint: "The source URL for your calendar feed. See the #{link_to 'PlaceCal Handbook', 'https://handbook.placecal.org/admin-handbook'} if you need help.".html_safe %> @@ -47,20 +45,22 @@

Location

- <%= f.input :strategy, - as: :radio_buttons, - label_method: ->(val){strategy_label(val)}, - hint: 'How should PlaceCal decide where events on this calendar are held?', - input_html: { 'v-model': 'strategy', 'v-on:change': "updateLocation" } %>
<%= f.association :place, label: 'Default location', collection: options_for_location, input_html: { class: 'form-control', data: {controller: "select2"} } %>
+ <%= f.input :strategy, + as: :radio_buttons, + label: 'Where should location information for this calendar come from?', + label_method: ->(val){strategy_label(val)}, + input_html: { 'v-model': 'strategy', 'v-on:change': "updateLocation" } %>
+
+

Public Contact Information

@@ -76,12 +76,10 @@
-

+
<%= f.button :submit, class: "btn btn-primary mr-3" %> <% unless @calendar.new_record? %> <%= link_to "Destroy Calendar", admin_calendar_path(@calendar), method: :delete, class: "btn btn-danger" %> <% end %> <% end %> - -<%= render('import') unless @calendar.new_record? %> diff --git a/app/views/admin/calendars/_import.html.erb b/app/views/admin/calendars/_import.html.erb deleted file mode 100644 index bdf544cd7..000000000 --- a/app/views/admin/calendars/_import.html.erb +++ /dev/null @@ -1,66 +0,0 @@ -


-

Import Status for <%= @calendar.name %>

- -<%= render 'status' %> - -<% if @calendar.can_be_requeued? %> - <% if @calendar.calendar_state.bad_source? %> -

We're unable to read this calendar - the URL may be broken or there may be some invalid data in it.
Please check the calendar is set up correctly and try again.
-

- <% end %> -

- <% if @calendar.last_import_at %> - Last updated <%= time_ago_in_words(@calendar.last_import_at) %> ago (<%= @calendar.last_import_at %>) - <% end %> -

- -
-
-

Update now

- <%= simple_form_for :import, { url: import_admin_calendar_path(@calendar) } do |f| %> - <%= f.input :starting_from, input_html: { name: 'starting_from', value: Date.today.strftime('%Y-%m-%d') }, placeholder: 'YYYY-MM-DD', hint: 'What date should the importer start from?' %> - <%= f.submit 'Queue for import now', class: 'btn btn-primary' %> - <% end %> -
-
- -

Important Notices

- <% if @calendar.notices.present? %> -

The following events could not be imported due to invalid data:

-
    - <% @calendar.notices.each do |text| %> -
  • <%= text %>
  • - <% end %> -
- <% else %> - No notices to show - <% end %> -
- -
- -<% elsif @calendar.is_busy? %> -

The calendar is currently being imported, please check back in a few minutes

- -<% elsif @calendar.calendar_state.error? %> -

Recent Updates

-

Unfortunately a critical error has occurred that means we cannot continue importing your calendar

-

Error: <%= @calendar.critical_error %>

- -<%= simple_form_for :import, { url: import_admin_calendar_path(@calendar) } do |f| %> - <%= f.input :starting_from, input_html: { name: 'starting_from', value: Date.today.strftime('%Y-%m-%d') }, placeholder: 'YYYY-MM-DD', hint: 'What date should the importer start from?' %> - <%= f.submit 'Reset and retry', class: 'btn btn-danger' %> -<% end %> -<% end %> - -


- -
-

Recent Updates

- <% @versions&.each do |date, activities| %> -
- <%= l(date, format: :datetime) %> - <%= display_time_since(date) %> - <%= render 'shared/recent_import_activity', activities: activities %> -
- <% end %> -
diff --git a/app/views/admin/calendars/_importer_overview.html.erb b/app/views/admin/calendars/_importer_overview.html.erb new file mode 100644 index 000000000..cf4ce8b10 --- /dev/null +++ b/app/views/admin/calendars/_importer_overview.html.erb @@ -0,0 +1,74 @@ +<% unless calendar.new_record? %> + <% retry_button = nil %> +
+

Import status

+ + <% if calendar.checksum_updated_at.present? && calendar.last_import_at.present? %> + <% if calendar.checksum_updated_at < 6.month.ago || calendar.last_import_at < 6.month.ago %> + + <% end %> + <% end %> + + <% if calendar.calendar_state.idle? %> + <% retry_button = :idle %> +

success

+ <% end %> + + <% if calendar.calendar_state.in_queue? || calendar.calendar_state.in_worker? %> +

loading

+

Please check back in a few minutes

+ <% end %> + + <% if calendar.calendar_state.error? || calendar.calendar_state.bad_source? %> + <% retry_button = :error %> + +

error

+ + + <% end %> + + + <% if calendar.checksum_updated_at.present? %> +

+ Source data last changed <%= time_ago_in_words(calendar.checksum_updated_at) %> ago (<%= calendar.checksum_updated_at %>) +

+ <% end %> + + <% if calendar.last_import_at.present? %> +

+ Last import ran <%= time_ago_in_words(calendar.last_import_at) %> ago (<%= calendar.last_import_at %>) +

+ <% end %> + + <% if calendar.notices.present? %> + <% retry_button = :error %> + +

Notices

+

The following events could not be imported due to invalid data:

+ +
    + <% calendar.notices.tally.each do |text, count| %> +
  • <%= text %> <% if count > 1 %>(<%= count %> times)<% end %>
  • + <% end %> +
+

Please check your calendar is set up correctly and try again.

+ <% end %> + + <% if retry_button %> + <%= simple_form_for :import, { url: import_admin_calendar_path(calendar) } do |f| %> + <% if retry_button == :idle %> + <%= f.submit 'Re-import now', class: 'btn btn-primary' %> + <% elsif retry_button == :error %> + <%= f.submit 'Retry', class: 'btn btn-danger' %> + <% end %> + <% end %> + <% end %> + +<% end %> + + diff --git a/app/views/admin/calendars/_status.html.erb b/app/views/admin/calendars/_status.html.erb deleted file mode 100644 index 702d38f3b..000000000 --- a/app/views/admin/calendars/_status.html.erb +++ /dev/null @@ -1 +0,0 @@ -

<%= @calendar.calendar_state %>

diff --git a/app/views/admin/calendars/index.html.erb b/app/views/admin/calendars/index.html.erb index 032931d68..5fca3e514 100644 --- a/app/views/admin/calendars/index.html.erb +++ b/app/views/admin/calendars/index.html.erb @@ -11,7 +11,7 @@ var columns = [ return (type == "display") ? date.strtime : date.unixtime; } }, - {"data": "updated_at", + {"data": "checksum_updated_at", "render": function (data, type, row) { date = JSON.parse(data.replace(/"/g, '"')); return (type == "display") ? date.strtime : date.unixtime; @@ -24,8 +24,8 @@ var columns = [ <%= render partial: 'layouts/admin/datatable', locals: { title: 'Calendars', model: :calendars, - column_titles: ['Name', 'Partners', 'Notices', 'Events', 'Status', 'Last imported', 'Last updated'], - columns: %i[name partner notice_count events calendar_state last_import_at updated_at], + column_titles: ['Name', 'Partners', 'Notices', 'Events', 'Status', 'Last imported', 'Source updated'], + columns: %i[name partner notice_count events calendar_state last_import_at checksum_updated_at], data: @calendars, source: admin_calendars_path(format: :json), new_link: new_admin_calendar_path diff --git a/app/views/admin/calendars/show.html.erb b/app/views/admin/calendars/show.html.erb index f8ca496cc..d0edcd13f 100644 --- a/app/views/admin/calendars/show.html.erb +++ b/app/views/admin/calendars/show.html.erb @@ -1,6 +1,8 @@

<%= @calendar.name %>

+

<%= link_to "Edit this calendar", edit_admin_calendar_path(@calendar.id), class: "btn btn-primary btn-sm" %>

-<%= render 'status' %> +<%= render 'importer_overview', calendar: @calendar %> +

Published by <%= link_to @calendar.partner, edit_admin_partner_path(@calendar.partner) %>. Using <%= content_tag :tt, @calendar.strategy %> strategy to import <%= content_tag :tt, @calendar.events.count %> events. <%= calendar_last_imported(@calendar) %>.

@@ -12,7 +14,6 @@ <% end %>

-

<%= link_to "Edit this calendar", edit_admin_calendar_path(@calendar.id), class: "btn btn-primary btn-sm" %>

@@ -32,22 +33,4 @@ <% end %>
-
- -

Important Notices

- <% if @calendar.notices.present? %> -

The following events could not be imported due to invalid data:

-
    - <% @calendar.notices.each do |notice| %> -
  • - <%= content_tag :strong, notice["event"]["dtstart"].to_date.strftime('%e %b %Y') %>. - <%= content_tag :em, notice["event"]["summary"] %>. - <%= notice["errors"].join(", ")%> -
  • - <% end %> -
- <% else %> - No notices to show - <% end %> -
diff --git a/app/views/admin/partners/_address_fields.html.erb b/app/views/admin/partners/_address_fields.html.erb index 3bb6b1fc1..46ecf2893 100644 --- a/app/views/admin/partners/_address_fields.html.erb +++ b/app/views/admin/partners/_address_fields.html.erb @@ -8,7 +8,9 @@ <%= address_form.input :street_address, class: "form-control address_1 address_field", - label: 'Street address' %> + label: 'Street address', + required: false + %> <%= address_form.input :street_address2, class: "form-control address_2 address_field", @@ -22,7 +24,9 @@ class: "form-control city address_field" %> <%= address_form.input :postcode, - class: "form-control postcode address_field" %> + class: "form-control postcode address_field", + required: false + %> <% if partner.can_clear_address?(current_user) %>
diff --git a/app/views/admin/partners/_form.html.erb b/app/views/admin/partners/_form.html.erb index f40486a20..6cbff49c6 100644 --- a/app/views/admin/partners/_form.html.erb +++ b/app/views/admin/partners/_form.html.erb @@ -143,16 +143,38 @@

Tags

-

What partnerships, categories and facilities does this partner have or belong to?

-

Partners may have up to 3 category tags.

- <%= f.association :tags, - label: false, - collection: options_for_partner_tags(@partner), - input_html: { class: 'form-check', data: { - controller: @partner_tags_controller, - "partner-tags-permitted-tags-value": permitted_options_for_partner_tags +

What other associations does this partner have apart from place.

+ <% if current_user.partnership_admin? || current_user.root? %> + <%= f.association :partnerships, + label: "Partnerships", + hint:"Which communities of interest should this partner appear on", + collection: options_for_partner_partnerships(), + input_html: { class: 'form-check', data: { + controller: 'partner-tags', + "partner-tags-permitted-tags-value": permitted_options_for_partner_tags + } } %> +
+ <% end %> + <%= f.association :facilities, + label: "Facilities", + hint:"What infrastructure does this partner provide.", + collection: Facility.all, + input_html: { class: 'form-check', data: { + controller: 'select2', } } %>
+ <%= f.association :categories, + label: "Categories", + hint:"Partners may have up to 3 category tags, the public will be able to filter by these", + collection: Category.all, + input_html: { class: 'form-check', data: { + controller: 'select2', + } } %> +
+

Event matching

+

Can events imported from other partner's calendars be listed at this partner if their address matches?

+ <%= f.input :can_be_assigned_events, class: "form-control" %> +
<% unless @partner.new_record? %> <% if policy(@partner).permitted_attributes.include? :hidden %>