pretix-map 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pretix-map
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: An overview map of the catchment area of previous orders. Measured by postcode
5
5
  Author-email: MarkenJaden <jjsch1410@gmail.com>
6
6
  Maintainer-email: MarkenJaden <jjsch1410@gmail.com>
@@ -1,12 +1,12 @@
1
- pretix_map-0.1.7.dist-info/licenses/LICENSE,sha256=MNMHjIJIjeQzQboYGsgFSRTBUHctF603DDa8MgCNyAg,554
2
- pretix_mapplugin/__init__.py,sha256=YpKDcdV7CqL8n45u267wKtyloM13FSVbOdrqgNZnSLM,22
1
+ pretix_map-0.1.8.dist-info/licenses/LICENSE,sha256=MNMHjIJIjeQzQboYGsgFSRTBUHctF603DDa8MgCNyAg,554
2
+ pretix_mapplugin/__init__.py,sha256=C69ADlbQREQlR15trneyA2sk8x0-oH4rDAX5fsv19_U,22
3
3
  pretix_mapplugin/apps.py,sha256=QOo53Z6zX0yB-Rz6I92tAu051sP38hM2iJlP9sk2JEg,802
4
4
  pretix_mapplugin/geocoding.py,sha256=N_JyjYU_JBouwE3oaL3G1wI5jAyezZzS5IBMgI_GdWQ,6053
5
- pretix_mapplugin/models.py,sha256=Ofx8S1UlpQVFt-pC9-_LKecF-Hv2uI2rDq5zJ5S_UJQ,2494
5
+ pretix_mapplugin/models.py,sha256=u_px4eaKWbh1hnUb7bdBEIuJUnvsts1AJ5CMoWLeQO8,2677
6
6
  pretix_mapplugin/signals.py,sha256=_SRT-3TllYzD9kUf0JG3PnuWa6cPHkFFSWInCDGm_j0,3155
7
- pretix_mapplugin/tasks.py,sha256=HKDFC-U1TtYDNrN26QVx133hn0K0BdBUm4qCCYHcgOY,6367
7
+ pretix_mapplugin/tasks.py,sha256=O6Xc6nFh4qMe8O5dBQWvK0pCfy6wcqKlpziZj0L8tLo,6966
8
8
  pretix_mapplugin/urls.py,sha256=gZC74OUSZI2ZUWQXm1pbz8taIV8jA9D2h7FvojRX9Ws,1458
9
- pretix_mapplugin/views.py,sha256=2SviWJunq_ZULYNNGz2F3kkJKHg1vNbhftEfmZ7QuAg,13049
9
+ pretix_mapplugin/views.py,sha256=htySZEkSGmcX_6k1kuzTFRn4uLOZonx4ih1-A58R-Jo,13580
10
10
  pretix_mapplugin/locale/de/LC_MESSAGES/django.mo,sha256=6VVRAqa0ixL-lDA1QwoVvG0wd5ZBwYjaR4P8T73hxhU,269
11
11
  pretix_mapplugin/locale/de/LC_MESSAGES/django.po,sha256=-HtJt_qb8k7C30pVXRuYeh5CIB_ISVt2HEAXGn2rVnw,311
12
12
  pretix_mapplugin/locale/de_Informal/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -18,10 +18,11 @@ pretix_mapplugin/management/commands/geocode_existing_orders.py,sha256=ACDCH17Wd
18
18
  pretix_mapplugin/migrations/0001_initial.py,sha256=i33QeU7ioVpk5HpSShdF-ievDUHsjTzKf2OWO9oSkl8,993
19
19
  pretix_mapplugin/migrations/0002_remove_ordergeocodedata_geocoded_timestamp_and_more.py,sha256=U0xDF4KcLqb1QTuDJvilr9NEECVorM7W8ll86RDWi-4,857
20
20
  pretix_mapplugin/migrations/0003_mapmilestone.py,sha256=2fcuMOSWcyF-wReVuGs53_a8pLpYLfNoCIIAekdRSUQ,991
21
+ pretix_mapplugin/migrations/0004_ordergeocodedata_success_level.py,sha256=Skl-dBqJvpGCTD8G302c7LnKA5ksAdkUntJdxhhJsvE,447
21
22
  pretix_mapplugin/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
23
  pretix_mapplugin/static/pretix_mapplugin/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
24
  pretix_mapplugin/static/pretix_mapplugin/css/salesmap.css,sha256=HgKozvQjWTzxJV4ua1hT9PiDzrvUSWrMnYRUvTTj4aI,1870
24
- pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js,sha256=8mLC6rQqJRDoPls4ykBIuN1fsxTkgDBNL6KtZaJLoos,32678
25
+ pretix_mapplugin/static/pretix_mapplugin/js/salesmap.js,sha256=CvdwCH1ATBOlSgRS_EdEIegw88emX96O9gjEBephWsA,34162
25
26
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.Default.css,sha256=YSWCMtmNZNwqex4CEw1nQhvFub2lmU7vcCKP-XVwwXA,1287
26
27
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/MarkerCluster.css,sha256=YU3qCpj_P06tdPBJGPax0bm6Q1wltfwjsho5TR4-TYc,872
27
28
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/leaflet-heat.js,sha256=65UqrlgGoRAnKfKRuriH3eeDrOhZgZo1SCenduc-SGo,5158
@@ -40,10 +41,10 @@ pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-ic
40
41
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-icon.png,sha256=V0w6XMqF9BFAhbaEFZbWLwDXyJLHsD8oy_owHesdxDc,1466
41
42
  pretix_mapplugin/static/pretix_mapplugin/libs/leaflet-sales-map/images/marker-shadow.png,sha256=Jk9cZAM58ELdcpBiz8BMF_jqDymIK1OOOEjtjxDttNo,618
42
43
  pretix_mapplugin/templates/pretix_mapplugin/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- pretix_mapplugin/templates/pretix_mapplugin/map_page.html,sha256=EqppF1UDMAAbR0paBjStId-1x754XdI5JVLl_hzD7zk,12346
44
- pretix_mapplugin/templates/pretix_mapplugin/milestones.html,sha256=2cdcmuA9XxDC3BFZuVL9tO-Nsz7whKasqLM9en3x5cw,2032
45
- pretix_map-0.1.7.dist-info/METADATA,sha256=hC6poaiu6tBYEaHrBfNqEeaXFRIfm0u0Q1vikjhQHr8,3575
46
- pretix_map-0.1.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
47
- pretix_map-0.1.7.dist-info/entry_points.txt,sha256=C3NAjeZHoCekafkLMCJynPcABRTK8AUprtQv7sUNDZs,137
48
- pretix_map-0.1.7.dist-info/top_level.txt,sha256=CAtEnkgA73zE9Gadm5mjt1SpXHBPOS-QWP0dQVoNToE,17
49
- pretix_map-0.1.7.dist-info/RECORD,,
44
+ pretix_mapplugin/templates/pretix_mapplugin/map_page.html,sha256=u-Z5xG9KkC5bCB3lu4c14pDtT476fN_vrzuPntJbpKQ,12513
45
+ pretix_mapplugin/templates/pretix_mapplugin/milestones.html,sha256=KamdrYmSmKgsy2-Pn-ChrYZf2Gel-iEA_vzOxRfR2sg,3080
46
+ pretix_map-0.1.8.dist-info/METADATA,sha256=nhoj5v3L8Y9e6q1mfGO4tIF8c2yytjRQmiLPiaL9Swo,3575
47
+ pretix_map-0.1.8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
48
+ pretix_map-0.1.8.dist-info/entry_points.txt,sha256=C3NAjeZHoCekafkLMCJynPcABRTK8AUprtQv7sUNDZs,137
49
+ pretix_map-0.1.8.dist-info/top_level.txt,sha256=CAtEnkgA73zE9Gadm5mjt1SpXHBPOS-QWP0dQVoNToE,17
50
+ pretix_map-0.1.8.dist-info/RECORD,,
@@ -1 +1 @@
1
- __version__ = "0.1.7"
1
+ __version__ = "0.1.8"
@@ -0,0 +1,15 @@
1
+ from django.db import migrations, models
2
+
3
+ class Migration(migrations.Migration):
4
+
5
+ dependencies = [
6
+ ('pretix_mapplugin', '0003_mapmilestone'),
7
+ ]
8
+
9
+ operations = [
10
+ migrations.AddField(
11
+ model_name='ordergeocodedata',
12
+ name='success_level',
13
+ field=models.CharField(blank=True, help_text='Precision of geocoding (e.g., street, city, zip, failed)', max_length=50, null=True),
14
+ ),
15
+ ]
@@ -21,6 +21,12 @@ class OrderGeocodeData(LoggedModel): # Keep LoggedModel if you want audit logs
21
21
  null=True, # Allow NULL in the database if geocoding fails
22
22
  blank=True # Allow blank in forms/admin
23
23
  )
24
+ success_level = models.CharField(
25
+ max_length=50,
26
+ null=True,
27
+ blank=True,
28
+ help_text="Precision of geocoding (e.g., street, city, zip, failed)"
29
+ )
24
30
 
25
31
  # Change to auto_now to update timestamp on every save (successful or null)
26
32
  last_geocoded_at = models.DateTimeField(
@@ -66,7 +66,6 @@ document.addEventListener('DOMContentLoaded', function () {
66
66
 
67
67
  // --- State ---
68
68
  let map = null, mapCompare = null, allData = [], filteredData = [], displayData = [], milestones = [], currency = "EUR";
69
- let itemAvailData = {};
70
69
  let compareData = [], compareFilteredData = [], compareDisplayData = [];
71
70
  let pinLayer = null, heatmapLayer = null, gridLayer = null, comparisonLayer = null, eventMarkerLayer = null;
72
71
  let pinLayerComp = null, heatmapLayerComp = null, gridLayerComp = null, eventMarkerLayerComp = null;
@@ -75,6 +74,7 @@ document.addEventListener('DOMContentLoaded', function () {
75
74
  let availableItems = new Set(), selectedItems = new Set();
76
75
  let currentDisplayMode = 'map'; // map | list | stats
77
76
  let charts = {};
77
+ let itemAvailData = {};
78
78
 
79
79
  function updateStatus(msg, isErr = false) {
80
80
  if (sel.overlay) {
@@ -160,6 +160,14 @@ document.addEventListener('DOMContentLoaded', function () {
160
160
  minOpacity: 0.4
161
161
  });
162
162
  }
163
+ if (heatmapLayerComp) {
164
+ heatmapLayerComp.setOptions({
165
+ radius: parseInt(sel.radiusIn.value),
166
+ blur: parseInt(sel.blurIn.value),
167
+ maxZoom: parseInt(sel.maxZoomIn.value),
168
+ minOpacity: 0.4
169
+ });
170
+ }
163
171
  };
164
172
  [sel.radiusIn, sel.blurIn, sel.maxZoomIn].forEach(i => i.oninput = updateHeat);
165
173
  sel.heatmapReset.onclick = () => {
@@ -173,6 +181,7 @@ document.addEventListener('DOMContentLoaded', function () {
173
181
  function switchDisplayMode(mode) {
174
182
  currentDisplayMode = mode;
175
183
  sel.map.style.display = (mode === 'map' ? 'block' : 'none');
184
+ sel.mapSplitRoot.style.display = (mode === 'map' ? 'flex' : 'none'); // FIX
176
185
  sel.failedContainer.style.display = (mode === 'list' ? 'block' : 'none');
177
186
  sel.analyticsView.style.display = (mode === 'stats' ? 'block' : 'none');
178
187
 
@@ -181,7 +190,10 @@ document.addEventListener('DOMContentLoaded', function () {
181
190
  sel.listToggle.textContent = (mode === 'list' ? 'Show Map' : 'Failed Orders');
182
191
  sel.statsToggle.textContent = (mode === 'stats' ? 'Show Map' : 'Analytics View');
183
192
 
184
- if (mode === 'map') map.invalidateSize();
193
+ if (mode === 'map') {
194
+ map.invalidateSize();
195
+ if (mapCompare) mapCompare.invalidateSize();
196
+ }
185
197
  }
186
198
 
187
199
  function renderStats(s) {
@@ -249,92 +261,6 @@ document.addEventListener('DOMContentLoaded', function () {
249
261
  updateTimelineDisplay();
250
262
  }
251
263
 
252
- function renderItemAvailability(steps) {
253
- if (!sel.itemAvailList || !steps || steps.length === 0) return;
254
- sel.itemAvailList.innerHTML = '';
255
- sel.itemAvailContainer.style.display = 'block';
256
-
257
- const minTs = steps[0];
258
- const maxTs = steps[steps.length - 1];
259
- const range = maxTs - minTs;
260
-
261
- Object.keys(itemAvailData).forEach(itemName => {
262
- const d = itemAvailData[itemName];
263
- const firstTs = d.first_sale ? new Date(d.first_sale).getTime() : null;
264
- const lastTs = d.last_sale ? new Date(d.last_sale).getTime() : null;
265
- const availFrom = d.available_from ? new Date(d.available_from).getTime() : null;
266
- const availUntil = d.available_until ? new Date(d.available_until).getTime() : null;
267
-
268
- if (!firstTs && !availFrom) return;
269
-
270
- const row = document.createElement('div');
271
- row.style.height = '14px';
272
- row.style.marginBottom = '2px';
273
- row.style.position = 'relative';
274
- row.style.width = '100%';
275
- row.style.display = 'flex';
276
- row.style.alignItems = 'center';
277
-
278
- const label = document.createElement('div');
279
- label.textContent = itemName;
280
- label.style.width = '120px';
281
- label.style.overflow = 'hidden';
282
- label.style.textOverflow = 'ellipsis';
283
- label.style.whiteSpace = 'nowrap';
284
- label.style.marginRight = '5px';
285
- row.appendChild(label);
286
-
287
- const barContainer = document.createElement('div');
288
- barContainer.style.flexGrow = '1';
289
- barContainer.style.height = '8px';
290
- barContainer.style.background = '#f9f9f9';
291
- barContainer.style.position = 'relative';
292
- barContainer.style.borderRadius = '4px';
293
- row.appendChild(barContainer);
294
-
295
- // Planned availability (light grey bar)
296
- if (availFrom || availUntil) {
297
- const start = availFrom ? Math.max(availFrom, minTs) : minTs;
298
- const end = availUntil ? Math.min(availUntil, maxTs) : maxTs;
299
- if (start < end) {
300
- const left = ((start - minTs) / range) * 100;
301
- const width = ((end - start) / range) * 100;
302
- const bar = document.createElement('div');
303
- bar.style.position = 'absolute';
304
- bar.style.left = `${left}%`;
305
- bar.style.width = `${width}%`;
306
- bar.style.height = '100%';
307
- bar.style.background = '#eee';
308
- bar.style.borderRadius = '4px';
309
- bar.title = `${itemName} (Configured Availability)`;
310
- barContainer.appendChild(bar);
311
- }
312
- }
313
-
314
- // Actual sales period (blue bar)
315
- if (firstTs && lastTs) {
316
- const start = Math.max(firstTs, minTs);
317
- const end = Math.min(lastTs, maxTs);
318
- if (start <= end) {
319
- const left = ((start - minTs) / range) * 100;
320
- const width = Math.max(((end - start) / range) * 100, 0.5); // Min width for visibility
321
- const bar = document.createElement('div');
322
- bar.style.position = 'absolute';
323
- bar.style.left = `${left}%`;
324
- bar.style.width = `${width}%`;
325
- bar.style.height = '100%';
326
- bar.style.background = 'rgba(54, 162, 235, 0.6)';
327
- bar.style.borderRadius = '4px';
328
- bar.style.zIndex = '1';
329
- bar.title = `${itemName} (Actual Sales)`;
330
- barContainer.appendChild(bar);
331
- }
332
- }
333
-
334
- sel.itemAvailList.appendChild(row);
335
- });
336
- }
337
-
338
264
  function renderTimelineVolumeChart(steps) {
339
265
  if (!sel.timelineChart) return;
340
266
  const ctx = sel.timelineChart.getContext('2d');
@@ -415,6 +341,87 @@ document.addEventListener('DOMContentLoaded', function () {
415
341
  });
416
342
  }
417
343
 
344
+ function renderItemAvailability(steps) {
345
+ if (!sel.itemAvailList || !steps || steps.length === 0) return;
346
+ sel.itemAvailList.innerHTML = '';
347
+
348
+ const minTs = steps[0];
349
+ const maxTs = steps[steps.length - 1];
350
+ const range = maxTs - minTs;
351
+
352
+ Object.keys(itemAvailData).forEach(itemName => {
353
+ const d = itemAvailData[itemName];
354
+ const firstTs = d.first_sale ? new Date(d.first_sale).getTime() : null;
355
+ const lastTs = d.last_sale ? new Date(d.last_sale).getTime() : null;
356
+ const availFrom = d.available_from ? new Date(d.available_from).getTime() : null;
357
+ const availUntil = d.available_until ? new Date(d.available_until).getTime() : null;
358
+
359
+ if (!firstTs && !availFrom) return;
360
+
361
+ const row = document.createElement('div');
362
+ row.style.marginBottom = '12px';
363
+ row.style.position = 'relative';
364
+ row.style.width = '100%';
365
+
366
+ const label = document.createElement('div');
367
+ label.textContent = itemName;
368
+ label.style.fontSize = '9px';
369
+ label.style.color = '#333';
370
+ label.style.marginBottom = '2px';
371
+ label.style.fontWeight = 'bold';
372
+ row.appendChild(label);
373
+
374
+ const barContainer = document.createElement('div');
375
+ barContainer.style.width = '100%';
376
+ barContainer.style.height = '6px';
377
+ barContainer.style.background = '#f0f0f0';
378
+ barContainer.style.position = 'relative';
379
+ barContainer.style.borderRadius = '3px';
380
+ row.appendChild(barContainer);
381
+
382
+ // Planned availability (light grey bar)
383
+ if (availFrom || availUntil) {
384
+ const start = availFrom ? Math.max(availFrom, minTs) : minTs;
385
+ const end = availUntil ? Math.min(availUntil, maxTs) : maxTs;
386
+ if (start < end) {
387
+ const left = ((start - minTs) / range) * 100;
388
+ const width = ((end - start) / range) * 100;
389
+ const bar = document.createElement('div');
390
+ bar.style.position = 'absolute';
391
+ bar.style.left = `${left}%`;
392
+ bar.style.width = `${width}%`;
393
+ bar.style.height = '100%';
394
+ bar.style.background = '#ddd';
395
+ bar.style.borderRadius = '3px';
396
+ bar.title = `${itemName} (Configured Availability)`;
397
+ barContainer.appendChild(bar);
398
+ }
399
+ }
400
+
401
+ // Actual sales period (blue bar)
402
+ if (firstTs && lastTs) {
403
+ const start = Math.max(firstTs, minTs);
404
+ const end = Math.min(lastTs, maxTs);
405
+ if (start <= end) {
406
+ const left = ((start - minTs) / range) * 100;
407
+ const width = Math.max(((end - start) / range) * 100, 0.5);
408
+ const bar = document.createElement('div');
409
+ bar.style.position = 'absolute';
410
+ bar.style.left = `${left}%`;
411
+ bar.style.width = `${width}%`;
412
+ bar.style.height = '100%';
413
+ bar.style.background = '#36A2EB';
414
+ bar.style.borderRadius = '3px';
415
+ bar.style.zIndex = '1';
416
+ bar.title = `${itemName} (Actual Sales)`;
417
+ barContainer.appendChild(bar);
418
+ }
419
+ }
420
+
421
+ sel.itemAvailList.appendChild(row);
422
+ });
423
+ }
424
+
418
425
  function updateTimelineDisplay() {
419
426
  const steps = sel.timeSlider._steps;
420
427
  if (!steps) return;
@@ -447,13 +454,17 @@ document.addEventListener('DOMContentLoaded', function () {
447
454
 
448
455
  function refreshLayers() {
449
456
  if (!map) return;
457
+
458
+ // Remove old layers from both maps
450
459
  [pinLayer, heatmapLayer, gridLayer].forEach(l => { if (l && map.hasLayer(l)) map.removeLayer(l); });
451
460
  if (mapCompare) {
452
461
  [pinLayerComp, heatmapLayerComp, gridLayerComp, eventMarkerLayerComp].forEach(l => { if (l && mapCompare.hasLayer(l)) mapCompare.removeLayer(l); });
453
462
  }
454
463
  if (comparisonLayer && map.hasLayer(comparisonLayer)) map.removeLayer(comparisonLayer);
455
464
 
465
+ // Check if split mode is needed (Comparison selected AND heatmap/grid view)
456
466
  const isSplitNeeded = currentCompareSlug && (currentView === 'heatmap' || currentView === 'grid');
467
+
457
468
  if (isSplitNeeded) {
458
469
  sel.mapSplitRoot.classList.add('map-split-active');
459
470
  sel.mainLabel.style.display = 'block';
@@ -466,19 +477,25 @@ document.addEventListener('DOMContentLoaded', function () {
466
477
  setTimeout(() => { map.invalidateSize(); }, 50);
467
478
  }
468
479
 
480
+ // Create layers for main map
469
481
  createPinLayer(); createHeatmapLayer(); createGridLayer();
482
+
483
+ // Create layers for comparison map if in split mode
470
484
  if (isSplitNeeded) {
471
485
  createPinLayer(true); createHeatmapLayer(true); createGridLayer(true);
472
486
  } else if (currentCompareSlug && currentView === 'pins') {
473
- const dots = compareData.map(l => l.lat ? L.circleMarker([l.lat, l.lon], { radius: 4, color: '#888', fillOpacity: 0.5 }).bindTooltip("Comparison") : null).filter(l => l);
487
+ // Pins comparison: Dots on the main map
488
+ const dots = compareData.map(l => l.lat ? L.circleMarker([l.lat, l.lon], { radius: 4, color: '#888', fillOpacity: 0.5 }).bindTooltip("Comparison Order") : null).filter(l => l);
474
489
  comparisonLayer = L.layerGroup(dots);
475
490
  }
491
+
476
492
  showCurrentView();
477
493
  }
478
494
 
479
495
  function createPinLayer(isCompare = false) {
480
496
  const data = isCompare ? compareDisplayData : displayData;
481
497
  if (data.length === 0) { if(isCompare) pinLayerComp = null; else pinLayer = null; return; }
498
+
482
499
  const markers = data.map(loc => {
483
500
  if (loc.lat == null) return null;
484
501
  let icon = icons.blue;
@@ -489,6 +506,7 @@ document.addEventListener('DOMContentLoaded', function () {
489
506
  if (loc.order_url) m.on('click', () => window.open(loc.order_url, '_blank'));
490
507
  return m;
491
508
  }).filter(m => m !== null);
509
+
492
510
  const layer = isClustering ? L.markerClusterGroup().addLayers(markers) : L.layerGroup(markers);
493
511
  if (isCompare) pinLayerComp = layer; else pinLayer = layer;
494
512
  }
@@ -496,6 +514,7 @@ document.addEventListener('DOMContentLoaded', function () {
496
514
  function createHeatmapLayer(isCompare = false) {
497
515
  const data = isCompare ? compareDisplayData : displayData;
498
516
  if (data.length === 0) { if(isCompare) heatmapLayerComp = null; else heatmapLayer = null; return; }
517
+
499
518
  let maxRev = Math.max(...data.map(d => d.revenue || 0), 1);
500
519
  const points = data.map(l => [l.lat, l.lon, isWeighted ? (l.revenue || 0) / maxRev : 1.0]);
501
520
  const layer = L.heatLayer(points, {
@@ -510,9 +529,11 @@ document.addEventListener('DOMContentLoaded', function () {
510
529
  function createGridLayer(isCompare = false) {
511
530
  const data = isCompare ? compareDisplayData : displayData;
512
531
  if (data.length === 0) { if(isCompare) gridLayerComp = null; else gridLayer = null; return; }
532
+
513
533
  const rawVal = parseInt(sel.gridSizeSlider.value);
514
534
  const gridSize = 0.4 * Math.pow(rawVal / 100, 2);
515
535
  sel.gridSizeVal.textContent = gridSize.toFixed(3);
536
+
516
537
  const bins = {}; let maxVal = 0;
517
538
  data.forEach(l => {
518
539
  if (l.lat == null) return;
@@ -532,18 +553,24 @@ document.addEventListener('DOMContentLoaded', function () {
532
553
 
533
554
  function showCurrentView() {
534
555
  if (!map) return;
556
+
557
+ // Main map view
535
558
  if (currentView === 'pins' && pinLayer) map.addLayer(pinLayer);
536
559
  else if (currentView === 'heatmap' && heatmapLayer) map.addLayer(heatmapLayer);
537
560
  else if (currentView === 'grid' && gridLayer) map.addLayer(gridLayer);
538
561
 
562
+ // Control panel updates
539
563
  sel.heatmapPanel.style.display = (currentView === 'heatmap' || currentView === 'grid' ? 'block' : 'none');
540
564
  sel.heatmapControls.style.display = (currentView === 'heatmap' ? 'block' : 'none');
541
565
  sel.gridControls.style.display = (currentView === 'grid' ? 'block' : 'none');
542
566
 
543
567
  if (sel.clusterToggle) sel.clusterToggle.style.display = (currentView === 'pins' ? 'inline-block' : 'none');
544
568
  if (eventMarkerLayer) map.addLayer(eventMarkerLayer);
569
+
570
+ // Dot comparison on main map
545
571
  if (comparisonLayer && currentView === 'pins') map.addLayer(comparisonLayer);
546
572
 
573
+ // Comparison map view (Split mode)
547
574
  if (mapCompare && sel.mapSplitRoot.classList.contains('map-split-active')) {
548
575
  if (currentView === 'pins' && pinLayerComp) mapCompare.addLayer(pinLayerComp);
549
576
  else if (currentView === 'heatmap' && heatmapLayerComp) mapCompare.addLayer(heatmapLayerComp);
@@ -570,12 +597,18 @@ document.addEventListener('DOMContentLoaded', function () {
570
597
  sel.compareLabel.textContent = "";
571
598
  refreshLayers(); return;
572
599
  }
600
+
573
601
  const option = sel.compareSelect.options[sel.compareSelect.selectedIndex];
574
602
  sel.compareLabel.textContent = option.text;
603
+
575
604
  const newUrl = sel.map.dataset.dataUrl.replace(/\/event\/([^\/]+)\/([^\/]+)\//, `/event/$1/${slug}/`);
576
605
  fetch(newUrl).then(r => r.json()).then(data => {
577
606
  compareData = (data.locations || []).map(loc => { loc.ts = loc.date ? new Date(loc.date).getTime() : 0; return loc; }).sort((a, b) => a.ts - b.ts);
607
+
608
+ // Handle event marker for comparison map
578
609
  if (data.event_marker) renderEventMarker(data.event_marker, true);
610
+
611
+ // Sync filters for comparison data
579
612
  applyFilters();
580
613
  });
581
614
  }
@@ -593,14 +626,35 @@ document.addEventListener('DOMContentLoaded', function () {
593
626
  function applyFilters() {
594
627
  showCanceled = sel.showCanceledCheck.checked;
595
628
  const filterFn = loc => (loc.items.length === 0 || loc.items.some(i => selectedItems.has(i))) && (showCanceled || loc.status !== 'canceled');
629
+
596
630
  filteredData = allData.filter(filterFn);
597
- if (compareData.length > 0) compareFilteredData = compareData.filter(filterFn);
631
+ if (compareData.length > 0) {
632
+ compareFilteredData = compareData.filter(filterFn);
633
+ }
634
+
598
635
  updateTimelineSlider();
599
636
  }
600
637
 
601
638
  function renderFailedOrders(orders) {
602
639
  const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
603
- sel.failedTbody.innerHTML = orders.map(o => `<tr><td><code>${o.code}</code></td><td>${o.address}</td><td><div class="btn-group"><a href="${o.url}" target="_blank" class="btn btn-default btn-xs">View</a><button class="btn btn-warning btn-xs retry-btn" data-url="${o.retry_url}">Retry</button></div></td></tr>`).join('') || '<tr><td colspan="3">None</td></tr>';
640
+ sel.failedTbody.innerHTML = orders.map(o => {
641
+ let statusBadge = `<span class="label label-danger">Failed</span>`;
642
+ if (o.success_level === 'city') statusBadge = `<span class="label label-warning">Only City</span>`;
643
+ else if (o.success_level === 'zip') statusBadge = `<span class="label label-info">Only Zip</span>`;
644
+
645
+ return `<tr>
646
+ <td><code>${o.code}</code></td>
647
+ <td>${o.address}</td>
648
+ <td>${statusBadge}</td>
649
+ <td>
650
+ <div class="btn-group">
651
+ <a href="${o.url}" target="_blank" class="btn btn-default btn-xs">View</a>
652
+ <button class="btn btn-warning btn-xs retry-btn" data-url="${o.retry_url}">Retry</button>
653
+ </div>
654
+ </td>
655
+ </tr>`;
656
+ }).join('') || '<tr><td colspan="4">None</td></tr>';
657
+
604
658
  sel.failedTbody.querySelectorAll('.retry-btn').forEach(btn => btn.onclick = () => {
605
659
  btn.disabled = true; btn.innerHTML = '...';
606
660
  fetch(btn.dataset.url, { method: 'POST', headers: { 'X-CSRFToken': csrfToken } }).then(r => r.json()).then(d => { if(d.success) { btn.className = 'btn btn-success btn-xs'; btn.innerHTML = '✔'; setTimeout(() => fetchData(sel.map.dataset.dataUrl), 1000); } });
@@ -608,4 +662,4 @@ document.addEventListener('DOMContentLoaded', function () {
608
662
  }
609
663
 
610
664
  init();
611
- });
665
+ });
pretix_mapplugin/tasks.py CHANGED
@@ -106,27 +106,40 @@ def geocode_order_task(self, order_pk: int, organizer_pk: int | None = None, nom
106
106
  # Try candidates in order
107
107
  coordinates = None
108
108
  successful_candidate = None
109
+ success_level = "failed"
109
110
 
110
- for candidate in candidates:
111
+ for i, candidate in enumerate(candidates):
111
112
  logger.debug(f"Attempting to geocode candidate for Order {order.code}: '{candidate}'")
112
113
  coordinates = geocode_address(candidate, nominatim_user_agent=nominatim_user_agent)
113
114
  if coordinates:
114
115
  successful_candidate = candidate
116
+ # Map candidate index to level (0: full, 1: zip+city, 2: zip)
117
+ if i == 0: success_level = "street"
118
+ elif i == 1: success_level = "city"
119
+ else: success_level = "zip"
115
120
  break # Success!
116
121
 
117
122
  with transaction.atomic():
118
123
  if coordinates:
119
124
  latitude, longitude = coordinates
120
125
  obj, created = OrderGeocodeData.objects.update_or_create(
121
- order=order, defaults={'latitude': latitude, 'longitude': longitude}
126
+ order=order, defaults={
127
+ 'latitude': latitude,
128
+ 'longitude': longitude,
129
+ 'success_level': success_level
130
+ }
122
131
  )
123
132
  log_level = logging.INFO if created else logging.DEBUG
124
133
  logger.log(log_level,
125
- f"Saved{' new' if created else ' updated'} geocode data for Order {order.code} using '{successful_candidate}': ({latitude}, {longitude})")
134
+ f"Saved{' new' if created else ' updated'} geocode data for Order {order.code} using '{successful_candidate}' (Level: {success_level}): ({latitude}, {longitude})")
126
135
  else:
127
136
  logger.warning(f"Geocoding failed for Order {order.code} (tried {len(candidates)} candidates). Storing null coordinates.")
128
137
  obj, created = OrderGeocodeData.objects.update_or_create(
129
- order=order, defaults={'latitude': None, 'longitude': None}
138
+ order=order, defaults={
139
+ 'latitude': None,
140
+ 'longitude': None,
141
+ 'success_level': 'failed'
142
+ }
130
143
  )
131
144
  # --- Scope deactivated automatically ---
132
145
 
@@ -110,7 +110,7 @@
110
110
  {% endif %}
111
111
  </div>
112
112
  <table class="table table-hover">
113
- <thead><tr><th>Code</th><th>Address</th><th>Action</th></tr></thead>
113
+ <thead><tr><th>Code</th><th>Address</th><th>Status</th><th>Action</th></tr></thead>
114
114
  <tbody id="failed-orders-tbody"></tbody>
115
115
  </table>
116
116
  </div>
@@ -151,10 +151,26 @@
151
151
  </div>
152
152
  </div>
153
153
 
154
- <div id="timeline-controls" style="margin-top: 10px; padding: 10px; background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd; position: relative;">
155
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;">
154
+ <div id="timeline-controls" style="margin-top: 10px; padding: 15px; background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd; position: relative;">
155
+ <div id="timeline-chart-container" style="position: relative; height: 50px; margin-bottom: 10px; background: white; border: 1px solid #eee; overflow: hidden;">
156
+ <canvas id="timeline-volume-chart" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
157
+ <div id="timeline-milestone-markers" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
158
+ </div>
159
+
160
+ <div style="display: flex; gap: 15px; align-items: center; margin-bottom: 15px;">
161
+ <button id="timeline-play-btn" class="btn btn-sm btn-default" style="flex-shrink: 0;"><i class="fa fa-play"></i> Play</button>
162
+ <div style="flex-grow: 1; position: relative; padding: 0 5px;">
163
+ <input type="range" id="timeline-slider" min="0" max="100" value="100" style="width: 100%; cursor: pointer; position: relative; z-index: 5; margin: 0;">
164
+ </div>
165
+ </div>
166
+
167
+ <div id="timeline-item-availability" style="margin-top: 10px; background: white; border: 1px solid #eee; padding: 10px 0;">
168
+ <div id="item-availability-list" style="position: relative; padding: 0 5px;"></div>
169
+ </div>
170
+
171
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 12px; color: #666;">
156
172
  <div>
157
- <label style="margin-bottom: 0;">{% trans "Timeline:" %} <span id="timeline-date-display" style="font-weight: normal;"></span></label>
173
+ <label style="margin-bottom: 0;">{% trans "Timeline:" %} <span id="timeline-date-display" style="font-weight: bold; color: #333;"></span></label>
158
174
  <span id="timeline-count-display" class="badge"></span>
159
175
  </div>
160
176
  <div>
@@ -166,17 +182,6 @@
166
182
  </select>
167
183
  </div>
168
184
  </div>
169
- <div id="timeline-chart-container" style="position: relative; height: 40px; margin-bottom: 5px; background: white; border: 1px solid #eee; overflow: hidden;">
170
- <canvas id="timeline-volume-chart" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
171
- <div id="timeline-milestone-markers" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></div>
172
- </div>
173
- <div id="timeline-item-availability" style="margin-bottom: 10px; font-size: 10px; max-height: 100px; overflow-y: auto; background: white; border: 1px solid #eee; display: none;">
174
- <div id="item-availability-list" style="padding: 5px; position: relative;"></div>
175
- </div>
176
- <div style="display: flex; gap: 10px; align-items: center;">
177
- <button id="timeline-play-btn" class="btn btn-sm btn-default"><i class="fa fa-play"></i> Play</button>
178
- <input type="range" id="timeline-slider" min="0" max="100" value="100" style="flex-grow: 1; cursor: pointer; position: relative; z-index: 5;">
179
- </div>
180
185
  </div>
181
186
  </div>
182
187
 
@@ -50,4 +50,29 @@
50
50
  </div>
51
51
  </div>
52
52
  </form>
53
+ <script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
54
+ <script type="text/javascript">
55
+ $(function () {
56
+ $(".datepickerfield").each(function () {
57
+ var format = $("body").attr("data-dateformat");
58
+ $(this).datetimepicker({
59
+ format: format,
60
+ locale: $("body").attr("data-datelocale"),
61
+ useCurrent: false,
62
+ showClear: true,
63
+ icons: {
64
+ time: 'fa fa-clock-o',
65
+ date: 'fa fa-calendar',
66
+ up: 'fa fa-chevron-up',
67
+ down: 'fa fa-chevron-down',
68
+ previous: 'fa fa-chevron-left',
69
+ next: 'fa fa-chevron-right',
70
+ today: 'fa fa-dot-circle-o',
71
+ clear: 'fa fa-trash',
72
+ close: 'fa fa-times'
73
+ }
74
+ });
75
+ });
76
+ });
77
+ </script>
53
78
  {% endblock %}
pretix_mapplugin/views.py CHANGED
@@ -220,11 +220,15 @@ class SalesMapDataView(EventSettingsViewMixin, View):
220
220
  tooltip_parts.append(f"<strong>Items:</strong> {order.positions.count()}")
221
221
  tooltip_parts.append(f"<strong>Total:</strong> {order.total} {event.currency}")
222
222
  if dist_km: tooltip_parts.append(f"<strong>Dist:</strong> {dist_km:.1f} km")
223
+ if entry.success_level:
224
+ level_map = {'street': _('Street'), 'city': _('City'), 'zip': _('Zip code')}
225
+ tooltip_parts.append(f"<strong>Precision:</strong> {level_map.get(entry.success_level, entry.success_level)}")
223
226
 
224
227
  locations_data.append({
225
228
  "lat": entry.latitude, "lon": entry.longitude, "tooltip": "<br>".join(tooltip_parts),
226
229
  "order_url": reverse('control:event.order', kwargs={'organizer': organizer.slug, 'event': event.slug, 'code': order.code}),
227
- "items": item_names, "date": iso_date, "dist": dist_km, "revenue": revenue, "status": status
230
+ "items": item_names, "date": iso_date, "dist": dist_km, "revenue": revenue, "status": status,
231
+ "precision": entry.success_level
228
232
  })
229
233
 
230
234
  failed_entries = OrderGeocodeData.objects.filter(order__event=event, latitude__isnull=True).select_related('order', 'order__invoice_address')
@@ -233,7 +237,8 @@ class SalesMapDataView(EventSettingsViewMixin, View):
233
237
  'pk': entry.order.pk, 'code': entry.order.code,
234
238
  'address': get_best_address_string(entry.order) or _("No address"),
235
239
  'url': reverse('control:event.order', kwargs={'organizer': organizer.slug, 'event': event.slug, 'code': entry.order.code}),
236
- 'retry_url': reverse('plugins:pretix_mapplugin:event.settings.salesmap.retry', kwargs={'organizer': organizer.slug, 'event': event.slug, 'order': entry.order.pk})
240
+ 'retry_url': reverse('plugins:pretix_mapplugin:event.settings.salesmap.retry', kwargs={'organizer': organizer.slug, 'event': event.slug, 'order': entry.order.pk}),
241
+ 'success_level': entry.success_level or 'failed'
237
242
  })
238
243
 
239
244
  avg_dist_val = round(sum(distances) / len(distances), 1) if distances else 0
@@ -291,6 +296,10 @@ class SalesMapView(EventSettingsViewMixin, TemplateView):
291
296
  'https://cdn.jsdelivr.net',
292
297
  "'unsafe-eval'" # Needed for some charting libs
293
298
  ],
299
+ 'connect-src': [
300
+ 'https://cdn.jsdelivr.net',
301
+ 'https://*.tile.openstreetmap.org'
302
+ ],
294
303
  'style-src': ["'unsafe-inline'"]
295
304
  }
296
305