mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Add some changes related to places management feature
This commit is contained in:
parent
78851c5f98
commit
e1f16c98a2
14 changed files with 1170 additions and 112 deletions
|
|
@ -6,24 +6,26 @@ module Api
|
|||
before_action :set_place, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@places = policy_scope(Place).includes(:tags)
|
||||
@places = policy_scope(Place).includes(:tags, :visits)
|
||||
@places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
|
||||
|
||||
render json: Api::PlaceSerializer.new(@places).serialize
|
||||
|
||||
render json: @places.map { |place| serialize_place(place) }
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @place
|
||||
render json: Api::PlaceSerializer.new(@place).serialize
|
||||
|
||||
render json: serialize_place(@place)
|
||||
end
|
||||
|
||||
def create
|
||||
@place = current_api_user.places.build(place_params)
|
||||
|
||||
authorize @place
|
||||
|
||||
if @place.save
|
||||
add_tags if tag_ids.present?
|
||||
render json: Api::PlaceSerializer.new(@place).serialize, status: :created
|
||||
render json: serialize_place(@place), status: :created
|
||||
else
|
||||
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
|
|
@ -33,8 +35,8 @@ module Api
|
|||
authorize @place
|
||||
|
||||
if @place.update(place_params)
|
||||
sync_tags if params[:place][:tag_ids]
|
||||
render json: Api::PlaceSerializer.new(@place).serialize
|
||||
set_tags if params[:place][:tag_ids]
|
||||
render json: serialize_place(@place)
|
||||
else
|
||||
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
|
|
@ -42,13 +44,15 @@ module Api
|
|||
|
||||
def destroy
|
||||
authorize @place
|
||||
|
||||
@place.destroy!
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def nearby
|
||||
authorize Place, :nearby?
|
||||
|
||||
|
||||
unless params[:latitude].present? && params[:longitude].present?
|
||||
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
|
||||
end
|
||||
|
|
@ -84,11 +88,33 @@ module Api
|
|||
@place.tags << tags
|
||||
end
|
||||
|
||||
def sync_tags
|
||||
def set_tags
|
||||
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
|
||||
tags = current_api_user.tags.where(id: tag_ids_param)
|
||||
@place.tags = tags
|
||||
end
|
||||
|
||||
def serialize_place(place)
|
||||
{
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
source: place.source,
|
||||
icon: place.tags.first&.icon,
|
||||
color: place.tags.first&.color,
|
||||
visits_count: place.visits.count,
|
||||
created_at: place.created_at,
|
||||
tags: place.tags.map do |tag|
|
||||
{
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
icon: tag.icon,
|
||||
color: tag.color
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,16 +6,19 @@ class TagsController < ApplicationController
|
|||
|
||||
def index
|
||||
@tags = policy_scope(Tag).ordered
|
||||
|
||||
authorize Tag
|
||||
end
|
||||
|
||||
def new
|
||||
@tag = current_user.tags.build
|
||||
|
||||
authorize @tag
|
||||
end
|
||||
|
||||
def create
|
||||
@tag = current_user.tags.build(tag_params)
|
||||
|
||||
authorize @tag
|
||||
|
||||
if @tag.save
|
||||
|
|
@ -41,7 +44,9 @@ class TagsController < ApplicationController
|
|||
|
||||
def destroy
|
||||
authorize @tag
|
||||
|
||||
@tag.destroy!
|
||||
|
||||
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
|
||||
end
|
||||
|
||||
|
|
|
|||
150
app/javascript/controllers/place_creation_controller.js
Normal file
150
app/javascript/controllers/place_creation_controller.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput",
|
||||
"nearbyList", "loadingSpinner", "tagCheckboxes"]
|
||||
static values = {
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.addEventListener('place:create', (e) => {
|
||||
this.open(e.detail.latitude, e.detail.longitude)
|
||||
})
|
||||
}
|
||||
|
||||
async open(latitude, longitude) {
|
||||
this.latitudeInputTarget.value = latitude
|
||||
this.longitudeInputTarget.value = longitude
|
||||
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
this.nameInputTarget.focus()
|
||||
|
||||
await this.loadNearbyPlaces(latitude, longitude)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalTarget.classList.remove('modal-open')
|
||||
this.formTarget.reset()
|
||||
this.nearbyListTarget.innerHTML = ''
|
||||
|
||||
const event = new CustomEvent('place:create:cancelled')
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
async loadNearbyPlaces(latitude, longitude) {
|
||||
this.loadingSpinnerTarget.classList.remove('hidden')
|
||||
this.nearbyListTarget.innerHTML = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/places/nearby?latitude=${latitude}&longitude=${longitude}&limit=5`,
|
||||
{ headers: { 'APIKEY': this.apiKeyValue } }
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load nearby places')
|
||||
|
||||
const data = await response.json()
|
||||
this.renderNearbyPlaces(data.places)
|
||||
} catch (error) {
|
||||
console.error('Error loading nearby places:', error)
|
||||
this.nearbyListTarget.innerHTML = '<p class="text-error">Failed to load suggestions</p>'
|
||||
} finally {
|
||||
this.loadingSpinnerTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
renderNearbyPlaces(places) {
|
||||
if (!places || places.length === 0) {
|
||||
this.nearbyListTarget.innerHTML = '<p class="text-sm text-gray-500">No nearby places found</p>'
|
||||
return
|
||||
}
|
||||
|
||||
const html = places.map(place => `
|
||||
<div class="card card-compact bg-base-200 cursor-pointer hover:bg-base-300 transition"
|
||||
data-action="click->place-creation#selectNearby"
|
||||
data-place-name="${this.escapeHtml(place.name)}"
|
||||
data-place-latitude="${place.latitude}"
|
||||
data-place-longitude="${place.longitude}">
|
||||
<div class="card-body">
|
||||
<h4 class="font-semibold">${this.escapeHtml(place.name)}</h4>
|
||||
${place.street ? `<p class="text-sm">${this.escapeHtml(place.street)}</p>` : ''}
|
||||
${place.city ? `<p class="text-xs text-gray-500">${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
this.nearbyListTarget.innerHTML = html
|
||||
}
|
||||
|
||||
selectNearby(event) {
|
||||
const element = event.currentTarget
|
||||
this.nameInputTarget.value = element.dataset.placeName
|
||||
this.latitudeInputTarget.value = element.dataset.placeLatitude
|
||||
this.longitudeInputTarget.value = element.dataset.placeLongitude
|
||||
}
|
||||
|
||||
async submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
const tagIds = Array.from(this.formTarget.querySelectorAll('input[name="tag_ids[]"]:checked'))
|
||||
.map(cb => cb.value)
|
||||
|
||||
const payload = {
|
||||
place: {
|
||||
name: formData.get('name'),
|
||||
latitude: parseFloat(formData.get('latitude')),
|
||||
longitude: parseFloat(formData.get('longitude')),
|
||||
source: 'manual',
|
||||
tag_ids: tagIds
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/places', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'APIKEY': this.apiKeyValue
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.errors?.join(', ') || 'Failed to create place')
|
||||
}
|
||||
|
||||
const place = await response.json()
|
||||
|
||||
this.close()
|
||||
this.showNotification('Place created successfully!', 'success')
|
||||
|
||||
const event = new CustomEvent('place:created', { detail: { place } })
|
||||
document.dispatchEvent(event)
|
||||
} catch (error) {
|
||||
console.error('Error creating place:', error)
|
||||
this.showNotification(error.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
const event = new CustomEvent('notification:show', {
|
||||
detail: { message, type },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return ''
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
}
|
||||
239
app/javascript/maps/places.js
Normal file
239
app/javascript/maps/places.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// Maps Places Layer Manager
|
||||
// Handles displaying user places with tag icons and colors on the map
|
||||
|
||||
import L from 'leaflet';
|
||||
|
||||
export class PlacesManager {
|
||||
constructor(map, apiKey) {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
this.placesLayer = null;
|
||||
this.places = [];
|
||||
this.markers = {};
|
||||
this.selectedTags = new Set();
|
||||
this.creationMode = false;
|
||||
this.creationMarker = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.placesLayer = L.layerGroup().addTo(this.map);
|
||||
await this.loadPlaces();
|
||||
this.setupMapClickHandler();
|
||||
}
|
||||
|
||||
async loadPlaces(tagIds = null) {
|
||||
try {
|
||||
const url = new URL('/api/v1/places', window.location.origin);
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
tagIds.forEach(id => url.searchParams.append('tag_ids[]', id));
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'APIKEY': this.apiKey }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load places');
|
||||
|
||||
this.places = await response.json();
|
||||
this.renderPlaces();
|
||||
} catch (error) {
|
||||
console.error('Error loading places:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderPlaces() {
|
||||
// Clear existing markers
|
||||
this.placesLayer.clearLayers();
|
||||
this.markers = {};
|
||||
|
||||
this.places.forEach(place => {
|
||||
const marker = this.createPlaceMarker(place);
|
||||
if (marker) {
|
||||
this.markers[place.id] = marker;
|
||||
marker.addTo(this.placesLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createPlaceMarker(place) {
|
||||
if (!place.latitude || !place.longitude) return null;
|
||||
|
||||
const icon = this.createPlaceIcon(place);
|
||||
const marker = L.marker([place.latitude, place.longitude], { icon });
|
||||
|
||||
const popupContent = this.createPopupContent(place);
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
createPlaceIcon(place) {
|
||||
const emoji = place.icon || place.tags[0]?.icon || '📍';
|
||||
const color = place.color || place.tags[0]?.color || '#4CAF50';
|
||||
|
||||
const iconHtml = `
|
||||
<div class="place-marker" style="
|
||||
background-color: ${color};
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
border: 2px solid white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(-45deg);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
">
|
||||
<span style="transform: rotate(45deg); font-size: 16px;">${emoji}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return L.divIcon({
|
||||
html: iconHtml,
|
||||
className: 'place-icon',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
popupAnchor: [0, -32]
|
||||
});
|
||||
}
|
||||
|
||||
createPopupContent(place) {
|
||||
const tags = place.tags.map(tag =>
|
||||
`<span class="badge badge-sm" style="background-color: ${tag.color}">
|
||||
${tag.icon} ${tag.name}
|
||||
</span>`
|
||||
).join(' ');
|
||||
|
||||
return `
|
||||
<div class="place-popup" style="min-width: 200px;">
|
||||
<h3 class="font-bold text-lg mb-2">${place.name}</h3>
|
||||
${tags ? `<div class="mb-2">${tags}</div>` : ''}
|
||||
${place.visits_count ? `<p class="text-sm">Visits: ${place.visits_count}</p>` : ''}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupMapClickHandler() {
|
||||
this.map.on('click', (e) => {
|
||||
if (this.creationMode) {
|
||||
this.handleMapClick(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Delegate event handling for delete buttons
|
||||
this.map.on('popupopen', (e) => {
|
||||
const popup = e.popup;
|
||||
const deleteBtn = popup.getElement()?.querySelector('[data-action="delete-place"]');
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const placeId = deleteBtn.dataset.placeId;
|
||||
await this.deletePlace(placeId);
|
||||
popup.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleMapClick(e) {
|
||||
const { lat, lng } = e.latlng;
|
||||
|
||||
// Remove existing creation marker
|
||||
if (this.creationMarker) {
|
||||
this.map.removeLayer(this.creationMarker);
|
||||
}
|
||||
|
||||
// Add temporary marker
|
||||
this.creationMarker = L.marker([lat, lng], {
|
||||
icon: this.createPlaceIcon({ icon: '📍', color: '#FF9800' })
|
||||
}).addTo(this.map);
|
||||
|
||||
// Trigger place creation modal
|
||||
this.triggerPlaceCreation(lat, lng);
|
||||
}
|
||||
|
||||
async triggerPlaceCreation(lat, lng) {
|
||||
const event = new CustomEvent('place:create', {
|
||||
detail: { latitude: lat, longitude: lng },
|
||||
bubbles: true
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async deletePlace(placeId) {
|
||||
if (!confirm('Are you sure you want to delete this place?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/places/${placeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'APIKEY': this.apiKey }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete place');
|
||||
|
||||
// Remove marker and reload
|
||||
if (this.markers[placeId]) {
|
||||
this.placesLayer.removeLayer(this.markers[placeId]);
|
||||
delete this.markers[placeId];
|
||||
}
|
||||
|
||||
this.places = this.places.filter(p => p.id !== parseInt(placeId));
|
||||
|
||||
this.showNotification('Place deleted successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error deleting place:', error);
|
||||
this.showNotification('Failed to delete place', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
enableCreationMode() {
|
||||
this.creationMode = true;
|
||||
this.map.getContainer().style.cursor = 'crosshair';
|
||||
this.showNotification('Click on the map to add a place', 'info');
|
||||
}
|
||||
|
||||
disableCreationMode() {
|
||||
this.creationMode = false;
|
||||
this.map.getContainer().style.cursor = '';
|
||||
|
||||
if (this.creationMarker) {
|
||||
this.map.removeLayer(this.creationMarker);
|
||||
this.creationMarker = null;
|
||||
}
|
||||
}
|
||||
|
||||
filterByTags(tagIds) {
|
||||
this.selectedTags = new Set(tagIds);
|
||||
this.loadPlaces(tagIds.length > 0 ? tagIds : null);
|
||||
}
|
||||
|
||||
async refreshPlaces() {
|
||||
const tagIds = this.selectedTags.size > 0 ? Array.from(this.selectedTags) : null;
|
||||
await this.loadPlaces(tagIds);
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.placesLayer) {
|
||||
this.map.addLayer(this.placesLayer);
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.placesLayer) {
|
||||
this.map.removeLayer(this.placesLayer);
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
const event = new CustomEvent('notification:show', {
|
||||
detail: { message, type },
|
||||
bubbles: true
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,22 +13,18 @@ module Taggable
|
|||
}
|
||||
end
|
||||
|
||||
# Add a tag to this taggable record
|
||||
def add_tag(tag)
|
||||
tags << tag unless tags.include?(tag)
|
||||
end
|
||||
|
||||
# Remove a tag from this taggable record
|
||||
def remove_tag(tag)
|
||||
tags.delete(tag)
|
||||
end
|
||||
|
||||
# Get all tag names for this taggable record
|
||||
def tag_names
|
||||
tags.pluck(:name)
|
||||
end
|
||||
|
||||
# Check if tagged with specific tag
|
||||
def tagged_with?(tag)
|
||||
tags.include?(tag)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ class Tag < ApplicationRecord
|
|||
has_many :places, through: :taggings, source: :taggable, source_type: 'Place'
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
validates :user, presence: true
|
||||
validates :color, format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true }
|
||||
|
||||
scope :for_user, ->(user) { where(user: user) }
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class PlaceSerializer
|
||||
include Alba::Resource
|
||||
|
||||
attributes :id, :name, :latitude, :longitude, :source, :created_at
|
||||
|
||||
attribute :icon do |place|
|
||||
place.tags.first&.icon
|
||||
end
|
||||
|
||||
attribute :color do |place|
|
||||
place.tags.first&.color
|
||||
end
|
||||
|
||||
many :tags do
|
||||
attributes :id, :name, :icon, :color
|
||||
end
|
||||
|
||||
attribute :visits_count do |place|
|
||||
place.visits.count
|
||||
end
|
||||
end
|
||||
end
|
||||
65
app/views/shared/_place_creation_modal.html.erb
Normal file
65
app/views/shared/_place_creation_modal.html.erb
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal" data-place-creation-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">Create New Place</h3>
|
||||
|
||||
<form data-place-creation-target="form" data-action="submit->place-creation#submit">
|
||||
<input type="hidden" name="latitude" data-place-creation-target="latitudeInput">
|
||||
<input type="hidden" name="longitude" data-place-creation-target="longitudeInput">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Nearby Places Suggestions</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="loading loading-spinner loading-sm absolute top-2 right-2 hidden" data-place-creation-target="loadingSpinner"></div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto" data-place-creation-target="nearbyList">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Place Name *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Enter place name..."
|
||||
class="input input-bordered w-full"
|
||||
data-place-creation-target="nameInput"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Tags</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2" data-place-creation-target="tagCheckboxes">
|
||||
<% current_user.tags.ordered.each do |tag| %>
|
||||
<label class="cursor-pointer">
|
||||
<input type="checkbox" name="tag_ids[]" value="<%= tag.id %>" class="checkbox checkbox-sm hidden peer">
|
||||
<span class="badge badge-lg peer-checked:badge-primary" style="background-color: <%= tag.color %>">
|
||||
<%= tag.icon %> <%= tag.name %>
|
||||
</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Click tags to select them for this place</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" data-action="click->place-creation#close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Place</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-backdrop" data-action="click->place-creation#close"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -5,7 +5,7 @@ FactoryBot.define do
|
|||
sequence(:name) { |n| "Place #{n}" }
|
||||
latitude { 54.2905245 }
|
||||
longitude { 13.0948638 }
|
||||
lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" }
|
||||
# lonlat is auto-generated by before_validation callback in Place model
|
||||
association :user
|
||||
|
||||
trait :with_geodata do
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ RSpec.describe Tag, type: :model do
|
|||
it { is_expected.to have_many(:places).through(:taggings) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:user) }
|
||||
|
||||
|
||||
describe 'validations' do
|
||||
subject { create(:tag) }
|
||||
|
||||
|
||||
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:user_id) }
|
||||
|
||||
|
||||
it 'validates hex color' do
|
||||
expect(build(:tag, color: '#FF5733')).to be_valid
|
||||
expect(build(:tag, color: 'invalid')).not_to be_valid
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Places', type: :request do
|
|||
let(:user) { create(:user) }
|
||||
let!(:place) { create(:place, user: user, name: 'Home', latitude: 40.7128, longitude: -74.0060) }
|
||||
let!(:tag) { create(:tag, user: user, name: 'Favorite') }
|
||||
let(:headers) { { 'APIKEY' => user.api_key } }
|
||||
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
|
||||
|
||||
describe 'GET /api/v1/places' do
|
||||
it 'returns user places' do
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::PlaceSerializer do
|
||||
describe '#call' do
|
||||
let(:place) do
|
||||
create(
|
||||
:place,
|
||||
:with_geodata,
|
||||
name: 'Central Park',
|
||||
longitude: -73.9665,
|
||||
latitude: 40.7812,
|
||||
lonlat: 'SRID=4326;POINT(-73.9665 40.7812)',
|
||||
city: 'New York',
|
||||
country: 'United States',
|
||||
source: 'photon',
|
||||
geodata: { 'amenity' => 'park', 'leisure' => 'park' }, reverse_geocoded_at: Time.zone.parse('2023-01-15T12:00:00Z')
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer) { described_class.new(place) }
|
||||
|
||||
it 'serializes a place into a hash with all attributes' do
|
||||
result = serializer.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:id]).to eq(place.id)
|
||||
expect(result[:name]).to eq('Central Park')
|
||||
expect(result[:longitude]).to eq(-73.9665)
|
||||
expect(result[:latitude]).to eq(40.7812)
|
||||
expect(result[:city]).to eq('New York')
|
||||
expect(result[:country]).to eq('United States')
|
||||
expect(result[:source]).to eq('photon')
|
||||
expect(result[:geodata]).to eq({ 'amenity' => 'park', 'leisure' => 'park' })
|
||||
expect(result[:reverse_geocoded_at]).to eq(Time.zone.parse('2023-01-15T12:00:00Z'))
|
||||
end
|
||||
|
||||
context 'with nil values' do
|
||||
let(:place_with_nils) do
|
||||
create(
|
||||
:place,
|
||||
name: 'Unknown Place',
|
||||
city: nil,
|
||||
country: nil,
|
||||
source: nil,
|
||||
geodata: {},
|
||||
reverse_geocoded_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer_with_nils) { described_class.new(place_with_nils) }
|
||||
|
||||
it 'handles nil values correctly' do
|
||||
result = serializer_with_nils.call
|
||||
|
||||
expect(result[:id]).to eq(place_with_nils.id)
|
||||
expect(result[:name]).to eq('Unknown Place')
|
||||
expect(result[:city]).to be_nil
|
||||
expect(result[:country]).to be_nil
|
||||
expect(result[:source]).to be_nil
|
||||
expect(result[:geodata]).to eq({})
|
||||
expect(result[:reverse_geocoded_at]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
330
spec/swagger/api/v1/places_controller_spec.rb
Normal file
330
spec/swagger/api/v1/places_controller_spec.rb
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'Places API', type: :request do
|
||||
path '/api/v1/places' do
|
||||
get 'Retrieves all places for the authenticated user' do
|
||||
tags 'Places'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
parameter name: :tag_ids, in: :query, type: :array, items: { type: :integer }, required: false, description: 'Filter places by tag IDs'
|
||||
|
||||
response '200', 'places found' do
|
||||
schema type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
source: { type: :string },
|
||||
icon: { type: :string, nullable: true },
|
||||
color: { type: :string, nullable: true },
|
||||
visits_count: { type: :integer },
|
||||
created_at: { type: :string, format: 'date-time' },
|
||||
tags: {
|
||||
type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
icon: { type: :string },
|
||||
color: { type: :string }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[id name latitude longitude]
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let!(:place) { create(:place, user: user) }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data).to be_an(Array)
|
||||
expect(data.first['id']).to eq(place.id)
|
||||
end
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post 'Creates a place' do
|
||||
tags 'Places'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
parameter name: :place, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
source: { type: :string },
|
||||
tag_ids: { type: :array, items: { type: :integer } }
|
||||
},
|
||||
required: %w[name latitude longitude]
|
||||
}
|
||||
|
||||
response '201', 'place created' do
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
source: { type: :string },
|
||||
icon: { type: :string, nullable: true },
|
||||
color: { type: :string, nullable: true },
|
||||
visits_count: { type: :integer },
|
||||
created_at: { type: :string, format: 'date-time' },
|
||||
tags: { type: :array }
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:tag) { create(:tag, user: user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) do
|
||||
{
|
||||
name: 'Coffee Shop',
|
||||
latitude: 40.7589,
|
||||
longitude: -73.9851,
|
||||
source: 'manual',
|
||||
tag_ids: [tag.id]
|
||||
}
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data['name']).to eq('Coffee Shop')
|
||||
expect(data['tags']).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
response '422', 'invalid request' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { { name: '' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
let(:place) { { name: 'Test', latitude: 40.0, longitude: -73.0 } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/places/nearby' do
|
||||
get 'Searches for nearby places using Photon geocoding API' do
|
||||
tags 'Places'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
parameter name: :latitude, in: :query, type: :number, format: :float, required: true, description: 'Latitude coordinate'
|
||||
parameter name: :longitude, in: :query, type: :number, format: :float, required: true, description: 'Longitude coordinate'
|
||||
parameter name: :radius, in: :query, type: :number, format: :float, required: false, description: 'Search radius in kilometers (default: 0.5)'
|
||||
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Maximum number of results (default: 10)'
|
||||
|
||||
response '200', 'nearby places found' do
|
||||
schema type: :object,
|
||||
properties: {
|
||||
places: {
|
||||
type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
distance: { type: :number, format: :float },
|
||||
type: { type: :string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:latitude) { 40.7589 }
|
||||
let(:longitude) { -73.9851 }
|
||||
let(:radius) { 1.0 }
|
||||
let(:limit) { 5 }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data).to have_key('places')
|
||||
expect(data['places']).to be_an(Array)
|
||||
end
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
let(:latitude) { 40.7589 }
|
||||
let(:longitude) { -73.9851 }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/places/{id}' do
|
||||
parameter name: :id, in: :path, type: :integer, description: 'Place ID'
|
||||
|
||||
get 'Retrieves a specific place' do
|
||||
tags 'Places'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
|
||||
response '200', 'place found' do
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
source: { type: :string },
|
||||
icon: { type: :string, nullable: true },
|
||||
color: { type: :string, nullable: true },
|
||||
visits_count: { type: :integer },
|
||||
created_at: { type: :string, format: 'date-time' },
|
||||
tags: { type: :array }
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { create(:place, user: user) }
|
||||
let(:id) { place.id }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data['id']).to eq(place.id)
|
||||
end
|
||||
end
|
||||
|
||||
response '404', 'place not found' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:id) { 'invalid' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
let(:place) { create(:place) }
|
||||
let(:id) { place.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
patch 'Updates a place' do
|
||||
tags 'Places'
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
parameter name: :place, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
tag_ids: { type: :array, items: { type: :integer } }
|
||||
}
|
||||
}
|
||||
|
||||
response '200', 'place updated' do
|
||||
schema type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
name: { type: :string },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
tags: { type: :array }
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:existing_place) { create(:place, user: user) }
|
||||
let(:id) { existing_place.id }
|
||||
let(:place) { { name: 'Updated Name' } }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data['name']).to eq('Updated Name')
|
||||
end
|
||||
end
|
||||
|
||||
response '404', 'place not found' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:id) { 'invalid' }
|
||||
let(:place) { { name: 'Updated' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '422', 'invalid request' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:existing_place) { create(:place, user: user) }
|
||||
let(:id) { existing_place.id }
|
||||
let(:place) { { name: '' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
let(:existing_place) { create(:place) }
|
||||
let(:id) { existing_place.id }
|
||||
let(:place) { { name: 'Updated' } }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
delete 'Deletes a place' do
|
||||
tags 'Places'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API key for authentication'
|
||||
|
||||
response '204', 'place deleted' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { create(:place, user: user) }
|
||||
let(:id) { place.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '404', 'place not found' do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:id) { 'invalid' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response '401', 'unauthorized' do
|
||||
let(:api_key) { 'invalid' }
|
||||
let(:place) { create(:place) }
|
||||
let(:id) { place.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -686,6 +686,347 @@ paths:
|
|||
- photoprism
|
||||
'404':
|
||||
description: photo not found
|
||||
"/api/v1/places":
|
||||
get:
|
||||
summary: Retrieves all places for the authenticated user
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
- name: tag_ids
|
||||
in: query
|
||||
items:
|
||||
type: integer
|
||||
required: false
|
||||
description: Filter places by tag IDs
|
||||
schema:
|
||||
type: array
|
||||
responses:
|
||||
'200':
|
||||
description: places found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
source:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
nullable: true
|
||||
color:
|
||||
type: string
|
||||
nullable: true
|
||||
visits_count:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- latitude
|
||||
- longitude
|
||||
'401':
|
||||
description: unauthorized
|
||||
post:
|
||||
summary: Creates a place
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: place created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
source:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
nullable: true
|
||||
color:
|
||||
type: string
|
||||
nullable: true
|
||||
visits_count:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
tags:
|
||||
type: array
|
||||
'422':
|
||||
description: invalid request
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
source:
|
||||
type: string
|
||||
tag_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
required:
|
||||
- name
|
||||
- latitude
|
||||
- longitude
|
||||
"/api/v1/places/nearby":
|
||||
get:
|
||||
summary: Searches for nearby places using Photon geocoding API
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
- name: latitude
|
||||
in: query
|
||||
format: float
|
||||
required: true
|
||||
description: Latitude coordinate
|
||||
schema:
|
||||
type: number
|
||||
- name: longitude
|
||||
in: query
|
||||
format: float
|
||||
required: true
|
||||
description: Longitude coordinate
|
||||
schema:
|
||||
type: number
|
||||
- name: radius
|
||||
in: query
|
||||
format: float
|
||||
required: false
|
||||
description: 'Search radius in kilometers (default: 0.5)'
|
||||
schema:
|
||||
type: number
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
description: 'Maximum number of results (default: 10)'
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: nearby places found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
places:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
distance:
|
||||
type: number
|
||||
format: float
|
||||
type:
|
||||
type: string
|
||||
'401':
|
||||
description: unauthorized
|
||||
"/api/v1/places/{id}":
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: Place ID
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
get:
|
||||
summary: Retrieves a specific place
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: place found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
source:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
nullable: true
|
||||
color:
|
||||
type: string
|
||||
nullable: true
|
||||
visits_count:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
tags:
|
||||
type: array
|
||||
'404':
|
||||
description: place not found
|
||||
'401':
|
||||
description: unauthorized
|
||||
patch:
|
||||
summary: Updates a place
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: place updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
tags:
|
||||
type: array
|
||||
'404':
|
||||
description: place not found
|
||||
'422':
|
||||
description: invalid request
|
||||
'401':
|
||||
description: unauthorized
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
tag_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
delete:
|
||||
summary: Deletes a place
|
||||
tags:
|
||||
- Places
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API key for authentication
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: place deleted
|
||||
'404':
|
||||
description: place not found
|
||||
'401':
|
||||
description: unauthorized
|
||||
"/api/v1/points/tracked_months":
|
||||
get:
|
||||
summary: Returns list of tracked years and months
|
||||
|
|
|
|||
Loading…
Reference in a new issue