From bbc29c4b503460af44cfd302c1d892087d90b6d9 Mon Sep 17 00:00:00 2001
From: Teal <git@teal.is>
Date: Tue, 4 Mar 2025 18:27:58 +0100
Subject: [PATCH] Add UI for custom data handling, ImportJob class selection

---
 app/controllers/assignments_controller.rb     |  12 +-
 app/controllers/candidates_controller.rb      |   6 +-
 app/controllers/conferences_controller.rb     | 123 +++++++++++-
 app/controllers/sessions_controller.rb        |  12 +-
 .../controllers/conference_form_controller.js | 181 ++++++++++++++++++
 app/jobs/pretalx/import_job.rb                |  32 ++--
 .../republica_2023_or_later/import_job.rb     |  23 ++-
 app/models/assignment.rb                      |   5 +-
 app/models/conference.rb                      |  80 ++++----
 app/subscribers/telegram_bot_subscriber.rb    |   2 +-
 app/views/conferences/edit.html.erb           | 158 +++++++++++++++
 app/views/conferences/index.html.erb          |  59 +++++-
 app/views/conferences/new.html.erb            | 115 +++++++++++
 app/views/conferences/show.html.erb           |   4 +-
 .../initializers/filter_parameter_logging.rb  |   4 +-
 config/queue.yml                              |  36 ++--
 config/routes.rb                              |   7 +-
 17 files changed, 759 insertions(+), 100 deletions(-)
 create mode 100644 app/javascript/controllers/conference_form_controller.js
 create mode 100644 app/views/conferences/edit.html.erb
 create mode 100644 app/views/conferences/new.html.erb

diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb
index 97715b6..8d30fce 100644
--- a/app/controllers/assignments_controller.rb
+++ b/app/controllers/assignments_controller.rb
@@ -1,14 +1,14 @@
 require "icalendar/tzinfo"
 
 class AssignmentsController < ApplicationController
-  before_action :authorize_shiftcoordinator, except: [ :index, :by_user ]
+  before_action :authorize_permission, except: %i[index by_user]
   before_action :set_session, :set_users
 
   def index
     @assignments = Assignment.all.joins(:session, :user).order("sessions.starts_at")
-    if params[:user_id]
-      @assignments = @assignments.where(user_id: params[:user_id])
-    end
+    return unless params[:user_id]
+
+    @assignments = @assignments.where(user_id: params[:user_id])
   end
 
   def create
@@ -115,6 +115,10 @@ class AssignmentsController < ApplicationController
 
   private
 
+  def authorize_permission
+    super("manage_assignments")
+  end
+
   def set_session
     conference = Conference.find_by(slug: params[:conference_slug])
     @session = Session.find_by(conference:, ref_id: params[:session_ref_id])
diff --git a/app/controllers/candidates_controller.rb b/app/controllers/candidates_controller.rb
index 5f1dac0..d599067 100644
--- a/app/controllers/candidates_controller.rb
+++ b/app/controllers/candidates_controller.rb
@@ -1,7 +1,7 @@
 require "icalendar/tzinfo"
 
 class CandidatesController < ApplicationController
-  before_action :authorize_shiftcoordinator, except: [ :create, :destroy_self ]
+  before_action :authorize_permission, except: %i[create destroy_self]
 
   def create
     @conference = Conference.find_by(slug: params[:conference_slug])
@@ -50,6 +50,10 @@ class CandidatesController < ApplicationController
 
   private
 
+  def authorize_permission
+    super("manage_assignments")
+  end
+
   def destroy_candidate(session, candidate)
     if candidate&.destroy
       Rails.logger.debug("destroyed candidate entry")
diff --git a/app/controllers/conferences_controller.rb b/app/controllers/conferences_controller.rb
index ebea5e7..d62e63b 100644
--- a/app/controllers/conferences_controller.rb
+++ b/app/controllers/conferences_controller.rb
@@ -1,11 +1,98 @@
 class ConferencesController < ApplicationController
+  before_action :authenticate_user!, except: [ :index, :show, :stats ]
+  before_action :authorize_permission, only: [ :new, :create, :edit, :update, :destroy ]
+  before_action :set_conference, only: [ :show, :edit, :update, :destroy, :stats ]
+
+  private
+
+  def authorize_permission
+    super("manage_conferences")
+  end
+
+  def set_conference
+    @conference = Conference.find_by(slug: params[:slug])
+  end
+
+  def conference_params
+    all_params = params.require(:conference).permit(:name, :slug, :starts_at, :ends_at, :url, :time_zone, :import_job_class, data: {}).to_h
+
+    data_hash = @conference&.data&.dup || {}
+
+    if params[:data].present?
+      params[:data].each do |key, value|
+        data_hash[key] = value.presence
+      end
+    end
+
+    if params[:custom_field_keys].present? && params[:custom_field_values].present?
+      keys = params[:custom_field_keys]
+      values = params[:custom_field_values]
+
+      keys.each_with_index do |key, index|
+        next if key.blank?
+        data_hash[key] = values[index].presence
+      end
+    end
+
+    all_params[:data] = data_hash
+
+    all_params
+  end
+
+  public
+
+  def self.available_import_job_classes
+    job_classes = {}
+
+    Rails.application.eager_load! if Rails.env.development?
+
+    ApplicationJob.descendants.each do |job_class|
+      if job_class.name.end_with?("ImportJob")
+        display_name = job_class.name.demodulize.gsub("ImportJob", "")
+
+        # If the class is in a module, add the module name
+        if job_class.name.include?("::")
+          module_name = job_class.name.deconstantize
+          display_name = "#{module_name} #{display_name}"
+        end
+
+        display_name = display_name.gsub("::", " ").gsub(/([A-Z])/, ' \1').strip
+
+        job_classes[job_class.name] = display_name
+      end
+    end
+
+    job_classes
+  end
+
+  def required_fields
+    import_job_class = params[:import_job_class]
+    result = {
+      required_fields: [],
+      metadata: {}
+    }
+
+    if import_job_class.present?
+      begin
+        klass = import_job_class.constantize
+        result[:required_fields] = klass.respond_to?(:required_data_fields) ? klass.required_data_fields : []
+        result[:metadata] = klass.respond_to?(:field_metadata) ? klass.field_metadata : {}
+      rescue NameError => e
+        Rails.logger.error("Could not find import job class #{import_job_class}: #{e}")
+      end
+    end
+
+    render json: result
+  end
+
   def index
     @conferences = Conference.all
   end
 
   def show
     @conference = Conference.find_by(slug: params[:slug])
-    @sessions = @conference.sessions.where.not(starts_at: nil).includes(:stage, :assignments).where(stage: @conference.relevant_stages).order(:starts_at)
+    @sessions = @conference.sessions.where.not(starts_at: nil).includes(:stage,
+                                                                        :assignments).where(stage: @conference.relevant_stages).order(:starts_at)
     if params[:date]
       date = Time.parse(params[:date])
       logger.debug(date)
@@ -20,16 +107,42 @@ class ConferencesController < ApplicationController
     @users = User.all
   end
 
+  def new
+    @conference = Conference.new
+  end
+
+  def create
+    @conference = Conference.new(conference_params)
+
+    if @conference.save
+      redirect_to conference_path(slug: @conference.slug), notice: "Conference was successfully created."
+    else
+      render :new, status: :unprocessable_entity
+    end
+  end
+
   def edit
+    # @conference is set by the before_action
   end
 
   def update
+    if @conference.update(conference_params)
+      redirect_to conference_path(slug: @conference.slug), notice: "Conference was successfully updated."
+    else
+      render :edit, status: :unprocessable_entity
+    end
+  end
+
+  def destroy
+    @conference.destroy
+    redirect_to conferences_path, notice: "Conference was successfully deleted."
   end
 
   def stats
     @conference = Conference.find_by(slug: params[:slug])
     @relevant_stages = @conference.relevant_stages
-    @relevant_sessions = @conference.sessions.includes(:stage, :assignments, :speakers, assignments: :user).where(stage: @conference.relevant_stages).order(:starts_at)
+    @relevant_sessions = @conference.sessions.includes(:stage, :assignments, :speakers,
+                                                       assignments: :user).where(stage: @conference.relevant_stages).order(:starts_at)
     @assignees = @relevant_sessions.map(&:assignments).flatten.map(&:user).uniq
     @language_stats = @relevant_sessions.group_by { |s| s.language }.transform_values(&:count)
     @speakers = @relevant_sessions.map(&:speakers)
@@ -44,6 +157,10 @@ class ConferencesController < ApplicationController
         scheduled_time_min: se.map { |x| (x.ends_at - x.starts_at) / 60.0 }.sum
       }
     end
-    @total_stats = @day_stats.values.inject { |m, x| m.merge(x) { |k, o, n| o + n } }.slice(:sessions_count, :wall_clock_time_min, :scheduled_time_min)
+    @total_stats = @day_stats.values.inject do |m, x|
+      m.merge(x) do |_k, o, n|
+        o + n
+      end
+    end.slice(:sessions_count, :wall_clock_time_min, :scheduled_time_min)
   end
 end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 2ea28ce..ec5761a 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,6 +1,6 @@
 class SessionsController < ApplicationController
   before_action :authenticate_user!, except: [ :index ]
-  before_action :authorize_shiftcoordinator, except: [ :index, :show ]
+  before_action :authorize_permission, except: %i[index show]
 
   def index
     @conference = Conference.find_by(slug: params[:conference_slug])
@@ -13,10 +13,8 @@ class SessionsController < ApplicationController
     end
 
     # Filter by stage name if provided
-    if params[:stage].present?
-      @sessions = @sessions.joins(:stage).where(stages: { name: params[:stage] })
-    end
-
+    return unless params[:stage].present?
+    @sessions = @sessions.joins(:stage).where(stages: { name: params[:stage] })
     # Further filtering options can be added here
   end
 
@@ -55,6 +53,10 @@ class SessionsController < ApplicationController
 
   private
 
+  def authorize_permission
+    super("manage_assignments")
+  end
+
   def notes_params
     params.require(:session).permit(:notes)
   end
diff --git a/app/javascript/controllers/conference_form_controller.js b/app/javascript/controllers/conference_form_controller.js
new file mode 100644
index 0000000..ccb03cd
--- /dev/null
+++ b/app/javascript/controllers/conference_form_controller.js
@@ -0,0 +1,181 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+  static targets = ["requiredFields", "customFields", "importJobClass", "customTemplate"]
+  
+  connect() {
+    // Initialize with at least one custom field if none exist
+    if (this.hasCustomFieldsTarget && this.customFieldsTarget.querySelectorAll('.custom-field-row').length === 0) {
+      this.addCustomField()
+    }
+    
+    // Load required fields based on the initial selection
+    if (this.hasImportJobClassTarget && this.importJobClassTarget.value) {
+      this.importJobClassChanged()
+    }
+  }
+  
+  importJobClassChanged() {
+    if (!this.hasImportJobClassTarget || !this.hasRequiredFieldsTarget) return
+    
+    const selectedClass = this.importJobClassTarget.value
+    if (!selectedClass) {
+      this.requiredFieldsTarget.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Select an import job class to see required fields</p>'
+      return
+    }
+    
+    // Store the current data values
+    const currentData = this.getCurrentDataValues()
+    
+    // Fetch the required fields for the selected import job class
+    fetch(`/conferences/required_fields?import_job_class=${encodeURIComponent(selectedClass)}`)
+      .then(response => response.json())
+      .then(data => {
+        // Clear the required fields container
+        this.requiredFieldsTarget.innerHTML = ''
+        
+        if (data.required_fields.length === 0) {
+          this.requiredFieldsTarget.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No required fields for this import job class</p>'
+          return
+        }
+        
+        // Add the required fields
+        data.required_fields.forEach(field => {
+          const metadata = data.metadata[field] || {}
+          const fieldValue = currentData[field] || ''
+          
+          const fieldHtml = this.generateRequiredFieldHtml(
+            field,
+            metadata.title || this.humanize(field),
+            metadata.description || '',
+            metadata.placeholder || '',
+            fieldValue,
+            metadata.required || false
+          )
+          
+          this.requiredFieldsTarget.insertAdjacentHTML('beforeend', fieldHtml)
+          
+          // Remove this field from currentData as it's now a required field
+          delete currentData[field]
+        })
+        
+        // Convert any remaining data fields to custom fields
+        this.convertToCustomFields(currentData)
+      })
+      .catch(error => {
+        console.error('Error fetching required fields:', error)
+        this.requiredFieldsTarget.innerHTML = '<p class="text-sm text-red-500">Error loading required fields</p>'
+      })
+  }
+  
+  getCurrentDataValues() {
+    const dataValues = {}
+    
+    // Get values from required fields
+    if (this.hasRequiredFieldsTarget) {
+      const inputs = this.requiredFieldsTarget.querySelectorAll('input[name^="data["]')
+      inputs.forEach(input => {
+        const matches = input.name.match(/data\[(.*?)\]/)
+        if (matches && matches.length > 1) {
+          dataValues[matches[1]] = input.value
+        }
+      })
+    }
+    
+    // Get values from custom fields
+    if (this.hasCustomFieldsTarget) {
+      const rows = this.customFieldsTarget.querySelectorAll('.custom-field-row')
+      rows.forEach(row => {
+        const keyInput = row.querySelector('input[name="custom_field_keys[]"]')
+        const valueInput = row.querySelector('input[name="custom_field_values[]"]')
+        if (keyInput && valueInput && keyInput.value) {
+          dataValues[keyInput.value] = valueInput.value
+        }
+      })
+    }
+    
+    // Also include any data fields that might be in hidden inputs
+    document.querySelectorAll('input[type="hidden"][name^="data["]').forEach(input => {
+      const matches = input.name.match(/data\[(.*?)\]/)
+      if (matches && matches.length > 1) {
+        dataValues[matches[1]] = input.value
+      }
+    })
+    
+    return dataValues
+  }
+  
+  convertToCustomFields(dataValues) {
+    if (!this.hasCustomFieldsTarget) return
+    
+    // Clear existing custom fields
+    this.customFieldsTarget.innerHTML = ''
+    
+    // Add custom fields for each data value
+    Object.entries(dataValues).forEach(([key, value]) => {
+      if (key && value) {
+        this.addCustomField(key, value)
+      }
+    })
+    
+    // Add at least one empty custom field if there are none
+    if (this.customFieldsTarget.querySelectorAll('.custom-field-row').length === 0) {
+      this.addCustomField()
+    }
+  }
+  
+  generateRequiredFieldHtml(field, title, description, placeholder, value, required) {
+    return `
+      <div class="mb-4">
+        <label for="data_${field}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+          ${title}${required ? ' <span class="text-red-500">*</span>' : ''}
+        </label>
+        ${description ? `<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">${description}</p>` : ''}
+        <input 
+          type="text" 
+          id="data_${field}"
+          name="data[${field}]" 
+          value="${this.escapeHtml(value)}"
+          placeholder="${this.escapeHtml(placeholder)}"
+          ${required ? 'required' : ''}
+          class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
+      </div>
+    `
+  }
+  
+  addCustomField(key = '', value = '') {
+    if (!this.hasCustomFieldsTarget || !this.hasCustomTemplateTarget) return
+    
+    const template = this.customTemplateTarget.innerHTML
+      .replace(/KEY_PLACEHOLDER/g, this.escapeHtml(key))
+      .replace(/VALUE_PLACEHOLDER/g, this.escapeHtml(value))
+    
+    this.customFieldsTarget.insertAdjacentHTML('beforeend', template)
+  }
+  
+  removeCustomField(event) {
+    const row = event.target.closest('.custom-field-row')
+    if (row) {
+      row.remove()
+    }
+  }
+  
+  // Helper to convert snake_case to Title Case
+  humanize(str) {
+    return str
+      .replace(/_/g, ' ')
+      .replace(/\b\w/g, l => l.toUpperCase())
+  }
+  
+  // Helper to escape HTML special characters
+  escapeHtml(str) {
+    if (!str) return ''
+    
+    return String(str)
+      .replace(/&/g, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;')
+      .replace(/'/g, '&#039;')
+  }
+}
diff --git a/app/jobs/pretalx/import_job.rb b/app/jobs/pretalx/import_job.rb
index ac83733..db4f801 100644
--- a/app/jobs/pretalx/import_job.rb
+++ b/app/jobs/pretalx/import_job.rb
@@ -93,20 +93,19 @@ module Pretalx
 
       Session.joins(:conference).where(conference:).each do |session|
         shifts_at_time = shifts[session.starts_at - 15.minutes]
-        unless shifts_at_time.nil?
-          shifts_at_time.each do |shift|
-            if session.stage.name == shift.dig("location", "name")
-              session.engelsystem_id = shift["id"]
-              session.engelsystem_url = shift["url"]
-              session.save
-              break
-            end
-          end
+        next if shifts_at_time.nil?
+
+        shifts_at_time.each do |shift|
+          next unless session.stage.name == shift.dig("location", "name")
+          session.engelsystem_id = shift["id"]
+          session.engelsystem_url = shift["url"]
+          session.save
+          break
         end
       end
     end
 
-    def perform(conference_slug, *args)
+    def perform(conference_slug, *_args)
       conference = Conference.find_by(slug: conference_slug)
       import_schedule(conference)
       import_engelsystem_refs(conference)
@@ -126,12 +125,13 @@ module Pretalx
           filedrop_url,
           basic_auth: {
             username: fetch_credential("filedrop_user"),
-            password: fetch_credential("filedrop_password") },
+            password: fetch_credential("filedrop_password")
+          },
           headers: { "Accept" => "application/json" },
           timeout: 30
           )
         data = JSON.parse(response.body)
-      rescue => e
+      rescue StandardError => e
         Rails.logger.warn("Filedrop response for #{session.ref_id} failed: #{e.message}")
         return {}
       end
@@ -175,11 +175,11 @@ module Pretalx
         session.filedrop_files.find_or_initialize_by(name: file_data["name"], checksum: file_data["meta"]["hash"]).tap do |file|
           file.size = file_data["meta"]["size"]
           file.orig_created = parse_datetime_or_nil(session.conference, file_data["meta"]["created"])
-          unless file_data["url"].blank?
+          if file_data["url"].blank?
+            Rails.logger.warn("Skipping incomplete file #{file.name} for #{session.ref_id}")
+          else
             file.download(filedrop_url + file_data["url"].sub(/\A\//, ""))
             file.save
-          else
-            Rails.logger.warn("Skipping incomplete file #{file.name} for #{session.ref_id}")
           end
         end
       end
@@ -187,7 +187,7 @@ module Pretalx
 
     def parse_datetime_or_nil(conference, datetime_string)
       DateTime.iso8601(datetime_string).in_time_zone(conference.time_zone)
-    rescue
+    rescue StandardError
       nil
     end
   end
diff --git a/app/jobs/republica_2023_or_later/import_job.rb b/app/jobs/republica_2023_or_later/import_job.rb
index adef5fc..1e69748 100644
--- a/app/jobs/republica_2023_or_later/import_job.rb
+++ b/app/jobs/republica_2023_or_later/import_job.rb
@@ -4,6 +4,25 @@ module Republica2023OrLater
   class ImportJob < ApplicationJob
     queue_as :default
 
+    def self.required_data_fields
+      [ "speakers_url", "sessions_url" ]
+    end
+
+    def self.field_metadata
+      {
+        "speakers_url" => {
+          title: "Speakers Data URL",
+          description: "URL to the speakers data in JSON format",
+          placeholder: "https://re-publica.com/sites/default/files/extappdata/<YEAR>/speaker.json"
+        },
+        "sessions_url" => {
+          title: "Sessions Data URL",
+          description: "URL to the sessions data in JSON format",
+          placeholder: "https://re-publica.com/sites/default/files/extappdata/<YEAR>/session.json"
+        }
+      }
+    end
+
     def import_speakers(conference, url)
       response = HTTParty.get(url)
       if response.success?
@@ -57,11 +76,11 @@ module Republica2023OrLater
       end
     end
 
-    def perform(conference_slug, *args)
+    def perform(conference_slug, *_args)
       conference = Conference.find_by(slug: conference_slug)
       import_speakers(conference, conference.data["speakers_url"])
       import_sessions(conference, conference.data["sessions_url"])
-      revision_set = RevisionSet.create!(conference:)
+      RevisionSet.create!(conference:)
     end
   end
 end
diff --git a/app/models/assignment.rb b/app/models/assignment.rb
index 91f00aa..3089f9a 100644
--- a/app/models/assignment.rb
+++ b/app/models/assignment.rb
@@ -33,10 +33,9 @@ class Assignment < ApplicationRecord
                                   )
 
     logger.debug(overlapping_assignments)
+    return unless overlapping_assignments.exists?
 
-    if overlapping_assignments.exists?
-      errors.add(:base, "This assignment overlaps with another assignment for this user.")
-    end
+    errors.add(:base, "This assignment overlaps with another assignment for this user.")
   end
 
   def notify_assignment_created
diff --git a/app/models/conference.rb b/app/models/conference.rb
index 29c4688..95a1914 100644
--- a/app/models/conference.rb
+++ b/app/models/conference.rb
@@ -43,6 +43,20 @@ class Conference < ApplicationRecord
     data["heartbeat_url"]
   end
 
+  # Get the required data fields for the import job class
+  def required_data_fields
+    return [] if import_job_class.blank?
+
+    begin
+      # Dynamically query the import job class for its required fields
+      klass = import_job_class.constantize
+      klass.respond_to?(:required_data_fields) ? klass.required_data_fields : []
+    rescue NameError => e
+      Rails.logger.error("Could not find import job class #{import_job_class}: #{e}")
+      []
+    end
+  end
+
   def fetch_translation_angel_id
     fetch_engelsystem("angeltypes")
       &.find { |t| t["name"] == "Translation Angel" }
@@ -50,22 +64,20 @@ class Conference < ApplicationRecord
   end
 
   def fetch_engelsystem(endpoint)
-    begin
-      endpoint_url = engelsystem_url + endpoint
-      Rails.logger.debug("Querying engelsystem API at #{endpoint_url}")
-      response = HTTParty.get(
-        endpoint_url,
-        headers: {
-          "Accept" => "application/json",
-          "x-api-key" => fetch_credential("engelsystem_token")
-        },
-        timeout: 10
-      )
-      response.success? ? JSON.parse(response.body)["data"] : nil
-    rescue => e
-      Rails.logger.warn("Engelsystem query for #{endpoint} failed: #{e.message}")
-      nil
-    end
+    endpoint_url = engelsystem_url + endpoint
+    Rails.logger.debug("Querying engelsystem API at #{endpoint_url}")
+    response = HTTParty.get(
+      endpoint_url,
+      headers: {
+        "Accept" => "application/json",
+        "x-api-key" => fetch_credential("engelsystem_token")
+      },
+      timeout: 10
+    )
+    response.success? ? JSON.parse(response.body)["data"] : nil
+  rescue StandardError => e
+    Rails.logger.warn("Engelsystem query for #{endpoint} failed: #{e.message}")
+    nil
   end
 
   def compare_engelsystem_shifts(additional_conferences = [])
@@ -74,32 +86,32 @@ class Conference < ApplicationRecord
 
     engelsystem_shifts = data.each_with_object({}) do |shift, hash|
       hash[shift["id"]] = shift
-        &.dig("needed_angel_types")
-        &.find { |t| t["angel_type"]["id"] == translation_angel_id }
-        &.dig("entries")
-        &.map { |t| t["user"]["name"] }
+                          &.dig("needed_angel_types")
+                          &.find { |t| t["angel_type"]["id"] == translation_angel_id }
+                          &.dig("entries")
+                          &.map { |t| t["user"]["name"] }
     end
 
     Session
-        .where(conference: [ self, *additional_conferences ])
-        .where.not(engelsystem_id: nil)
-        .includes(assignments: :user)
-        .group_by(&:engelsystem_id)
-        .each do |engelsystem_id, sessions|
+      .where(conference: [ self, *additional_conferences ])
+      .where.not(engelsystem_id: nil)
+      .includes(assignments: :user)
+      .group_by(&:engelsystem_id)
+      .each do |engelsystem_id, sessions|
       engelsystem_assigned = engelsystem_shifts[engelsystem_id]
-      local_assigned = sessions.flat_map(&:assignments).map { |a|a.user.name }
+      local_assigned = sessions.flat_map(&:assignments).map { |a| a.user.name }
 
       only_engelsystem = engelsystem_assigned - local_assigned
       only_local = local_assigned - engelsystem_assigned
 
-      unless only_engelsystem.blank? and only_local.blank?
-        puts "============================="
-        puts "Session: #{sessions[0].title} (#{engelsystem_id})"
-        puts "============================="
-        puts "Not signed up in engelsystem: #{only_local.join(", ")}" unless only_local.blank?
-        puts "Missing in local assignments: #{only_engelsystem.join(", ")}" unless only_engelsystem.blank?
-        puts
-      end
+      next if only_engelsystem.blank? and only_local.blank?
+
+      puts "============================="
+      puts "Session: #{sessions[0].title} (#{engelsystem_id})"
+      puts "============================="
+      puts "Not signed up in engelsystem: #{only_local.join(', ')}" unless only_local.blank?
+      puts "Missing in local assignments: #{only_engelsystem.join(', ')}" unless only_engelsystem.blank?
+      puts
     end
 
     true
diff --git a/app/subscribers/telegram_bot_subscriber.rb b/app/subscribers/telegram_bot_subscriber.rb
index 43f83ae..caef521 100644
--- a/app/subscribers/telegram_bot_subscriber.rb
+++ b/app/subscribers/telegram_bot_subscriber.rb
@@ -12,7 +12,7 @@ class TelegramBotSubscriber
 
   def handle_session_updated(event)
     Rails.logger.info("session event #{event.inspect}")
-    model_name, action = event.name.split(".")
+    _, action = event.name.split(".")
     session = event.payload[:record]
     changes = event.payload[:changes]
 
diff --git a/app/views/conferences/edit.html.erb b/app/views/conferences/edit.html.erb
new file mode 100644
index 0000000..4acecd2
--- /dev/null
+++ b/app/views/conferences/edit.html.erb
@@ -0,0 +1,158 @@
+<div class="container mx-auto px-4 py-8">
+  <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">Edit Conference: <%= @conference.name %></h1>
+
+  <%= form_with(model: @conference, url: conference_path(slug: @conference.slug), method: :patch, class: "space-y-6") do |form| %>
+    <% if @conference.errors.any? %>
+      <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
+        <h2 class="font-bold"><%= pluralize(@conference.errors.count, "error") %> prohibited this conference from being saved:</h2>
+        <ul class="list-disc list-inside mt-2">
+          <% @conference.errors.full_messages.each do |message| %>
+            <li><%= message %></li>
+          <% end %>
+        </ul>
+      </div>
+    <% end %>
+
+    <div class="space-y-4">
+      <div>
+        <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+      </div>
+
+      <div>
+        <%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.text_field :slug, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Used in URLs. Should contain only lowercase letters, numbers, and hyphens.</p>
+      </div>
+
+      <div>
+        <%= form.label :url, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.url_field :url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+      </div>
+
+      <div>
+        <%= form.label :time_zone, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.time_zone_select :time_zone, nil, {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" } %>
+      </div>
+
+      <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
+        <div>
+          <%= form.label :starts_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.datetime_local_field :starts_at, value: @conference.starts_at&.strftime('%Y-%m-%dT%H:%M'), class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        </div>
+
+        <div>
+          <%= form.label :ends_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.datetime_local_field :ends_at, value: @conference.ends_at&.strftime('%Y-%m-%dT%H:%M'), class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        </div>
+      </div>
+
+      <div data-controller="dynamic-fields">
+        <div>
+          <%= form.label :import_job_class, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.select :import_job_class, 
+                         [["Select Import Job Class", ""]] + 
+                         ConferencesController.available_import_job_classes.map { |class_name, display_name| [display_name, class_name] },
+                         {}, 
+                         { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
+                           data: { dynamic_fields_target: "importJobClass", action: "change->dynamic-fields#updateRequiredFields" } } %>
+          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Select the import job class to see required data fields</p>
+        </div>
+
+        <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600">
+          <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Required Data Fields</legend>
+          
+          <div class="space-y-4" data-dynamic-fields-target="requiredFields">
+            <% @conference.required_data_fields.each do |field| %>
+              <div class="mb-4">
+                <%= form.label "data[#{field}]", field.humanize, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+                <%= form.url_field "data[#{field}]", value: @conference.data&.dig(field), class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+              </div>
+            <% end %>
+            
+            <% if @conference.required_data_fields.empty? %>
+              <p class="text-sm text-gray-500 dark:text-gray-400">No required fields for this import job class</p>
+            <% end %>
+          </div>
+        </fieldset>
+
+      <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600" data-controller="dynamic-fields">
+        <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Custom Data Fields</legend>
+        
+        <div class="space-y-4" data-dynamic-fields-target="container">
+          <% if @conference.data.present? %>
+            <% @conference.data.except('schedule_url', 'filedrop_url', 'engelsystem_url', 'heartbeat_url').each do |key, value| %>
+              <div class="flex items-center space-x-2 nested-field">
+                <div class="flex-1">
+                  <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label>
+                  <input type="text" value="<%= key %>" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" data-field-key>
+                </div>
+                <div class="flex-1">
+                  <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label>
+                  <input type="text" name="data[<%= key %>]" value="<%= value %>" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
+                </div>
+                <div class="flex items-end">
+                  <button type="button" data-action="click->dynamic-fields#removeField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
+                    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                      <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+            <% end %>
+          <% end %>
+        </div>
+
+        <template data-dynamic-fields-target="template">
+          <div class="flex items-center space-x-2 nested-field">
+            <div class="flex-1">
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label>
+              <input type="text" name="data[custom_key_NEW_RECORD]" placeholder="Enter key" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" data-field-key>
+            </div>
+            <div class="flex-1">
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label>
+              <input type="text" name="data[custom_value_NEW_RECORD]" placeholder="Enter value" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
+            </div>
+            <div class="flex items-end">
+              <button type="button" data-action="click->dynamic-fields#removeField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
+                <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                  <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
+                </svg>
+              </button>
+            </div>
+          </div>
+        </template>
+
+        <div class="mt-4">
+          <button type="button" data-action="click->dynamic-fields#addField" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-indigo-900 dark:text-indigo-200 dark:hover:bg-indigo-800">
+            <svg xmlns="http://www.w3.org/2000/svg" class="-ml-0.5 mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
+              <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
+            </svg>
+            Add Custom Field
+          </button>
+        </div>
+      </fieldset>
+    </div>
+
+    <div class="flex justify-between pt-6">
+      <%= link_to "Cancel", conference_path(slug: @conference.slug), class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600" %>
+      
+      <%= form.submit "Update Conference", class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-700 dark:hover:bg-blue-800" %>
+    </div>
+  <% end %>
+
+  <div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
+    <h2 class="text-xl font-bold mb-4 dark:text-gray-200">Danger Zone</h2>
+    
+    <div class="bg-red-50 border border-red-300 rounded-md p-4 dark:bg-red-900/20 dark:border-red-800">
+      <h3 class="text-lg font-medium text-red-800 dark:text-red-300">Delete This Conference</h3>
+      <p class="mt-1 text-sm text-red-700 dark:text-red-400">Once you delete a conference, there is no going back. This will delete all associated data including sessions, speakers, and stages.</p>
+      
+      <div class="mt-4">
+        <%= button_to "Delete Conference", conference_path(slug: @conference.slug), method: :delete, 
+            data: { confirm: "Are you sure you want to delete this conference? This action cannot be undone." }, 
+            class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800" %>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/app/views/conferences/index.html.erb b/app/views/conferences/index.html.erb
index 4fcb2ed..514fc3d 100644
--- a/app/views/conferences/index.html.erb
+++ b/app/views/conferences/index.html.erb
@@ -1,8 +1,53 @@
-<div>
-<h1 class="text-xl my-4 dark:text-red-500">Conferences</h1>
-<ul>
-<% @conferences.each do |conference| %>
-<li><%= link_to conference.name, conference_path(conference.slug), class: "inline-block px-4 py-2 text-slate-500 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-gray-600 rounded-md" %></li>
-<% end %>
-</ul>
+<div class="container mx-auto px-4 py-8">
+  <div class="flex justify-between items-center mb-6">
+    <h1 class="text-2xl font-bold dark:text-gray-200">Conferences</h1>
+    
+    <% if user_signed_in? && current_user.has_role?("events_admin") %>
+      <%= link_to new_conference_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800" do %>
+        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
+          <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
+        </svg>
+        New Conference
+      <% end %>
+    <% end %>
+  </div>
+
+  <div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
+    <ul class="divide-y divide-gray-200 dark:divide-gray-700">
+      <% if @conferences.empty? %>
+        <li class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">No conferences available.</li>
+      <% else %>
+        <% @conferences.each do |conference| %>
+          <li>
+            <div class="px-4 py-4 flex items-center justify-between">
+              <div class="min-w-0 flex-1">
+                <div class="flex items-center">
+                  <p class="text-lg font-medium text-blue-600 dark:text-blue-400">
+                    <%= link_to conference.name, conference_path(slug: conference.slug) %>
+                  </p>
+                </div>
+                <div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400">
+                  <% if conference.starts_at && conference.ends_at %>
+                    <span>
+                      <%= conference.starts_at.strftime("%b %d, %Y") %> - <%= conference.ends_at.strftime("%b %d, %Y") %>
+                    </span>
+                  <% end %>
+                </div>
+              </div>
+              
+              <% if user_signed_in? && current_user.has_role?("events_admin") %>
+                <div class="flex-shrink-0 flex">
+                  <%= link_to edit_conference_path(slug: conference.slug), class: "ml-2 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" do %>
+                    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                      <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
+                    </svg>
+                  <% end %>
+                </div>
+              <% end %>
+            </div>
+          </li>
+        <% end %>
+      <% end %>
+    </ul>
+  </div>
 </div>
diff --git a/app/views/conferences/new.html.erb b/app/views/conferences/new.html.erb
new file mode 100644
index 0000000..6f58f4f
--- /dev/null
+++ b/app/views/conferences/new.html.erb
@@ -0,0 +1,115 @@
+<div class="container mx-auto px-4 py-8">
+  <h1 class="text-2xl font-bold mb-6 dark:text-gray-200">New Conference</h1>
+
+  <%= form_with(model: @conference, url: conferences_path, class: "space-y-6") do |form| %>
+    <% if @conference.errors.any? %>
+      <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
+        <h2 class="font-bold"><%= pluralize(@conference.errors.count, "error") %> prohibited this conference from being saved:</h2>
+        <ul class="list-disc list-inside mt-2">
+          <% @conference.errors.full_messages.each do |message| %>
+            <li><%= message %></li>
+          <% end %>
+        </ul>
+      </div>
+    <% end %>
+
+    <div class="space-y-4">
+      <div>
+        <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+      </div>
+
+      <div>
+        <%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.text_field :slug, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Used in URLs. Should contain only lowercase letters, numbers, and hyphens.</p>
+      </div>
+
+      <div>
+        <%= form.label :url, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.url_field :url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+      </div>
+
+      <div>
+        <%= form.label :time_zone, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+        <%= form.time_zone_select :time_zone, nil, { include_blank: "Select Time Zone" }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" } %>
+      </div>
+
+      <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
+        <div>
+          <%= form.label :starts_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.datetime_local_field :starts_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        </div>
+
+        <div>
+          <%= form.label :ends_at, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.datetime_local_field :ends_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" %>
+        </div>
+      </div>
+
+      <div data-controller="dynamic-fields">
+        <div>
+          <%= form.label :import_job_class, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
+          <%= form.select :import_job_class, 
+                         [["Select Import Job Class", ""]] + 
+                         ConferencesController.available_import_job_classes.map { |class_name, display_name| [display_name, class_name] },
+                         {}, 
+                         { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
+                           data: { dynamic_fields_target: "importJobClass", action: "change->dynamic-fields#updateRequiredFields" } } %>
+          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Select the import job class to see required data fields</p>
+        </div>
+
+        <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600">
+          <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Required Data Fields</legend>
+          
+          <div class="space-y-4" data-dynamic-fields-target="requiredFields">
+            <!-- Required fields will be dynamically added here -->
+            <p class="text-sm text-gray-500 dark:text-gray-400">Select an import job class to see required fields</p>
+          </div>
+        </fieldset>
+
+      <fieldset class="mt-6 border border-gray-300 rounded-md p-4 dark:border-gray-600" data-controller="dynamic-fields">
+        <legend class="px-2 text-sm font-medium text-gray-700 dark:text-gray-300">Custom Data Fields</legend>
+        
+        <div class="space-y-4" data-dynamic-fields-target="container">
+          <!-- Dynamic fields will be added here -->
+        </div>
+
+        <template data-dynamic-fields-target="template">
+          <div class="flex items-center space-x-2 nested-field">
+            <div class="flex-1">
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Key</label>
+              <input type="text" name="data[custom_key_NEW_RECORD]" placeholder="Enter key" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" data-field-key>
+            </div>
+            <div class="flex-1">
+              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Value</label>
+              <input type="text" name="data[custom_value_NEW_RECORD]" placeholder="Enter value" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
+            </div>
+            <div class="flex items-end">
+              <button type="button" data-action="click->dynamic-fields#removeField" class="mt-1 p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
+                <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                  <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
+                </svg>
+              </button>
+            </div>
+          </div>
+        </template>
+
+        <div class="mt-4">
+          <button type="button" data-action="click->dynamic-fields#addField" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-indigo-900 dark:text-indigo-200 dark:hover:bg-indigo-800">
+            <svg xmlns="http://www.w3.org/2000/svg" class="-ml-0.5 mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
+              <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
+            </svg>
+            Add Custom Field
+          </button>
+        </div>
+      </fieldset>
+    </div>
+
+    <div class="flex justify-between pt-6">
+      <%= link_to "Cancel", conferences_path, class: "px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600" %>
+      
+      <%= form.submit "Create Conference", class: "inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-700 dark:hover:bg-blue-800" %>
+    </div>
+  <% end %>
+</div>
diff --git a/app/views/conferences/show.html.erb b/app/views/conferences/show.html.erb
index 0528867..2d8fa31 100644
--- a/app/views/conferences/show.html.erb
+++ b/app/views/conferences/show.html.erb
@@ -13,8 +13,8 @@ current_time = Time.zone.now.in_time_zone(@conference.time_zone)
     <%= render partial: 'assignments/filteredlist_option', locals: { user: } %>
   <% end %>
 </template>
-<div>
-  <div>
+<div class="container mx-auto px-4 py-8">
+  <div class="mb-4">
     <a href="#now" onclick="document.querySelector('#now')?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); return false" class="underline text-blue-500">Jump to current time</a>
   </div>
   <h1 class="text-2xl font-bold my-2 dark:text-red-500"><%= @conference.name %></h1>
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
index c2d89e2..262e862 100644
--- a/config/initializers/filter_parameter_logging.rb
+++ b/config/initializers/filter_parameter_logging.rb
@@ -3,6 +3,6 @@
 # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
 # Use this to limit dissemination of sensitive information.
 # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
-Rails.application.config.filter_parameters += [
-  :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
+Rails.application.config.filter_parameters += %i[
+  passw secret token _key crypt salt certificate otp ssn
 ]
diff --git a/config/queue.yml b/config/queue.yml
index 6dde0de..0ff67ce 100644
--- a/config/queue.yml
+++ b/config/queue.yml
@@ -1,18 +1,18 @@
-# default: &default
-#   dispatchers:
-#     - polling_interval: 1
-#       batch_size: 500
-#   workers:
-#     - queues: "*"
-#       threads: 3
-#       processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
-#       polling_interval: 0.1
-#
-# development:
-#  <<: *default
-#
-# test:
-#  <<: *default
-#
-# production:
-#  <<: *default
+default: &default
+  dispatchers:
+    - polling_interval: 1
+      batch_size: 500
+  workers:
+    - queues: "*"
+      threads: 3
+      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
+      polling_interval: 0.1
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/config/routes.rb b/config/routes.rb
index e139691..9fbdf75 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -19,14 +19,17 @@ Rails.application.routes.draw do
   root "conferences#index"
 
   resources :conferences, param: :slug do
+    collection do
+      get "required_fields", to: "conferences#required_fields"
+    end
     get "stats", on: :member
     get ":date", action: :show, on: :member, as: :date, date: /\d{4}-\d{2}-\d{2}/
     resources :sessions, param: :ref_id do
       member do
         patch :update_notes
       end
-      resources :assignments, only: [ :create, :destroy ]
-      resources :candidates, only: [ :create, :destroy ]
+      resources :assignments, only: %i[create destroy]
+      resources :candidates, only: %i[create destroy]
       delete "candidates", to: "candidates#destroy_self"
     end
     resources :speakers, param: :ref_id
-- 
GitLab