diff --git a/.dockerignore b/.dockerignore index a85b5fb6b..8c75b254c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,8 +20,10 @@ storage/ # Files generated by scripts or compiled public/sitemap.xml +public/tenants/*/sitemap.xml public/assets/ public/machine_learning/data/ +public/tenants/*/machine_learning/data/ # Bundler config, cache and gemsets **/.bundle/ diff --git a/.gitignore b/.gitignore index 7e10963eb..28d1c676c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ tmp/ # Files generated by scripts or compiled /public/sitemap.xml +/public/tenants/*/sitemap.xml /public/assets/ /public/machine_learning/data/ +/public/tenants/*/machine_learning/data/ # Bundler config, cache and gemsets .bundle/ diff --git a/Gemfile b/Gemfile index 9d247a509..7cc13c7fe 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem "recipient_interceptor", "~> 0.3.1" gem "redcarpet", "~> 3.5.1" gem "responders", "~> 3.0.1" gem "rinku", "~> 2.0.6", require: "rails_rinku" +gem "ros-apartment", "~> 2.11.0", require: "apartment" gem "sassc-rails", "~> 2.1.2" gem "savon", "~> 2.13.0" gem "sitemap_generator", "~> 6.3.0" diff --git a/Gemfile.lock b/Gemfile.lock index 5cece524d..9693b05ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -472,7 +472,7 @@ GEM pronto-scss (0.11.0) pronto (~> 0.11.0) scss_lint (~> 0.43, >= 0.43.0) - public_suffix (5.0.0) + public_suffix (4.0.7) puma (4.3.12) nio4r (~> 2.0) racc (1.6.0) @@ -534,6 +534,11 @@ GEM retriable (3.1.2) rexml (3.2.5) rinku (2.0.6) + ros-apartment (2.11.0) + activerecord (>= 5.0.0, < 7.1) + parallel (< 2.0) + public_suffix (>= 2.0.5, < 5.0) + rack (>= 1.3.6, < 3.0) rspec-core (3.11.0) rspec-support (~> 3.11.0) rspec-expectations (3.11.0) @@ -784,6 +789,7 @@ DEPENDENCIES redcarpet (~> 3.5.1) responders (~> 3.0.1) rinku (~> 2.0.6) + ros-apartment (~> 2.11.0) rspec-rails (~> 5.1.2) rubocop (~> 1.35.1) rubocop-performance (~> 1.11.4) diff --git a/app/components/admin/menu_component.html.erb b/app/components/admin/menu_component.html.erb index 750ee18ec..8e70d9cb7 100644 --- a/app/components/admin/menu_component.html.erb +++ b/app/components/admin/menu_component.html.erb @@ -110,6 +110,7 @@ <%= t("admin.menu.title_settings") %> <%= link_list( settings_link, + tenants_link, tags_link, geozones_link, images_link, diff --git a/app/components/admin/menu_component.rb b/app/components/admin/menu_component.rb index 4ef5d262f..9fc4c5319 100644 --- a/app/components/admin/menu_component.rb +++ b/app/components/admin/menu_component.rb @@ -1,5 +1,6 @@ class Admin::MenuComponent < ApplicationComponent include LinkListHelper + delegate :can?, to: :helpers private @@ -32,8 +33,8 @@ class Admin::MenuComponent < ApplicationComponent end def settings? - controllers_names = ["settings", "tags", "geozones", "images", "content_blocks", - "local_census_records", "imports"] + controllers_names = ["settings", "tenants", "tags", "geozones", "images", + "content_blocks", "local_census_records", "imports"] controllers_names.include?(controller_name) && controller.class.module_parent != Admin::Poll::Questions::Answers end @@ -300,6 +301,16 @@ class Admin::MenuComponent < ApplicationComponent ] end + def tenants_link + if can?(:read, Tenant) + [ + t("admin.menu.multitenancy"), + admin_tenants_path, + controller_name == "tenants" + ] + end + end + def tags_link [ t("admin.menu.proposals_topics"), diff --git a/app/components/admin/tenants/edit_component.html.erb b/app/components/admin/tenants/edit_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/edit_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/edit_component.rb b/app/components/admin/tenants/edit_component.rb new file mode 100644 index 000000000..ee73145d4 --- /dev/null +++ b/app/components/admin/tenants/edit_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::EditComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + tenant.name + end +end diff --git a/app/components/admin/tenants/form_component.html.erb b/app/components/admin/tenants/form_component.html.erb new file mode 100644 index 000000000..1c01da639 --- /dev/null +++ b/app/components/admin/tenants/form_component.html.erb @@ -0,0 +1,7 @@ +<%= form_for [:admin, tenant] do |f| %> + <%= render "shared/errors", resource: tenant %> + + <%= f.text_field :name %> + <%= f.text_field :schema %> + <%= f.submit %> +<% end %> diff --git a/app/components/admin/tenants/form_component.rb b/app/components/admin/tenants/form_component.rb new file mode 100644 index 000000000..8db70a94b --- /dev/null +++ b/app/components/admin/tenants/form_component.rb @@ -0,0 +1,7 @@ +class Admin::Tenants::FormComponent < ApplicationComponent + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end +end diff --git a/app/components/admin/tenants/index_component.html.erb b/app/components/admin/tenants/index_component.html.erb new file mode 100644 index 000000000..005eb377d --- /dev/null +++ b/app/components/admin/tenants/index_component.html.erb @@ -0,0 +1,27 @@ +<%= header do %> + <%= link_to t("admin.tenants.index.create"), new_admin_tenant_path %> +<% end %> + + + + + + + + + + + + <% @tenants.each do |tenant| %> + + + + + + <% end %> + +
<%= attribute_name(:name) %><%= attribute_name(:schema) %><%= t("admin.shared.actions") %>
<%= tenant.name %><%= tenant.schema %> + <%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) do |actions| %> + <%= actions.action(:show, text: t("admin.shared.view"), path: root_url(host: tenant.host)) %> + <% end %> +
diff --git a/app/components/admin/tenants/index_component.rb b/app/components/admin/tenants/index_component.rb new file mode 100644 index 000000000..eb37a6832 --- /dev/null +++ b/app/components/admin/tenants/index_component.rb @@ -0,0 +1,18 @@ +class Admin::Tenants::IndexComponent < ApplicationComponent + include Header + attr_reader :tenants + + def initialize(tenants) + @tenants = tenants + end + + def title + t("admin.menu.multitenancy") + end + + private + + def attribute_name(attribute) + Tenant.human_attribute_name(attribute) + end +end diff --git a/app/components/admin/tenants/new_component.html.erb b/app/components/admin/tenants/new_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/new_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/new_component.rb b/app/components/admin/tenants/new_component.rb new file mode 100644 index 000000000..d0ebacf4d --- /dev/null +++ b/app/components/admin/tenants/new_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::NewComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + t("admin.tenants.new.title") + end +end diff --git a/app/components/layout/common_html_attributes_component.html.erb b/app/components/layout/common_html_attributes_component.html.erb new file mode 100644 index 000000000..fce00b5b2 --- /dev/null +++ b/app/components/layout/common_html_attributes_component.html.erb @@ -0,0 +1 @@ +<%= attributes -%> diff --git a/app/components/layout/common_html_attributes_component.rb b/app/components/layout/common_html_attributes_component.rb new file mode 100644 index 000000000..bface8c6c --- /dev/null +++ b/app/components/layout/common_html_attributes_component.rb @@ -0,0 +1,21 @@ +class Layout::CommonHTMLAttributesComponent < ApplicationComponent + delegate :rtl?, to: :helpers + + private + + def attributes + sanitize([dir, lang, html_class].compact.join(" ")) + end + + def dir + 'dir="rtl"' if rtl? + end + + def lang + "lang=\"#{I18n.locale}\"" + end + + def html_class + "class=\"tenant-#{Tenant.current_schema}\"" if Rails.application.config.multitenancy + end +end diff --git a/app/controllers/admin/tenants_controller.rb b/app/controllers/admin/tenants_controller.rb new file mode 100644 index 000000000..020118ebf --- /dev/null +++ b/app/controllers/admin/tenants_controller.rb @@ -0,0 +1,35 @@ +class Admin::TenantsController < Admin::BaseController + load_and_authorize_resource + + def index + @tenants = @tenants.order(:name) + end + + def new + end + + def edit + end + + def create + if @tenant.save + redirect_to admin_tenants_path, notice: t("admin.tenants.create.notice") + else + render :new + end + end + + def update + if @tenant.update(tenant_params) + redirect_to admin_tenants_path, notice: t("admin.tenants.update.notice") + else + render :edit + end + end + + private + + def tenant_params + params.require(:tenant).permit(:name, :schema) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a6e67a98..fd9c77a6a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -26,12 +26,13 @@ class ApplicationController < ActionController::Base def authenticate_http_basic authenticate_or_request_with_http_basic do |username, password| - username == Rails.application.secrets.http_basic_username && password == Rails.application.secrets.http_basic_password + username == Tenant.current_secrets.http_basic_username && + password == Tenant.current_secrets.http_basic_password end end def http_basic_auth_site? - Rails.application.secrets.http_basic_auth + Tenant.current_secrets.http_basic_auth end def verify_lock diff --git a/app/controllers/concerns/remotely_translatable.rb b/app/controllers/concerns/remotely_translatable.rb index 6bbf791d1..7508a29f3 100644 --- a/app/controllers/concerns/remotely_translatable.rb +++ b/app/controllers/concerns/remotely_translatable.rb @@ -26,6 +26,6 @@ module RemotelyTranslatable end def api_key_has_been_set_in_secrets? - Rails.application.secrets.microsoft_api_key.present? + Tenant.current_secrets.microsoft_api_key.present? end end diff --git a/app/controllers/robots_controller.rb b/app/controllers/robots_controller.rb new file mode 100644 index 000000000..2d445fa01 --- /dev/null +++ b/app/controllers/robots_controller.rb @@ -0,0 +1,7 @@ +class RobotsController < ApplicationController + skip_authorization_check + + def index + respond_to :text + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e1c35eee5..a0ff6f621 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -53,6 +53,8 @@ module ApplicationHelper if image polymorphic_path(image) + elsif AssetFinder.find_asset(File.join(Tenant.subfolder_path, filename)) + File.join(Tenant.subfolder_path, filename) else filename end diff --git a/app/helpers/layouts_helper.rb b/app/helpers/layouts_helper.rb index e97727b34..32ddde240 100644 --- a/app/helpers/layouts_helper.rb +++ b/app/helpers/layouts_helper.rb @@ -9,4 +9,8 @@ module LayoutsHelper link_to(text, path, options.merge(title: title)) end end + + def common_html_attributes + render Layout::CommonHTMLAttributesComponent.new + end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index ed9e0b7b2..1fc1410e0 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,12 +1,21 @@ class ApplicationMailer < ActionMailer::Base - helper :settings - helper :application - helper :mailer + helper :application, :layouts, :mailer, :settings default from: proc { "#{Setting["mailer_from_name"]} <#{Setting["mailer_from_address"]}>" } layout "mailer" before_action :set_asset_host + after_action :set_smtp_settings + + def default_url_options + Tenant.current_url_options + end def set_asset_host self.asset_host ||= root_url.delete_suffix("/") end + + def set_smtp_settings + unless Tenant.default? + mail.delivery_method.settings.merge!(Tenant.current_secrets.smtp_settings.to_h) + end + end end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index f6e2bb038..9e3556e80 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -134,6 +134,10 @@ module Abilities can :manage, LocalCensusRecord can [:create, :read], LocalCensusRecords::Import + + if Rails.application.config.multitenancy && Tenant.default? + can [:create, :read, :update], Tenant + end end end end diff --git a/app/models/machine_learning.rb b/app/models/machine_learning.rb index 76be044b8..a9504c0a4 100644 --- a/app/models/machine_learning.rb +++ b/app/models/machine_learning.rb @@ -3,7 +3,6 @@ class MachineLearning attr_accessor :job SCRIPTS_FOLDER = Rails.root.join("public", "machine_learning", "scripts").freeze - DATA_FOLDER = Rails.root.join("public", "machine_learning", "data").freeze def initialize(job) @job = job @@ -11,6 +10,10 @@ class MachineLearning @previous_modified_date = set_previous_modified_date end + def data_folder + self.class.data_folder + end + def run begin export_proposals_to_json @@ -81,17 +84,25 @@ class MachineLearning "comments.json" end + def data_folder + Rails.root.join("public", tenant_data_folder) + end + + def tenant_data_folder + Tenant.path_with_subfolder("machine_learning/data") + end + def data_output_files files = { tags: [], related_content: [], comments_summary: [] } - files[:tags] << proposals_tags_filename if File.exists?(DATA_FOLDER.join(proposals_tags_filename)) - files[:tags] << proposals_taggings_filename if File.exists?(DATA_FOLDER.join(proposals_taggings_filename)) - files[:tags] << investments_tags_filename if File.exists?(DATA_FOLDER.join(investments_tags_filename)) - files[:tags] << investments_taggings_filename if File.exists?(DATA_FOLDER.join(investments_taggings_filename)) - files[:related_content] << proposals_related_filename if File.exists?(DATA_FOLDER.join(proposals_related_filename)) - files[:related_content] << investments_related_filename if File.exists?(DATA_FOLDER.join(investments_related_filename)) - files[:comments_summary] << proposals_comments_summary_filename if File.exists?(DATA_FOLDER.join(proposals_comments_summary_filename)) - files[:comments_summary] << investments_comments_summary_filename if File.exists?(DATA_FOLDER.join(investments_comments_summary_filename)) + files[:tags] << proposals_tags_filename if File.exists?(data_folder.join(proposals_tags_filename)) + files[:tags] << proposals_taggings_filename if File.exists?(data_folder.join(proposals_taggings_filename)) + files[:tags] << investments_tags_filename if File.exists?(data_folder.join(investments_tags_filename)) + files[:tags] << investments_taggings_filename if File.exists?(data_folder.join(investments_taggings_filename)) + files[:related_content] << proposals_related_filename if File.exists?(data_folder.join(proposals_related_filename)) + files[:related_content] << investments_related_filename if File.exists?(data_folder.join(investments_related_filename)) + files[:comments_summary] << proposals_comments_summary_filename if File.exists?(data_folder.join(proposals_comments_summary_filename)) + files[:comments_summary] << investments_comments_summary_filename if File.exists?(data_folder.join(investments_comments_summary_filename)) files end @@ -110,10 +121,10 @@ class MachineLearning proposals_comments_summary_filename, investments_comments_summary_filename ] - json = Dir[DATA_FOLDER.join("*.json")].map do |full_path_filename| + json = Dir[data_folder.join("*.json")].map do |full_path_filename| full_path_filename.split("/").last end - csv = Dir[DATA_FOLDER.join("*.csv")].map do |full_path_filename| + csv = Dir[data_folder.join("*.csv")].map do |full_path_filename| full_path_filename.split("/").last end (json + csv - excluded).sort @@ -152,7 +163,7 @@ class MachineLearning end def data_path(filename) - "/machine_learning/data/" + filename + "/#{tenant_data_folder}/#{filename}" end def script_kinds @@ -196,29 +207,35 @@ class MachineLearning private def create_data_folder - FileUtils.mkdir_p DATA_FOLDER + FileUtils.mkdir_p data_folder end def export_proposals_to_json create_data_folder - filename = DATA_FOLDER.join(MachineLearning.proposals_filename) + filename = data_folder.join(MachineLearning.proposals_filename) Proposal::Exporter.new.to_json_file(filename) end def export_budget_investments_to_json create_data_folder - filename = DATA_FOLDER.join(MachineLearning.investments_filename) + filename = data_folder.join(MachineLearning.investments_filename) Budget::Investment::Exporter.new(Array.new).to_json_file(filename) end def export_comments_to_json create_data_folder - filename = DATA_FOLDER.join(MachineLearning.comments_filename) + filename = data_folder.join(MachineLearning.comments_filename) Comment::Exporter.new.to_json_file(filename) end def run_machine_learning_scripts - output = `cd #{SCRIPTS_FOLDER} && python #{job.script} 2>&1` + command = if Tenant.default? + "python #{job.script}" + else + "CONSUL_TENANT=#{Tenant.current_schema} python #{job.script}" + end + + output = `cd #{SCRIPTS_FOLDER} && #{command} 2>&1` result = $?.success? if result == false job.update!(finished_at: Time.current, error: output) @@ -254,7 +271,7 @@ class MachineLearning end def import_ml_proposals_comments_summary - json_file = DATA_FOLDER.join(MachineLearning.proposals_comments_summary_filename) + json_file = data_folder.join(MachineLearning.proposals_comments_summary_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| attributes.delete(:id) @@ -266,7 +283,7 @@ class MachineLearning end def import_ml_investments_comments_summary - json_file = DATA_FOLDER.join(MachineLearning.investments_comments_summary_filename) + json_file = data_folder.join(MachineLearning.investments_comments_summary_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| attributes.delete(:id) @@ -278,7 +295,7 @@ class MachineLearning end def import_proposals_related_content - json_file = DATA_FOLDER.join(MachineLearning.proposals_related_filename) + json_file = data_folder.join(MachineLearning.proposals_related_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |related| id = related.delete(:id) @@ -306,7 +323,7 @@ class MachineLearning end def import_budget_investments_related_content - json_file = DATA_FOLDER.join(MachineLearning.investments_related_filename) + json_file = data_folder.join(MachineLearning.investments_related_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |related| id = related.delete(:id) @@ -335,7 +352,7 @@ class MachineLearning def import_ml_proposals_tags ids = {} - json_file = DATA_FOLDER.join(MachineLearning.proposals_tags_filename) + json_file = data_folder.join(MachineLearning.proposals_tags_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| if attributes[:name].present? @@ -348,7 +365,7 @@ class MachineLearning end end - json_file = DATA_FOLDER.join(MachineLearning.proposals_taggings_filename) + json_file = data_folder.join(MachineLearning.proposals_taggings_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| if attributes[:tag_id].present? @@ -365,7 +382,7 @@ class MachineLearning def import_ml_investments_tags ids = {} - json_file = DATA_FOLDER.join(MachineLearning.investments_tags_filename) + json_file = data_folder.join(MachineLearning.investments_tags_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| if attributes[:name].present? @@ -378,7 +395,7 @@ class MachineLearning end end - json_file = DATA_FOLDER.join(MachineLearning.investments_taggings_filename) + json_file = data_folder.join(MachineLearning.investments_taggings_filename) json_data = JSON.parse(File.read(json_file)).each(&:deep_symbolize_keys!) json_data.each do |attributes| if attributes[:tag_id].present? @@ -421,13 +438,13 @@ class MachineLearning end def last_modified_date_for(filename) - return nil unless File.exists? DATA_FOLDER.join(filename) + return nil unless File.exists? data_folder.join(filename) - File.mtime DATA_FOLDER.join(filename) + File.mtime data_folder.join(filename) end def updated_file?(filename) - return false unless File.exists? DATA_FOLDER.join(filename) + return false unless File.exists? data_folder.join(filename) return true unless previous_modified_date[filename].present? last_modified_date_for(filename) > previous_modified_date[filename] diff --git a/app/models/tenant.rb b/app/models/tenant.rb new file mode 100644 index 000000000..45d9e2373 --- /dev/null +++ b/app/models/tenant.rb @@ -0,0 +1,125 @@ +class Tenant < ApplicationRecord + validates :schema, + presence: true, + uniqueness: true, + exclusion: { in: ->(*) { excluded_subdomains }}, + format: { with: URI::DEFAULT_PARSER.regexp[:HOST] } + validates :name, presence: true, uniqueness: true + + after_create :create_schema + after_update :rename_schema + after_destroy :destroy_schema + + def self.resolve_host(host) + return nil unless Rails.application.config.multitenancy.present? + return nil if host.blank? || host.match?(Resolv::AddressRegex) + + host_domain = allowed_domains.find { |domain| host == domain || host.ends_with?(".#{domain}") } + host.delete_prefix("www.").sub(/\.?#{host_domain}\Z/, "").presence + end + + def self.allowed_domains + dev_domains = %w[localhost lvh.me example.com] + dev_domains + [default_host] + end + + def self.excluded_subdomains + %w[mail public shared_extensions www] + end + + def self.default_url_options + ActionMailer::Base.default_url_options + end + + def self.default_host + default_url_options[:host] + end + + def self.current_url_options + default_url_options.merge(host: current_host) + end + + def self.current_host + host_for(current_schema) + end + + def self.host_for(schema) + if schema == "public" + default_host + elsif default_host == "localhost" + "#{schema}.lvh.me" + else + "#{schema}.#{default_host}" + end + end + + def self.current_secrets + if default? + Rails.application.secrets + else + @secrets ||= {} + @cached_rails_secrets ||= Rails.application.secrets + + if @cached_rails_secrets != Rails.application.secrets + @secrets = {} + @cached_rails_secrets = nil + end + + @secrets[current_schema] ||= Rails.application.secrets.merge( + Rails.application.secrets.dig(:tenants, current_schema.to_sym).to_h + ) + end + end + + def self.subfolder_path + if default? + "" + else + File.join("tenants", current_schema) + end + end + + def self.path_with_subfolder(filename_or_folder) + File.join(subfolder_path, filename_or_folder).delete_prefix("/") + end + + def self.default? + current_schema == "public" + end + + def self.current_schema + Apartment::Tenant.current + end + + def self.switch(...) + Apartment::Tenant.switch(...) + end + + def self.run_on_each(&block) + ["public"].union(Apartment.tenant_names).each do |schema| + switch(schema, &block) + end + end + + def host + self.class.host_for(schema) + end + + private + + def create_schema + Apartment::Tenant.create(schema) + end + + def rename_schema + if saved_change_to_schema? + ActiveRecord::Base.connection.execute( + "ALTER SCHEMA \"#{schema_before_last_save}\" RENAME TO \"#{schema}\";" + ) + end + end + + def destroy_schema + Apartment::Tenant.drop(schema) + end +end diff --git a/app/views/admin/tenants/edit.html.erb b/app/views/admin/tenants/edit.html.erb new file mode 100644 index 000000000..710ff10cb --- /dev/null +++ b/app/views/admin/tenants/edit.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::EditComponent.new(@tenant) %> diff --git a/app/views/admin/tenants/index.html.erb b/app/views/admin/tenants/index.html.erb new file mode 100644 index 000000000..51cd3b7a6 --- /dev/null +++ b/app/views/admin/tenants/index.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::IndexComponent.new(@tenants) %> diff --git a/app/views/admin/tenants/new.html.erb b/app/views/admin/tenants/new.html.erb new file mode 100644 index 000000000..be2d4f4ca --- /dev/null +++ b/app/views/admin/tenants/new.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::NewComponent.new(@tenant) %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 47cba9f90..48ad4426d 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -1,6 +1,5 @@ - lang="<%= I18n.locale %>"> - +> <%= render "layouts/common_head", default_title: "Admin" %> <%= content_for :head %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 04fa2920a..495a18838 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - lang="<%= I18n.locale %>" data-current-user-id="<%= current_user&.id %>"> + data-current-user-id="<%= current_user&.id %>"> <%= render "layouts/common_head", default_title: setting["org_name"] %> <%= render "layouts/meta_tags" %> diff --git a/app/views/layouts/dashboard.html.erb b/app/views/layouts/dashboard.html.erb index cf33f6f4f..ffdfc9439 100644 --- a/app/views/layouts/dashboard.html.erb +++ b/app/views/layouts/dashboard.html.erb @@ -1,5 +1,5 @@ - + data-current-user-id="<%= current_user&.id %>"> <%= render "layouts/common_head", default_title: setting["org_name"] %> <%= render "layouts/meta_tags" %> diff --git a/app/views/layouts/devise.html.erb b/app/views/layouts/devise.html.erb index e9ebac423..cc4db0fca 100644 --- a/app/views/layouts/devise.html.erb +++ b/app/views/layouts/devise.html.erb @@ -1,5 +1,5 @@ - lang="<%= I18n.locale %>"> +> <%= render "layouts/common_head", default_title: setting["org_name"] %> <%= render "layouts/meta_tags" %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 6691fe4fb..c5dcf1fd6 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -1,5 +1,5 @@ - +> <%= t("mailers.title") %> diff --git a/app/views/layouts/management.html.erb b/app/views/layouts/management.html.erb index 182c12f4a..5bb9f525a 100644 --- a/app/views/layouts/management.html.erb +++ b/app/views/layouts/management.html.erb @@ -1,6 +1,5 @@ - lang="<%= I18n.locale %>"> - +> <%= render "layouts/common_head", default_title: "Management" %> <%= stylesheet_link_tag "print", media: "print" %> diff --git a/public/robots.txt b/app/views/robots/index.text.erb similarity index 83% rename from public/robots.txt rename to app/views/robots/index.text.erb index 9327c9c1b..29c1e6ce3 100644 --- a/public/robots.txt +++ b/app/views/robots/index.text.erb @@ -13,3 +13,5 @@ Disallow: /*?*search Disallow: /*?*locale-switcher Disallow: /*?*filter Disallow: user_id + +Sitemap: <%= "#{root_url}#{Tenant.path_with_subfolder("sitemap.xml")}" %> diff --git a/app/views/shared/_social_media_meta_tags.html.erb b/app/views/shared/_social_media_meta_tags.html.erb index 01811be41..bd7117708 100644 --- a/app/views/shared/_social_media_meta_tags.html.erb +++ b/app/views/shared/_social_media_meta_tags.html.erb @@ -17,4 +17,4 @@ " /> " /> - + diff --git a/config/application.rb b/config/application.rb index d18d64a15..82c552a05 100644 --- a/config/application.rb +++ b/config/application.rb @@ -134,6 +134,9 @@ module Consul config.autoload_paths << "#{Rails.root}/app/graphql/custom" config.autoload_paths << "#{Rails.root}/app/models/custom" config.paths["app/views"].unshift(Rails.root.join("app", "views", "custom")) + + # Set to true to enable managing different tenants using the same application + config.multitenancy = Rails.application.secrets.multitenancy end end diff --git a/config/database-docker.yml.example b/config/database-docker.yml.example index 48e1c30db..2fb012a61 100644 --- a/config/database-docker.yml.example +++ b/config/database-docker.yml.example @@ -5,6 +5,7 @@ default: &default host: database #<--the name of the db in the docker-compose pool: 5 port: 5432 + schema_search_path: "public,shared_extensions" username: postgres password: <%= ENV["POSTGRES_PASSWORD"] %> diff --git a/config/database.yml.example b/config/database.yml.example index 2f8b7463f..2e5e8d82d 100644 --- a/config/database.yml.example +++ b/config/database.yml.example @@ -3,6 +3,7 @@ default: &default encoding: unicode host: localhost pool: 5 + schema_search_path: "public,shared_extensions" username: password: diff --git a/config/database.yml.gitlab b/config/database.yml.gitlab index 8ada1adf2..aeaef364b 100644 --- a/config/database.yml.gitlab +++ b/config/database.yml.gitlab @@ -3,6 +3,7 @@ default: &default encoding: unicode host: postgres pool: 5 + schema_search_path: "public,shared_extensions" username: consul password: diff --git a/config/environments/development.rb b/config/environments/development.rb index 42304e757..13479dc34 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -18,7 +18,7 @@ Rails.application.configure do config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - config.cache_store = :memory_store + config.cache_store = :memory_store, { namespace: proc { Tenant.current_schema }} config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } @@ -28,6 +28,10 @@ Rails.application.configure do config.cache_store = :null_store end + # Allow accessing the application through a domain so subdomains can be used + config.hosts << "lvh.me" + config.hosts << /.*\.lvh\.me/ + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.default_url_options = { host: "localhost", port: 3000 } diff --git a/config/environments/production.rb b/config/environments/production.rb index 2eb1a3cdd..47c8ac22a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -58,7 +58,7 @@ Rails.application.configure do config.log_tags = [:request_id] # Use a different cache store in production. - config.cache_store = :mem_cache_store + config.cache_store = :mem_cache_store, { namespace: proc { Tenant.current_schema }} # Use a real queuing backend for Active Job (and separate queues per environment). # config.active_job.queue_adapter = :resque diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 1d947e064..87c57f36d 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -58,7 +58,7 @@ Rails.application.configure do config.log_tags = [:request_id] # Use a different cache store in production. - config.cache_store = :mem_cache_store + config.cache_store = :mem_cache_store, { namespace: proc { Tenant.current_schema }} # Use a real queuing backend for Active Job (and separate queues per environment). # config.active_job.queue_adapter = :resque diff --git a/config/environments/test.rb b/config/environments/test.rb index e58456200..467972d2e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -59,6 +59,9 @@ Rails.application.configure do Bullet.raise = true # raise an error if n+1 query occurs end end + + # Allow managing different tenants using the same application + config.multitenancy = true end require Rails.root.join("config", "environments", "custom", "test") diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb new file mode 100644 index 000000000..511699f4b --- /dev/null +++ b/config/initializers/apartment.rb @@ -0,0 +1,117 @@ +# You can have Apartment route to the appropriate Tenant by adding some Rack middleware. +# Apartment can support many different "Elevators" that can take care of this routing to your data. +# Require whichever Elevator you're using below or none if you have a custom one. +# +require "apartment/elevators/generic" +# require "apartment/elevators/domain" +# require "apartment/elevators/subdomain" +# require "apartment/elevators/first_subdomain" +# require "apartment/elevators/host" + +# +# Apartment Configuration +# +Apartment.configure do |config| + ENV["IGNORE_EMPTY_TENANTS"] = "true" if Rails.env.test? || Rails.application.config.multitenancy.blank? + config.seed_after_create = !Rails.env.test? + + # Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace. + # A typical example would be a Customer or Tenant model that stores each Tenant's information. + # + config.excluded_models = %w[Delayed::Job Tenant] + + # In order to migrate all of your Tenants you need to provide a list of Tenant names to Apartment. + # You can make this dynamic by providing a Proc object to be called on migrations. + # This object should yield either: + # - an array of strings representing each Tenant name. + # - a hash which keys are tenant names, and values custom db config + # (must contain all key/values required in database.yml) + # + # config.tenant_names = lambda{ Customer.pluck(:tenant_name) } + # config.tenant_names = ["tenant1", "tenant2"] + # config.tenant_names = { + # "tenant1" => { + # adapter: "postgresql", + # host: "some_server", + # port: 5555, + # database: "postgres" # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # }, + # "tenant2" => { + # adapter: "postgresql", + # database: "postgres" # this is not the name of the tenant's db + # # but the name of the database to connect to before creating the tenant's db + # # mandatory in postgresql + # } + # } + # config.tenant_names = lambda do + # Tenant.all.each_with_object({}) do |tenant, hash| + # hash[tenant.name] = tenant.db_configuration + # end + # end + # + config.tenant_names = -> { Tenant.pluck :schema } + + # PostgreSQL: + # Specifies whether to use PostgreSQL schemas or create a new database per Tenant. + # + # MySQL: + # Specifies whether to switch databases by using `use` statement or re-establish connection. + # + # The default behaviour is true. + # + # config.use_schemas = true + + # + # ==> PostgreSQL only options + + # Apartment can be forced to use raw SQL dumps instead of schema.rb for creating new schemas. + # Use this when you are using some extra features in PostgreSQL that can't be represented in + # schema.rb, like materialized views etc. (only applies with use_schemas set to true). + # (Note: this option doesn't use db/structure.sql, it creates SQL dump by executing pg_dump) + # + # config.use_sql = false + + # There are cases where you might want some schemas to always be in your search_path + # e.g when using a PostgreSQL extension like hstore. + # Any schemas added here will be available along with your selected Tenant. + # + config.persistent_schemas = ["shared_extensions"] + + # <== PostgreSQL only options + # + + # By default, and only when not using PostgreSQL schemas, Apartment will prepend the environment + # to the tenant name to ensure there is no conflict between your environments. + # This is mainly for the benefit of your development and test environments. + # Uncomment the line below if you want to disable this behaviour in production. + # + # config.prepend_environment = !Rails.env.production? + + # When using PostgreSQL schemas, the database dump will be namespaced, and + # apartment will substitute the default namespace (usually public) with the + # name of the new tenant when creating a new tenant. Some items must maintain + # a reference to the default namespace (ie public) - for instance, a default + # uuid generation. Uncomment the line below to create a list of namespaced + # items in the schema dump that should *not* have their namespace replaced by + # the new tenant + # + # config.pg_excluded_names = ["uuid_generate_v4"] + + # Specifies whether the database and schema (when using PostgreSQL schemas) will prepend in ActiveRecord log. + # Uncomment the line below if you want to enable this behavior. + # + # config.active_record_log = true +end + +# Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that +# you want to switch to. +Rails.application.config.middleware.insert_before Warden::Manager, Apartment::Elevators::Generic, ->(request) do + Tenant.resolve_host(request.host) +end + +# Rails.application.config.middleware.use Apartment::Elevators::Domain +# Rails.application.config.middleware.use Apartment::Elevators::Subdomain +# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain +# Rails.application.config.middleware.use Apartment::Elevators::Host diff --git a/config/initializers/delayed_job_config.rb b/config/initializers/delayed_job_config.rb index 3bcbfa4a1..ce3c6e201 100644 --- a/config/initializers/delayed_job_config.rb +++ b/config/initializers/delayed_job_config.rb @@ -14,3 +14,15 @@ Delayed::Worker.read_ahead = 10 Delayed::Worker.default_queue_name = "default" Delayed::Worker.raise_signal_exceptions = :term Delayed::Worker.logger = Logger.new(File.join(Rails.root, "log", "delayed_job.log")) + +class ApartmentDelayedJobPlugin < Delayed::Plugin + callbacks do |lifecycle| + lifecycle.before(:enqueue) { |job| job.tenant = Tenant.current_schema } + + lifecycle.around(:perform) do |worker, job, *args, &block| + Tenant.switch(job.tenant) { block.call(worker, job, *args) } + end + end +end + +Delayed::Worker.plugins << ApartmentDelayedJobPlugin diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index aeb30c2f1..e297bfd48 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -245,14 +245,26 @@ Devise.setup do |config| # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' - config.omniauth :twitter, Rails.application.secrets.twitter_key, Rails.application.secrets.twitter_secret - config.omniauth :facebook, Rails.application.secrets.facebook_key, Rails.application.secrets.facebook_secret, scope: "email", info_fields: "email,name,verified" - config.omniauth :google_oauth2, Rails.application.secrets.google_oauth2_key, Rails.application.secrets.google_oauth2_secret + config.omniauth :twitter, + Rails.application.secrets.twitter_key, + Rails.application.secrets.twitter_secret, + setup: OmniauthTenantSetup.twitter + config.omniauth :facebook, + Rails.application.secrets.facebook_key, + Rails.application.secrets.facebook_secret, + scope: "email", + info_fields: "email,name,verified", + setup: OmniauthTenantSetup.facebook + config.omniauth :google_oauth2, + Rails.application.secrets.google_oauth2_key, + Rails.application.secrets.google_oauth2_secret, + setup: OmniauthTenantSetup.google_oauth2 config.omniauth :wordpress_oauth2, Rails.application.secrets.wordpress_oauth2_key, Rails.application.secrets.wordpress_oauth2_secret, strategy_class: OmniAuth::Strategies::Wordpress, - client_options: { site: Rails.application.secrets.wordpress_oauth2_site } + client_options: { site: Rails.application.secrets.wordpress_oauth2_site }, + setup: OmniauthTenantSetup.wordpress_oauth2 # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index 0e9f59e65..c3800181f 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -124,6 +124,9 @@ en: images: one: "Image" other: "Images" + tenant: + one: "tenant" + other: "tenants" topic: one: "Topic" other: "Topics" @@ -378,6 +381,8 @@ en: body: Body tag: name: "Type the name of the topic" + tenant: + schema: "Subdomain" topic: title: "Title" description: "Initial text" diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index b1b68b4b8..68cbbd3d5 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -785,6 +785,7 @@ en: comments: "Comments" local_census_records: Manage local census machine_learning: "AI / Machine learning" + multitenancy: Multitenancy administrators: index: title: Administrators @@ -1628,6 +1629,15 @@ en: notice: "Card updated successfully" destroy: notice: "Card removed successfully" + tenants: + create: + notice: Tenant created successfully + index: + create: Create tenant + new: + title: New tenant + update: + notice: Tenant updated successfully homepage: title: Homepage description: The active modules appear in the homepage in the same order as here. diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index 58305df15..102beb8f4 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -124,6 +124,9 @@ es: images: one: "Imagen" other: "Imágenes" + tenant: + one: "entidad" + other: "entidades" topic: one: "Tema" other: "Temas" @@ -378,6 +381,8 @@ es: body: Contenido tag: name: "Escribe el nombre del tema" + tenant: + schema: "Subdominio" topic: title: "Título" description: "Texto inicial" diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 08caffd41..cca53ce34 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -784,6 +784,7 @@ es: comments: "Comentarios" local_census_records: Gestionar censo local machine_learning: "IA / Machine learning" + multitenancy: Multientidad administrators: index: title: Administradores @@ -1627,6 +1628,15 @@ es: notice: "Tarjeta actualizada con éxito" destroy: notice: "Tarjeta eliminada con éxito" + tenants: + create: + notice: Entidad creada correctamente + index: + create: Crear entidad + new: + title: Nueva entidad + update: + notice: Entidad actualizada correctamente homepage: title: Homepage description: Los módulos activos aparecerán en la homepage en el mismo orden que aquí. diff --git a/config/routes.rb b/config/routes.rb index d216d1c81..29a4083c6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,7 @@ Rails.application.routes.draw do root "welcome#index" get "/welcome", to: "welcome#welcome" get "/consul.json", to: "installation#details" + get "robots.txt", to: "robots#index" resources :stats, only: [:index] resources :images, only: [:destroy] diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 56a2cf77e..752942b82 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -277,6 +277,8 @@ namespace :admin do post :execute, on: :collection delete :cancel, on: :collection end + + resources :tenants, except: [:show, :destroy] end resolve "Milestone" do |milestone| diff --git a/config/schedule.rb b/config/schedule.rb index 4e7675dc5..b9e3be61c 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -24,7 +24,7 @@ every 1.minute do end every 1.day, at: "5:00 am" do - rake "-s sitemap:refresh" + rake "-s sitemap:refresh:no_ping" end every 2.hours do diff --git a/config/secrets.yml.example b/config/secrets.yml.example index dc335a7c5..d04c93778 100644 --- a/config/secrets.yml.example +++ b/config/secrets.yml.example @@ -18,6 +18,7 @@ http_basic_auth: &http_basic_auth development: http_basic_username: "dev" http_basic_password: "pass" + multitenancy: false secret_key_base: 56792feef405a59b18ea7db57b4777e855103882b926413d4afdfb8c0ea8aa86ea6649da4e729c5f5ae324c0ab9338f789174cf48c544173bc18fdc3b14262e4 <<: *maps @@ -48,6 +49,16 @@ staging: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false + tenants: + # If you've enabled multitenancy, you can overwrite secrets for a + # specific tenant with: + # + # my_tenant_subdomain: + # secret_key: my_secret_value + # + # Currently you can overwrite SMTP, SMS, manager, microsoft API, + # HTTP basic, twitter, facebook, google and wordpress settings. <<: *maps <<: *apis @@ -74,6 +85,16 @@ preproduction: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false + tenants: + # If you've enabled multitenancy, you can overwrite secrets for a + # specific tenant with: + # + # my_tenant_subdomain: + # secret_key: my_secret_value + # + # Currently you can overwrite SMTP, SMS, manager, microsoft API, + # HTTP basic, twitter, facebook, google and wordpress settings. twitter_key: "" twitter_secret: "" facebook_key: "" @@ -105,6 +126,16 @@ production: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false + tenants: + # If you've enabled multitenancy, you can overwrite secrets for a + # specific tenant with: + # + # my_tenant_subdomain: + # secret_key: my_secret_value + # + # Currently you can overwrite SMTP, SMS, manager, microsoft API, + # HTTP basic, twitter, facebook, google and wordpress settings. twitter_key: "" twitter_secret: "" facebook_key: "" diff --git a/config/sitemap.rb b/config/sitemap.rb index 3a2664e55..ecf4378f3 100644 --- a/config/sitemap.rb +++ b/config/sitemap.rb @@ -6,49 +6,54 @@ class SitemapGenerator::FileAdapter end end SitemapGenerator::Sitemap.namer = SitemapGenerator::SimpleNamer.new(:sitemap, extension: ".xml") - -# default host SitemapGenerator::Sitemap.verbose = false if Rails.env.test? -SitemapGenerator::Sitemap.default_host = root_url(ActionMailer::Base.default_url_options) -# sitemap generator -SitemapGenerator::Sitemap.create do - add help_path - add how_to_use_path - add faq_path +Tenant.run_on_each do + SitemapGenerator::Sitemap.default_host = root_url(Tenant.current_url_options) + SitemapGenerator::Sitemap.sitemaps_path = Tenant.subfolder_path - if Setting["process.debates"] - add debates_path, priority: 0.7, changefreq: "daily" - Debate.find_each do |debate| - add debate_path(debate), lastmod: debate.updated_at + SitemapGenerator::Sitemap.create do + add help_path + add how_to_use_path + add faq_path + + if Setting["process.debates"] + add debates_path, priority: 0.7, changefreq: "daily" + Debate.find_each do |debate| + add debate_path(debate), lastmod: debate.updated_at + end + end + + if Setting["process.proposals"] + add proposals_path, priority: 0.7, changefreq: "daily" + Proposal.find_each do |proposal| + add proposal_path(proposal), lastmod: proposal.updated_at + end + end + + if Setting["process.budgets"] + add budgets_path, priority: 0.7, changefreq: "daily" + Budget.find_each do |budget| + add budget_path(budget), lastmod: budget.updated_at + end + end + + if Setting["process.polls"] + add polls_path, priority: 0.7, changefreq: "daily" + Poll.find_each do |poll| + add poll_path(poll), lastmod: poll.starts_at + end + end + + if Setting["process.legislation"] + add legislation_processes_path, priority: 0.7, changefreq: "daily" + Legislation::Process.find_each do |process| + add legislation_process_path(process), lastmod: process.start_date + end end end - if Setting["process.proposals"] - add proposals_path, priority: 0.7, changefreq: "daily" - Proposal.find_each do |proposal| - add proposal_path(proposal), lastmod: proposal.updated_at - end - end - - if Setting["process.budgets"] - add budgets_path, priority: 0.7, changefreq: "daily" - Budget.find_each do |budget| - add budget_path(budget), lastmod: budget.updated_at - end - end - - if Setting["process.polls"] - add polls_path, priority: 0.7, changefreq: "daily" - Poll.find_each do |poll| - add poll_path(poll), lastmod: poll.starts_at - end - end - - if Setting["process.legislation"] - add legislation_processes_path, priority: 0.7, changefreq: "daily" - Legislation::Process.find_each do |process| - add legislation_process_path(process), lastmod: process.start_date - end + unless Rails.env.development? || Rails.env.test? + SitemapGenerator::Sitemap.ping_search_engines end end diff --git a/config/storage.yml b/config/storage.yml index 590c3bb76..09c938b65 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -1,9 +1,9 @@ local: - service: Disk + service: TenantDisk root: <%= Rails.root.join("storage") %> test: - service: Disk + service: TenantDisk root: <%= Rails.root.join("tmp/storage") %> # s3: diff --git a/db/dev_seeds.rb b/db/dev_seeds.rb index ebcfeacb5..73834c32d 100644 --- a/db/dev_seeds.rb +++ b/db/dev_seeds.rb @@ -1,9 +1,17 @@ -ActiveRecord::Tasks::DatabaseTasks.truncate_all unless Rails.env.test? +unless Rails.env.test? + Tenant.destroy_all if Tenant.default? + ActiveRecord::Tasks::DatabaseTasks.truncate_all +end + @logger = Logger.new(STDOUT) @logger.formatter = proc do |_severity, _datetime, _progname, msg| - msg unless @avoid_log + msg unless Rails.env.test? end +def load_dev_seeds(dev_seeds_file) + load Rails.root.join("db", "dev_seeds", "#{dev_seeds_file}.rb") +end + def section(section_title) @logger.info section_title yield @@ -28,28 +36,30 @@ def random_locales_attributes(**attribute_names_with_values) end end -require_relative "dev_seeds/settings" -require_relative "dev_seeds/geozones" -require_relative "dev_seeds/users" -require_relative "dev_seeds/tags_categories" -require_relative "dev_seeds/debates" -require_relative "dev_seeds/proposals" -require_relative "dev_seeds/budgets" -require_relative "dev_seeds/comments" -require_relative "dev_seeds/votes" -require_relative "dev_seeds/flags" -require_relative "dev_seeds/hiddings" -require_relative "dev_seeds/banners" -require_relative "dev_seeds/polls" -require_relative "dev_seeds/communities" -require_relative "dev_seeds/legislation_processes" -require_relative "dev_seeds/newsletters" -require_relative "dev_seeds/notifications" -require_relative "dev_seeds/widgets" -require_relative "dev_seeds/admin_notifications" -require_relative "dev_seeds/legislation_proposals" -require_relative "dev_seeds/milestones" -require_relative "dev_seeds/pages" -require_relative "dev_seeds/sdg" +log "Creating dev seeds for tenant #{Tenant.current_schema}" unless Tenant.default? + +load_dev_seeds "settings" +load_dev_seeds "geozones" +load_dev_seeds "users" +load_dev_seeds "tags_categories" +load_dev_seeds "debates" +load_dev_seeds "proposals" +load_dev_seeds "budgets" +load_dev_seeds "comments" +load_dev_seeds "votes" +load_dev_seeds "flags" +load_dev_seeds "hiddings" +load_dev_seeds "banners" +load_dev_seeds "polls" +load_dev_seeds "communities" +load_dev_seeds "legislation_processes" +load_dev_seeds "newsletters" +load_dev_seeds "notifications" +load_dev_seeds "widgets" +load_dev_seeds "admin_notifications" +load_dev_seeds "legislation_proposals" +load_dev_seeds "milestones" +load_dev_seeds "pages" +load_dev_seeds "sdg" log "All dev seeds created successfuly 👍" diff --git a/db/dev_seeds/admin_notifications.rb b/db/dev_seeds/admin_notifications.rb index e75139c7d..132976cf6 100644 --- a/db/dev_seeds/admin_notifications.rb +++ b/db/dev_seeds/admin_notifications.rb @@ -5,7 +5,7 @@ section "Creating Admin Notifications & Templates" do -> { I18n.t("seeds.admin_notifications.proposal.#{attribute}") } end ).merge( - link: Rails.application.routes.url_helpers.proposals_url(ActionMailer::Base.default_url_options), + link: Rails.application.routes.url_helpers.proposals_url(Tenant.current_url_options), segment_recipient: "administrators" ) ).deliver diff --git a/db/migrate/20180502075740_create_tenants.rb b/db/migrate/20180502075740_create_tenants.rb new file mode 100644 index 000000000..323fa3d6f --- /dev/null +++ b/db/migrate/20180502075740_create_tenants.rb @@ -0,0 +1,12 @@ +class CreateTenants < ActiveRecord::Migration[4.2] + def change + create_table :tenants do |t| + t.string :name + t.string :schema + t.timestamps null: false + + t.index :name, unique: true + t.index :schema, unique: true + end + end +end diff --git a/db/migrate/20190214103106_add_tenant_to_delayed_job.rb b/db/migrate/20190214103106_add_tenant_to_delayed_job.rb new file mode 100644 index 000000000..5e9ec4388 --- /dev/null +++ b/db/migrate/20190214103106_add_tenant_to_delayed_job.rb @@ -0,0 +1,5 @@ +class AddTenantToDelayedJob < ActiveRecord::Migration[4.2] + def change + add_column :delayed_jobs, :tenant, :string + end +end diff --git a/db/migrate/20200602233844_create_shared_extensions_schema.rb b/db/migrate/20200602233844_create_shared_extensions_schema.rb new file mode 100644 index 000000000..1a35bc80c --- /dev/null +++ b/db/migrate/20200602233844_create_shared_extensions_schema.rb @@ -0,0 +1,137 @@ +class CreateSharedExtensionsSchema < ActiveRecord::Migration[6.0] + def up + unless schema_exists?(extensions_schema) + execute_or_log_create_schema_warning("CREATE SCHEMA #{extensions_schema}") + end + + %w[unaccent pg_trgm].each do |extension| + if extension_enabled?(extension) + unless extension_already_in_extensions_schema?(extension) + execute_or_log_extension_warning("ALTER EXTENSION #{extension} SET SCHEMA #{extensions_schema}") + end + else + execute_or_log_extension_warning("CREATE EXTENSION #{extension} SCHEMA #{extensions_schema}") + end + end + + unless schema_exists?(extensions_schema) && public_has_usage_privilege_on_extensions_schema? + execute_or_log_grant_usage_warning("GRANT usage ON SCHEMA #{extensions_schema} TO public") + end + + show_full_warning_message if warning_messages.any? + end + + def down + %w[unaccent pg_trgm].each do |extension| + unless extension_already_in_public_schema?(extension) + execute "ALTER EXTENSION #{extension} SET SCHEMA public;" + end + end + + execute "DROP SCHEMA #{extensions_schema};" if schema_exists?(extensions_schema) + end + + private + + def extensions_schema + "shared_extensions" + end + + def extension_already_in_extensions_schema?(extension) + associated_schema_id_for(extension) == extensions_schema_id + end + + def extension_already_in_public_schema?(extension) + associated_schema_id_for(extension) == public_schema_id + end + + def associated_schema_id_for(extension) + query_value("SELECT extnamespace FROM pg_extension WHERE extname=#{quote(extension)}") + end + + def extensions_schema_id + schema_id_for(extensions_schema) + end + + def public_schema_id + schema_id_for("public") + end + + def schema_id_for(schema) + query_value("SELECT oid FROM pg_namespace WHERE nspname=#{quote(schema)}") + end + + def execute_or_log_create_schema_warning(statement) + if create_permission? + execute statement + else + log_warning( + "GRANT CREATE ON DATABASE #{query_value("SELECT CURRENT_DATABASE()")} "\ + "TO #{query_value("SELECT CURRENT_USER")}" + ) + log_warning(statement) + end + end + + def execute_or_log_extension_warning(statement) + if superuser? + execute statement + else + log_warning(statement) + end + end + + def execute_or_log_grant_usage_warning(statement) + if schema_exists?(extensions_schema) && grant_usage_permission? + execute statement + else + log_warning(statement) + end + end + + def create_permission? + query_value("SELECT has_database_privilege(CURRENT_USER, CURRENT_DATABASE(), 'CREATE');") + end + + def superuser? + query_value("SELECT usesuper FROM pg_user WHERE usename = CURRENT_USER") + end + + def grant_usage_permission? + query_value("SELECT has_schema_privilege(CURRENT_USER, '#{extensions_schema}', 'CREATE');") + end + + def public_has_usage_privilege_on_extensions_schema? + query_value("SELECT has_schema_privilege('public', '#{extensions_schema}', 'USAGE');") + end + + def log_warning(statement) + warning_messages.push(statement) + end + + def warning_messages + @warning_messages ||= [] + end + + def show_full_warning_message + message = <<~WARNING + ---------------------- Multitenancy Warning ---------------------- + Multitenancy is a feature that allows managing multiple + institutions in a completely independent way using just one + CONSUL installation. + + NOTE: If you aren't going to use multitenancy, you can safely + ignore this warning. + + If you'd like to enable this feature, first run: + #{warning_messages.join(";\n ")}; + using a user with enough database privileges. + + Check the CONSUL release notes for more information. + ------------------------------------------------------------------ + WARNING + + puts message + Rails.logger.warn(message) + end +end diff --git a/db/schema.rb b/db/schema.rb index 20c37835e..5ff1ee3d6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -561,6 +561,7 @@ ActiveRecord::Schema.define(version: 2022_09_15_154808) do t.string "queue" t.datetime "created_at" t.datetime "updated_at" + t.string "tenant" t.index ["priority", "run_at"], name: "delayed_jobs_priority" end @@ -1555,6 +1556,15 @@ ActiveRecord::Schema.define(version: 2022_09_15_154808) do t.index ["proposals_count"], name: "index_tags_on_proposals_count" end + create_table "tenants", id: :serial, force: :cascade do |t| + t.string "name" + t.string "schema" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_tenants_on_name", unique: true + t.index ["schema"], name: "index_tenants_on_schema", unique: true + end + create_table "topics", id: :serial, force: :cascade do |t| t.string "title", null: false t.text "description" diff --git a/lib/active_storage/service/tenant_disk_service.rb b/lib/active_storage/service/tenant_disk_service.rb new file mode 100644 index 000000000..836983d03 --- /dev/null +++ b/lib/active_storage/service/tenant_disk_service.rb @@ -0,0 +1,13 @@ +require "active_storage/service/disk_service" + +module ActiveStorage + class Service::TenantDiskService < Service::DiskService + def path_for(key) + if Tenant.default? + super + else + super.sub(root, File.join(root, Tenant.subfolder_path)) + end + end + end +end diff --git a/lib/application_logger.rb b/lib/application_logger.rb index 44a4a0fde..33b1e0af8 100644 --- a/lib/application_logger.rb +++ b/lib/application_logger.rb @@ -3,6 +3,10 @@ class ApplicationLogger logger.info(message) unless Rails.env.test? end + def warn(message) + logger.warn(message) unless Rails.env.test? + end + def logger @logger ||= Logger.new(STDOUT).tap do |logger| logger.formatter = proc { |severity, _datetime, _progname, msg| "#{severity} #{msg}\n" } diff --git a/lib/manager_authenticator.rb b/lib/manager_authenticator.rb index 5ad17cb07..d6976470e 100644 --- a/lib/manager_authenticator.rb +++ b/lib/manager_authenticator.rb @@ -31,7 +31,7 @@ class ManagerAuthenticator end def client - @client ||= Savon.client(wsdl: Rails.application.secrets.managers_url) + @client ||= Savon.client(wsdl: Tenant.current_secrets.managers_url) end def parser @@ -39,6 +39,6 @@ class ManagerAuthenticator end def application_key - Rails.application.secrets.managers_application_key.to_s + Tenant.current_secrets.managers_application_key.to_s end end diff --git a/lib/omniauth_tenant_setup.rb b/lib/omniauth_tenant_setup.rb new file mode 100644 index 000000000..57d010947 --- /dev/null +++ b/lib/omniauth_tenant_setup.rb @@ -0,0 +1,47 @@ +module OmniauthTenantSetup + class << self + def twitter + ->(env) do + oauth(env, secrets.twitter_key, secrets.twitter_secret) + end + end + + def facebook + ->(env) do + oauth2(env, secrets.facebook_key, secrets.facebook_secret) + end + end + + def google_oauth2 + ->(env) do + oauth2(env, secrets.google_oauth2_key, secrets.google_oauth2_secret) + end + end + + def wordpress_oauth2 + ->(env) do + oauth2(env, secrets.wordpress_oauth2_key, secrets.wordpress_oauth2_secret) + end + end + + private + + def oauth(env, key, secret) + unless Tenant.default? + env["omniauth.strategy"].options[:consumer_key] = key + env["omniauth.strategy"].options[:consumer_secret] = secret + end + end + + def oauth2(env, key, secret) + unless Tenant.default? + env["omniauth.strategy"].options[:client_id] = key + env["omniauth.strategy"].options[:client_secret] = secret + end + end + + def secrets + Tenant.current_secrets + end + end +end diff --git a/lib/remote_translations/microsoft/available_locales.rb b/lib/remote_translations/microsoft/available_locales.rb index 8adee78f7..21ebf05cd 100644 --- a/lib/remote_translations/microsoft/available_locales.rb +++ b/lib/remote_translations/microsoft/available_locales.rb @@ -36,7 +36,7 @@ class RemoteTranslations::Microsoft::AvailableLocales uri = URI(host + path) request = Net::HTTP::Get.new(uri) - request["Ocp-Apim-Subscription-Key"] = Rails.application.secrets.microsoft_api_key + request["Ocp-Apim-Subscription-Key"] = Tenant.current_secrets.microsoft_api_key response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| http.request(request) diff --git a/lib/remote_translations/microsoft/client.rb b/lib/remote_translations/microsoft/client.rb index d7e1bc5d9..a4b256fda 100644 --- a/lib/remote_translations/microsoft/client.rb +++ b/lib/remote_translations/microsoft/client.rb @@ -6,7 +6,7 @@ class RemoteTranslations::Microsoft::Client PREVENTING_TRANSLATION_KEY = "notranslate".freeze def initialize - api_key = Rails.application.secrets.microsoft_api_key + api_key = Tenant.current_secrets.microsoft_api_key @client = TranslatorText::Client.new(api_key) end diff --git a/lib/sms_api.rb b/lib/sms_api.rb index e3187b710..84389123f 100644 --- a/lib/sms_api.rb +++ b/lib/sms_api.rb @@ -9,11 +9,11 @@ class SMSApi def url return "" unless end_point_available? - URI.parse(Rails.application.secrets.sms_end_point).to_s + URI.parse(Tenant.current_secrets.sms_end_point).to_s end def authorization - Base64.encode64("#{Rails.application.secrets.sms_username}:#{Rails.application.secrets.sms_password}") + Base64.encode64("#{Tenant.current_secrets.sms_username}:#{Tenant.current_secrets.sms_password}") end def sms_deliver(phone, code) diff --git a/lib/tasks/budgets.rake b/lib/tasks/budgets.rake index 172944420..7143e1e3e 100644 --- a/lib/tasks/budgets.rake +++ b/lib/tasks/budgets.rake @@ -1,13 +1,13 @@ namespace :budgets do namespace :email do desc "Sends emails to authors of selected investments" - task selected: :environment do - Budget.last.email_selected + task :selected, [:tenant] => :environment do |_, args| + Tenant.switch(args[:tenant]) { Budget.current.email_selected } end desc "Sends emails to authors of unselected investments" - task unselected: :environment do - Budget.last.email_unselected + task :unselected, [:tenant] => :environment do |_, args| + Tenant.switch(args[:tenant]) { Budget.current.email_unselected } end end end diff --git a/lib/tasks/consul.rake b/lib/tasks/consul.rake index 6f5e3724b..f5a57c1c6 100644 --- a/lib/tasks/consul.rake +++ b/lib/tasks/consul.rake @@ -7,6 +7,7 @@ namespace :consul do desc "Runs tasks needed to upgrade from 1.5.0 to 1.6.0" task "execute_release_1.6.0_tasks": [ "db:calculate_tsv", - "polls:set_ends_at_to_end_of_day" + "polls:set_ends_at_to_end_of_day", + "db:add_schema_search_path" ] end diff --git a/lib/tasks/create_shared_extensions_schema.rake b/lib/tasks/create_shared_extensions_schema.rake new file mode 100644 index 000000000..32e52b429 --- /dev/null +++ b/lib/tasks/create_shared_extensions_schema.rake @@ -0,0 +1,9 @@ +require Rails.root.join("db/migrate/20200602233844_create_shared_extensions_schema.rb") + +Rake::Task["db:schema:load"].enhance do + CreateSharedExtensionsSchema.new.up +end + +Rake::Task["db:test:purge"].enhance do + CreateSharedExtensionsSchema.new.up +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index cb6b20f92..7b38838fa 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -1,9 +1,8 @@ namespace :db do desc "Resets the database and loads it from db/dev_seeds.rb" - task :dev_seed, [:print_log] => [:environment] do |t, args| - @avoid_log = args[:print_log] == "avoid_log" + task :dev_seed, [:tenant] => [:environment] do |_, args| I18n.enforce_available_locales = false - load(Rails.root.join("db", "dev_seeds.rb")) + Tenant.switch(args[:tenant]) { load(Rails.root.join("db", "dev_seeds.rb")) } end desc "Calculates the TSV column for all comments and proposal notifications" @@ -16,4 +15,30 @@ namespace :db do logger.info "Calculating tsvector for proposal notifications" ProposalNotification.with_hidden.find_each(&:calculate_tsvector) end + + desc "Adds shared extensions to the schema search path in the database.yml file" + task add_schema_search_path: :environment do + logger = ApplicationLogger.new + logger.info "Adding search path to config/database.yml" + + config = Rails.application.config.paths["config/database"].first + lines = File.readlines(config) + changes_done = false + + adapter_indices = lines.map.with_index do |line, index| + index if line.start_with?(" adapter: postgresql") + end.compact + + adapter_indices.reverse_each do |index| + unless lines[index + 1]&.match?("schema_search_path") + lines.insert(index + 1, " schema_search_path: \"public,shared_extensions\"\n") + changes_done = true + end + end + + if changes_done + File.write(config, lines.join) + logger.warn "The database search path has been updated. Restart the application to apply the changes." + end + end end diff --git a/lib/tasks/files.rake b/lib/tasks/files.rake index 978b5f4f1..929eaa951 100644 --- a/lib/tasks/files.rake +++ b/lib/tasks/files.rake @@ -1,8 +1,10 @@ namespace :files do desc "Removes cached attachments which weren't deleted for some reason" task remove_old_cached_attachments: :environment do - ActiveStorage::Blob.unattached - .where("active_storage_blobs.created_at <= ?", 1.day.ago) - .find_each(&:purge_later) + Tenant.run_on_each do + ActiveStorage::Blob.unattached + .where("active_storage_blobs.created_at <= ?", 1.day.ago) + .find_each(&:purge_later) + end end end diff --git a/lib/tasks/settings.rake b/lib/tasks/settings.rake index 11ac39c4b..f98aca240 100644 --- a/lib/tasks/settings.rake +++ b/lib/tasks/settings.rake @@ -2,7 +2,7 @@ namespace :settings do desc "Add new settings" task add_new_settings: :environment do ApplicationLogger.new.info "Adding new settings" - Setting.add_new_settings + Tenant.run_on_each { Setting.add_new_settings } end desc "Rename existing settings" diff --git a/lib/tasks/stats.rake b/lib/tasks/stats.rake index 66456a0b5..b7d397301 100644 --- a/lib/tasks/stats.rake +++ b/lib/tasks/stats.rake @@ -2,23 +2,28 @@ namespace :stats do desc "Generates stats which are not cached yet" task generate: :environment do ApplicationLogger.new.info "Updating budget and poll stats" - admin_ability = Ability.new(Administrator.first.user) - Budget.accessible_by(admin_ability, :read_stats).find_each do |budget| - Budget::Stats.new(budget).generate - print "." - end + Tenant.run_on_each do + admin_ability = Ability.new(Administrator.first.user) - Poll.accessible_by(admin_ability, :stats).find_each do |poll| - Poll::Stats.new(poll).generate - print "." + Budget.accessible_by(admin_ability, :read_stats).find_each do |budget| + Budget::Stats.new(budget).generate + print "." + end + + Poll.accessible_by(admin_ability, :stats).find_each do |poll| + Poll::Stats.new(poll).generate + print "." + end end end desc "Expires stats cache" task expire_cache: :environment do - [Budget, Poll].each do |model_class| - model_class.find_each { |record| record.find_or_create_stats_version.touch } + Tenant.run_on_each do + [Budget, Poll].each do |model_class| + model_class.find_each { |record| record.find_or_create_stats_version.touch } + end end end diff --git a/lib/tasks/votes.rake b/lib/tasks/votes.rake index 59e809cd9..d86bdb4c5 100644 --- a/lib/tasks/votes.rake +++ b/lib/tasks/votes.rake @@ -5,9 +5,12 @@ namespace :votes do models.each do |model| print "Updating votes hot_score for #{model}s" - model.find_each do |resource| - new_hot_score = resource.calculate_hot_score - resource.update_columns(hot_score: new_hot_score, updated_at: Time.current) + + Tenant.run_on_each do + model.find_each do |resource| + new_hot_score = resource.calculate_hot_score + resource.update_columns(hot_score: new_hot_score, updated_at: Time.current) + end end puts " ✅ " end diff --git a/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py b/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py index da40f73b3..d43cfbc70 100644 --- a/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py +++ b/public/machine_learning/scripts/budgets_related_content_and_tags_nmf.py @@ -63,14 +63,17 @@ tqdm_notebook = True # In[2]: +import os +if os.environ.get("CONSUL_TENANT"): + data_path = '../../tenants/' + os.environ["CONSUL_TENANT"] + '/machine_learning/data' +else: + data_path = '../data' -data_path = '../data' config_file = 'budgets_related_content_and_tags_nmf.ini' logging_file ='budgets_related_content_and_tags_nmf.log' # Read the configuration file -import os import configparser config = configparser.ConfigParser() check_file(os.path.join(data_path,config_file)) diff --git a/public/machine_learning/scripts/budgets_summary_comments_textrank.py b/public/machine_learning/scripts/budgets_summary_comments_textrank.py index 1c0faf07b..a1dec2b6f 100644 --- a/public/machine_learning/scripts/budgets_summary_comments_textrank.py +++ b/public/machine_learning/scripts/budgets_summary_comments_textrank.py @@ -60,14 +60,17 @@ tqdm_notebook = True # In[ ]: +import os +if os.environ.get("CONSUL_TENANT"): + data_path = '../../tenants/' + os.environ["CONSUL_TENANT"] + '/machine_learning/data' +else: + data_path = '../data' -data_path = '../data' config_file = 'budgets_summary_comments_textrank.ini' logging_file ='budgets_summary_comments_textrank.log' # Read the configuration file -import os import configparser config = configparser.ConfigParser() check_file(os.path.join(data_path,config_file)) diff --git a/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py b/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py index 4c303ad28..df0af7945 100644 --- a/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py +++ b/public/machine_learning/scripts/proposals_related_content_and_tags_nmf.py @@ -63,14 +63,17 @@ tqdm_notebook = True # In[2]: +import os +if os.environ.get("CONSUL_TENANT"): + data_path = '../../tenants/' + os.environ["CONSUL_TENANT"] + '/machine_learning/data' +else: + data_path = '../data' -data_path = '../data' config_file = 'proposals_related_content_and_tags_nmf.ini' logging_file ='proposals_related_content_and_tags_nmf.log' # Read the configuration file -import os import configparser config = configparser.ConfigParser() check_file(os.path.join(data_path,config_file)) diff --git a/public/machine_learning/scripts/proposals_summary_comments_textrank.py b/public/machine_learning/scripts/proposals_summary_comments_textrank.py index 440083558..ac5d2569a 100644 --- a/public/machine_learning/scripts/proposals_summary_comments_textrank.py +++ b/public/machine_learning/scripts/proposals_summary_comments_textrank.py @@ -60,14 +60,17 @@ tqdm_notebook = True # In[3]: +import os +if os.environ.get("CONSUL_TENANT"): + data_path = '../../tenants/' + os.environ["CONSUL_TENANT"] + '/machine_learning/data' +else: + data_path = '../data' -data_path = '../data' config_file = 'proposals_summary_comments_textrank.ini' logging_file ='proposals_summary_comments_textrank.log' # Read the configuration file -import os import configparser config = configparser.ConfigParser() check_file(os.path.join(data_path,config_file)) diff --git a/spec/components/layout/common_html_attributes_component_spec.rb b/spec/components/layout/common_html_attributes_component_spec.rb new file mode 100644 index 000000000..ded22103c --- /dev/null +++ b/spec/components/layout/common_html_attributes_component_spec.rb @@ -0,0 +1,66 @@ +require "rails_helper" + +describe Layout::CommonHTMLAttributesComponent do + let(:component) { Layout::CommonHTMLAttributesComponent.new } + + context "with multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it "includes the default language by default" do + render_inline component + + expect(page.text).to eq 'lang="en"' + end + + it "includes the current language" do + I18n.with_locale(:es) { render_inline component } + + expect(page.text).to eq 'lang="es"' + end + end + + context "with multitenancy enabled" do + it "includes a class with the 'public' suffix for the default tenant" do + render_inline component + + expect(page.text).to eq 'lang="en" class="tenant-public"' + end + + it "includes a class with the schema name as suffix for other tenants" do + allow(Tenant).to receive(:current_schema).and_return "private" + + render_inline component + + expect(page.text).to eq 'lang="en" class="tenant-private"' + end + end + + context "RTL languages" do + let!(:default_enforce) { I18n.enforce_available_locales } + + before do + I18n.enforce_available_locales = false + allow(I18n).to receive(:available_locales).and_return(%i[ar en es]) + end + + after { I18n.enforce_available_locales = default_enforce } + + context "with multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it "includes the dir attribute" do + I18n.with_locale(:ar) { render_inline component } + + expect(page.text).to eq 'dir="rtl" lang="ar"' + end + end + + context "with multitenancy enabled" do + it "includes the dir and the class attributes" do + I18n.with_locale(:ar) { render_inline component } + + expect(page.text).to eq 'dir="rtl" lang="ar" class="tenant-public"' + end + end + end +end diff --git a/spec/factories/administration.rb b/spec/factories/administration.rb index 07a110ca5..de6df90be 100644 --- a/spec/factories/administration.rb +++ b/spec/factories/administration.rb @@ -98,4 +98,9 @@ FactoryBot.define do value_es { "Texto en español" } value_en { "Text in english" } end + + factory :tenant do + sequence(:name) { |n| "Tenant #{n}" } + sequence(:schema) { |n| "subdomain#{n}" } + end end diff --git a/spec/lib/tasks/budgets_spec.rb b/spec/lib/tasks/budgets_spec.rb new file mode 100644 index 000000000..ff4419440 --- /dev/null +++ b/spec/lib/tasks/budgets_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +describe "budget tasks" do + describe "rake budgets:email:selected" do + before { Rake::Task["budgets:email:selected"].reenable } + + it "sends emails to users from the current budget and not the last budget created" do + create(:budget_investment, :selected, author: create(:user, email: "selectme@consul.dev")) + create(:budget, :drafting) + + Rake.application.invoke_task("budgets:email:selected") + + expect(ActionMailer::Base.deliveries.count).to eq 1 + expect(ActionMailer::Base.deliveries.last.to).to eq ["selectme@consul.dev"] + end + + it "accepts specifying the tenant" do + create(:budget_investment, :selected, author: create(:user, email: "default@consul.dev")) + create(:tenant, schema: "different") + + Tenant.switch("different") do + create(:budget_investment, :selected, author: create(:user, email: "different@consul.dev")) + end + + Rake.application.invoke_task("budgets:email:selected[different]") + + expect(ActionMailer::Base.deliveries.count).to eq 1 + expect(ActionMailer::Base.deliveries.last.to).to eq ["different@consul.dev"] + end + end + + describe "rake budgets:email:unselected" do + before { Rake::Task["budgets:email:unselected"].reenable } + + it "sends emails to users from the current budget and not the last budget created" do + create(:budget_investment, author: create(:user, email: "ignorme@consul.dev")) + create(:budget, :drafting) + + Rake.application.invoke_task("budgets:email:unselected") + + expect(ActionMailer::Base.deliveries.count).to eq 1 + expect(ActionMailer::Base.deliveries.last.to).to eq ["ignorme@consul.dev"] + end + + it "accepts specifying the tenant" do + create(:budget_investment, author: create(:user, email: "default@consul.dev")) + create(:tenant, schema: "different") + + Tenant.switch("different") do + create(:budget_investment, author: create(:user, email: "different@consul.dev")) + end + + Rake.application.invoke_task("budgets:email:unselected[different]") + + expect(ActionMailer::Base.deliveries.count).to eq 1 + expect(ActionMailer::Base.deliveries.last.to).to eq ["different@consul.dev"] + end + end +end diff --git a/spec/lib/tasks/dev_seed_spec.rb b/spec/lib/tasks/dev_seed_spec.rb index 71c816df3..bb2ac5994 100644 --- a/spec/lib/tasks/dev_seed_spec.rb +++ b/spec/lib/tasks/dev_seed_spec.rb @@ -1,11 +1,18 @@ require "rails_helper" describe "rake db:dev_seed" do - let :run_rake_task do - Rake.application.invoke_task("db:dev_seed[avoid_log]") - end + before { Rake::Task["db:dev_seed"].reenable } it "seeds the database without errors" do - expect { run_rake_task }.not_to raise_error + expect { Rake.application.invoke_task("db:dev_seed") }.not_to raise_error + end + + it "can seed a tenant" do + create(:tenant, schema: "democracy") + + Rake.application.invoke_task("db:dev_seed[democracy]") + + expect(Debate.count).to eq 0 + Tenant.switch("democracy") { expect(Debate.count).not_to eq 0 } end end diff --git a/spec/lib/tasks/sitemap_spec.rb b/spec/lib/tasks/sitemap_spec.rb index dc2fa7edd..379b9c8a8 100644 --- a/spec/lib/tasks/sitemap_spec.rb +++ b/spec/lib/tasks/sitemap_spec.rb @@ -2,6 +2,7 @@ require "rails_helper" describe "rake sitemap:create", type: :system do let(:file) { Rails.root.join("public", "sitemap.xml") } + let(:run_rake_task) { Rake.application.invoke_task("sitemap:create") } before do FileUtils.rm_f(file) @@ -9,11 +10,7 @@ describe "rake sitemap:create", type: :system do end describe "when processes are enabled" do - before { Rake.application.invoke_task("sitemap:create") } - - it "generates a sitemap" do - expect(file).to exist - end + before { run_rake_task } it "generates a valid sitemap" do sitemap = Nokogiri::XML(File.open(file)) @@ -24,16 +21,16 @@ describe "rake sitemap:create", type: :system do sitemap = File.read(file) # Static pages - expect(sitemap).to include(faq_path) - expect(sitemap).to include(help_path) - expect(sitemap).to include(how_to_use_path) + expect(sitemap).to have_content(faq_path) + expect(sitemap).to have_content(help_path) + expect(sitemap).to have_content(how_to_use_path) # Dynamic URLs - expect(sitemap).to include(polls_path) - expect(sitemap).to include(budgets_path) - expect(sitemap).to include(debates_path) - expect(sitemap).to include(proposals_path) - expect(sitemap).to include(legislation_processes_path) + expect(sitemap).to have_content(polls_path) + expect(sitemap).to have_content(budgets_path) + expect(sitemap).to have_content(debates_path) + expect(sitemap).to have_content(proposals_path) + expect(sitemap).to have_content(legislation_processes_path) expect(sitemap).to have_content("0.7", count: 5) expect(sitemap).to have_content("daily", count: 5) @@ -48,11 +45,7 @@ describe "rake sitemap:create", type: :system do Setting["process.polls"] = nil Setting["process.legislation"] = nil - Rake.application.invoke_task("sitemap:create") - end - - it "generates a sitemap" do - expect(file).to exist + run_rake_task end it "generates a valid sitemap" do @@ -64,19 +57,61 @@ describe "rake sitemap:create", type: :system do sitemap = File.read(file) # Static pages - expect(sitemap).to include(faq_path) - expect(sitemap).to include(help_path) - expect(sitemap).to include(how_to_use_path) + expect(sitemap).to have_content(faq_path) + expect(sitemap).to have_content(help_path) + expect(sitemap).to have_content(how_to_use_path) # Dynamic URLs - expect(sitemap).not_to include(polls_path) - expect(sitemap).not_to include(budgets_path) - expect(sitemap).not_to include(debates_path) - expect(sitemap).not_to include(proposals_path) - expect(sitemap).not_to include(legislation_processes_path) + expect(sitemap).not_to have_content(polls_path) + expect(sitemap).not_to have_content(budgets_path) + expect(sitemap).not_to have_content(debates_path) + expect(sitemap).not_to have_content(proposals_path) + expect(sitemap).not_to have_content(legislation_processes_path) expect(sitemap).not_to have_content("0.7") expect(sitemap).not_to have_content("daily") end end + + it "generates a sitemap for every tenant" do + allow(Tenant).to receive(:default_url_options).and_return({ host: "consul.dev" }) + FileUtils.rm_f(Dir.glob(Rails.root.join("public", "tenants", "*", "sitemap.xml"))) + + create(:tenant, schema: "debates") + create(:tenant, schema: "proposals") + + Setting["process.budgets"] = true + Setting["process.debates"] = false + Setting["process.proposals"] = false + + Tenant.switch("debates") do + Setting["process.debates"] = true + Setting["process.budgets"] = false + Setting["process.proposals"] = false + end + + Tenant.switch("proposals") do + Setting["process.proposals"] = true + Setting["process.budgets"] = false + Setting["process.debates"] = false + end + + run_rake_task + + public_sitemap = File.read(file) + debates_sitemap = File.read(Rails.root.join("public", "tenants", "debates", "sitemap.xml")) + proposals_sitemap = File.read(Rails.root.join("public", "tenants", "proposals", "sitemap.xml")) + + expect(public_sitemap).to have_content budgets_url(host: "consul.dev") + expect(public_sitemap).not_to have_content debates_path + expect(public_sitemap).not_to have_content proposals_path + + expect(debates_sitemap).to have_content debates_url(host: "debates.consul.dev") + expect(debates_sitemap).not_to have_content budgets_path + expect(debates_sitemap).not_to have_content proposals_path + + expect(proposals_sitemap).to have_content proposals_url(host: "proposals.consul.dev") + expect(proposals_sitemap).not_to have_content budgets_path + expect(proposals_sitemap).not_to have_content debates_path + end end diff --git a/spec/mailers/application_mailer_spec.rb b/spec/mailers/application_mailer_spec.rb index 6a189c235..665dcca53 100644 --- a/spec/mailers/application_mailer_spec.rb +++ b/spec/mailers/application_mailer_spec.rb @@ -1,6 +1,28 @@ require "rails_helper" describe ApplicationMailer do + describe "#default_url_options" do + it "returns the same options on the default tenant" do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "consul.dev" }) + + expect(ApplicationMailer.new.default_url_options).to eq({ host: "consul.dev" }) + end + + it "returns the host with a subdomain on other tenants" do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "consul.dev" }) + allow(Tenant).to receive(:current_schema).and_return("my") + + expect(ApplicationMailer.new.default_url_options).to eq({ host: "my.consul.dev" }) + end + + it "uses lvh.me for subdomains when the host is localhost" do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "localhost", port: 3000 }) + allow(Tenant).to receive(:current_schema).and_return("dev") + + expect(ApplicationMailer.new.default_url_options).to eq({ host: "dev.lvh.me", port: 3000 }) + end + end + describe "#set_asset_host" do let(:mailer) { ApplicationMailer.new } @@ -24,6 +46,24 @@ describe ApplicationMailer do expect(mailer.asset_host).to eq "https://localhost:3000" end + it "returns the host with a subdomain on other tenants" do + allow(ActionMailer::Base).to receive(:default_url_options).and_return(host: "consul.dev") + allow(Tenant).to receive(:current_schema).and_return("my") + + mailer.set_asset_host + + expect(mailer.asset_host).to eq "http://my.consul.dev" + end + + it "uses lvh.me for subdomains when the host is localhost" do + allow(ActionMailer::Base).to receive(:default_url_options).and_return(host: "localhost", port: 3000) + allow(Tenant).to receive(:current_schema).and_return("dev") + + mailer.set_asset_host + + expect(mailer.asset_host).to eq "http://dev.lvh.me:3000" + end + it "returns the asset host when set manually" do default_asset_host = ActionMailer::Base.asset_host diff --git a/spec/mailers/mailer_spec.rb b/spec/mailers/mailer_spec.rb index 21231ca4d..af991075c 100644 --- a/spec/mailers/mailer_spec.rb +++ b/spec/mailers/mailer_spec.rb @@ -51,4 +51,59 @@ describe Mailer do expect(user.subscriptions_token).to eq "subscriptions_token_value" end end + + describe "multitenancy" do + it "uses the current tenant when using delayed jobs", :delay_jobs do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "consul.dev" }) + create(:tenant, schema: "delay") + + Tenant.switch("delay") do + Setting["org_name"] = "Delayed tenant" + + Mailer.delay.user_invite("test@consul.dev") + end + + Delayed::Worker.new.work_off + body = ActionMailer::Base.deliveries.last.body.to_s + expect(body).to match "Delayed tenant" + expect(body).to match "href=\"http://delay.consul.dev/" + expect(body).to match "src=\"http://delay.consul.dev/" + end + + describe "SMTP settings" do + let(:default_settings) { { address: "mail.consul.dev", username: "main" } } + let(:super_settings) { { address: "super.consul.dev", username: "super" } } + + before do + allow(Rails.application).to receive(:secrets).and_return(ActiveSupport::OrderedOptions.new.merge( + smtp_settings: default_settings, + tenants: { + supermailer: { smtp_settings: super_settings } + } + )) + end + + it "does not overwrite the settings for the default tenant" do + Mailer.user_invite("test@consul.dev").deliver_now + + expect(ActionMailer::Base.deliveries.last.delivery_method.settings).to eq({}) + end + + it "uses specific secret settings for tenants overwriting them" do + allow(Tenant).to receive(:current_schema).and_return("supermailer") + + Mailer.user_invite("test@consul.dev").deliver_now + + expect(ActionMailer::Base.deliveries.last.delivery_method.settings).to eq super_settings + end + + it "uses the default secret settings for other tenants" do + allow(Tenant).to receive(:current_schema).and_return("ultramailer") + + Mailer.user_invite("test@consul.dev").deliver_now + + expect(ActionMailer::Base.deliveries.last.delivery_method.settings).to eq default_settings + end + end + end end diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb index 4579aa4c8..4b8e2376f 100644 --- a/spec/models/abilities/administrator_spec.rb +++ b/spec/models/abilities/administrator_spec.rb @@ -164,4 +164,37 @@ describe Abilities::Administrator do it { should be_able_to(:destroy, SDG::Manager) } it { should be_able_to(:manage, Widget::Card) } + + describe "tenants" do + context "with multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it { should_not be_able_to :create, Tenant } + it { should_not be_able_to :read, Tenant } + it { should_not be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + end + + context "with multitenancy enabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(true) } + + it { should be_able_to :create, Tenant } + it { should be_able_to :read, Tenant } + it { should be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + + it "does not allow administrators from other tenants to manage tenants " do + create(:tenant, schema: "subsidiary") + + Tenant.switch("subsidiary") do + admin = create(:administrator).user + + expect(admin).not_to be_able_to :create, Tenant + expect(admin).not_to be_able_to :read, Tenant + expect(admin).not_to be_able_to :update, Tenant + expect(admin).not_to be_able_to :destroy, Tenant + end + end + end + end end diff --git a/spec/models/attachable_spec.rb b/spec/models/attachable_spec.rb new file mode 100644 index 000000000..2cb13825b --- /dev/null +++ b/spec/models/attachable_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +describe Attachable do + it "stores attachments for the default tenant in the default folder" do + file_path = build(:image).file_path + + expect(file_path).to include "storage/" + expect(file_path).not_to include "storage//" + expect(file_path).not_to include "tenants" + end + + it "stores tenant attachments in a folder for the tenant" do + allow(Tenant).to receive(:current_schema).and_return("image-master") + + expect(build(:image).file_path).to include "storage/tenants/image-master/" + end +end diff --git a/spec/models/machine_learning_spec.rb b/spec/models/machine_learning_spec.rb index da0788e15..f3f8af8f7 100644 --- a/spec/models/machine_learning_spec.rb +++ b/spec/models/machine_learning_spec.rb @@ -309,7 +309,7 @@ describe MachineLearning do machine_learning = MachineLearning.new(job) machine_learning.send(:export_proposals_to_json) - json_file = MachineLearning::DATA_FOLDER.join("proposals.json") + json_file = MachineLearning.data_folder.join("proposals.json") json = JSON.parse(File.read(json_file)) expect(json).to be_an Array @@ -335,7 +335,7 @@ describe MachineLearning do machine_learning = MachineLearning.new(job) machine_learning.send(:export_budget_investments_to_json) - json_file = MachineLearning::DATA_FOLDER.join("budget_investments.json") + json_file = MachineLearning.data_folder.join("budget_investments.json") json = JSON.parse(File.read(json_file)) expect(json).to be_an Array @@ -359,7 +359,7 @@ describe MachineLearning do machine_learning = MachineLearning.new(job) machine_learning.send(:export_comments_to_json) - json_file = MachineLearning::DATA_FOLDER.join("comments.json") + json_file = MachineLearning.data_folder.join("comments.json") json = JSON.parse(File.read(json_file)) expect(json).to be_an Array @@ -428,7 +428,7 @@ describe MachineLearning do ] filename = "ml_comments_summaries_proposals.json" - json_file = MachineLearning::DATA_FOLDER.join(filename) + json_file = MachineLearning.data_folder.join(filename) expect(File).to receive(:read).with(json_file).and_return data.to_json machine_learning.send(:import_ml_proposals_comments_summary) @@ -450,7 +450,7 @@ describe MachineLearning do ] filename = "ml_comments_summaries_budgets.json" - json_file = MachineLearning::DATA_FOLDER.join(filename) + json_file = MachineLearning.data_folder.join(filename) expect(File).to receive(:read).with(json_file).and_return data.to_json machine_learning.send(:import_ml_investments_comments_summary) @@ -476,7 +476,7 @@ describe MachineLearning do ] filename = "ml_related_content_proposals.json" - json_file = MachineLearning::DATA_FOLDER.join(filename) + json_file = MachineLearning.data_folder.join(filename) expect(File).to receive(:read).with(json_file).and_return data.to_json machine_learning.send(:import_proposals_related_content) @@ -504,7 +504,7 @@ describe MachineLearning do ] filename = "ml_related_content_budgets.json" - json_file = MachineLearning::DATA_FOLDER.join(filename) + json_file = MachineLearning.data_folder.join(filename) expect(File).to receive(:read).with(json_file).and_return data.to_json machine_learning.send(:import_budget_investments_related_content) @@ -538,11 +538,11 @@ describe MachineLearning do ] tags_filename = "ml_tags_proposals.json" - tags_json_file = MachineLearning::DATA_FOLDER.join(tags_filename) + tags_json_file = MachineLearning.data_folder.join(tags_filename) expect(File).to receive(:read).with(tags_json_file).and_return tags_data.to_json taggings_filename = "ml_taggings_proposals.json" - taggings_json_file = MachineLearning::DATA_FOLDER.join(taggings_filename) + taggings_json_file = MachineLearning.data_folder.join(taggings_filename) expect(File).to receive(:read).with(taggings_json_file).and_return taggings_data.to_json machine_learning.send(:import_ml_proposals_tags) @@ -580,11 +580,11 @@ describe MachineLearning do ] tags_filename = "ml_tags_budgets.json" - tags_json_file = MachineLearning::DATA_FOLDER.join(tags_filename) + tags_json_file = MachineLearning.data_folder.join(tags_filename) expect(File).to receive(:read).with(tags_json_file).and_return tags_data.to_json taggings_filename = "ml_taggings_budgets.json" - taggings_json_file = MachineLearning::DATA_FOLDER.join(taggings_filename) + taggings_json_file = MachineLearning.data_folder.join(taggings_filename) expect(File).to receive(:read).with(taggings_json_file).and_return taggings_data.to_json machine_learning.send(:import_ml_investments_tags) diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb new file mode 100644 index 000000000..0d5cdfc8b --- /dev/null +++ b/spec/models/tenant_spec.rb @@ -0,0 +1,303 @@ +require "rails_helper" + +describe Tenant do + describe ".resolve_host" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "consul.dev" }) + end + + it "returns nil for empty hosts" do + expect(Tenant.resolve_host("")).to be nil + expect(Tenant.resolve_host(nil)).to be nil + end + + it "returns nil for IP addresses" do + expect(Tenant.resolve_host("127.0.0.1")).to be nil + end + + it "returns nil using development and test domains" do + expect(Tenant.resolve_host("localhost")).to be nil + expect(Tenant.resolve_host("lvh.me")).to be nil + expect(Tenant.resolve_host("example.com")).to be nil + expect(Tenant.resolve_host("www.example.com")).to be nil + end + + it "treats lvh.me as localhost" do + expect(Tenant.resolve_host("jupiter.lvh.me")).to eq "jupiter" + expect(Tenant.resolve_host("www.lvh.me")).to be nil + end + + it "returns nil for the default host" do + expect(Tenant.resolve_host("consul.dev")).to be nil + end + + it "ignores the www prefix" do + expect(Tenant.resolve_host("www.consul.dev")).to be nil + end + + it "returns subdomains when present" do + expect(Tenant.resolve_host("saturn.consul.dev")).to eq "saturn" + end + + it "ignores the www prefix when subdomains are present" do + expect(Tenant.resolve_host("www.saturn.consul.dev")).to eq "saturn" + end + + it "returns nested additional subdomains" do + expect(Tenant.resolve_host("europa.jupiter.consul.dev")).to eq "europa.jupiter" + end + + it "ignores the www prefix in additional nested subdomains" do + expect(Tenant.resolve_host("www.europa.jupiter.consul.dev")).to eq "europa.jupiter" + end + + it "does not ignore www if it isn't the prefix" do + expect(Tenant.resolve_host("wwwsaturn.consul.dev")).to eq "wwwsaturn" + expect(Tenant.resolve_host("saturn.www.consul.dev")).to eq "saturn.www" + end + + it "returns the host as a subdomain" do + expect(Tenant.resolve_host("consul.dev.consul.dev")).to eq "consul.dev" + end + + it "returns nested subdomains containing the host" do + expect(Tenant.resolve_host("saturn.consul.dev.consul.dev")).to eq "saturn.consul.dev" + end + + it "returns full domains when they don't contain the host" do + expect(Tenant.resolve_host("unrelated.dev")).to eq "unrelated.dev" + expect(Tenant.resolve_host("mercury.anotherconsul.dev")).to eq "mercury.anotherconsul.dev" + end + + it "ignores the www prefix in full domains" do + expect(Tenant.resolve_host("www.unrelated.dev")).to eq "unrelated.dev" + expect(Tenant.resolve_host("www.mercury.anotherconsul.dev")).to eq "mercury.anotherconsul.dev" + end + + context "multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it "always returns nil" do + expect(Tenant.resolve_host("saturn.consul.dev")).to be nil + expect(Tenant.resolve_host("jupiter.lvh.me")).to be nil + end + end + + context "default host contains subdomains" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "demo.consul.dev" }) + end + + it "ignores subdomains already present in the default host" do + expect(Tenant.resolve_host("demo.consul.dev")).to be nil + end + + it "ignores the www prefix" do + expect(Tenant.resolve_host("www.demo.consul.dev")).to be nil + end + + it "returns additional subdomains" do + expect(Tenant.resolve_host("saturn.demo.consul.dev")).to eq "saturn" + end + + it "ignores the www prefix in additional subdomains" do + expect(Tenant.resolve_host("www.saturn.demo.consul.dev")).to eq "saturn" + end + + it "returns nested additional subdomains" do + expect(Tenant.resolve_host("europa.jupiter.demo.consul.dev")).to eq "europa.jupiter" + end + + it "ignores the www prefix in additional nested subdomains" do + expect(Tenant.resolve_host("www.europa.jupiter.demo.consul.dev")).to eq "europa.jupiter" + end + + it "does not ignore www if it isn't the prefix" do + expect(Tenant.resolve_host("wwwsaturn.demo.consul.dev")).to eq "wwwsaturn" + expect(Tenant.resolve_host("saturn.www.demo.consul.dev")).to eq "saturn.www" + end + end + + context "default host is similar to development and test domains" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "mylvh.me" }) + end + + it "returns nil for the default host" do + expect(Tenant.resolve_host("mylvh.me")).to be nil + end + + it "returns subdomains when present" do + expect(Tenant.resolve_host("neptune.mylvh.me")).to eq "neptune" + end + end + end + + describe ".host_for" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "consul.dev" }) + end + + it "returns the default host for the default schema" do + expect(Tenant.host_for("public")).to eq "consul.dev" + end + + it "returns the host with a subdomain on other schemas" do + expect(Tenant.host_for("uranus")).to eq "uranus.consul.dev" + end + + it "uses lvh.me for subdomains when the host is localhost" do + allow(Tenant).to receive(:default_url_options).and_return({ host: "localhost" }) + + expect(Tenant.host_for("uranus")).to eq "uranus.lvh.me" + end + end + + describe ".current_secrets" do + context "same secrets for all tenants" do + before do + allow(Rails.application).to receive(:secrets).and_return(ActiveSupport::OrderedOptions.new.merge( + star: "Sun", + volume: "Medium" + )) + end + + it "returns the default secrets for the default tenant" do + allow(Tenant).to receive(:current_schema).and_return("public") + + expect(Tenant.current_secrets.star).to eq "Sun" + expect(Tenant.current_secrets.volume).to eq "Medium" + end + + it "returns the default secrets for other tenants" do + allow(Tenant).to receive(:current_schema).and_return("earth") + + expect(Tenant.current_secrets.star).to eq "Sun" + expect(Tenant.current_secrets.volume).to eq "Medium" + end + end + + context "tenant overwriting secrets" do + before do + allow(Rails.application).to receive(:secrets).and_return(ActiveSupport::OrderedOptions.new.merge( + star: "Sun", + volume: "Medium", + tenants: { proxima: { star: "Alpha Centauri" }} + )) + end + + it "returns the default secrets for the default tenant" do + allow(Tenant).to receive(:current_schema).and_return("public") + + expect(Tenant.current_secrets.star).to eq "Sun" + expect(Tenant.current_secrets.volume).to eq "Medium" + end + + it "returns the overwritten secrets for tenants overwriting them" do + allow(Tenant).to receive(:current_schema).and_return("proxima") + + expect(Tenant.current_secrets.star).to eq "Alpha Centauri" + expect(Tenant.current_secrets.volume).to eq "Medium" + end + + it "returns the default secrets for other tenants" do + allow(Tenant).to receive(:current_schema).and_return("earth") + + expect(Tenant.current_secrets.star).to eq "Sun" + expect(Tenant.current_secrets.volume).to eq "Medium" + end + end + end + + describe ".run_on_each" do + it "runs the code on all tenants, including the default one" do + create(:tenant, schema: "andromeda") + create(:tenant, schema: "milky-way") + + Tenant.run_on_each do + Setting["org_name"] = "oh-my-#{Tenant.current_schema}" + end + + expect(Setting["org_name"]).to eq "oh-my-public" + + Tenant.switch("andromeda") do + expect(Setting["org_name"]).to eq "oh-my-andromeda" + end + + Tenant.switch("milky-way") do + expect(Setting["org_name"]).to eq "oh-my-milky-way" + end + end + end + + describe "validations" do + let(:tenant) { build(:tenant) } + + it "is valid" do + expect(tenant).to be_valid + end + + it "is not valid without a schema" do + tenant.schema = nil + expect(tenant).not_to be_valid + end + + it "is not valid with an already existing schema" do + expect(create(:tenant, schema: "subdomainx")).to be_valid + expect(build(:tenant, schema: "subdomainx")).not_to be_valid + end + + it "is not valid with an excluded subdomain" do + %w[mail public shared_extensions www].each do |subdomain| + tenant.schema = subdomain + expect(tenant).not_to be_valid + end + end + + it "is valid with nested subdomains" do + tenant.schema = "multiple.sub.domains" + expect(tenant).to be_valid + end + + it "is not valid with an invalid subdomain" do + tenant.schema = "my sub domain" + expect(tenant).not_to be_valid + end + + it "is not valid without a name" do + tenant.name = "" + expect(tenant).not_to be_valid + end + + it "is not valid with an already existing name" do + expect(create(:tenant, name: "Name X")).to be_valid + expect(build(:tenant, name: "Name X")).not_to be_valid + end + end + + describe "#create_schema" do + it "creates a schema creating a record" do + create(:tenant, schema: "new") + expect { Tenant.switch("new") { nil } }.not_to raise_exception + end + end + + describe "#rename_schema" do + it "renames the schema when updating the schema" do + tenant = create(:tenant, schema: "typo") + tenant.update!(schema: "notypo") + + expect { Tenant.switch("typo") { nil } }.to raise_exception(Apartment::TenantNotFound) + expect { Tenant.switch("notypo") { nil } }.not_to raise_exception + end + end + + describe "#destroy_schema" do + it "drops the schema when destroying a record" do + tenant = create(:tenant, schema: "wrong") + tenant.destroy! + + expect { Tenant.switch("wrong") { nil } }.to raise_exception(Apartment::TenantNotFound) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 5d8e11d91..08fa8c4c3 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,3 +65,14 @@ Capybara.enable_aria_label = true Capybara.disable_animation = true OmniAuth.config.test_mode = true + +def with_subdomain(subdomain, &block) + app_host = Capybara.app_host + + begin + Capybara.app_host = "http://#{subdomain}.lvh.me" + block.call + ensure + Capybara.app_host = app_host + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 59c87016a..a2f22cf87 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -117,6 +117,14 @@ RSpec.configure do |config| Delayed::Worker.delay_jobs = false end + config.before(:each, :seed_tenants) do + Apartment.seed_after_create = true + end + + config.after(:each, :seed_tenants) do + Apartment.seed_after_create = false + end + config.before(:each, :small_window) do @window_size = Capybara.current_window.size Capybara.current_window.resize_to(639, 479) diff --git a/spec/system/admin/machine_learning_spec.rb b/spec/system/admin/machine_learning_spec.rb index 03a476026..d058f8eb9 100644 --- a/spec/system/admin/machine_learning_spec.rb +++ b/spec/system/admin/machine_learning_spec.rb @@ -207,7 +207,7 @@ describe "Machine learning" do end scenario "Show output files info on settins page" do - FileUtils.mkdir_p MachineLearning::DATA_FOLDER + FileUtils.mkdir_p MachineLearning.data_folder allow_any_instance_of(MachineLearning).to receive(:run) do MachineLearningJob.first.update!(finished_at: 2.minutes.from_now) @@ -215,9 +215,9 @@ describe "Machine learning" do script: "proposals_summary_comments_textrank.py", kind: "comments_summary", updated_at: 2.minutes.from_now) - comments_file = MachineLearning::DATA_FOLDER.join(MachineLearning.comments_filename) + comments_file = MachineLearning.data_folder.join(MachineLearning.comments_filename) File.write(comments_file, [].to_json) - proposals_comments_summary_file = MachineLearning::DATA_FOLDER.join(MachineLearning.proposals_comments_summary_filename) + proposals_comments_summary_file = MachineLearning.data_folder.join(MachineLearning.proposals_comments_summary_filename) File.write(proposals_comments_summary_file, [].to_json) end diff --git a/spec/system/admin/tenants_spec.rb b/spec/system/admin/tenants_spec.rb new file mode 100644 index 000000000..4454fc6fc --- /dev/null +++ b/spec/system/admin/tenants_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +describe "Tenants", :admin, :seed_tenants do + before { allow(Tenant).to receive(:default_host).and_return("localhost") } + + scenario "Create" do + visit admin_root_path + + within("#side_menu") do + click_link "Settings" + click_link "Multitenancy" + end + + click_link "Create tenant" + fill_in "Subdomain", with: "earth" + fill_in "Name", with: "Earthlings" + click_button "Create tenant" + + expect(page).to have_content "Tenant created successfully" + + within("tr", text: "earth") { click_link "View" } + + expect(current_host).to eq "http://earth.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end + + scenario "Update" do + create(:tenant, schema: "moon") + + visit admin_tenants_path + within("tr", text: "moon") { click_link "Edit" } + + fill_in "Subdomain", with: "the-moon" + click_button "Update tenant" + + expect(page).to have_content "Tenant updated successfully" + + within("tr", text: "the-moon") { click_link "View" } + + expect(current_host).to eq "http://the-moon.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end +end diff --git a/spec/system/multitenancy_spec.rb b/spec/system/multitenancy_spec.rb new file mode 100644 index 000000000..45f6d473f --- /dev/null +++ b/spec/system/multitenancy_spec.rb @@ -0,0 +1,172 @@ +require "rails_helper" + +describe "Multitenancy", :seed_tenants do + before do + create(:tenant, schema: "mars") + create(:tenant, schema: "venus") + end + + scenario "Disabled features", :no_js do + Tenant.switch("mars") { Setting["process.debates"] = true } + Tenant.switch("venus") { Setting["process.debates"] = nil } + + with_subdomain("mars") do + visit debates_path + + expect(page).to have_css "#debates" + end + + with_subdomain("venus") do + expect { visit debates_path }.to raise_exception(FeatureFlags::FeatureDisabled) + end + end + + scenario "Content is different for differents tenants" do + Tenant.switch("mars") { create(:poll, name: "Human rights for Martians?") } + + with_subdomain("mars") do + visit polls_path + + expect(page).to have_content "Human rights for Martians?" + expect(page).to have_css "html.tenant-mars" + expect(page).not_to have_css "html.tenant-venus" + end + + with_subdomain("venus") do + visit polls_path + + expect(page).to have_content "There are no open votings" + expect(page).to have_css "html.tenant-venus" + expect(page).not_to have_css "html.tenant-mars" + end + end + + scenario "PostgreSQL extensions work for tenants" do + Tenant.switch("mars") { login_as(create(:user)) } + + with_subdomain("mars") do + visit new_proposal_path + fill_in "Proposal title", with: "Use the unaccent extension in Mars" + fill_in "Proposal summary", with: "tsvector for María the Martian" + check "I agree to the Privacy Policy and the Terms and conditions of use" + + click_button "Create proposal" + + expect(page).to have_content "Proposal created successfully." + + click_link "No, I want to publish the proposal" + + expect(page).to have_content "You've created a proposal!" + + visit proposals_path + click_button "Advanced search" + fill_in "With the text", with: "Maria the Martian" + click_button "Filter" + + expect(page).to have_content "Search results" + expect(page).to have_content "María the Martian" + end + end + + scenario "Creating content in one tenant doesn't affect other tenants" do + Tenant.switch("mars") { login_as(create(:user)) } + + with_subdomain("mars") do + visit new_debate_path + fill_in "Debate title", with: "Found any water here?" + fill_in_ckeditor "Initial debate text", with: "Found any water here?" + check "I agree to the Privacy Policy and the Terms and conditions of use" + + click_button "Start a debate" + + expect(page).to have_content "Debate created successfully." + expect(page).to have_content "Found any water here?" + end + + with_subdomain("venus") do + visit debates_path + + expect(page).to have_content "Sign in" + expect(page).not_to have_css ".debate" + + visit new_debate_path + + expect(page).to have_content "You must sign in or register to continue." + end + end + + scenario "Users from another tenant cannot vote" do + Tenant.switch("mars") { create(:proposal, title: "Earth invasion") } + Tenant.switch("venus") { login_as(create(:user)) } + + with_subdomain("venus") do + visit proposals_path + + expect(page).to have_content "Sign out" + expect(page).not_to have_content "Earth invasion" + end + + with_subdomain("mars") do + visit proposals_path + + within(".proposal", text: "Earth invasion") do + click_button "Support" + + expect(page).to have_content "You must sign in or sign up to continue" + end + end + end + + scenario "Sign up into subdomain" do + with_subdomain("mars") do + visit "/" + click_link "Register" + + fill_in "Username", with: "Marty McMartian" + fill_in "Email", with: "marty@consul.dev" + fill_in "Password", with: "20151021" + fill_in "Confirm password", with: "20151021" + check "By registering you accept the terms and conditions of use" + click_button "Register" + + confirm_email + + expect(page).to have_content "Your account has been confirmed." + end + end + + scenario "Users from another tenant can't sign in" do + Tenant.switch("mars") { create(:user, email: "marty@consul.dev", password: "20151021") } + + with_subdomain("mars") do + visit new_user_session_path + fill_in "Email or username", with: "marty@consul.dev" + fill_in "Password", with: "20151021" + click_button "Enter" + + expect(page).to have_content "You have been signed in successfully." + end + + with_subdomain("venus") do + visit new_user_session_path + fill_in "Email or username", with: "marty@consul.dev" + fill_in "Password", with: "20151021" + click_button "Enter" + + expect(page).to have_content "Invalid Email or username or password." + end + end + + scenario "Uses the right tenant after failing to sign in" do + with_subdomain("mars") do + visit new_user_session_path + fill_in "Email or username", with: "wrong@consul.dev" + fill_in "Password", with: "wrong" + click_button "Enter" + + expect(page).to have_content "Invalid Email or username or password" + expect(page).to have_css "html.tenant-mars" + expect(page).not_to have_css "html.tenant-public" + end + end +end diff --git a/spec/system/robots_spec.rb b/spec/system/robots_spec.rb new file mode 100644 index 000000000..3e728f24b --- /dev/null +++ b/spec/system/robots_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +describe "robots.txt" do + scenario "uses the default sitemap for the default tenant" do + visit "/robots.txt" + + expect(page).to have_content "Sitemap: #{app_host}/sitemap.xml" + end + + scenario "uses a different sitemap for other tenants" do + create(:tenant, schema: "cyborgs") + + with_subdomain("cyborgs") do + visit "/robots.txt" + + expect(page).to have_content "Sitemap: http://cyborgs.lvh.me:#{app_port}/tenants/cyborgs/sitemap.xml" + end + end +end