Skip to content

Commit 0c86f55

Browse files
authored
Merge pull request #111 from jedie/kml
Support KML track import created by Pentax K-1
2 parents a75d46f + 0b1e9f0 commit 0c86f55

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+30161
-17096
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,12 @@ Because this is a project and not really a reuse-able-app ;)
221221

222222
[comment]: <> (✂✂✂ auto generated history start ✂✂✂)
223223

224-
* [**dev**](https://github.com/jedie/django-for-runners/compare/v0.17.4...main)
224+
* [v0.18.0.dev1](https://github.com/jedie/django-for-runners/compare/v0.17.4...v0.18.0.dev1)
225+
* 2024-08-01 - Replace metaweather.com with open-meteo.com
226+
* 2024-08-01 - Update test_add_gpx()
227+
* 2024-08-01 - Update Leaflet to v1.9.4 and fix styles
228+
* 2024-08-01 - Support KML track import created by Pentax K-1
229+
* 2024-08-01 - Catch metaweather.com error
225230
* 2024-07-31 - Project updates
226231
* 2024-07-31 - Update requirements
227232
* 2024-01-18 - +typeguard +manageprojects updates

dev_scripts/download_leaflet.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ cd ../for_runners/static/leaflet
1212
# download link from:
1313
# https://leafletjs.com/download.html
1414
#
15-
wget http://cdn.leafletjs.com/leaflet/v1.3.1/leaflet.zip
15+
wget http://cdn.leafletjs.com/leaflet/v1.9.4/leaflet.zip
1616

1717
unzip -u leaflet.zip
1818

for_runners/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
Store your GPX tracks of your running (or other sports activity) in django.
44
"""
55

6-
__version__ = '0.17.4'
6+
__version__ = '0.18.0.dev1'
77
__author__ = 'Jens Diemer <[email protected]>'

for_runners/app_settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@
3838
# All cash values are stored as decimal field without any currency information
3939
# This symbol will be just added ;)
4040
FOR_RUNNERS_CURRENCY_SYMBOL = "€"
41+
42+
# Use requests cache:
43+
REQUEST_CACHE = True

for_runners/geo.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from django.core.cache import cache
1111
from geopy.geocoders import Nominatim
1212

13+
from for_runners.request_session import USER_AGENT
14+
1315

1416
log = logging.getLogger(__name__)
1517

@@ -70,7 +72,7 @@ def reverse_geo(lat, lon) -> Optional[Address]:
7072
log.debug('reverse geo from cache')
7173
full_address, raw_address = address
7274
else:
73-
geolocator = Nominatim(user_agent="django-for-runners")
75+
geolocator = Nominatim(user_agent=USER_AGENT)
7476

7577
# https://nominatim.org/release-docs/develop/api/Reverse/
7678
location = geolocator.reverse(
@@ -89,7 +91,7 @@ def reverse_geo(lat, lon) -> Optional[Address]:
8991
cache.set(cache_key, address, timeout=None) # cache forever
9092

9193
short_address = construct_short_address(address=raw_address)
92-
log.info(f'short_address={short_address}')
94+
log.info(f'lat={lat2} lon={lon2} {short_address=}')
9395

9496
return Address(short_address, full_address)
9597

for_runners/gpx.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import gpxpy
1212
from gpxpy.geo import distance as geo_distance
13+
from gpxpy.gpx import GPX
1314

1415
# https://github.com/jedie/django-for-runners
1516
from for_runners.exceptions import GpxDataError
@@ -20,12 +21,18 @@
2021
)
2122

2223

23-
def get_identifier(gpxpy_instance):
24+
def get_identifier(gpxpy_instance: GPX) -> Identifier:
2425
"""
2526
:return: Identifier named tuple
2627
"""
2728
time_bounds = gpxpy_instance.get_time_bounds()
2829

30+
if not time_bounds.start_time:
31+
raise GpxDataError("No start time found!")
32+
33+
if not time_bounds.end_time:
34+
raise GpxDataError("No end time found!")
35+
2936
try:
3037
first_track = gpxpy_instance.tracks[0]
3138
except IndexError:
@@ -106,21 +113,10 @@ def get_prefix_id(self):
106113
return result
107114

108115

109-
def parse_gpx(content):
110-
# if 'creator="Garmin Connect"' in content:
111-
# work-a-round until
112-
# https://github.com/tkrajina/gpxpy/issues/115#issuecomment-392798245 fixed
113-
# return garmin2gpxpy(content)
114-
115-
return gpxpy.parse(content)
116-
117-
118-
def parse_gpx_file(filepath):
116+
def parse_gpx_file(filepath) -> GPX:
119117
assert filepath.is_file(), f"File not found: '{filepath}'"
120-
with filepath.open("r") as f:
121-
content = f.read()
122-
123-
return parse_gpx(content)
118+
content = filepath.read_text()
119+
return gpxpy.parse(content)
124120

125121

126122
def iter_points(gpxpy_instance):

for_runners/gpx_tools/__init__.py

Whitespace-only changes.

for_runners/gpx_tools/kml.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import dataclasses
2+
import datetime
3+
import logging
4+
import re
5+
6+
import gpxpy.gpx
7+
from gpxpy.gpx import GPX, GPXTrackPoint
8+
from lxml import etree
9+
from lxml.etree import Element
10+
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
def get_single_text(node: Element, xpath: str, namespaces) -> str | None:
16+
if elements := node.xpath(xpath, namespaces=namespaces):
17+
assert len(elements) == 1, f'Expected 1 element, got {len(elements)}'
18+
return elements[0].text
19+
20+
21+
@dataclasses.dataclass
22+
class Coordinates:
23+
longitude: float
24+
latitude: float
25+
altitude: float
26+
27+
28+
def parse_coordinates(coordinates: str) -> Coordinates | None:
29+
"""
30+
>>> parse_coordinates('2.444563,51.052540,8.0')
31+
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0)
32+
"""
33+
match = re.match(r'\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*', coordinates)
34+
if match:
35+
lon, lat, alt = map(float, match.groups())
36+
return Coordinates(longitude=lon, latitude=lat, altitude=alt)
37+
38+
39+
def get_coordinates(placemark: Element, namespaces) -> Coordinates | None:
40+
if coordinates := get_single_text(placemark, './/kml:coordinates', namespaces=namespaces):
41+
return parse_coordinates(coordinates)
42+
43+
44+
def parse_datetime(datetime_str) -> datetime.datetime | None:
45+
dt_part, tz_part = datetime_str.rsplit(' ', 1)
46+
47+
try:
48+
dt = datetime.datetime.strptime(dt_part, '%Y/%m/%d %H:%M:%S')
49+
except ValueError:
50+
log.exception('Failed to parse datetime string %r', datetime_str)
51+
return None
52+
53+
if not tz_part.startswith('UTC'):
54+
log.warning('Timezone not in UTC format: %r', tz_part)
55+
return None
56+
57+
sign = 1 if tz_part[3] == '+' else -1
58+
try:
59+
hours_offset = int(tz_part[4:6])
60+
minutes_offset = int(tz_part[7:9])
61+
except ValueError:
62+
log.exception('Failed to parse timezone offset %r', tz_part)
63+
return None
64+
65+
tz_offset = datetime.timedelta(hours=sign * hours_offset, minutes=sign * minutes_offset)
66+
dt = dt.replace(tzinfo=datetime.timezone(tz_offset))
67+
return dt
68+
69+
70+
def datetime_from_description(placemark: Element, namespaces):
71+
if description := get_single_text(placemark, 'kml:description', namespaces=namespaces):
72+
dt_str = description.partition('<br>')[0]
73+
return parse_datetime(dt_str)
74+
75+
76+
def kml2gpx(kml_file) -> GPX:
77+
"""
78+
Convert a KML file to a GPX object.
79+
Notes:
80+
* Only tested with KML files from a Pentax K-1 camera!
81+
"""
82+
gpx = GPX()
83+
track = gpxpy.gpx.GPXTrack()
84+
gpx.tracks.append(track)
85+
86+
segment = gpxpy.gpx.GPXTrackSegment()
87+
track.segments.append(segment)
88+
89+
root = etree.parse(kml_file).getroot()
90+
namespaces = {'kml': root.nsmap.get(None)}
91+
92+
for placemark in root.xpath('//kml:Placemark', namespaces=namespaces):
93+
dt = datetime_from_description(placemark, namespaces=namespaces)
94+
if not dt:
95+
continue
96+
97+
coordinates = get_coordinates(placemark, namespaces=namespaces)
98+
if not coordinates:
99+
continue
100+
101+
point = GPXTrackPoint(
102+
latitude=coordinates.latitude,
103+
longitude=coordinates.longitude,
104+
elevation=coordinates.altitude,
105+
time=dt,
106+
)
107+
segment.points.append(point)
108+
109+
return gpx

for_runners/gpx_tools/tests/__init__.py

Whitespace-only changes.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from datetime import datetime, timedelta, timezone
2+
3+
from bx_py_utils.test_utils.datetime import parse_dt
4+
from django.test import SimpleTestCase
5+
from gpxpy.gpx import GPX
6+
7+
from for_runners.gpx_tools.kml import Coordinates, kml2gpx, parse_coordinates, parse_datetime
8+
from for_runners.tests.fixture_files import get_fixture_path
9+
10+
11+
class KmlTestCase(SimpleTestCase):
12+
13+
def test_parse_coordinates(self):
14+
self.assertEqual(
15+
parse_coordinates('2.444563,51.052540,8.0'),
16+
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0),
17+
)
18+
self.assertEqual(
19+
parse_coordinates('-2.444563,-51.052540,-8.0'),
20+
Coordinates(longitude=-2.444563, latitude=-51.05254, altitude=-8.0),
21+
)
22+
self.assertEqual(
23+
parse_coordinates(' 2.444563 , 51.052540 , 8.0 '),
24+
Coordinates(longitude=2.444563, latitude=51.05254, altitude=8.0),
25+
)
26+
self.assertIsNone(parse_coordinates('2.444563,51.052540'))
27+
self.assertIsNone(parse_coordinates('abc,def,ghi'))
28+
29+
def test_parse_datetime(self):
30+
self.assertEqual(
31+
parse_datetime('2024/07/21 14:30:24 UTC+01:00'),
32+
datetime(
33+
2024,
34+
7,
35+
21,
36+
14,
37+
30,
38+
24,
39+
tzinfo=timezone(timedelta(hours=1)),
40+
),
41+
)
42+
self.assertEqual(
43+
parse_datetime('2024/07/21 14:30:24 UTC-05:30'),
44+
datetime(
45+
2024,
46+
7,
47+
21,
48+
14,
49+
30,
50+
24,
51+
tzinfo=timezone(timedelta(hours=-5, minutes=-30)),
52+
),
53+
)
54+
55+
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm:
56+
self.assertIsNone(parse_datetime('2024/07/21 14:30:24'))
57+
self.assertIn('Failed to parse datetime string', '\n'.join(cm.output))
58+
59+
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm:
60+
self.assertIsNone(parse_datetime('21/07/2024 14:30:24 UTC+01:00'))
61+
self.assertIn('Failed to parse datetime string', '\n'.join(cm.output))
62+
63+
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING') as cm:
64+
self.assertIsNone(parse_datetime('2024/07/21 14:30:24 UTC+1:00'))
65+
self.assertIn('Failed to parse timezone offset', '\n'.join(cm.output))
66+
67+
def test_kml2gpx(self):
68+
fixture_path = get_fixture_path('PentaxK1.KML')
69+
70+
with self.assertLogs('for_runners.gpx_tools.kml', 'WARNING'):
71+
gpx = kml2gpx(fixture_path)
72+
self.assertIsInstance(gpx, GPX)
73+
74+
first_point = gpx.tracks[0].segments[0].points[0]
75+
self.assertEqual(first_point.latitude, 51.052540)
76+
self.assertEqual(first_point.longitude, 2.444563)
77+
self.assertEqual(first_point.elevation, 8.0)
78+
self.assertEqual(first_point.time, parse_dt('2024-07-21T14:30:24+01:00'))
79+
80+
last_point = gpx.tracks[0].segments[0].points[-1]
81+
self.assertEqual(last_point.latitude, 50.944859)
82+
self.assertEqual(last_point.longitude, 1.847900)
83+
self.assertEqual(last_point.elevation, 14.0)
84+
self.assertEqual(last_point.time, parse_dt('2024-07-21T21:28:31+01:00'))

for_runners/management/commands/import_gpx.py renamed to for_runners/management/commands/import_tracks.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717

1818
class Command(BaseCommand):
19-
help = "Import GPS files (*.gpx)"
19+
help = "Import GPS track files (*.gpx|*.kml) for a user"
2020

2121
def add_arguments(self, parser):
2222
parser.add_argument(
@@ -27,7 +27,7 @@ def add_arguments(self, parser):
2727
required=True,
2828
help="The user to assign to the imported files",
2929
)
30-
parser.add_argument("path", help="Path to *.gpx files")
30+
parser.add_argument("path", help="Path to *.gpx/*.kml files")
3131

3232
def handle(self, *args, **options):
3333
username = options.get("username")
@@ -44,15 +44,20 @@ def handle(self, *args, **options):
4444
path = Path(options.get("path"))
4545
path = path.expanduser()
4646
path = path.resolve()
47+
48+
if path.is_file():
49+
self.stderr.write(f"ERROR: Given path '{path}' is a file, but must be a directory that conains the files!")
50+
sys.exit(4)
51+
4752
if not path.is_dir():
4853
self.stderr.write(f"ERROR: Given path '{path}' is not a existing directory!")
49-
sys.exit(4)
54+
sys.exit(5)
5055

5156
self.stdout.write(f"Read directory: {path}")
5257
self.stdout.write("\n")
5358

5459
new_tracks = 0
55-
for no, instance in enumerate(add_from_files(gpx_files_file_path=path, user=user), 1):
60+
for no, instance in enumerate(add_from_files(tracks_path=path, user=user), 1):
5661
self.stdout.write(self.style.SUCCESS("%i - Add new track: %s" % (no, instance)))
5762
new_tracks += 1
5863

for_runners/managers/gpx.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
from django.db import models
99

10+
from for_runners.gpx import Identifier
11+
1012

1113
log = logging.getLogger(__name__)
1214

1315

1416
class GpxModelQuerySet(models.QuerySet):
15-
def get_by_identifier(self, identifier):
17+
def get_by_identifier(self, identifier: Identifier):
1618
"""
1719
:param identifier: 'Identifier' namedtuple created here: for_runners.gpx.get_identifier
1820
"""

0 commit comments

Comments
 (0)