mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Add visits detection
This commit is contained in:
parent
1e207f297c
commit
5394e9dd52
34 changed files with 473 additions and 112 deletions
|
|
@ -4,3 +4,4 @@ DATABASE_PASSWORD=password
|
|||
DATABASE_NAME=dawarich_development
|
||||
DATABASE_PORT=5432
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
GOOGLE_PLACES_API_KEY=''
|
||||
|
|
|
|||
3
Gemfile
3
Gemfile
|
|
@ -9,8 +9,7 @@ gem 'chartkick'
|
|||
gem 'data_migrate'
|
||||
gem 'devise'
|
||||
gem 'geocoder'
|
||||
gem 'geokit'
|
||||
gem 'geokit-rails'
|
||||
gem 'google_places'
|
||||
gem 'importmap-rails'
|
||||
gem 'kaminari'
|
||||
gem 'lograge'
|
||||
|
|
|
|||
|
|
@ -141,7 +141,13 @@ GEM
|
|||
rails (>= 3.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google_places (2.0.0)
|
||||
httparty (>= 0.13.1)
|
||||
hashdiff (1.1.0)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.0.1)
|
||||
|
|
@ -186,6 +192,8 @@ GEM
|
|||
mini_mime (1.1.5)
|
||||
minitest (5.24.1)
|
||||
msgpack (1.7.2)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
mutex_m (0.2.0)
|
||||
net-imap (0.4.12)
|
||||
date
|
||||
|
|
@ -429,6 +437,7 @@ DEPENDENCIES
|
|||
geocoder
|
||||
geokit
|
||||
geokit-rails
|
||||
google_places
|
||||
importmap-rails
|
||||
kaminari
|
||||
lograge
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,15 +6,17 @@ class VisitsController < ApplicationController
|
|||
|
||||
def index
|
||||
order_by = params[:order_by] || 'asc'
|
||||
status = params[:status] || 'confirmed'
|
||||
|
||||
visits = current_user
|
||||
.visits
|
||||
.where(status: :pending)
|
||||
.or(current_user.visits.where(status: :confirmed))
|
||||
.where(status:)
|
||||
.order(started_at: order_by)
|
||||
.group_by { |visit| visit.started_at.to_date }
|
||||
.map { |k, v| { date: k, visits: v } }
|
||||
|
||||
@suggested_visits_count = current_user.visits.suggested.count
|
||||
|
||||
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
|
|
|
|||
32
app/javascript/controllers/visit_modal_map_controller.js
Normal file
32
app/javascript/controllers/visit_modal_map_controller.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L, { latLng } from "leaflet";
|
||||
import { osmMapLayer } from "../maps/layers";
|
||||
|
||||
// Connects to data-controller="visit-modal-map"
|
||||
export default class extends Controller {
|
||||
static targets = ["container"];
|
||||
|
||||
connect() {
|
||||
console.log("Visits maps controller connected");
|
||||
this.coordinates = JSON.parse(this.element.dataset.coordinates);
|
||||
this.center = JSON.parse(this.element.dataset.center);
|
||||
this.radius = this.element.dataset.radius;
|
||||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 17);
|
||||
|
||||
osmMapLayer(this.map),
|
||||
this.addMarkers();
|
||||
|
||||
L.circle([this.center[0], this.center[1]], {
|
||||
radius: this.radius,
|
||||
color: 'red',
|
||||
fillColor: '#f03',
|
||||
fillOpacity: 0.5
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
addMarkers() {
|
||||
this.coordinates.forEach((coordinate) => {
|
||||
L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,15 @@
|
|||
class ReverseGeocodingJob < ApplicationJob
|
||||
queue_as :reverse_geocoding
|
||||
|
||||
def perform(point_id)
|
||||
def perform(klass, id)
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
|
||||
ReverseGeocoding::FetchData.new(point_id).call
|
||||
data_fetcher(klass, id).call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def data_fetcher(klass, id)
|
||||
"ReverseGeocoding::#{klass.pluralize.camelize}::FetchData".constantize.new(id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
7
app/jobs/visit_suggesting_job.rb
Normal file
7
app/jobs/visit_suggesting_job.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class VisitSuggestingJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(*args)
|
||||
# Do something later
|
||||
end
|
||||
end
|
||||
|
|
@ -5,4 +5,6 @@ class Area < ApplicationRecord
|
|||
has_many :visits, dependent: :destroy
|
||||
|
||||
validates :name, :latitude, :longitude, :radius, presence: true
|
||||
|
||||
def center = [latitude.to_f, longitude.to_f]
|
||||
end
|
||||
|
|
|
|||
17
app/models/place.rb
Normal file
17
app/models/place.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Place < ApplicationRecord
|
||||
validates :name, :longitude, :latitude, presence: true
|
||||
|
||||
enum source: { manual: 0, google_places: 1 }
|
||||
|
||||
after_commit :async_reverse_geocode, on: :create
|
||||
|
||||
private
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
end
|
||||
|
|
@ -33,6 +33,6 @@ class Point < ApplicationRecord
|
|||
def async_reverse_geocode
|
||||
return unless REVERSE_GEOCODING_ENABLED
|
||||
|
||||
ReverseGeocodingJob.perform_later(id)
|
||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,5 +7,11 @@ class Visit < ApplicationRecord
|
|||
|
||||
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
||||
|
||||
enum status: { pending: 0, confirmed: 1, declined: 2 }
|
||||
enum status: { suggested: 0, confirmed: 1, declined: 2 }
|
||||
|
||||
delegate :name, to: :area, prefix: true
|
||||
|
||||
def coordinates
|
||||
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class Areas::Visits::Create
|
|||
v.name = "#{area.name}, #{time_range}"
|
||||
v.ended_at = Time.zone.at(visit_points.last.timestamp)
|
||||
v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60
|
||||
v.status = :pending
|
||||
v.status = :suggested
|
||||
end
|
||||
|
||||
visit.save!
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Jobs::Create
|
||||
class InvalidJobName < StandardError; end
|
||||
|
||||
attr_reader :job_name, :user
|
||||
|
||||
def initialize(job_name, user_id)
|
||||
|
|
|
|||
65
app/services/reverse_geocoding/places/fetch_data.rb
Normal file
65
app/services/reverse_geocoding/places/fetch_data.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ReverseGeocoding::Places::FetchData
|
||||
attr_reader :place
|
||||
|
||||
def initialize(place_id)
|
||||
@place = Place.find(place_id)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("Place with id #{place_id} not found: #{e.message}")
|
||||
end
|
||||
|
||||
def call
|
||||
return if place.reverse_geocoded?
|
||||
|
||||
if ENV['GOOGLE_PLACES_API_KEY'].blank?
|
||||
Rails.logger.warn('GOOGLE_PLACES_API_KEY is not set')
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
data = Geocoder.search([place.latitude, place.longitude])
|
||||
google_place_ids = data.map { _1.data['place_id'] }
|
||||
first_place = google_places_client.spot(google_place_ids.shift)
|
||||
|
||||
update_place(first_place)
|
||||
google_place_ids.each { |google_place_id| fetch_and_create_place(google_place_id) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def google_places_client
|
||||
@google_places_client ||= GooglePlaces::Client.new(ENV['GOOGLE_PLACES_API_KEY'])
|
||||
end
|
||||
|
||||
def update_place(place)
|
||||
place.update!(
|
||||
name: place.name,
|
||||
latitude: place.lat,
|
||||
longitude: place.lng,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
raw_data: place.raw_data,
|
||||
source: :google_places,
|
||||
reverse_geocoded_at: Time.zone.now
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_and_create_place(place_id)
|
||||
place_data = google_places_client.spot(place_id)
|
||||
|
||||
Place.create!(
|
||||
name: place_data.name,
|
||||
latitude: place_data.lat,
|
||||
longitude: place_data.lng,
|
||||
city: place_data.city,
|
||||
country: place_data.country,
|
||||
raw_data: place_data.raw_data,
|
||||
source: :google_places
|
||||
)
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
place.geodata.present?
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ReverseGeocoding::FetchData
|
||||
class ReverseGeocoding::Points::FetchData
|
||||
attr_reader :point
|
||||
|
||||
def initialize(point_id)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
require 'geokit'
|
||||
|
||||
class Visits::Detect
|
||||
def initialize
|
||||
a = 5.days.ago.beginning_of_month
|
||||
b = 5.days.ago.end_of_month
|
||||
@points = Point.order(timestamp: :asc).where(timestamp: a..b)
|
||||
end
|
||||
|
||||
def call
|
||||
# Group points by day
|
||||
points_by_day = @points.group_by { |point| point_date(point) }
|
||||
|
||||
# Iterate through each day's points
|
||||
points_by_day.each do |day, day_points|
|
||||
# Sort points by timestamp
|
||||
day_points.sort_by! { |point| point.timestamp }
|
||||
|
||||
# Call the method for each day's points
|
||||
grouped_points = group_points_by_radius(day_points)
|
||||
|
||||
# Print the grouped points for the day
|
||||
puts "Day: #{day}"
|
||||
grouped_points.each_with_index do |group, index|
|
||||
puts "Group #{index + 1}:"
|
||||
group.each do |point|
|
||||
puts point
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Method to convert timestamp to date
|
||||
def point_date(point)
|
||||
Time.zone.at(point.timestamp).to_date
|
||||
end
|
||||
|
||||
# Method to check if two points are within a certain radius (in meters)
|
||||
def within_radius?(point1, point2, radius)
|
||||
loc1 = Geokit::LatLng.new(point1.latitude, point1.longitude)
|
||||
loc2 = Geokit::LatLng.new(point2.latitude, point2.longitude)
|
||||
loc1.distance_to(loc2, units: :kms) * 1000 <= radius
|
||||
end
|
||||
|
||||
# Method to group points by increasing radius
|
||||
def group_points_by_radius(day_points, initial_radius = 30, max_radius = 100, step = 10)
|
||||
grouped = []
|
||||
remaining_points = day_points.dup
|
||||
|
||||
while remaining_points.any?
|
||||
point = remaining_points.shift
|
||||
group = [point]
|
||||
radius = initial_radius
|
||||
|
||||
while radius <= max_radius
|
||||
remaining_points.each do |next_point|
|
||||
group << next_point if within_radius?(point, next_point, radius)
|
||||
end
|
||||
|
||||
if group.size > 1
|
||||
remaining_points -= group
|
||||
grouped << group
|
||||
break
|
||||
else
|
||||
radius += step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped
|
||||
end
|
||||
end
|
||||
|
||||
# Execute the detection
|
||||
# Visits::Detect.new.call
|
||||
58
app/services/visits/group_points.rb
Normal file
58
app/services/visits/group_points.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Visits::GroupPoints
|
||||
INITIAL_RADIUS = 30 # meters
|
||||
MAX_RADIUS = 100 # meters
|
||||
RADIUS_STEP = 10 # meters
|
||||
MIN_VISIT_DURATION = 3 * 60 # 3 minutes in seconds
|
||||
|
||||
attr_reader :day_points, :initial_radius, :max_radius, :step
|
||||
|
||||
def initialize(day_points, initial_radius = INITIAL_RADIUS, max_radius = MAX_RADIUS, step = RADIUS_STEP)
|
||||
@day_points = day_points
|
||||
@initial_radius = initial_radius
|
||||
@max_radius = max_radius
|
||||
@step = step
|
||||
end
|
||||
|
||||
def group_points_by_radius
|
||||
grouped = []
|
||||
remaining_points = day_points.dup
|
||||
|
||||
while remaining_points.any?
|
||||
point = remaining_points.shift
|
||||
radius = initial_radius
|
||||
|
||||
while radius <= max_radius
|
||||
new_group = [point]
|
||||
|
||||
remaining_points.each do |next_point|
|
||||
break unless within_radius?(new_group.first, next_point, radius)
|
||||
|
||||
new_group << next_point
|
||||
end
|
||||
|
||||
if new_group.size > 1
|
||||
group_duration = new_group.last.timestamp - new_group.first.timestamp
|
||||
|
||||
if group_duration >= MIN_VISIT_DURATION
|
||||
remaining_points -= new_group
|
||||
grouped << new_group
|
||||
end
|
||||
|
||||
break
|
||||
else
|
||||
radius += step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def within_radius?(point1, point2, radius)
|
||||
point1.distance_to(point2) * 1000 <= radius
|
||||
end
|
||||
end
|
||||
48
app/services/visits/suggest.rb
Normal file
48
app/services/visits/suggest.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Visits::Suggest
|
||||
def initialize(start_at: nil, end_at: nil)
|
||||
start_at ||= Date.new(2024, 7, 15).to_datetime.beginning_of_day.to_i
|
||||
end_at ||= Date.new(2024, 7, 19).to_datetime.end_of_day.to_i
|
||||
@points = Point.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||
end
|
||||
|
||||
def call
|
||||
points_by_day = @points.group_by { |point| point_date(point) }
|
||||
|
||||
result = {}
|
||||
|
||||
points_by_day.each do |day, day_points|
|
||||
day_points.sort_by!(&:timestamp)
|
||||
|
||||
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
|
||||
day_result = prepare_day_result(grouped_points)
|
||||
result[day] = day_result
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def point_date(point) = Time.zone.at(point.timestamp).to_date.to_s
|
||||
|
||||
def calculate_radius(center_point, group)
|
||||
max_distance = group.map { |point| center_point.distance_to(point) }.max
|
||||
|
||||
(max_distance / 10.0).ceil * 10
|
||||
end
|
||||
|
||||
def prepare_day_result(grouped_points)
|
||||
result = {}
|
||||
|
||||
grouped_points.each do |group|
|
||||
center_point = group.first
|
||||
radius = calculate_radius(center_point, group)
|
||||
key = "#{center_point.latitude},#{center_point.longitude},#{radius}m,#{group.size}"
|
||||
result[key] = group.count
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,19 @@
|
|||
|
||||
<div class="flex justify-between my-5">
|
||||
<h1 class="font-bold text-4xl">Visits</h1>
|
||||
<div role="tablist" class="tabs tabs-boxed">
|
||||
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :confirmed))}" %>
|
||||
<%= link_to visits_path(status: :suggested), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :suggested))}" do %>
|
||||
Suggested
|
||||
<% if @suggested_visits_count.positive? %>
|
||||
<span class="badge badge-sm badge-primary mx-1"><%= @suggested_visits_count %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to 'Declined', visits_path(status: :declined), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :declined))}" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">Order by:</span>
|
||||
<%= link_to 'Newest', visits_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
|
|
@ -16,7 +29,7 @@
|
|||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
<p class="py-6">
|
||||
Here you'll find your visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
||||
Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,19 +62,49 @@
|
|||
<div class="group relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>">
|
||||
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.suggested? %>">
|
||||
<%= visit.area.name %>
|
||||
</div>
|
||||
<div>
|
||||
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
|
||||
</div>
|
||||
</div>
|
||||
<% if visit.pending? %>
|
||||
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
|
||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success mr-1' %>
|
||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error' %>
|
||||
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
|
||||
<% if visit.suggested? %>
|
||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
||||
<% end %>
|
||||
<!-- The button to open modal -->
|
||||
<label for="visit_details_popup_<%= visit.id %>" class='btn btn-xs btn-info'>Map</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" id="visit_details_popup_<%= visit.id %>" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box w-10/12">
|
||||
<h3 class="text-lg font-bold">
|
||||
<%= visit.area_name %>, <%= visit.started_at.strftime('%d.%m.%Y') %>, <%= visit.started_at.strftime('%H:%M') %> - <%= visit.ended_at.strftime('%H:%M') %>
|
||||
</h3>
|
||||
<div class="flex justify-between my-5">
|
||||
<div><a class='link'><</a> Cafe, Sterndamm 86D <a class='link'>></a>, <%= visit.points.count %></div>
|
||||
<div class='flex'>
|
||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class='w-full h-[25rem]'
|
||||
data-controller="visit-modal-map"
|
||||
data-coordinates="<%= visit.coordinates %>"
|
||||
data-radius="<%= visit.area.radius %>"
|
||||
data-center="<%= visit.area.center %>"
|
||||
>
|
||||
<div data-visit-modal-map-target="container" class="h-[25rem] w-auto h-96">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<label class="modal-backdrop" for="visit_details_popup_<%= visit.id %>">Close</label>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Geocoder.configure(
|
||||
config = {
|
||||
# geocoding service request timeout, in seconds (default 3):
|
||||
# timeout: 5,
|
||||
|
||||
|
|
@ -13,4 +13,11 @@ Geocoder.configure(
|
|||
expiration: 1.day # Defaults to `nil`
|
||||
# prefix: "another_key:" # Defaults to `geocoder:`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if ENV['GOOGLE_PLACES_API_KEY'].present?
|
||||
config[:lookup] = :google
|
||||
config[:api_key] = ENV['GOOGLE_PLACES_API_KEY']
|
||||
end
|
||||
|
||||
Geocoder.configure(config)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
# config/schedule.yml
|
||||
|
||||
stat_creating_job:
|
||||
cron: "0 */6 * * *"
|
||||
cron: "0 */6 * * *" # every 6 hours
|
||||
class: "StatCreatingJob"
|
||||
queue: default
|
||||
|
||||
area_visits_calculation_scheduling_job:
|
||||
cron: "0 * * * *" # every hour
|
||||
cron: "0 0 * * *" # every day at 0:00
|
||||
class: "AreaVisitsCalculationSchedulingJob"
|
||||
queue: default
|
||||
|
||||
visit_suggesting_job:
|
||||
cron: "0 1 * * *" # every day at 1:00
|
||||
class: "VisitSuggestingJob"
|
||||
queue: default
|
||||
|
|
|
|||
18
db/migrate/20240805150111_create_places.rb
Normal file
18
db/migrate/20240805150111_create_places.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreatePlaces < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :places do |t|
|
||||
t.string :name, null: false
|
||||
t.decimal :longitude, precision: 10, scale: 6, null: false
|
||||
t.decimal :latitude, precision: 10, scale: 6, null: false
|
||||
t.string :city
|
||||
t.string :country
|
||||
t.integer :source, default: 0
|
||||
t.jsonb :geodata, default: {}, null: false
|
||||
t.datetime :reverse_geocoded_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
generated
18
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -53,6 +53,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
|||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
|
|
@ -90,6 +93,19 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
|||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
create_table "places", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||
t.decimal "latitude", precision: 10, scale: 6, null: false
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.integer "source", default: 0
|
||||
t.jsonb "geodata", default: {}, null: false
|
||||
t.datetime "reverse_geocoded_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "points", force: :cascade do |t|
|
||||
t.integer "battery_status"
|
||||
t.string "ping"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ services:
|
|||
APPLICATION_HOSTS: localhost
|
||||
TIME_ZONE: Europe/London
|
||||
APPLICATION_PROTOCOL: http
|
||||
GOOGLE_PLACES_API_KEY: ''
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
|
@ -79,6 +80,7 @@ services:
|
|||
APPLICATION_HOSTS: localhost
|
||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
||||
APPLICATION_PROTOCOL: http
|
||||
GOOGLE_PLACES_API_KEY: ''
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
|
|
|||
5
spec/factories/places.rb
Normal file
5
spec/factories/places.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FactoryBot.define do
|
||||
factory :place do
|
||||
name { "MyString" }
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,6 @@ FactoryBot.define do
|
|||
ended_at { Time.zone.now + 1.hour }
|
||||
duration { 1.hour }
|
||||
name { 'Visit' }
|
||||
status { 'pending' }
|
||||
status { 'suggested' }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe ReverseGeocodingJob, type: :job do
|
||||
describe '#perform' do
|
||||
subject(:perform) { described_class.new.perform(point.id) }
|
||||
subject(:perform) { described_class.new.perform('Point', point.id) }
|
||||
|
||||
let(:point) { create(:point) }
|
||||
|
||||
|
|
@ -19,12 +19,12 @@ RSpec.describe ReverseGeocodingJob, type: :job do
|
|||
expect { perform }.not_to(change { point.reload.city })
|
||||
end
|
||||
|
||||
it 'does not call ReverseGeocoding::FetchData' do
|
||||
allow(ReverseGeocoding::FetchData).to receive(:new).and_call_original
|
||||
it 'does not call ReverseGeocoding::Points::FetchData' do
|
||||
allow(ReverseGeocoding::Points::FetchData).to receive(:new).and_call_original
|
||||
|
||||
perform
|
||||
|
||||
expect(ReverseGeocoding::FetchData).not_to have_received(:new)
|
||||
expect(ReverseGeocoding::Points::FetchData).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -35,11 +35,11 @@ RSpec.describe ReverseGeocodingJob, type: :job do
|
|||
|
||||
it 'calls Geocoder' do
|
||||
allow(Geocoder).to receive(:search).and_return([stubbed_geocoder])
|
||||
allow(ReverseGeocoding::FetchData).to receive(:new).and_call_original
|
||||
allow(ReverseGeocoding::Points::FetchData).to receive(:new).and_call_original
|
||||
|
||||
perform
|
||||
|
||||
expect(ReverseGeocoding::FetchData).to have_received(:new).with(point.id)
|
||||
expect(ReverseGeocoding::Points::FetchData).to have_received(:new).with(point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
5
spec/jobs/visit_suggesting_job_spec.rb
Normal file
5
spec/jobs/visit_suggesting_job_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe VisitSuggestingJob, type: :job do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
5
spec/models/place_spec.rb
Normal file
5
spec/models/place_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Place, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
|
|
@ -49,6 +49,13 @@ RSpec.describe Point, type: :model do
|
|||
it 'enqueues ReverseGeocodeJob' do
|
||||
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
|
||||
it 'enqueues ReverseGeocodeJob with correct arguments' do
|
||||
point.save
|
||||
|
||||
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
||||
.with('Point', point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,11 +17,83 @@ RSpec.describe '/visits', type: :request do
|
|||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
|
||||
context 'with confirmed visits' do
|
||||
let!(:confirmed_visits) { create_list(:visit, 3, user:, status: :confirmed) }
|
||||
|
||||
it 'returns confirmed visits' do
|
||||
get visits_url
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).to match_array(confirmed_visits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with suggested visits' do
|
||||
let!(:suggested_visits) { create_list(:visit, 3, user:, status: :suggested) }
|
||||
|
||||
it 'does not return suggested visits' do
|
||||
get visits_url
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).not_to include(suggested_visits)
|
||||
end
|
||||
|
||||
it 'returns suggested visits' do
|
||||
get visits_url, params: { status: 'suggested' }
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).to match_array(suggested_visits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with declined visits' do
|
||||
let!(:declined_visits) { create_list(:visit, 3, user:, status: :declined) }
|
||||
|
||||
it 'does not return declined visits' do
|
||||
get visits_url
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).not_to include(declined_visits)
|
||||
end
|
||||
|
||||
it 'returns declined visits' do
|
||||
get visits_url, params: { status: 'declined' }
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).to match_array(declined_visits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with suggested visits' do
|
||||
let!(:suggested_visits) { create_list(:visit, 3, user:, status: :suggested) }
|
||||
|
||||
it 'does not return suggested visits' do
|
||||
get visits_url
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).not_to include(suggested_visits)
|
||||
end
|
||||
|
||||
it 'returns suggested visits' do
|
||||
get visits_url, params: { status: 'suggested' }
|
||||
|
||||
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||
v[:visits]
|
||||
end.flatten).to match_array(suggested_visits)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /update' do
|
||||
context 'with valid parameters' do
|
||||
let(:visit) { create(:visit, user:, status: :pending) }
|
||||
let(:visit) { create(:visit, user:, status: :suggested) }
|
||||
|
||||
it 'confirms the requested visit' do
|
||||
patch visit_url(visit), params: { visit: { status: :confirmed } }
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ RSpec.describe Jobs::Create do
|
|||
described_class.new(job_name, user.id).call
|
||||
|
||||
points.each do |point|
|
||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.id)
|
||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.class.to_s, point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -33,7 +33,7 @@ RSpec.describe Jobs::Create do
|
|||
described_class.new(job_name, user.id).call
|
||||
|
||||
points_without_address.each do |point|
|
||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.id)
|
||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.class.to_s, point.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReverseGeocoding::FetchData do
|
||||
RSpec.describe ReverseGeocoding::Points::FetchData do
|
||||
subject(:fetch_data) { described_class.new(point.id).call }
|
||||
|
||||
let(:point) { create(:point) }
|
||||
Loading…
Reference in a new issue