diff --git a/app/controllers/api/settings_controller.rb b/app/controllers/api/settings_controller.rb index ddb022015..34579c5df 100644 --- a/app/controllers/api/settings_controller.rb +++ b/app/controllers/api/settings_controller.rb @@ -12,10 +12,10 @@ class API::SettingsController < API::ApiController authorize Setting @setting = Setting.find_or_initialize_by(name: params[:name]) render status: :not_modified and return if setting_params[:value] == @setting.value - render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.before_update(@setting) + render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.update_allowed?(@setting) if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile) - SettingService.after_update(@setting) + SettingService.run_after_update([@setting]) render status: :ok else render json: @setting.errors.full_messages, status: :unprocessable_entity @@ -31,17 +31,17 @@ class API::SettingsController < API::ApiController next if !setting[:name] || !setting[:value] db_setting = Setting.find_or_initialize_by(name: setting[:name]) - if !SettingService.before_update(db_setting) + if !SettingService.update_allowed?(db_setting) db_setting.errors.add(:-, "#{I18n.t("settings.#{setting[:name]}")}: #{I18n.t('settings.locked_setting')}") elsif db_setting.save db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile) - SettingService.after_update(db_setting) end @settings.push db_setting may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive? end end + SettingService.run_after_update(@settings) end def show @@ -61,7 +61,7 @@ class API::SettingsController < API::ApiController authorize Setting setting = Setting.find_or_create_by(name: params[:name]) - render status: :locked, json: { error: 'locked setting' } and return unless SettingService.before_update(setting) + render status: :locked, json: { error: 'locked setting' } and return unless SettingService.update_allowed?(setting) first_val = setting.history_values.order(created_at: :asc).limit(1).first new_val = HistoryValue.create!( @@ -69,7 +69,7 @@ class API::SettingsController < API::ApiController value: first_val&.value, invoicing_profile_id: current_user.invoicing_profile.id ) - SettingService.after_update(setting) + SettingService.run_after_update([setting]) render json: new_val, status: :ok end diff --git a/app/frontend/src/javascript/components/base/fab-tabs.tsx b/app/frontend/src/javascript/components/base/fab-tabs.tsx index 486cf9d49..09c3d47fd 100644 --- a/app/frontend/src/javascript/components/base/fab-tabs.tsx +++ b/app/frontend/src/javascript/components/base/fab-tabs.tsx @@ -1,5 +1,7 @@ import { ReactNode, useEffect, useState } from 'react'; import * as React from 'react'; +import _ from 'lodash'; +import { usePrevious } from '../../lib/use-previous'; type tabId = string|number; @@ -21,9 +23,12 @@ interface FabTabsProps { */ export const FabTabs: React.FC = ({ tabs, defaultTab, className }) => { const [active, setActive] = useState(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]); + const previousTabs = usePrevious(tabs); useEffect(() => { - setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]); + if (!_.isEqual(previousTabs?.filter(Boolean).map(t => t.id), tabs?.filter(Boolean).map(t => t?.id))) { + setActive(tabs.filter(Boolean).find(t => t.id === defaultTab) || tabs.filter(Boolean)[0]); + } }, [tabs]); /** diff --git a/app/frontend/src/javascript/lib/use-previous.ts b/app/frontend/src/javascript/lib/use-previous.ts new file mode 100644 index 000000000..92dc9fef6 --- /dev/null +++ b/app/frontend/src/javascript/lib/use-previous.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +// provides the previous value of a Prop, in a useEffect hook +// Credits to: https://stackoverflow.com/a/57706747/1039377 +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; diff --git a/app/models/advanced_accounting.rb b/app/models/advanced_accounting.rb index 740b186b7..8c469bf88 100644 --- a/app/models/advanced_accounting.rb +++ b/app/models/advanced_accounting.rb @@ -8,4 +8,25 @@ class AdvancedAccounting < ApplicationRecord belongs_to :space, foreign_type: 'Space', foreign_key: 'accountable_id', inverse_of: :advanced_accounting belongs_to :event, foreign_type: 'Event', foreign_key: 'accountable_id', inverse_of: :advanced_accounting belongs_to :product, foreign_type: 'Product', foreign_key: 'accountable_id', inverse_of: :advanced_accounting + belongs_to :plan, foreign_type: 'Plan', foreign_key: 'accountable_id', inverse_of: :advanced_accounting + + after_save :rebuild_accounting_lines + + private + + def rebuild_accounting_lines + invoices = case accountable_type + when 'Machine', 'Training', 'Space', 'Event' + accountable.reservations.map(&:invoice_items).flatten.map(&:invoice).uniq + when 'Product' + accountable.order_items.map(&:order).flatten.map(&:invoice).uniq + when 'Plan' + accountable.subscriptions.map(&:invoice_items).flatten.map(&:invoice).uniq + else + raise TypeError "Unknown accountable_type #{accountable_type}" + end + ids = invoices.map(&:id) + AccountingLine.where(invoice_id: ids).destroy_all + AccountingWorker.perform_async(:invoices, ids) + end end diff --git a/app/models/concerns/openlab_sync.rb b/app/models/concerns/openlab_sync.rb new file mode 100644 index 000000000..e2104873d --- /dev/null +++ b/app/models/concerns/openlab_sync.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: false + +# module definition +module OpenlabSync + extend ActiveSupport::Concern + + included do + after_create :openlab_create, if: :openlab_sync_active? + run_after_update :openlab_update, if: :openlab_sync_active? + after_destroy :openlab_destroy, if: :openlab_sync_active? + + def openlab_create + OpenlabWorker.perform_in(2.seconds, :create, id) if published? + end + + def openlab_update + return unless published? + + if state_was == 'draft' + OpenlabWorker.perform_async(:create, id) + else + OpenlabWorker.perform_async(:update, id) + end + end + + def openlab_destroy + OpenlabWorker.perform_async(:destroy, id) + end + + def openlab_attributes + OpenLabService.to_hash(self) + end + + def openlab_sync_active? + self.class.openlab_sync_active? + end + end + + class_methods do + def openlab_sync_active? + Setting.get('openlab_app_secret').present? + end + end +end diff --git a/app/models/order_item.rb b/app/models/order_item.rb index f6e76ec5c..6948bcbec 100644 --- a/app/models/order_item.rb +++ b/app/models/order_item.rb @@ -4,6 +4,7 @@ class OrderItem < ApplicationRecord belongs_to :order belongs_to :orderable, polymorphic: true + belongs_to :product, foreign_type: 'Product', foreign_key: 'orderable_id', inverse_of: :order_items validates :orderable, :order_id, :amount, presence: true end diff --git a/app/models/product.rb b/app/models/product.rb index 777209525..5028d4ba7 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -20,6 +20,8 @@ class Product < ApplicationRecord has_many :product_stock_movements, dependent: :destroy accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + has_many :order_items, as: :orderable, dependent: :nullify + has_one :advanced_accounting, as: :accountable, dependent: :destroy accepts_nested_attributes_for :advanced_accounting, allow_destroy: true diff --git a/app/models/project/openlab_sync.rb b/app/models/project/openlab_sync.rb deleted file mode 100644 index 66ee04370..000000000 --- a/app/models/project/openlab_sync.rb +++ /dev/null @@ -1,65 +0,0 @@ -module Project::OpenlabSync - extend ActiveSupport::Concern - - included do - include ActionView::Helpers::SanitizeHelper - - after_create :openlab_create, if: :openlab_sync_active? - after_update :openlab_update, if: :openlab_sync_active? - after_destroy :openlab_destroy, if: :openlab_sync_active? - - def openlab_create - OpenlabWorker.perform_in(2.seconds, :create, self.id) if self.published? - end - - def openlab_update - if self.published? - if self.state_was == 'draft' - OpenlabWorker.perform_async(:create, self.id) - else - OpenlabWorker.perform_async(:update, self.id) - end - end - end - - def openlab_destroy - OpenlabWorker.perform_async(:destroy, self.id) - end - - def openlab_attributes - { - id: id, slug: slug, name: name, description: description, tags: tags, - machines: machines.map(&:name), - components: components.map(&:name), - themes: themes.map(&:name), - author: author&.user&.profile&.full_name, - collaborators: users.map { |u| u&.profile&.full_name }, - steps_body: steps_body, - image_path: project_image&.attachment&.medium&.url, - project_path: "/#!/projects/#{slug}", - updated_at: updated_at.to_s(:iso8601), - created_at: created_at.to_s(:iso8601), - published_at: published_at.to_s(:iso8601) - } - end - - def steps_body - concatenated_steps = project_steps.map { |s| "#{s.title} #{s.description}" } - .join(' ').gsub('

', '

') - .gsub("\r\n", ' ').gsub("\n\r", ' ') - .gsub("\n", ' ').gsub("\r", ' ').gsub("\t", ' ') - - strip_tags(concatenated_steps).strip - end - - def openlab_sync_active? - self.class.openlab_sync_active? - end - end - - class_methods do - def openlab_sync_active? - Setting.get('openlab_app_secret').present? - end - end -end diff --git a/app/services/accounting/accounting_code_service.rb b/app/services/accounting/accounting_code_service.rb index 060b3a5cc..1a4876b03 100644 --- a/app/services/accounting/accounting_code_service.rb +++ b/app/services/accounting/accounting_code_service.rb @@ -43,7 +43,7 @@ class Accounting::AccountingCodeService raise ArgumentError('invalid section') unless %i[code analytical_section].include?(section) if type == :code - item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting.send(section) : nil + item_code = Setting.get('advanced_accounting') ? invoice_item.object.reservable.advanced_accounting&.send(section) : nil return Setting.get("accounting_#{invoice_item.object.reservable_type}_code") if item_code.nil? && section == :code item_code diff --git a/app/services/accounting/accounting_service.rb b/app/services/accounting/accounting_service.rb index fe2077530..f3cece6e6 100644 --- a/app/services/accounting/accounting_service.rb +++ b/app/services/accounting/accounting_service.rb @@ -12,11 +12,16 @@ class Accounting::AccountingService @journal_code = Setting.get('accounting_journal_code') || '' end + # build accounting lines for invoices between the provided dates def build(start_date, end_date) - # build accounting lines - lines = [] invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC') - invoices.each do |i| + build_from_invoices(invoices) + end + + # build accounting lines for the provided invoices + def build_from_invoices(invoices) + lines = [] + invoices.find_each do |i| Rails.logger.debug { "processing invoice #{i.id}..." } unless Rails.env.test? lines << generate_lines(i) end diff --git a/app/services/open_lab_service.rb b/app/services/open_lab_service.rb new file mode 100644 index 000000000..0aae0d0ec --- /dev/null +++ b/app/services/open_lab_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Provides methods to sync projects on OpenLab +class OpenLabService + class << self + include ActionView::Helpers::SanitizeHelper + + def to_hash(project) + { + id: project.id, + slug: project.slug, + name: project.name, + description: project.description, + tags: project.tags, + machines: project.machines.map(&:name), + components: project.components.map(&:name), + themes: project.themes.map(&:name), + author: project.author&.user&.profile&.full_name, + collaborators: project.users.map { |u| u&.profile&.full_name }, + steps_body: steps_body(project), + image_path: project.project_image&.attachment&.medium&.url, + project_path: "/#!/projects/#{project.slug}", + updated_at: project.updated_at.to_s(:iso8601), + created_at: project.created_at.to_s(:iso8601), + published_at: project.published_at.to_s(:iso8601) + } + end + + def steps_body(project) + concatenated_steps = project.project_steps.map { |s| "#{s.title} #{s.description}" } + .join(' ').gsub('

', '

') + .gsub("\r\n", ' ').gsub("\n\r", ' ') + .gsub("\n", ' ').gsub("\r", ' ').gsub("\t", ' ') + + strip_tags(concatenated_steps).strip + end + end +end diff --git a/app/services/setting_service.rb b/app/services/setting_service.rb index c90048ac6..9443d100f 100644 --- a/app/services/setting_service.rb +++ b/app/services/setting_service.rb @@ -5,72 +5,80 @@ # so this service provides a wrapper around these operations. class SettingService class << self - def before_update(setting) + def update_allowed?(setting) return false if Rails.application.secrets.locked_settings.include? setting.name true end - def after_update(setting) - update_theme_stylesheet(setting) - update_home_stylesheet(setting) - notify_privacy_update(setting) - sync_stripe_objects(setting) - build_stats(setting) - export_projects_to_openlab(setting) - validate_admins(setting) + def run_after_update(settings) + update_theme_stylesheet(settings) + update_home_stylesheet(settings) + notify_privacy_update(settings) + sync_stripe_objects(settings) + build_stats(settings) + export_projects_to_openlab(settings) + validate_admins(settings) + update_accounting_line(settings) end private # rebuild the theme stylesheet - def update_theme_stylesheet(setting) - return unless %w[main_color secondary_color].include? setting.name + def update_theme_stylesheet(settings) + return unless (%w[main_color secondary_color] & settings.map(&:name)).count.positive? Stylesheet.theme&.rebuild! end # rebuild the home page stylesheet - def update_home_stylesheet(setting) - return unless setting.name == 'home_css' + def update_home_stylesheet(settings) + return unless settings.any? { |s| s.name == 'home_css' } Stylesheet.home_page&.rebuild! end # notify about a change in privacy policy - def notify_privacy_update(setting) - return unless setting.name == 'privacy_body' + def notify_privacy_update(settings) + return unless settings.any? { |s| s.name == 'privacy_body' } NotifyPrivacyUpdateWorker.perform_async(setting.id) end # sync all objects on stripe - def sync_stripe_objects(setting) - return unless %w[stripe_secret_key online_payment_module].include?(setting.name) + def sync_stripe_objects(settings) + return unless (%w[stripe_secret_key online_payment_module] & settings.map(&:name)).count.positive? SyncObjectsOnStripeWorker.perform_async(setting.history_values.last&.invoicing_profile&.user&.id) end # generate the statistics since the last update - def build_stats(setting) - return unless setting.name == 'statistics_module' && setting.value == 'true' + def build_stats(settings) + return unless settings.any? { |s| s.name == 'statistics_module' && s.value == 'true' } PeriodStatisticsWorker.perform_async(setting.previous_update) end # export projects to openlab - def export_projects_to_openlab(setting) - return unless %w[openlab_app_id openlab_app_secret].include?(setting.name) && + def export_projects_to_openlab(settings) + return unless (%w[openlab_app_id openlab_app_secret] & settings.map(&:name)).count.positive? && Setting.get('openlab_app_id').present? && Setting.get('openlab_app_secret').present? Project.all.each(&:openlab_create) end # automatically validate the admins - def validate_admins(setting) - return unless setting.name == 'user_validation_required' && setting.value == 'true' + def validate_admins(settings) + return unless settings.any? { |s| s.name == 'user_validation_required' && s.value == 'true' } User.admins.each { |admin| admin.update(validated_at: DateTime.current) if admin.validated_at.nil? } end + + def update_accounting_line(settings) + return unless settings.any? { |s| s.name.match(/^accounting_/) || s.name == 'advanced_accounting' } + + AccountingLine.destroy_all + AccountingWorker.perform_async(:all) + end end end diff --git a/app/workers/accounting_worker.rb b/app/workers/accounting_worker.rb index 1a5f9578c..925e90c6f 100644 --- a/app/workers/accounting_worker.rb +++ b/app/workers/accounting_worker.rb @@ -4,8 +4,23 @@ class AccountingWorker include Sidekiq::Worker - def perform + def perform(action = :today, *params) + send(action, *params) + end + + def today service = Accounting::AccountingService.new service.build(DateTime.current.beginning_of_day, DateTime.current.end_of_day) end + + def invoices(invoices_ids) + service = Accounting::AccountingService.new + invoices = Invoice.where(id: invoices_ids) + service.build_from_invoices(invoices) + end + + def all + service = Accounting::AccountingService.new + service.build_from_invoices(Invoice.all) + end end