diff --git a/.env.example b/.env.example index 80ab17aa5..f98c69fb8 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ AMBER_CLIENT_SECRET= FROM_EMAIL=noreply@societeitflux.nl ICT_EMAIL=ict@csvalpha.nl ADMIN_EMAIL=streepsysteem@societeitflux.nl +TREASURER_TITLE=penningmeester TREASURER_EMAIL=penningmeester@societeitflux.nl TREASURER_NAME= TREASURER_PHONE= @@ -28,12 +29,15 @@ COMPANY_KVK=41 032 169 SITE_NAME=S.O.F.I.A. SITE_SHORT_NAME=SOFIA +SITE_LONG_NAME=Streepsysteem voor de Ordentelijke Festiviteiten van Inleggend Alpha SITE_ASSOCIATION=C.S.V. Alpha CODE_BEER=8010 +CODE_LOW_ALCOHOL_BEER=8011 CODE_CRAFT_BEER=8015 CODE_NON_ALCOHOLIC=8020 CODE_DISTILLED=8030 +CODE_WHISKEY=8035 CODE_WINE=8040 CODE_FOOD=8050 CODE_TOBACCO=8060 diff --git a/.github/workflows/cleanup-registry.yml b/.github/workflows/cleanup-registry.yml index ba90d6107..32b82f8df 100644 --- a/.github/workflows/cleanup-registry.yml +++ b/.github/workflows/cleanup-registry.yml @@ -5,20 +5,14 @@ on: - cron: '0 0 * * 1' # https://crontab.guru/#0_0_*_*_1 workflow_dispatch: -env: - IMAGE_NAMES: sofia - jobs: cleanup: name: Cleanup runs-on: ubuntu-latest steps: - - name: Delete old versions - uses: snok/container-retention-policy@f617f1ca161a52bce48417eedd76924e71d0b4d9 # v2.1.0 + - name: Delete untagged images + uses: actions/delete-package-versions@0d39a63126868f5eefaa47169615edd3c0f61e20 # v4.1.1 with: - image-names: ${{ env.IMAGE_NAMES }} - cut-off: 2 days ago UTC - account-type: org - org-name: ${{ github.repository_owner }} - skip-tags: latest,staging - token: ${{ secrets.GH_PAT }} + package-name: ${{ github.event.repository.name }} + package-type: container + delete-only-untagged-versions: true diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml index 441795fa2..738ed981c 100644 --- a/.github/workflows/continuous-delivery.yml +++ b/.github/workflows/continuous-delivery.yml @@ -7,6 +7,13 @@ on: description: Merge staging into master first? (y/N) required: false default: 'n' + target_env: + type: choice + description: Target environment + options: + - csvalpha + - luxadmosam + default: 'csvalpha' concurrency: group: cd-${{ github.ref_name }} @@ -20,11 +27,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate branch + env: + TARGET_ENV: ${{ github.event.inputs.target_env }} run: | if [ "$GITHUB_REF_NAME" != 'staging' ] && [ "$GITHUB_REF_NAME" != 'master' ]; then echo 'This workflow can only be run on branches staging and master.' exit 1 fi + if [ "$TARGET_ENV" == 'luxadmosam' ] && [ "$GITHUB_REF_NAME" != 'master' ]; then + echo 'Only the master branch can be deployed to Lux ad Mosam.' + exit 1 + fi metadata: name: Metadata @@ -41,6 +54,7 @@ jobs: id: get_metadata env: INPUT_MERGE: ${{ github.event.inputs.merge }} + TARGET_ENV: ${{ github.event.inputs.target_env }} run: | if [ "$GITHUB_REF_NAME" = 'master' ]; then if [ "${INPUT_MERGE,,}" = 'y' ]; then @@ -52,7 +66,11 @@ jobs: fi fi - echo 'stage=production' >> "$GITHUB_OUTPUT" + if [ "$TARGET_ENV" == 'luxadmosam' ]; then + echo 'stage=luxproduction' >> "$GITHUB_OUTPUT" + else + echo 'stage=production' >> "$GITHUB_OUTPUT" + fi else echo 'stage=staging' >> "$GITHUB_OUTPUT" fi @@ -130,8 +148,12 @@ jobs: steps: - name: Get environment URL id: get_url + env: + TARGET_ENV: ${{ github.event.inputs.target_env }} run: | - if [ "$GITHUB_REF_NAME" = 'master' ]; then + if [ "$TARGET_ENV" == 'luxadmosam' ] && [ "$GITHUB_REF_NAME" = 'master' ]; then + echo 'environment_url=https://luxstreep.csvalpha.nl' >> "$GITHUB_OUTPUT" + elif [ "$GITHUB_REF_NAME" = 'master' ]; then echo 'environment_url=https://streep.csvalpha.nl' >> "$GITHUB_OUTPUT" else echo 'environment_url=https://stagingstreep.csvalpha.nl' >> "$GITHUB_OUTPUT" diff --git a/.rubocop.yml b/.rubocop.yml index 29bf50a25..7e692d4f3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,6 +22,7 @@ AllCops: Rails/UnknownEnv: Environments: - production + - luxproduction - development - test - staging diff --git a/Dockerfile b/Dockerfile index fa8dec243..c43ecb67e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ ARG RAILS_MASTER_KEY # Pre-install gems, so that they can be cached. COPY Gemfile* /app/ -RUN if [ "$RAILS_ENV" = 'production' ] || [ "$RAILS_ENV" = 'staging' ]; then \ +RUN if [ "$RAILS_ENV" = 'production' ] || [ "$RAILS_ENV" = 'staging' ] || [ "$RAILS_ENV" = 'luxproduction' ]; then \ bundle config set --local without 'development test'; \ else \ bundle config set --local without 'development'; \ @@ -44,7 +44,7 @@ COPY . /app/ # Precompile assets after copying app because whole Rails pipeline is needed. RUN --mount=type=secret,id=rails_master_key \ - if [ "$RAILS_ENV" = 'production' ] || [ "$RAILS_ENV" = 'staging' ]; then \ + if [ "$RAILS_ENV" = 'production' ] || [ "$RAILS_ENV" = 'staging' ] || [ "$RAILS_ENV" = 'luxproduction' ]; then \ # Use secret if RAILS_MASTER_KEY build arg is not set. RAILS_MASTER_KEY="${RAILS_MASTER_KEY:-$(cat /run/secrets/rails_master_key)}" bundle exec rails assets:precompile; \ else \ diff --git a/Gemfile b/Gemfile index e073ebd83..ba5dd2b0c 100644 --- a/Gemfile +++ b/Gemfile @@ -21,7 +21,7 @@ gem 'omniauth', '~> 2.1.1' gem 'omniauth-oauth2', '~> 1.8.0' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.1' gem 'paper_trail', '~> 14.0.0' -gem 'paranoia', '~> 2.6.1' +gem 'paranoia', '~> 3.0.0' gem 'pg', '~> 1.5.3' gem 'puma', '~> 6.2.2' gem 'pundit', '~> 2.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 5b315c5fd..c49d9e062 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -100,7 +100,7 @@ GEM sidekiq (>= 6.0) coderay (1.1.3) colorize (0.8.1) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.4) connection_pool (2.4.0) consistency_fail (0.3.7) crass (1.0.6) @@ -113,7 +113,7 @@ GEM devise-i18n (1.11.0) devise (>= 4.9.0) diff-lcs (1.5.0) - digest (3.1.0) + digest (3.1.1) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -183,7 +183,7 @@ GEM domain_name (~> 0.5) http-form_data (2.3.0) http_parser.rb (0.8.0) - i18n (1.13.0) + i18n (1.14.5) concurrent-ruby (~> 1.0) jbuilder (2.11.5) actionview (>= 5.0.0) @@ -234,7 +234,7 @@ GEM mini_portile2 (2.8.2) mini_racer (0.6.3) libv8-node (~> 16.10.0.0) - minitest (5.18.0) + minitest (5.24.1) mollie-api-ruby (4.7.1) msgpack (1.7.0) multi_json (1.15.0) @@ -290,8 +290,8 @@ GEM activerecord (>= 6.0) request_store (~> 1.4) parallel (1.23.0) - paranoia (2.6.1) - activerecord (>= 5.1, < 7.1) + paranoia (3.0.0) + activerecord (>= 6, < 8.1) parser (3.2.2.1) ast (~> 2.4.1) pg (1.5.3) @@ -497,7 +497,7 @@ GEM tilt (2.1.0) timecop (0.9.5) timeliness (0.4.5) - timeout (0.3.0) + timeout (0.3.1) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -573,7 +573,7 @@ DEPENDENCIES omniauth-oauth2 (~> 1.8.0) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.1) paper_trail (~> 14.0.0) - paranoia (~> 2.6.1) + paranoia (~> 3.0.0) pg (~> 1.5.3) pry-byebug pry-rails diff --git a/app/assets/stylesheets/price_lists.scss b/app/assets/stylesheets/price_lists.scss index c8cd21f2f..b1ba68630 100644 --- a/app/assets/stylesheets/price_lists.scss +++ b/app/assets/stylesheets/price_lists.scss @@ -1,21 +1,59 @@ @import 'theme_sofia'; -.center-text { - margin: 0 auto; - text-align: center; -} +.price-lists-table { + width: fit-content; + max-height: calc(100vh - 18rem); + + .center-text { + margin: 0 auto; + text-align: center; + } -th { - &.products-id { + .products-id, + .products-cancel-edit { + left: 0; width: 1.75rem; } - &.products-actions { - width: 4rem; + .products-name { + left: 2.1rem; + width: 6rem; + } + + .products-actions, + .products-new-edit-button, + .products-save-new { + right: 0; + } + + .products-id, + .products-cancel-edit, + .products-name, + .products-new-edit-button, + .products-save-new { + position: sticky; + z-index: 1; + background-color: $body-bg; + } + + .products-actions { + position: sticky; + z-index: 1; + } + + thead { + position: sticky; + top: 0; + z-index: 2; + background-color: $body-bg; + } + + th { + &.products-actions { + width: 4rem; + } } -} -.price_lists_table { tr { &:last-child { td { @@ -23,38 +61,39 @@ th { } } } -} -td { - &.products-new-edit-button { - padding: .5rem 1rem; - } - &.products-new { - input { - &.form-control { - width: 6rem; - } + td { + &.products-new-edit-button { + padding: .5rem 1rem; } - select { - &.form-control { - width: 4rem; + &.products-new { + input { + &.form-control { + width: 6rem; + } + } + + select { + &.form-control { + width: 4rem; + } } } - } - &.products-new-price { - input { - &.form-control { - width: 2rem; + &.products-new-price { + input { + &.form-control { + width: 4rem; + } } } - } - &.products-cancel-new, - &.products-cancel-edit, - &.products-save-new { - vertical-align: middle; + &.products-cancel-new, + &.products-cancel-edit, + &.products-save-new { + vertical-align: middle; + } } } diff --git a/app/controllers/credit_mutations_controller.rb b/app/controllers/credit_mutations_controller.rb index 17bba8bbf..4ed43df97 100644 --- a/app/controllers/credit_mutations_controller.rb +++ b/app/controllers/credit_mutations_controller.rb @@ -17,7 +17,7 @@ def create # rubocop:disable Metrics/MethodLength, Metrics/AbcSize respond_to do |format| if @mutation.save - NewCreditMutationNotificationJob.perform_later(@mutation) if Rails.env.production? || Rails.env.staging? + NewCreditMutationNotificationJob.perform_later(@mutation) if Rails.env.production? || Rails.env.staging? || Rails.env.luxproduction? format.html { redirect_to which_redirect?, flash: { success: 'Inleg of mutatie aangemaakt' } } format.json do render json: @mutation, include: { user: { methods: User.orderscreen_json_includes } } diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 0be81a169..4f6ed90e6 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -22,9 +22,15 @@ def create # rubocop:disable Metrics/AbcSize end end - def add + def add # rubocop:disable Metrics/AbcSize authorize :payment + if Rails.application.config.x.mollie_api_key.blank? + flash[:error] = 'iDEAL is niet beschikbaar' + redirect_to users_path + return + end + @user = current_user @payment = Payment.new @@ -39,7 +45,9 @@ def callback # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/Pe end if payment.completed? - flash[:error] = 'Deze betaling is al gesloten. Mocht het bedrag niet bij uw inleg staan neem dan contact op met de penningmeester.' + flash[:error] = + "Deze betaling is al gesloten. Mocht het bedrag niet bij uw inleg staan + neem dan contact op met de #{Rails.application.config.x.treasurer_title}" else tries = 3 begin @@ -47,8 +55,8 @@ def callback # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/Pe if payment.mollie_payment.paid? flash[:success] = 'iDEAL betaling geslaagd' else - flash[:error] = 'Uw iDEAL betaling is mislukt. - Mocht het bedrag wel van uw rekening zijn gegaan neem dan contact op met de penningmeester' + flash[:error] = "Uw iDEAL betaling is mislukt. Mocht het bedrag wel van uw rekening zijn gegaan + neem dan contact op met de #{Rails.application.config.x.treasurer_title}" end rescue ActiveRecord::StaleObjectError => e raise e unless (tries -= 1).positive? diff --git a/app/controllers/price_lists_controller.rb b/app/controllers/price_lists_controller.rb index 62c22909d..6a1a02ec3 100644 --- a/app/controllers/price_lists_controller.rb +++ b/app/controllers/price_lists_controller.rb @@ -4,14 +4,14 @@ class PriceListsController < ApplicationController after_action :verify_authorized def index - recent_price_lists = PriceList.order(created_at: :desc).limit(6) + price_lists = PriceList.order(created_at: :desc) products = Product.all.order(:id).includes(:product_prices) - authorize recent_price_lists + authorize price_lists @price_list = PriceList.new - @recent_price_lists_json = recent_price_lists.to_json(except: %i[created_at updated_at deleted_at]) + @price_lists_json = price_lists.to_json(except: %i[created_at updated_at deleted_at]) @products_json = products.to_json( include: { product_prices: { except: %i[created_at updated_at deleted_at] } }, methods: :t_category, @@ -43,6 +43,36 @@ def update redirect_to @price_list end + def archive + @price_list = PriceList.find(params[:id]) + authorize @price_list + + @price_list.archived_at = Time.zone.now + + respond_to do |format| + if @price_list.save + format.json { render json: @price_list.archived_at } + else + format.json { render json: @price_list.errors, status: :unprocessable_entity } + end + end + end + + def unarchive + @price_list = PriceList.find(params[:id]) + authorize @price_list + + @price_list.archived_at = nil + + respond_to do |format| + if @price_list.save + format.json { render json: @price_list.archived_at } + else + format.json { render json: @price_list.errors, status: :unprocessable_entity } + end + end + end + private def permitted_attributes diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 94a4ef7ad..ea3ff35e9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -117,7 +117,7 @@ def activities # rubocop:disable Metrics/AbcSize private def send_slack_users_refresh_notification - return unless Rails.env.production? || Rails.env.staging? + return unless Rails.env.production? || Rails.env.staging? || Rails.env.luxproduction? # :nocov: SlackMessageJob.perform_later("User ##{current_user.id} (#{current_user.name}) " \ diff --git a/app/javascript/packs/price_lists.js b/app/javascript/packs/price_lists.js index 82fd06a2a..7585ec79d 100644 --- a/app/javascript/packs/price_lists.js +++ b/app/javascript/packs/price_lists.js @@ -8,7 +8,7 @@ Vue.use(VueResource); document.addEventListener('turbolinks:load', () => { Vue.http.headers.common['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); - var element = document.getElementById('price_lists_table'); + var element = document.getElementById('pricelists-container'); if (element != null) { var priceLists = JSON.parse(element.dataset.priceLists); var products = JSON.parse(element.dataset.products); @@ -19,7 +19,16 @@ document.addEventListener('turbolinks:load', () => { new Vue({ el: element, data: () => { - return { priceLists: priceLists, products: products }; + return { priceLists: priceLists, products: products, showArchived: false }; + }, + computed: { + filteredPriceLists: function() { + if (this.showArchived) { + return priceLists; + } else { + return priceLists.filter(priceList => !priceList.archived_at); + } + } }, methods: { findPrice: function(product, priceList) { @@ -124,6 +133,22 @@ document.addEventListener('turbolinks:load', () => { return products; }, + archivePriceList: function(priceList) { + this.$http.post(`/price_lists/${priceList.id}/archive`, {}).then((response) => { + priceList.archived_at = response.data; + }, (response) => { + this.errors = response.data.errors; + }); + }, + + unarchivePriceList: function(priceList) { + this.$http.post(`/price_lists/${priceList.id}/unarchive`, {}).then((response) => { + priceList.archived_at = response.data; + }, (response) => { + this.errors = response.data.errors; + }); + }, + productPriceToCurrency: function(productPrice) { return (productPrice && productPrice.price) ? `€ ${parseFloat(productPrice.price).toFixed(2)}` : ''; }, diff --git a/app/jobs/credit_insufficient_notification_job.rb b/app/jobs/credit_insufficient_notification_job.rb index 87b4bd616..fe99065b4 100644 --- a/app/jobs/credit_insufficient_notification_job.rb +++ b/app/jobs/credit_insufficient_notification_job.rb @@ -23,14 +23,14 @@ def users_with_insufficient_credit User.all.select { |user| user.credit.negative? } end - def send_notification_delivery_reports(success_count, unnotifyable_users) + def send_notification_delivery_reports(success_count, unnotifyable_users) # rubocop:disable Metrics/AbcSize User.treasurer.each do |treasurer| UserCreditMailer.credit_delivery_report_mail( treasurer, success_count, unnotifyable_users ).deliver_later end - return unless Rails.env.production? || Rails.env.staging? + return unless Rails.env.production? || Rails.env.staging? || Rails.env.luxproduction? SlackMessageJob.perform_later("Er is voor #{Rails.application.config.x.amber_api_host} een saldomail " \ "verstuurd naar #{success_count} mensen, en #{unnotifyable_users.count} saldomail(s) kon(den) niet " \ diff --git a/app/jobs/payment_poll_job.rb b/app/jobs/payment_poll_job.rb index 4ecee9bd2..de1e5e1cb 100644 --- a/app/jobs/payment_poll_job.rb +++ b/app/jobs/payment_poll_job.rb @@ -9,7 +9,7 @@ def perform # it will be checked again the next time this poll job runs end - return unless Rails.env.production? || Rails.env.staging? + return unless Rails.env.production? || Rails.env.staging? || Rails.env.luxproduction? HealthCheckJob.perform_later('payment_poll') end diff --git a/app/mailers/invoice_mailer.rb b/app/mailers/invoice_mailer.rb index 6db7ee09d..b22410cf3 100644 --- a/app/mailers/invoice_mailer.rb +++ b/app/mailers/invoice_mailer.rb @@ -1,12 +1,16 @@ class InvoiceMailer < ApplicationMailer - def invoice_mail(invoice) # rubocop:disable Metrics/AbcSize + def invoice_mail(invoice) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @user = Struct.new(:name).new(invoice.name) @invoice = invoice - @cab_link = url_for(controller: 'invoices', action: 'show', id: invoice.token) - @cab_text = 'iDeal betaling' + if Rails.application.config.x.mollie_api_key.present? + @cab_link = url_for(controller: 'invoices', action: 'show', id: invoice.token) + @cab_text = 'iDeal betaling' + else + @cab_disabled = true + end attachments["#{invoice.human_id}.pdf"] = WickedPdf.new.pdf_from_string( - render_to_string(pdf: invoice.human_id.to_s, template: 'invoices/show.html.erb', layout: 'pdf.html.erb') + render_to_string(pdf: invoice.human_id.to_s, template: 'invoices/show', layout: 'pdf') ) mail to: @invoice.email, subject: "Factuur #{invoice.human_id} #{Rails.application.config.x.company_name}" diff --git a/app/models/product.rb b/app/models/product.rb index c01cb05c1..8082251ac 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,5 +1,6 @@ class Product < ApplicationRecord - enum category: { beer: 0, craft_beer: 6, non_alcoholic: 1, distilled: 2, wine: 3, food: 4, tobacco: 5, donation: 7 } + enum category: { beer: 0, low_alcohol_beer: 9, craft_beer: 6, non_alcoholic: 1, distilled: 2, whiskey: 8, wine: 3, food: 4, tobacco: 5, + donation: 7 } has_many :product_prices, dependent: :destroy has_many :price_lists, through: :product_prices, dependent: :restrict_with_error @@ -10,7 +11,7 @@ class Product < ApplicationRecord accepts_nested_attributes_for :product_prices, allow_destroy: true def requires_age - %w[beer craft_beer distilled wine tobacco].include? category + %w[beer craft_beer distilled whiskey wine tobacco].include? category end def t_category diff --git a/app/models/role.rb b/app/models/role.rb index afb6680fa..779a9ad66 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -7,7 +7,7 @@ class Role < ApplicationRecord def name if treasurer? - 'Penningmeester' + Rails.application.config.x.treasurer_title.capitalize elsif main_bartender? 'Hoofdtapper' end diff --git a/app/policies/payment_policy.rb b/app/policies/payment_policy.rb index 9d3036a15..aba51a76d 100644 --- a/app/policies/payment_policy.rb +++ b/app/policies/payment_policy.rb @@ -1,17 +1,21 @@ class PaymentPolicy < ApplicationPolicy def index? - user.treasurer? + mollie_enabled? && user.treasurer? end def create? - user + mollie_enabled? && user end def add? - user + mollie_enabled? && user end def invoice_callback? - record && !record.completed? + mollie_enabled? && record && !record.completed? + end + + def mollie_enabled? + Rails.application.config.x.mollie_api_key.present? end end diff --git a/app/policies/price_list_policy.rb b/app/policies/price_list_policy.rb index 4d6d653ec..a28c98dec 100644 --- a/app/policies/price_list_policy.rb +++ b/app/policies/price_list_policy.rb @@ -15,6 +15,14 @@ def update? create? end + def archive? + create? + end + + def unarchive? + create? + end + def search? index? end diff --git a/app/views/activities/_cannot_order_modal.html.erb b/app/views/activities/_cannot_order_modal.html.erb index df30baef1..ad6e47f39 100644 --- a/app/views/activities/_cannot_order_modal.html.erb +++ b/app/views/activities/_cannot_order_modal.html.erb @@ -7,7 +7,7 @@