Fix map initialization and park area selection in moderation submission edit view

This commit is contained in:
pacnpal
2024-11-14 01:56:56 +00:00
parent d9990ae241
commit fd97ed31cb
3 changed files with 420 additions and 79 deletions

View File

@@ -1,54 +1,56 @@
from django import template from django import template
from django.apps import apps from django.utils.safestring import mark_safe
from django.core.exceptions import ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType
from rides.models import CATEGORY_CHOICES
register = template.Library() register = template.Library()
@register.filter @register.filter
def get_object(value, model_path): def get_object_name(value, model_path):
""" """Get object name from ID and model path."""
Template filter to get an object instance from its ID and model path.
Usage: {{ value|get_object:'app_label.ModelName' }}
"""
if not value: if not value:
return None return None
app_label, model = model_path.split('.')
try: try:
app_label, model_name = model_path.split('.') content_type = ContentType.objects.get(app_label=app_label.lower(), model=model.lower())
model = apps.get_model(app_label, model_name) model_class = content_type.model_class()
return model.objects.get(id=value) obj = model_class.objects.get(id=value)
except (ValueError, LookupError, ObjectDoesNotExist): return str(obj)
except Exception:
return None return None
@register.filter @register.filter
def get_category_display(value): def get_category_display(value):
""" """Get display value for ride category."""
Template filter to get the display name for a ride category. if not value:
Usage: {{ value|get_category_display }} return None
"""
try: categories = {
return dict(CATEGORY_CHOICES).get(value, value) 'RC': 'Roller Coaster',
except (KeyError, AttributeError): 'DR': 'Dark Ride',
return value 'FR': 'Flat Ride',
'WR': 'Water Ride',
@register.filter 'TR': 'Transport',
def get_object_name(value, model_path): 'OT': 'Other'
""" }
Template filter to get an object's name from its ID and model path. return categories.get(value, value)
Usage: {{ value|get_object_name:'app_label.ModelName' }}
"""
obj = get_object(value, model_path)
return obj.name if obj else None
@register.filter @register.filter
def get_park_area_name(value, park_id): def get_park_area_name(value, park_id):
""" """Get park area name from ID and park ID."""
Template filter to get a park area's name from its ID and park ID. if not value or not park_id:
Usage: {{ value|get_park_area_name:park_id }}
"""
try:
ParkArea = apps.get_model('parks', 'ParkArea')
area = ParkArea.objects.get(id=value, park_id=park_id)
return area.name
except (ValueError, LookupError, ObjectDoesNotExist):
return None return None
try:
from parks.models import ParkArea
area = ParkArea.objects.get(id=value, park_id=park_id)
return str(area)
except Exception:
return None
@register.filter
def get_item(dictionary, key):
"""Get item from dictionary by key."""
if not dictionary or not key:
return []
return dictionary.get(str(key), [])

View File

@@ -0,0 +1,360 @@
{% load static %}
<style>
/* Ensure map container and its elements stay below other UI elements */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
z-index: 1 !important;
}
.leaflet-control {
z-index: 2 !important;
}
</style>
<div class="location-widget" id="locationWidget-{{ submission.id }}">
{# Search Form #}
<div class="relative mb-4">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Search Location
</label>
<input type="text"
id="locationSearch-{{ submission.id }}"
class="relative w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search for a location..."
autocomplete="off"
style="z-index: 10;">
<div id="searchResults-{{ submission.id }}"
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
</div>
</div>
{# Map Container #}
<div class="relative mb-4" style="z-index: 1;">
<div id="locationMap-{{ submission.id }}" class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
</div>
{# Location Form Fields #}
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2" style="z-index: 10;">
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Street Address
</label>
<input type="text"
name="street_address"
id="streetAddress-{{ submission.id }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.street_address }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<input type="text"
name="city"
id="city-{{ submission.id }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.city }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
State/Region
</label>
<input type="text"
name="state"
id="state-{{ submission.id }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.state }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
</label>
<input type="text"
name="country"
id="country-{{ submission.id }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.country }}">
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Postal Code
</label>
<input type="text"
name="postal_code"
id="postalCode-{{ submission.id }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="{{ form.postal_code }}">
</div>
</div>
{# Hidden Coordinate Fields #}
<div class="hidden">
<input type="hidden" name="latitude" id="latitude-{{ submission.id }}" value="{{ form.latitude }}">
<input type="hidden" name="longitude" id="longitude-{{ submission.id }}" value="{{ form.longitude }}">
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let maps = {};
let markers = {};
const searchInput = document.getElementById('locationSearch-{{ submission.id }}');
const searchResults = document.getElementById('searchResults-{{ submission.id }}');
let searchTimeout;
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
try {
const rounded = Number(value).toFixed(decimalPlaces);
const strValue = rounded.replace('.', '').replace('-', '');
const strippedValue = strValue.replace(/0+$/, '');
if (strippedValue.length > maxDigits) {
return Number(Number(value).toFixed(decimalPlaces - 1));
}
return rounded;
} catch (error) {
console.error('Coordinate normalization failed:', error);
return null;
}
}
function validateCoordinates(lat, lng) {
const normalizedLat = normalizeCoordinate(lat, 9, 6);
const normalizedLng = normalizeCoordinate(lng, 10, 6);
if (normalizedLat === null || normalizedLng === null) {
throw new Error('Invalid coordinate format');
}
const parsedLat = parseFloat(normalizedLat);
const parsedLng = parseFloat(normalizedLng);
if (parsedLat < -90 || parsedLat > 90) {
throw new Error('Latitude must be between -90 and 90 degrees.');
}
if (parsedLng < -180 || parsedLng > 180) {
throw new Error('Longitude must be between -180 and 180 degrees.');
}
return { lat: normalizedLat, lng: normalizedLng };
}
function initMap() {
const submissionId = '{{ submission.id }}';
const mapId = `locationMap-${submissionId}`;
const mapContainer = document.getElementById(mapId);
if (!mapContainer) {
console.error(`Map container ${mapId} not found`);
return;
}
// If map already exists, remove it
if (maps[submissionId]) {
maps[submissionId].remove();
delete maps[submissionId];
delete markers[submissionId];
}
// Create new map
maps[submissionId] = L.map(mapId).setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(maps[submissionId]);
// Initialize with existing coordinates if available
const initialLat = document.getElementById(`latitude-${submissionId}`).value;
const initialLng = document.getElementById(`longitude-${submissionId}`).value;
if (initialLat && initialLng) {
try {
const normalized = validateCoordinates(initialLat, initialLng);
addMarker(normalized.lat, normalized.lng);
} catch (error) {
console.error('Invalid initial coordinates:', error);
}
}
// Handle map clicks
maps[submissionId].on('click', async function(e) {
try {
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
if (!response.ok) {
throw new Error('Geocoding request failed');
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
updateLocation(normalized.lat, normalized.lng, data);
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
});
}
function addMarker(lat, lng) {
const submissionId = '{{ submission.id }}';
if (markers[submissionId]) {
markers[submissionId].remove();
}
markers[submissionId] = L.marker([lat, lng]).addTo(maps[submissionId]);
maps[submissionId].setView([lat, lng], 13);
}
function updateLocation(lat, lng, data) {
try {
const normalized = validateCoordinates(lat, lng);
const submissionId = '{{ submission.id }}';
// Update coordinates
document.getElementById(`latitude-${submissionId}`).value = normalized.lat;
document.getElementById(`longitude-${submissionId}`).value = normalized.lng;
// Update marker
addMarker(normalized.lat, normalized.lng);
// Update form fields with English names where available
const address = data.address || {};
document.getElementById(`streetAddress-${submissionId}`).value =
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
document.getElementById(`city-${submissionId}`).value =
address.city || address.town || address.village || '';
document.getElementById(`state-${submissionId}`).value =
address.state || address.region || '';
document.getElementById(`country-${submissionId}`).value = address.country || '';
document.getElementById(`postalCode-${submissionId}`).value = address.postcode || '';
} catch (error) {
console.error('Location update failed:', error);
alert(error.message || 'Failed to update location. Please try again.');
}
}
function selectLocation(result) {
if (!result) return;
try {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
if (isNaN(lat) || isNaN(lon)) {
throw new Error('Invalid coordinates in search result');
}
const normalized = validateCoordinates(lat, lon);
// Create a normalized address object
const address = {
name: result.display_name || result.name || '',
address: {
house_number: result.address ? result.address.house_number : '',
road: result.address ? (result.address.road || result.address.street) : '',
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
state: result.address ? (result.address.state || result.address.region) : '',
country: result.address ? result.address.country : '',
postcode: result.address ? result.address.postcode : ''
}
};
updateLocation(normalized.lat, normalized.lng, address);
searchResults.classList.add('hidden');
searchInput.value = address.name;
} catch (error) {
console.error('Location selection failed:', error);
alert(error.message || 'Failed to select location. Please try again.');
}
}
// Handle location search
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (!query) {
searchResults.classList.add('hidden');
return;
}
searchTimeout = setTimeout(async function() {
try {
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search request failed');
}
const data = await response.json();
if (data.results && data.results.length > 0) {
const resultsHtml = data.results.map((result, index) => `
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
</div>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
searchResults.classList.remove('hidden');
// Store results data
searchResults.dataset.results = JSON.stringify(data.results);
// Add click handlers
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
el.addEventListener('click', function() {
const results = JSON.parse(searchResults.dataset.results);
const result = results[this.dataset.resultIndex];
selectLocation(result);
});
});
} else {
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
searchResults.classList.remove('hidden');
}
} catch (error) {
console.error('Search failed:', error);
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
searchResults.classList.remove('hidden');
}
}, 300);
});
// Hide search results when clicking outside
document.addEventListener('click', function(e) {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.classList.add('hidden');
}
});
// Initialize map when the element becomes visible
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
if (mapContainer && window.getComputedStyle(mapContainer).display !== 'none') {
initMap();
observer.disconnect();
}
}
});
});
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
if (mapContainer) {
observer.observe(mapContainer.parentElement.parentElement, { attributes: true });
}
});
</script>

View File

@@ -1,4 +1,10 @@
{% load moderation_tags %} {% load moderation_tags %}
{% load static %}
{% block extra_head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{% endblock %}
{% for submission in submissions %} {% for submission in submissions %}
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50" <div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
id="submission-{{ submission.id }}" id="submission-{{ submission.id }}"
@@ -239,9 +245,13 @@
id="park-area-select-{{ submission.id }}" id="park-area-select-{{ submission.id }}"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"> class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
<option value="">Select area</option> <option value="">Select area</option>
{% for area_id, area_name in park_areas %} {% with park_id=submission.changes.park %}
{% with areas=park_areas_by_park|get_item:park_id %}
{% for area_id, area_name in areas %}
<option value="{{ area_id }}" {% if value == area_id %}selected{% endif %}>{{ area_name }}</option> <option value="{{ area_id }}" {% if value == area_id %}selected{% endif %}>{{ area_name }}</option>
{% endfor %} {% endfor %}
{% endwith %}
{% endwith %}
</select> </select>
{% elif field == 'manufacturer' %} {% elif field == 'manufacturer' %}
<div class="relative space-y-2"> <div class="relative space-y-2">
@@ -344,6 +354,14 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<!-- Location Widget for Parks -->
{% if submission.content_type.model == 'park' %}
<div class="col-span-2 p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<h3 class="mb-4 text-lg font-semibold">Location</h3>
{% include "moderation/partials/location_widget.html" with form=submission.changes %}
</div>
{% endif %}
<!-- Coaster Fields --> <!-- Coaster Fields -->
<div x-show="showCoasterFields" <div x-show="showCoasterFields"
x-cloak x-cloak
@@ -426,43 +444,4 @@
hx-post="{% url 'moderation:reject_submission' submission.id %}" hx-post="{% url 'moderation:reject_submission' submission.id %}"
hx-target="#submissions-content" hx-target="#submissions-content"
hx-include="closest .review-notes" hx-include="closest .review-notes"
hx-confirm="Are you sure you want to reject this submission?" hx-confirm="Are you sure you want to reject
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-times"></i>
Reject
</button>
{% endif %}
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-sm hover:shadow-md"
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
hx-target="#submissions-content"
hx-include="closest .review-notes"
hx-confirm="Are you sure you want to escalate this submission?"
hx-indicator="#loading-indicator">
<i class="mr-2 fas fa-arrow-up"></i>
Escalate
</button>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="relative p-8 text-center bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="text-gray-600 dark:text-gray-400">
<i class="mb-4 text-5xl fas fa-inbox"></i>
<p class="text-lg">No submissions found matching your filters.</p>
</div>
<div id="loading-indicator"
class="absolute inset-0 flex items-center justify-center rounded-lg htmx-indicator bg-white/80 dark:bg-gray-900/80">
<div class="flex items-center p-6 space-x-4">
<div class="w-8 h-8 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
<span class="text-gray-900 dark:text-gray-300">Loading...</span>
</div>
</div>
</div>
{% endfor %}