geo-activity-playground 0.42.0__py3-none-any.whl → 0.43.1__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.
Files changed (32) hide show
  1. geo_activity_playground/alembic/script.py.mako +0 -6
  2. geo_activity_playground/alembic/versions/da2cba03b71d_add_photos.py +40 -0
  3. geo_activity_playground/alembic/versions/script.py.mako +6 -0
  4. geo_activity_playground/core/activities.py +3 -1
  5. geo_activity_playground/core/datamodel.py +48 -22
  6. geo_activity_playground/core/enrichment.py +4 -2
  7. geo_activity_playground/core/meta_search.py +78 -34
  8. geo_activity_playground/core/missing_values.py +4 -3
  9. geo_activity_playground/core/paths.py +2 -0
  10. geo_activity_playground/core/test_missing_values.py +5 -0
  11. geo_activity_playground/webui/app.py +38 -13
  12. geo_activity_playground/webui/blueprints/activity_blueprint.py +25 -15
  13. geo_activity_playground/webui/blueprints/entry_views.py +4 -1
  14. geo_activity_playground/webui/blueprints/photo_blueprint.py +198 -0
  15. geo_activity_playground/webui/blueprints/upload_blueprint.py +11 -0
  16. geo_activity_playground/webui/search_util.py +23 -7
  17. geo_activity_playground/webui/templates/activity/show.html.j2 +46 -11
  18. geo_activity_playground/webui/templates/eddington/distance.html.j2 +1 -2
  19. geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +1 -2
  20. geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +18 -15
  21. geo_activity_playground/webui/templates/heatmap/index.html.j2 +1 -2
  22. geo_activity_playground/webui/templates/page.html.j2 +8 -0
  23. geo_activity_playground/webui/templates/photo/map.html.j2 +45 -0
  24. geo_activity_playground/webui/templates/photo/new.html.j2 +13 -0
  25. geo_activity_playground/webui/templates/search/index.html.j2 +6 -3
  26. geo_activity_playground/webui/templates/search_form.html.j2 +47 -22
  27. geo_activity_playground/webui/templates/summary/index.html.j2 +12 -10
  28. {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/METADATA +2 -1
  29. {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/RECORD +32 -28
  30. {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/LICENSE +0 -0
  31. {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/WHEEL +0 -0
  32. {geo_activity_playground-0.42.0.dist-info → geo_activity_playground-0.43.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,198 @@
1
+ import datetime
2
+ import pathlib
3
+ import uuid
4
+
5
+ import dateutil.parser
6
+ import exifread
7
+ import geojson
8
+ import sqlalchemy
9
+ from flask import Blueprint
10
+ from flask import redirect
11
+ from flask import render_template
12
+ from flask import request
13
+ from flask import Response
14
+ from flask import url_for
15
+ from PIL import Image
16
+ from PIL import ImageOps
17
+
18
+ from ...core.config import ConfigAccessor
19
+ from ...core.datamodel import Activity
20
+ from ...core.datamodel import DB
21
+ from ...core.datamodel import Photo
22
+ from ...core.paths import PHOTOS_DIR
23
+ from ..authenticator import Authenticator
24
+ from ..authenticator import needs_authentication
25
+ from ..flasher import Flasher
26
+ from ..flasher import FlashTypes
27
+
28
+
29
+ def ratio_to_decimal(numbers: list[exifread.utils.Ratio]) -> float:
30
+ deg, min, sec = numbers.values
31
+ return deg.decimal() + min.decimal() / 60 + sec.decimal() / 3600
32
+
33
+
34
+ def get_metadata_from_image(path: pathlib.Path) -> dict:
35
+ with open(path, "rb") as f:
36
+ tags = exifread.process_file(f)
37
+ metadata = {}
38
+ try:
39
+ metadata["latitude"] = ratio_to_decimal(tags["GPS GPSLatitude"])
40
+ metadata["longitude"] = ratio_to_decimal(tags["GPS GPSLongitude"])
41
+ except KeyError:
42
+ pass
43
+ try:
44
+ metadata["time"] = datetime.datetime.strptime(
45
+ str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S"
46
+ )
47
+ except KeyError:
48
+ pass
49
+
50
+ return metadata
51
+
52
+
53
+ def make_photo_blueprint(
54
+ config_accessor: ConfigAccessor, authenticator: Authenticator, flasher: Flasher
55
+ ) -> Blueprint:
56
+ blueprint = Blueprint("photo", __name__, template_folder="templates")
57
+
58
+ @blueprint.route("/get/<int:id>/<int:size>.webp")
59
+ def get(id: int, size: int) -> Response:
60
+ assert size < 5000
61
+ photo = DB.session.get_one(Photo, id)
62
+
63
+ original_path = PHOTOS_DIR() / "original" / photo.path
64
+ small_path = PHOTOS_DIR() / f"size-{size}" / photo.path.with_suffix(".webp")
65
+
66
+ if not small_path.exists():
67
+ with Image.open(original_path) as im:
68
+ target_size = (size, size)
69
+ im = ImageOps.contain(im, target_size)
70
+ small_path.parent.mkdir(exist_ok=True)
71
+ im.save(small_path)
72
+
73
+ with open(small_path, "rb") as f:
74
+ return Response(f.read(), mimetype="image/webp")
75
+
76
+ @blueprint.route("/map")
77
+ def map() -> str:
78
+ return render_template("photo/map.html.j2")
79
+
80
+ @blueprint.route("/map-for-all/photos.geojson")
81
+ def map_for_all() -> Response:
82
+ photos = DB.session.scalars(sqlalchemy.select(Photo)).all()
83
+ fc = geojson.FeatureCollection(
84
+ features=[
85
+ geojson.Feature(
86
+ geometry=geojson.Point((photo.longitude, photo.latitude)),
87
+ properties={
88
+ "photo_id": photo.id,
89
+ "url_marker": url_for(".get", id=photo.id, size=128),
90
+ "url_popup": url_for(".get", id=photo.id, size=512),
91
+ "url_full": url_for(".get", id=photo.id, size=4096),
92
+ },
93
+ )
94
+ for photo in photos
95
+ ]
96
+ )
97
+ return Response(
98
+ geojson.dumps(fc, sort_keys=True, indent=2, ensure_ascii=False),
99
+ mimetype="application/json",
100
+ )
101
+
102
+ @blueprint.route("/map-for-activity/<int:activity_id>/photos.geojson")
103
+ def map_for_activity(activity_id: int) -> Response:
104
+ activity = DB.session.get_one(Activity, activity_id)
105
+ fc = geojson.FeatureCollection(
106
+ features=[
107
+ geojson.Feature(
108
+ geometry=geojson.Point((photo.longitude, photo.latitude)),
109
+ properties={
110
+ "photo_id": photo.id,
111
+ "url_marker": url_for(".get", id=photo.id, size=128),
112
+ "url_popup": url_for(".get", id=photo.id, size=512),
113
+ "url_full": url_for(".get", id=photo.id, size=4096),
114
+ },
115
+ )
116
+ for photo in activity.photos
117
+ ]
118
+ )
119
+ return Response(
120
+ geojson.dumps(fc, sort_keys=True, indent=2, ensure_ascii=False),
121
+ mimetype="application/json",
122
+ )
123
+
124
+ @blueprint.route("/new", methods=["GET", "POST"])
125
+ @needs_authentication(authenticator)
126
+ def new() -> Response:
127
+ if request.method == "POST":
128
+ # check if the post request has the file part
129
+ if "file" not in request.files:
130
+ flasher.flash_message(
131
+ "No file could be found. Did you select a file?", FlashTypes.WARNING
132
+ )
133
+ return redirect(url_for(".new"))
134
+
135
+ file = request.files["file"]
136
+ # If the user does not select a file, the browser submits an
137
+ # empty file without a filename.
138
+ if file.filename == "":
139
+ flasher.flash_message("No selected file.", FlashTypes.WARNING)
140
+ return redirect(url_for(".new"))
141
+ if not file:
142
+ flasher.flash_message("Empty file uploaded.", FlashTypes.WARNING)
143
+ return redirect(url_for(".new"))
144
+
145
+ filename = str(uuid.uuid4()) + pathlib.Path(file.filename).suffix
146
+ path = PHOTOS_DIR() / "original" / filename
147
+ path.parent.mkdir(exist_ok=True)
148
+ file.save(path)
149
+ metadata = get_metadata_from_image(path)
150
+
151
+ if "time" not in metadata:
152
+ flasher.flash_message(
153
+ "Your image doesn't have the EXIF attribute 'EXIF DateTimeOriginal' and hence cannot be dated.",
154
+ FlashTypes.DANGER,
155
+ )
156
+ return redirect(url_for(".new"))
157
+ time: datetime.datetime = metadata["time"]
158
+
159
+ activity = DB.session.scalar(
160
+ sqlalchemy.select(Activity)
161
+ .where(
162
+ Activity.start.is_not(None),
163
+ Activity.elapsed_time.is_not(None),
164
+ Activity.start <= time,
165
+ )
166
+ .order_by(Activity.start.desc())
167
+ .limit(1)
168
+ )
169
+ if activity is None or activity.start + activity.elapsed_time < time:
170
+ flasher.flash_message(
171
+ f"Your image is from {time} but no activity could be found. Please first upload an activity or fix the time in the photo",
172
+ FlashTypes.DANGER,
173
+ )
174
+ print(activity)
175
+
176
+ if "latitude" not in metadata:
177
+ time_series = activity.time_series
178
+ print(time_series)
179
+ row = time_series.loc[time_series["time"] >= time].iloc[0]
180
+ metadata["latitude"] = row["latitude"]
181
+ metadata["longitude"] = row["longitude"]
182
+
183
+ photo = Photo(
184
+ filename=filename,
185
+ time=time,
186
+ latitude=metadata["latitude"],
187
+ longitude=metadata["longitude"],
188
+ activity=activity,
189
+ )
190
+
191
+ DB.session.add(photo)
192
+ DB.session.commit()
193
+
194
+ return redirect(f"/activity/{activity.id}")
195
+ else:
196
+ return render_template("photo/new.html.j2")
197
+
198
+ return blueprint
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import pathlib
3
3
 
4
+ import sqlalchemy
4
5
  from flask import Blueprint
5
6
  from flask import flash
6
7
  from flask import redirect
@@ -10,7 +11,10 @@ from flask import url_for
10
11
 
11
12
  from ...core.activities import ActivityRepository
12
13
  from ...core.config import Config
14
+ from ...core.datamodel import DB
15
+ from ...core.datamodel import Kind
13
16
  from ...core.enrichment import populate_database_from_extracted
17
+ from ...core.tasks import work_tracker_path
14
18
  from ...explorer.tile_visits import compute_tile_evolution
15
19
  from ...explorer.tile_visits import compute_tile_visits_new
16
20
  from ...explorer.tile_visits import TileVisitAccessor
@@ -114,6 +118,13 @@ def scan_for_activities(
114
118
  populate_database_from_extracted(config)
115
119
 
116
120
  if len(repository) > 0:
121
+ kinds = DB.session.scalars(sqlalchemy.select(Kind)).all()
122
+ if all(kind.consider_for_achievements == False for kind in kinds):
123
+ for kind in kinds:
124
+ kind.consider_for_achievements = True
125
+ DB.session.commit()
126
+ tile_visit_accessor.reset()
127
+ work_tracker_path("tile-state").unlink()
117
128
  compute_tile_visits_new(repository, tile_visit_accessor)
118
129
  compute_tile_evolution(tile_visit_accessor.tile_state, config)
119
130
  tile_visit_accessor.save()
@@ -1,3 +1,5 @@
1
+ from typing import Optional
2
+
1
3
  from werkzeug.datastructures import MultiDict
2
4
 
3
5
  from ..core.config import ConfigAccessor
@@ -7,13 +9,20 @@ from .authenticator import Authenticator
7
9
 
8
10
 
9
11
  def search_query_from_form(args: MultiDict) -> SearchQuery:
10
- query = SearchQuery(
11
- equipment=args.getlist("equipment"),
12
- kind=args.getlist("kind"),
13
- name=args.get("name", None),
14
- name_case_sensitive=_parse_bool(args.get("name_case_sensitive", "false")),
15
- start_begin=_parse_date_or_none(args.get("start_begin", None)),
16
- start_end=_parse_date_or_none(args.get("start_end", None)),
12
+ query = SearchQuery.from_primitives(
13
+ {
14
+ "equipment": map(int, args.getlist("equipment")),
15
+ "kind": map(int, args.getlist("kind")),
16
+ "tag": map(int, args.getlist("tag")),
17
+ "name": args.get("name", None),
18
+ "name_case_sensitive": _parse_bool(
19
+ args.get("name_case_sensitive", "false")
20
+ ),
21
+ "start_begin": args.get("start_begin", None),
22
+ "start_end": args.get("start_end", None),
23
+ "distance_km_min": _optional_float(args.get("distance_km_min", None)),
24
+ "distance_km_max": _optional_float(args.get("distance_km_max", None)),
25
+ }
17
26
  )
18
27
 
19
28
  return query
@@ -23,6 +32,13 @@ def _parse_bool(s: str) -> bool:
23
32
  return s == "true"
24
33
 
25
34
 
35
+ def _optional_float(s: str) -> Optional[float]:
36
+ if s:
37
+ return float(s)
38
+ else:
39
+ return None
40
+
41
+
26
42
  class SearchQueryHistory:
27
43
  def __init__(
28
44
  self, config_accessor: ConfigAccessor, authenticator: Authenticator
@@ -10,14 +10,16 @@
10
10
  <div class="row mb-3">
11
11
  <div class="col-sm-12 col-md-4">
12
12
  <dl>
13
- <dt>Name</dt>
14
- <dd>{{ activity.name }}</dd>
15
-
16
13
  {% if activity.kind %}
17
14
  <dt>Kind</dt>
18
15
  <dd>{{ activity.kind.name }}</dd>
19
16
  {% endif %}
20
17
 
18
+ {% if activity.equipment %}
19
+ <dt>Equipment</dt>
20
+ <dd>{{ activity.equipment.name }}</dd>
21
+ {% endif %}
22
+
21
23
  {% if activity.tags %}
22
24
  <dt>Tags</dt>
23
25
  <dd>
@@ -61,11 +63,6 @@
61
63
  <dd>{{ activity.elevation_gain|round(0)|int }} m</dd>
62
64
  {% endif %}
63
65
 
64
- {% if activity.equipment %}
65
- <dt>Equipment</dt>
66
- <dd>{{ activity.equipment.name }}</dd>
67
- {% endif %}
68
-
69
66
  {% if new_tiles[14] %}
70
67
  <dt>New Explorer Tiles</dt>
71
68
  <dd>{{ new_tiles[14] }}</dd>
@@ -97,9 +94,33 @@
97
94
  }).addTo(map);
98
95
 
99
96
  let geojson = L.geoJSON({{ color_line_geojson| safe }}, {
100
- style: function (feature) { return { color: feature.properties.color } }
97
+ style: function (feature) { return { color: feature.properties.color ? feature.properties.color : 'red' } }
101
98
  }).addTo(map)
102
99
  map.fitBounds(geojson.getBounds());
100
+
101
+
102
+
103
+ fetch("{{ url_for('photo.map_for_activity', activity_id = activity.id) }}")
104
+ .then(function (response) {
105
+ return response.json();
106
+ })
107
+ .then(function (data) {
108
+ L.geoJSON(data, {
109
+ pointToLayer: function (feature, latlng) {
110
+ return L.marker(latlng, {
111
+ icon: new L.Icon({
112
+ iconSize: [32, 32],
113
+ iconAnchor: [16, 16],
114
+ popupAnchor: [16, 0],
115
+ iconUrl: feature.properties.url_marker,
116
+ })
117
+ });
118
+ },
119
+ onEachFeature: function (feature, layer) {
120
+ layer.bindPopup(`<a href="${feature.properties.url_full}" target="_blank"><img src="${feature.properties.url_popup}" /></a>`)
121
+ }
122
+ }).addTo(map);
123
+ });
103
124
  </script>
104
125
 
105
126
 
@@ -113,6 +134,7 @@
113
134
  }
114
135
  </style>
115
136
 
137
+ {% if line_color_bar %}
116
138
  <div>
117
139
  {% for value, color in line_color_bar.colors %}
118
140
  <span class="colorbar" style="width: 15px; background-color: {{ color }}">{{ value }}</span>
@@ -132,11 +154,23 @@
132
154
  </select>
133
155
  </form>
134
156
  </div>
157
+ {% endif %}
135
158
  </div>
136
159
  </div>
137
160
 
138
-
161
+ {% if activity.photos %}
162
+ <h2 class="mb-3">Photos</h2>
139
163
  <div class="row mb-3">
164
+ {% for photo in activity.photos %}
165
+ <div class="col-md-3">
166
+ <img src="{{ url_for('photo.get', id=photo.id, size=512) }}" width="100%" />
167
+ </div>
168
+ {% endfor %}
169
+ </div>
170
+ {% endif %}
171
+
172
+
173
+ <div class=" row mb-3">
140
174
  <div class="col">
141
175
  <h2>Distance & speed</h2>
142
176
  </div>
@@ -212,7 +246,8 @@
212
246
 
213
247
  {% if new_tiles_geojson %}
214
248
  <h2>New explorer tiles</h2>
215
- <p>With this activity you have explored new explorer tiles. The following maps show the new tiles on the respective zoom
249
+ <p>With this activity you have explored new explorer tiles. The following maps show the new tiles on the respective
250
+ zoom
216
251
  levels.</p>
217
252
  <script>
218
253
  function add_map(id, geojson) {
@@ -1,11 +1,10 @@
1
1
  {% extends "page.html.j2" %}
2
- {% from "search_form.html.j2" import search_form %}
3
2
 
4
3
  {% block container %}
5
4
  <h1 class="mb-3">Eddington Number</h1>
6
5
 
7
6
  <div class="mb-3">
8
- {{ search_form(query, equipments_avail, kinds_avail, search_query_favorites, search_query_last, request_url) }}
7
+ {% include "search_form.html.j2" %}
9
8
  </div>
10
9
 
11
10
  <div class="row mb-3">
@@ -1,11 +1,10 @@
1
1
  {% extends "page.html.j2" %}
2
- {% from "search_form.html.j2" import search_form %}
3
2
 
4
3
  {% block container %}
5
4
  <h1 class="mb-3">Eddington Number for Elevation Gain</h1>
6
5
 
7
6
  <div class="mb-3">
8
- {{ search_form(query, equipments_avail, kinds_avail, search_query_favorites, search_query_last, request_url) }}
7
+ {% include "search_form.html.j2" %}
9
8
  </div>
10
9
 
11
10
  <div class="row mb-3">
@@ -1,11 +1,10 @@
1
1
  {% extends "page.html.j2" %}
2
- {% from "search_form.html.j2" import search_form %}
3
2
 
4
3
  {% block container %}
5
4
  <h1 class="mb-3">Eddington Number for Elevation Gain</h1>
6
5
 
7
6
  <div class="mb-3">
8
- {{ search_form(query, equipments_avail, kinds_avail, search_query_favorites, search_query_last, request_url) }}
7
+ {% include "search_form.html.j2" %}
9
8
  </div>
10
9
 
11
10
  <div class="row mb-3">
@@ -13,20 +12,21 @@
13
12
  <form method="GET">
14
13
  <div class="mb-3">
15
14
  <label class="form-label">Divisor</label>
16
- <select class="form-select" aria-label="Divisor" name="elevation_eddington_divisor" onchange="this.form.submit()">
15
+ <select class="form-select" aria-label="Divisor" name="elevation_eddington_divisor"
16
+ onchange="this.form.submit()">
17
17
  {% for dv in divisor_values_avail %}
18
18
  <option {% if dv==elevation_eddington_divisor %} selected {% endif %}>{{ dv }}</option>
19
19
  {% endfor %}
20
20
  {% for key, value in query.items() %}
21
- {% if value and key != "active" %}
22
- {% if value is sequence %}
23
- {% for v in value %}
24
- <input type="hidden" name="{{ key }}" value="{{ v }}"/>
25
- {% endfor %}
26
- {% else %}
27
- <input type="hidden" name="{{ key }}" value="{{ value }}"/>
28
- {% endif %}
29
- {% endif %}
21
+ {% if value and key != "active" %}
22
+ {% if value is sequence %}
23
+ {% for v in value %}
24
+ <input type="hidden" name="{{ key }}" value="{{ v }}" />
25
+ {% endfor %}
26
+ {% else %}
27
+ <input type="hidden" name="{{ key }}" value="{{ value }}" />
28
+ {% endif %}
29
+ {% endif %}
30
30
  {% endfor %}
31
31
  </select>
32
32
  </div>
@@ -37,9 +37,11 @@
37
37
  <p>Your Eddington number with divisor {{ elevation_eddington_divisor }} is <b>{{ eddington_number }}</b>.</p>
38
38
 
39
39
  <p>That means that on {{ (eddington_number / elevation_eddington_divisor) | int }} separate days you
40
- have recorded activities with an elevation gain of more than {{ eddington_number }} m. Going high is one thing, going often is
40
+ have recorded activities with an elevation gain of more than {{ eddington_number }} m. Going high is one
41
+ thing, going often is
41
42
  another. But going high often is hard. Also if you increment the Eddington number, all days with less
42
- elevation gain will not count any more. It becomes increasingly hard to increment the Eddington number because you
43
+ elevation gain will not count any more. It becomes increasingly hard to increment the Eddington number
44
+ because you
43
45
  don't only need to achieve a higher count, but all flatter activities don't count towards the bigger number.
44
46
  </p>
45
47
  </div>
@@ -74,7 +76,8 @@
74
76
  <div class="col">
75
77
  <h2>Plot</h2>
76
78
 
77
- <p>In a graphical representation, the Eddington number is the elevation gain where the red line intersects with the
79
+ <p>In a graphical representation, the Eddington number is the elevation gain where the red line intersects with
80
+ the
78
81
  blue area.</p>
79
82
 
80
83
  {{ vega_direct("logarithmic_plot", logarithmic_plot) }}
@@ -1,11 +1,10 @@
1
1
  {% extends "page.html.j2" %}
2
- {% from "search_form.html.j2" import search_form %}
3
2
 
4
3
  {% block container %}
5
4
  <h1 class="mb-3">Heatmap</h1>
6
5
 
7
6
  <div class="mb-3">
8
- {{ search_form(query, equipments_avail, kinds_avail, search_query_favorites, search_query_last, request_url) }}
7
+ {% include "search_form.html.j2" %}
9
8
  </div>
10
9
 
11
10
  <div class="row mb-3">
@@ -123,6 +123,12 @@
123
123
  </li>
124
124
  {% endif %}
125
125
 
126
+ {% if photo_count > 0 %}
127
+ <li class="nav-item">
128
+ <a class="nav-link" aria-current="page" href="{{ url_for('photo.map') }}">Photo Map</a>
129
+ </li>
130
+ {% endif %}
131
+
126
132
  <li class="nav-item dropdown">
127
133
  <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
128
134
  aria-expanded="false">
@@ -131,6 +137,8 @@
131
137
  <ul class="dropdown-menu">
132
138
  <li><a class="dropdown-item" href="{{ url_for('upload.index') }}">Upload Activities</a>
133
139
  </li>
140
+ <li><a class="dropdown-item" href="{{ url_for('photo.new') }}">Upload Photos</a>
141
+ </li>
134
142
  <li><a class="dropdown-item" href="{{ url_for('upload.reload') }}">Scan New
135
143
  Activities</a>
136
144
  </li>
@@ -0,0 +1,45 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <h1>Photo Map</h1>
5
+
6
+ <div id="photo-map" style="height: 800px;" class="mb-3"></div>
7
+ <script>
8
+ var map = L.map('photo-map', {
9
+ fullscreenControl: true
10
+ });
11
+ L.tileLayer('/tile/color/{z}/{x}/{y}.png', {
12
+ maxZoom: 19,
13
+ attribution: '{{ map_tile_attribution|safe }}'
14
+ }).addTo(map);
15
+
16
+ fetch("{{ url_for('photo.map_for_all') }}")
17
+ .then(function (response) {
18
+ return response.json();
19
+ })
20
+ .then(function (data) {
21
+ let layer = L.geoJSON(data, {
22
+ pointToLayer: function (feature, latlng) {
23
+ return L.marker(latlng, {
24
+ icon: new L.Icon({
25
+ iconSize: [32, 32],
26
+ iconAnchor: [16, 16],
27
+ popupAnchor: [16, 0],
28
+ iconUrl: feature.properties.url_marker,
29
+ })
30
+ });
31
+ },
32
+ onEachFeature: function (feature, layer) {
33
+ layer.bindPopup(`<a href="${feature.properties.url_full}" target="_blank"><img src="${feature.properties.url_popup}" /></a>`)
34
+ }
35
+ })
36
+
37
+ let group = L.markerClusterGroup()
38
+ group.addLayer(layer)
39
+
40
+ group.addTo(map);
41
+ map.fitBounds(group.getBounds());
42
+ });
43
+ </script>
44
+
45
+ {% endblock %}
@@ -0,0 +1,13 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <h1>Upload Photo</h1>
5
+
6
+ <form method="POST" enctype="multipart/form-data">
7
+ <div class="mb-3">
8
+ <label for="file" class="form-label">Photo file</label>
9
+ <input type="file" name="file" id="file" class="form-control">
10
+ </div>
11
+ <button type="submit" class="btn btn-primary">Upload</button>
12
+ </form>
13
+ {% endblock %}
@@ -1,12 +1,11 @@
1
1
  {% extends "page.html.j2" %}
2
- {% from "search_form.html.j2" import search_form %}
3
2
 
4
3
  {% block container %}
5
4
 
6
5
  <h1 class="row mb-3">Activities Overview & Search</h1>
7
6
 
8
7
  <div class="mb-3">
9
- {{ search_form(query, equipments_avail, kinds_avail, search_query_favorites, search_query_last, request_url) }}
8
+ {% include "search_form.html.j2" %}
10
9
  </div>
11
10
 
12
11
  <table class="table table-sort table-arrows">
@@ -32,7 +31,11 @@
32
31
  </td>
33
32
  <td>{{ '%.1f' % activity["distance_km"] }} km</td>
34
33
  <td>{{ activity.elapsed_time|td }}</td>
35
- <td>{{ activity.average_speed_moving_kmh|round(1) }}</td>
34
+ <td>
35
+ {% if not activity.average_speed_moving_kmh|isna %}
36
+ {{ activity.average_speed_moving_kmh|round(1) }}
37
+ {% endif %}
38
+ </td>
36
39
  <td>{{ activity["equipment"] }}</td>
37
40
  <td>{{ activity['kind'] }}</td>
38
41
  </tr>