Files
aperonight/app/models/event.rb
kbe 5279ebe1a4 feat(event available/sold out): Promoter can mark event as sold out or available
On the event page, promoter can choose to mark the event as "sold out" using the status field or as "published". Only published event can be marked as sold out if promoter thinks he cannot handle all the people available.
2025-09-16 17:15:09 +02:00

272 lines
9.0 KiB
Ruby
Executable File

# Event model representing nightlife events and events
# Manages event details, location data, and publication state
require "net/http"
require "json"
class Event < ApplicationRecord
# Define states for Event lifecycle management
# draft: Initial state when Event is being created
# published: Event is visible to public and can be discovered
# canceled: Event has been canceled by organizer
# sold_out: Event has reached capacity and tickets are no longer available
enum :state, {
draft: 0,
published: 1,
canceled: 2,
sold_out: 3
}, default: :draft
# === Relations ===
belongs_to :user
has_many :ticket_types
has_many :tickets, through: :ticket_types
has_many :orders
# === Callbacks ===
before_validation :geocode_address, if: :should_geocode_address?
# Validations for Event attributes
# Basic information
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
validates :state, presence: true, inclusion: { in: states.keys }
validates :image, length: { maximum: 500 } # URL or path to image
# Venue information
validates :venue_name, presence: true, length: { maximum: 100 }
validates :venue_address, presence: true, length: { maximum: 200 }
# Geographic coordinates for map display
validates :latitude, presence: true, numericality: {
greater_than_or_equal_to: -90,
less_than_or_equal_to: 90
}
validates :longitude, presence: true, numericality: {
greater_than_or_equal_to: -180,
less_than_or_equal_to: 180
}
# Scopes for querying events with common filters
scope :featured, -> { where(featured: true) } # Get featured events for homepage
scope :published, -> { where(state: :published) } # Get publicly visible events
scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{query}%") } # Search by name (case-insensitive)
# Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
# === Instance Methods ===
# Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful?
coordinates_look_valid?
end
# Get a user-friendly status message about geocoding
def geocoding_status_message
return nil if geocoding_successful?
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
end
# Check if ticket booking is currently allowed for this event
def booking_allowed?
return false unless published?
return false if sold_out?
return false if canceled?
# Check if event has started and if booking during event is disabled
if event_started? && !allow_booking_during_event?
return false
end
true
end
# Check if the event has already started
def event_started?
return false if start_time.blank?
Time.current >= start_time
end
# Check if the event has ended
def event_ended?
return false if end_time.blank?
Time.current >= end_time
end
# Check if booking is allowed during the event
# This is a simple attribute reader that defaults to false if nil
def allow_booking_during_event?
!!allow_booking_during_event
end
# Duplicate an event with all its ticket types
def duplicate(clone_ticket_types: true)
# Duplicate the event
new_event = self.dup
new_event.name = "Copie de #{name}"
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
new_event.state = :draft
new_event.created_at = Time.current
new_event.updated_at = Time.current
Event.transaction do
if new_event.save
# Duplicate all ticket types if requested
if clone_ticket_types
ticket_types.each do |ticket_type|
new_ticket_type = ticket_type.dup
new_ticket_type.event = new_event
new_ticket_type.save!
end
end
new_event
else
nil
end
end
rescue
nil
end
private
# Determine if we should perform server-side geocoding
def should_geocode_address?
# Don't geocode if address is blank
return false if venue_address.blank?
# Don't geocode if we already have valid coordinates (likely from frontend)
return false if coordinates_look_valid?
# Only geocode if address changed and we don't have coordinates
venue_address_changed?
end
# Check if the current coordinates look like they were set by frontend geocoding
def coordinates_look_valid?
return false if latitude.blank? || longitude.blank?
lat_f = latitude.to_f
lng_f = longitude.to_f
# Basic sanity checks for coordinate ranges
return false if lat_f < -90 || lat_f > 90
return false if lng_f < -180 || lng_f > 180
# Check if coordinates are not the default fallback coordinates
fallback_lat = 46.603354
fallback_lng = 1.888334
# Check if coordinates are not exactly 0,0 (common invalid default)
return false if lat_f == 0.0 && lng_f == 0.0
# Coordinates are valid if they're not exactly the fallback coordinates
!(lat_f == fallback_lat && lng_f == fallback_lng)
end
# Automatically geocode address to get latitude and longitude
# This only runs when no valid coordinates are provided (fallback for non-JS users)
def geocode_address
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
# Store original coordinates in case we need to fall back
original_lat = latitude
original_lng = longitude
begin
# Use OpenStreetMap Nominatim API for geocoding
encoded_address = URI.encode_www_form_component(venue_address.strip)
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Get.new(uri)
request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)"
request["Accept"] = "application/json"
response = http.request(request)
if response.code == "200"
data = JSON.parse(response.body)
if data.any?
result = data.first
self.latitude = result["lat"].to_f.round(6)
self.longitude = result["lon"].to_f.round(6)
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
return
end
end
# If we reach here, geocoding failed
handle_geocoding_failure(original_lat, original_lng)
rescue => e
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
handle_geocoding_failure(original_lat, original_lng)
end
end
# Handle geocoding failure with fallback strategies
def handle_geocoding_failure(original_lat, original_lng)
# Strategy 1: Keep existing coordinates if this is an update
if original_lat.present? && original_lng.present?
self.latitude = original_lat
self.longitude = original_lng
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
return
end
# Strategy 2: Try to extract country/city and use approximate coordinates
fallback_coordinates = get_fallback_coordinates_from_address
if fallback_coordinates
self.latitude = fallback_coordinates[:lat]
self.longitude = fallback_coordinates[:lng]
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
return
end
# Strategy 3: Use default coordinates (center of France) as last resort
# This ensures the event can still be created
self.latitude = 46.603354 # Center of France
self.longitude = 1.888334
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
end
# Extract country/city from address and return approximate coordinates
def get_fallback_coordinates_from_address
address_lower = venue_address.downcase
# Common French cities with approximate coordinates
french_cities = {
"paris" => { lat: 48.8566, lng: 2.3522 },
"lyon" => { lat: 45.7640, lng: 4.8357 },
"marseille" => { lat: 43.2965, lng: 5.3698 },
"toulouse" => { lat: 43.6047, lng: 1.4442 },
"nice" => { lat: 43.7102, lng: 7.2620 },
"nantes" => { lat: 47.2184, lng: -1.5536 },
"montpellier" => { lat: 43.6110, lng: 3.8767 },
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
"lille" => { lat: 50.6292, lng: 3.0573 }
}
# Check if any known city is mentioned in the address
french_cities.each do |city, coords|
if address_lower.include?(city)
return coords
end
end
# Check for common country indicators
if address_lower.include?("france") || address_lower.include?("french")
return { lat: 46.603354, lng: 1.888334 } # Center of France
end
nil
end
end