Merge pull request #4030 from consul/multitenancy

Add support for multitenancy
This commit is contained in:
Javi Martín
2022-11-19 18:07:25 +01:00
committed by GitHub
100 changed files with 1937 additions and 203 deletions

View File

@@ -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/

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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"

View File

@@ -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)

View File

@@ -110,6 +110,7 @@
<a href="#" class="settings-link"><%= t("admin.menu.title_settings") %></a>
<%= link_list(
settings_link,
tenants_link,
tags_link,
geozones_link,
images_link,

View File

@@ -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"),

View File

@@ -0,0 +1,3 @@
<%= back_link_to admin_tenants_path %>
<%= header %>
<%= render Admin::Tenants::FormComponent.new(tenant) %>

View File

@@ -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

View File

@@ -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 %>

View File

@@ -0,0 +1,7 @@
class Admin::Tenants::FormComponent < ApplicationComponent
attr_reader :tenant
def initialize(tenant)
@tenant = tenant
end
end

View File

@@ -0,0 +1,27 @@
<%= header do %>
<%= link_to t("admin.tenants.index.create"), new_admin_tenant_path %>
<% end %>
<table>
<thead>
<tr>
<th><%= attribute_name(:name) %></th>
<th><%= attribute_name(:schema) %></th>
<th><%= t("admin.shared.actions") %></th>
</tr>
</thead>
<tbody>
<% @tenants.each do |tenant| %>
<tr id="<%= dom_id(tenant) %>">
<td><%= tenant.name %></td>
<td><%= tenant.schema %></td>
<td>
<%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) do |actions| %>
<%= actions.action(:show, text: t("admin.shared.view"), path: root_url(host: tenant.host)) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<%= back_link_to admin_tenants_path %>
<%= header %>
<%= render Admin::Tenants::FormComponent.new(tenant) %>

View File

@@ -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

View File

@@ -0,0 +1 @@
<%= attributes -%>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
class RobotsController < ApplicationController
skip_authorization_check
def index
respond_to :text
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

125
app/models/tenant.rb Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::EditComponent.new(@tenant) %>

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::IndexComponent.new(@tenants) %>

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::NewComponent.new(@tenant) %>

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html>
<html <%= "dir=rtl" if rtl? %> lang="<%= I18n.locale %>">
<html <%= common_html_attributes %>>
<head>
<%= render "layouts/common_head", default_title: "Admin" %>
<%= content_for :head %>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html <%= "dir=rtl" if rtl? %> lang="<%= I18n.locale %>" data-current-user-id="<%= current_user&.id %>">
<html <%= common_html_attributes %> data-current-user-id="<%= current_user&.id %>">
<head>
<%= render "layouts/common_head", default_title: setting["org_name"] %>
<%= render "layouts/meta_tags" %>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>" data-current-user-id="<%= current_user&.id %>">
<html <%= common_html_attributes %> data-current-user-id="<%= current_user&.id %>">
<head>
<%= render "layouts/common_head", default_title: setting["org_name"] %>
<%= render "layouts/meta_tags" %>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html <%= "dir=rtl" if rtl? %> lang="<%= I18n.locale %>">
<html <%= common_html_attributes %>>
<head>
<%= render "layouts/common_head", default_title: setting["org_name"] %>
<%= render "layouts/meta_tags" %>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html lang="<%= I18n.locale %>">
<html <%= common_html_attributes %>>
<head>
<title><%= t("mailers.title") %></title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">

View File

@@ -1,6 +1,5 @@
<!DOCTYPE html>
<html <%= "dir=rtl" if rtl? %> lang="<%= I18n.locale %>">
<html <%= common_html_attributes %>>
<head>
<%= render "layouts/common_head", default_title: "Management" %>
<%= stylesheet_link_tag "print", media: "print" %>

View File

@@ -13,3 +13,5 @@ Disallow: /*?*search
Disallow: /*?*locale-switcher
Disallow: /*?*filter
Disallow: user_id
Sitemap: <%= "#{root_url}#{Tenant.path_with_subfolder("sitemap.xml")}" %>

View File

@@ -17,4 +17,4 @@
<meta id="ogimage" property="og:image" content="<%= root_url + (local_assigns[:og_image_url] || "social_media_icon.png") %>" />
<meta property="og:site_name" content="<%= setting["org_name"] %>" />
<meta id="ogdescription" property="og:description" content="<%= description %>" />
<meta property="fb:app_id" content="<%= Rails.application.secrets.facebook_key %>" />
<meta property="fb:app_id" content="<%= Tenant.current_secrets.facebook_key %>" />

View File

@@ -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

View File

@@ -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"] %>

View File

@@ -3,6 +3,7 @@ default: &default
encoding: unicode
host: localhost
pool: 5
schema_search_path: "public,shared_extensions"
username:
password:

View File

@@ -3,6 +3,7 @@ default: &default
encoding: unicode
host: postgres
pool: 5
schema_search_path: "public,shared_extensions"
username: consul
password:

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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.

View File

@@ -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"

View File

@@ -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í.

View File

@@ -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]

View File

@@ -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|

View File

@@ -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

View File

@@ -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: ""

View File

@@ -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

View File

@@ -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:

View File

@@ -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 👍"

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddTenantToDelayedJob < ActiveRecord::Migration[4.2]
def change
add_column :delayed_jobs, :tenant, :string
end
end

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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" }

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

303
spec/models/tenant_spec.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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