geo-activity-playground 0.27.1__py3-none-any.whl → 0.28.0__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 (29) hide show
  1. geo_activity_playground/__main__.py +1 -2
  2. geo_activity_playground/core/activities.py +3 -3
  3. geo_activity_playground/core/config.py +1 -0
  4. geo_activity_playground/core/tasks.py +2 -2
  5. geo_activity_playground/explorer/tile_visits.py +135 -134
  6. geo_activity_playground/webui/activity/controller.py +29 -13
  7. geo_activity_playground/webui/activity/templates/activity/show.html.j2 +6 -11
  8. geo_activity_playground/webui/app.py +10 -2
  9. geo_activity_playground/webui/auth/blueprint.py +27 -0
  10. geo_activity_playground/webui/auth/templates/auth/index.html.j2 +21 -0
  11. geo_activity_playground/webui/authenticator.py +49 -0
  12. geo_activity_playground/webui/equipment/controller.py +2 -1
  13. geo_activity_playground/webui/explorer/controller.py +3 -3
  14. geo_activity_playground/webui/heatmap/heatmap_controller.py +19 -6
  15. geo_activity_playground/webui/settings/blueprint.py +34 -1
  16. geo_activity_playground/webui/settings/controller.py +43 -0
  17. geo_activity_playground/webui/settings/templates/settings/admin-password.html.j2 +19 -0
  18. geo_activity_playground/webui/settings/templates/settings/index.html.j2 +18 -0
  19. geo_activity_playground/webui/settings/templates/settings/sharepic.html.j2 +22 -0
  20. geo_activity_playground/webui/square_planner/controller.py +1 -1
  21. geo_activity_playground/webui/templates/page.html.j2 +1 -1
  22. geo_activity_playground/webui/upload/blueprint.py +7 -0
  23. geo_activity_playground/webui/upload/controller.py +4 -8
  24. geo_activity_playground/webui/upload/templates/upload/index.html.j2 +15 -31
  25. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.28.0.dist-info}/METADATA +1 -1
  26. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.28.0.dist-info}/RECORD +29 -24
  27. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.28.0.dist-info}/LICENSE +0 -0
  28. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.28.0.dist-info}/WHEEL +0 -0
  29. {geo_activity_playground-0.27.1.dist-info → geo_activity_playground-0.28.0.dist-info}/entry_points.txt +0 -0
@@ -109,10 +109,9 @@ def make_activity_repository(
109
109
 
110
110
 
111
111
  def main_cache(basedir: pathlib.Path) -> None:
112
- repository, tile_visit_accessor, config_accessor = make_activity_repository(
112
+ (repository, tile_visit_accessor, config_accessor) = make_activity_repository(
113
113
  basedir, False
114
114
  )
115
- scan_for_activities(repository, tile_visit_accessor, config_accessor())
116
115
 
117
116
 
118
117
  if __name__ == "__main__":
@@ -128,11 +128,11 @@ class ActivityRepository:
128
128
  else:
129
129
  return None
130
130
 
131
- def get_activity_ids(self, only_achievements: bool = False) -> set[int]:
131
+ def get_activity_ids(self, only_achievements: bool = False) -> list[int]:
132
132
  if only_achievements:
133
- return set(self.meta.loc[self.meta["consider_for_achievements"]].index)
133
+ return list(self.meta.loc[self.meta["consider_for_achievements"]].index)
134
134
  else:
135
- return set(self.meta.index)
135
+ return list(self.meta.index)
136
136
 
137
137
  def iter_activities(self, new_to_old=True, dropna=False) -> Iterator[ActivityMeta]:
138
138
  direction = -1 if new_to_old else 1
@@ -33,6 +33,7 @@ class Config:
33
33
  privacy_zones: dict[str, list[list[float]]] = dataclasses.field(
34
34
  default_factory=dict
35
35
  )
36
+ sharepic_suppressed_fields: list[str] = dataclasses.field(default_factory=list)
36
37
  strava_client_id: int = 131693
37
38
  strava_client_secret: str = "0ccc0100a2c218512a7ef0cea3b0e322fb4b4365"
38
39
  strava_client_code: Optional[str] = None
@@ -59,8 +59,8 @@ class WorkTracker:
59
59
  else:
60
60
  self._done = set()
61
61
 
62
- def filter(self, ids: Iterable) -> set:
63
- return set(ids) - self._done
62
+ def filter(self, ids: Iterable) -> list:
63
+ return [elem for elem in ids if elem not in self._done]
64
64
 
65
65
  def mark_done(self, id: int) -> None:
66
66
  self._done.add(id)
@@ -25,139 +25,159 @@ from geo_activity_playground.core.tiles import interpolate_missing_tile
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
27
 
28
+ class TileInfo(TypedDict):
29
+ activity_ids: set[int]
30
+ first_time: datetime.datetime
31
+ first_id: int
32
+ last_time: datetime.datetime
33
+ last_id: int
34
+
35
+
28
36
  class TileHistoryRow(TypedDict):
37
+ activity_id: int
29
38
  time: datetime.datetime
30
39
  tile_x: int
31
40
  tile_y: int
32
41
 
33
42
 
43
+ class TileEvolutionState:
44
+ def __init__(self) -> None:
45
+ self.num_neighbors: dict[tuple[int, int], int] = {}
46
+ self.memberships: dict[tuple[int, int], tuple[int, int]] = {}
47
+ self.clusters: dict[tuple[int, int], list[tuple[int, int]]] = {}
48
+ self.cluster_evolution = pd.DataFrame()
49
+ self.square_start = 0
50
+ self.cluster_start = 0
51
+ self.max_square_size = 0
52
+ self.visited_tiles: set[tuple[int, int]] = set()
53
+ self.square_evolution = pd.DataFrame()
54
+ self.square_x: Optional[int] = None
55
+ self.square_y: Optional[int] = None
56
+
57
+
58
+ class TileState(TypedDict):
59
+ tile_visits: dict[int, dict[tuple[int, int], TileInfo]]
60
+ tile_history: dict[int, pd.DataFrame]
61
+ activities_per_tile: dict[int, set[int]]
62
+ processed_activities: set[int]
63
+ evolution_state: dict[int, TileEvolutionState]
64
+ version: int
65
+
66
+
67
+ TILE_STATE_VERSION = 2
68
+
69
+
34
70
  class TileVisitAccessor:
35
- TILE_EVOLUTION_STATES_PATH = pathlib.Path("Cache/tile-evolution-state.pickle")
36
- TILE_HISTORIES_PATH = pathlib.Path(f"Cache/tile-history.pickle")
37
- TILE_VISITS_PATH = pathlib.Path(f"Cache/tile-visits.pickle")
38
- ACTIVITIES_PER_TILE_PATH = pathlib.Path(f"Cache/activities-per-tile.pickle")
71
+ PATH = pathlib.Path("Cache/tile-state-2.pickle")
39
72
 
40
73
  def __init__(self) -> None:
41
- self.visits: dict[int, dict[tuple[int, int], dict[str, Any]]] = try_load_pickle(
42
- self.TILE_VISITS_PATH
43
- ) or collections.defaultdict(dict)
44
- "zoom (tile_x, tile_y) tile_info"
45
-
46
- self.histories: dict[int, pd.DataFrame] = try_load_pickle(
47
- self.TILE_HISTORIES_PATH
48
- ) or collections.defaultdict(pd.DataFrame)
49
-
50
- self.states = try_load_pickle(
51
- self.TILE_EVOLUTION_STATES_PATH
52
- ) or collections.defaultdict(TileEvolutionState)
53
-
54
- self.activities_per_tile: dict[
55
- int, dict[tuple[int, int], set[int]]
56
- ] = try_load_pickle(self.ACTIVITIES_PER_TILE_PATH) or collections.defaultdict(
57
- dict
58
- )
74
+ self.tile_state: TileState = try_load_pickle(self.PATH)
75
+ if (
76
+ self.tile_state is None
77
+ or self.tile_state.get("version", None) != TILE_STATE_VERSION
78
+ ):
79
+ self.tile_state = make_tile_state()
80
+ # TODO: Reset work tracker
59
81
 
60
82
  def save(self) -> None:
61
- with open(self.TILE_VISITS_PATH, "wb") as f:
62
- pickle.dump(self.visits, f)
83
+ tmp_path = self.PATH.with_suffix(".tmp")
84
+ with open(tmp_path, "wb") as f:
85
+ pickle.dump(self.tile_state, f)
86
+ tmp_path.rename(self.PATH)
63
87
 
64
- with open(self.TILE_HISTORIES_PATH, "wb") as f:
65
- pickle.dump(self.histories, f)
66
88
 
67
- with open(self.TILE_EVOLUTION_STATES_PATH, "wb") as f:
68
- pickle.dump(self.states, f)
89
+ def make_defaultdict_dict():
90
+ return collections.defaultdict(dict)
69
91
 
70
- with open(self.ACTIVITIES_PER_TILE_PATH, "wb") as f:
71
- pickle.dump(self.activities_per_tile, f)
72
92
 
93
+ def make_defaultdict_set():
94
+ return collections.defaultdict(set)
95
+
96
+
97
+ def make_tile_state() -> TileState:
98
+ tile_state: TileState = {
99
+ "tile_visits": collections.defaultdict(make_defaultdict_dict),
100
+ "tile_history": collections.defaultdict(pd.DataFrame),
101
+ "activities_per_tile": collections.defaultdict(make_defaultdict_set),
102
+ "processed_activities": set(),
103
+ "evolution_state": collections.defaultdict(TileEvolutionState),
104
+ "version": TILE_STATE_VERSION,
105
+ }
106
+ return tile_state
73
107
 
74
- def compute_tile_visits(
75
- repository: ActivityRepository, tile_visits_accessor: TileVisitAccessor
76
- ) -> None:
77
- present_activity_ids = repository.get_activity_ids()
78
- work_tracker = WorkTracker(work_tracker_path("tile-visits"))
79
-
80
- changed_zoom_tile = collections.defaultdict(set)
81
-
82
- # Delete visits from removed activities.
83
- for zoom, activities_per_tile in tile_visits_accessor.activities_per_tile.items():
84
- for tile, activity_ids in activities_per_tile.items():
85
- deleted_ids = activity_ids - present_activity_ids
86
- if deleted_ids:
87
- logger.debug(
88
- f"Removing activities {deleted_ids} from tile {tile} at {zoom=}."
89
- )
90
- for activity_id in deleted_ids:
91
- activity_ids.remove(activity_id)
92
- work_tracker.discard(activity_id)
93
- changed_zoom_tile[zoom].add(tile)
94
108
 
95
- # Add visits from new activities.
96
- activity_ids_to_process = work_tracker.filter(repository.get_activity_ids())
109
+ def compute_tile_visits_new(
110
+ repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
111
+ ) -> None:
112
+ work_tracker = WorkTracker(work_tracker_path("tile-state"))
97
113
  for activity_id in tqdm(
98
- activity_ids_to_process, desc="Extract explorer tile visits"
114
+ work_tracker.filter(repository.get_activity_ids()), desc="Tile visits (new)"
99
115
  ):
100
- for zoom in range(20):
101
- for time, tile_x, tile_y in _tiles_from_points(
102
- repository.get_time_series(activity_id), zoom
103
- ):
104
- tile = (tile_x, tile_y)
105
- if tile not in tile_visits_accessor.activities_per_tile[zoom]:
106
- tile_visits_accessor.activities_per_tile[zoom][tile] = set()
107
- tile_visits_accessor.activities_per_tile[zoom][tile].add(activity_id)
108
- changed_zoom_tile[zoom].add(tile)
116
+ do_tile_stuff(repository, tile_visit_accessor.tile_state, activity_id)
109
117
  work_tracker.mark_done(activity_id)
110
-
111
- # Update tile visits structure.
112
- for zoom, changed_tiles in tqdm(
113
- changed_zoom_tile.items(), desc="Incorporate changes in tiles"
114
- ):
115
- soa = {"activity_id": [], "time": [], "tile_x": [], "tile_y": []}
116
-
117
- for tile in changed_tiles:
118
- activity_ids = tile_visits_accessor.activities_per_tile[zoom][tile]
119
- activities = [
120
- repository.get_activity_by_id(activity_id)
121
- for activity_id in activity_ids
122
- ]
123
- activities_to_consider = [
124
- activity
125
- for activity in activities
126
- if activity["consider_for_achievements"]
127
- ]
128
- activities_to_consider.sort(key=lambda activity: activity["start"])
129
-
130
- if activities_to_consider:
131
- tile_visits_accessor.visits[zoom][tile] = {
132
- "first_time": activities_to_consider[0]["start"],
133
- "first_id": activities_to_consider[0]["id"],
134
- "last_time": activities_to_consider[-1]["start"],
135
- "last_id": activities_to_consider[-1]["id"],
136
- "activity_ids": {
137
- activity["id"] for activity in activities_to_consider
138
- },
139
- }
140
-
141
- soa["activity_id"].append(activities_to_consider[0]["id"])
142
- soa["time"].append(activities_to_consider[0]["start"])
143
- soa["tile_x"].append(tile[0])
144
- soa["tile_y"].append(tile[1])
145
- else:
146
- if tile in tile_visits_accessor.visits[zoom]:
147
- del tile_visits_accessor.visits[zoom][tile]
148
-
149
- df = pd.DataFrame(soa)
150
- if len(df) > 0:
151
- df = pd.concat([tile_visits_accessor.histories[zoom], df])
152
- df.sort_values("time", inplace=True)
153
- tile_visits_accessor.histories[zoom] = df.groupby(
154
- ["tile_x", "tile_y"]
155
- ).head(1)
156
-
157
- tile_visits_accessor.save()
118
+ tile_visit_accessor.save()
158
119
  work_tracker.close()
159
120
 
160
121
 
122
+ def do_tile_stuff(
123
+ repository: ActivityRepository, tile_state: TileState, activity_id: int
124
+ ) -> None:
125
+ activity = repository.get_activity_by_id(activity_id)
126
+ time_series = repository.get_time_series(activity_id)
127
+
128
+ activity_tiles = pd.DataFrame(
129
+ _tiles_from_points(time_series, 19), columns=["time", "tile_x", "tile_y"]
130
+ )
131
+ for zoom in reversed(range(20)):
132
+ activities_per_tile = tile_state["activities_per_tile"][zoom]
133
+
134
+ new_tile_history_soa = {
135
+ "activity_id": [],
136
+ "time": [],
137
+ "tile_x": [],
138
+ "tile_y": [],
139
+ }
140
+
141
+ activity_tiles = activity_tiles.groupby(["tile_x", "tile_y"]).head(1)
142
+
143
+ for time, tile in zip(
144
+ activity_tiles["time"],
145
+ zip(activity_tiles["tile_x"], activity_tiles["tile_y"]),
146
+ ):
147
+ if activity["consider_for_achievements"]:
148
+ if tile not in activities_per_tile:
149
+ new_tile_history_soa["activity_id"].append(activity_id)
150
+ new_tile_history_soa["time"].append(time)
151
+ new_tile_history_soa["tile_x"].append(tile[0])
152
+ new_tile_history_soa["tile_y"].append(tile[1])
153
+
154
+ tile_visit = tile_state["tile_visits"][zoom][tile]
155
+ if not tile_visit:
156
+ tile_visit["activity_ids"] = {activity_id}
157
+ else:
158
+ tile_visit["activity_ids"].add(activity_id)
159
+
160
+ first_time = tile_visit.get("first_time", None)
161
+ last_time = tile_visit.get("last_time", None)
162
+ if first_time is None or time < first_time:
163
+ tile_visit["first_id"] = activity_id
164
+ tile_visit["first_time"] = time
165
+ if last_time is None or time > last_time:
166
+ tile_visit["last_id"] = activity_id
167
+ tile_visit["last_time"] = time
168
+
169
+ activities_per_tile[tile].add(activity_id)
170
+
171
+ if new_tile_history_soa["activity_id"]:
172
+ tile_state["tile_history"][zoom] = pd.concat(
173
+ [tile_state["tile_history"][zoom], pd.DataFrame(new_tile_history_soa)]
174
+ )
175
+
176
+ # Move up one layer in the quad-tree.
177
+ activity_tiles["tile_x"] //= 2
178
+ activity_tiles["tile_y"] //= 2
179
+
180
+
161
181
  def _tiles_from_points(
162
182
  time_series: pd.DataFrame, zoom: int
163
183
  ) -> Iterator[tuple[datetime.datetime, int, int]]:
@@ -181,38 +201,19 @@ def _tiles_from_points(
181
201
  yield (t1,) + interpolated
182
202
 
183
203
 
184
- class TileEvolutionState:
185
- def __init__(self) -> None:
186
- self.num_neighbors: dict[tuple[int, int], int] = {}
187
- self.memberships: dict[tuple[int, int], tuple[int, int]] = {}
188
- self.clusters: dict[tuple[int, int], list[tuple[int, int]]] = {}
189
- self.cluster_evolution = pd.DataFrame()
190
- self.square_start = 0
191
- self.cluster_start = 0
192
- self.max_square_size = 0
193
- self.visited_tiles: set[tuple[int, int]] = set()
194
- self.square_evolution = pd.DataFrame()
195
- self.square_x: Optional[int] = None
196
- self.square_y: Optional[int] = None
197
-
198
-
199
- def compute_tile_evolution(
200
- tile_visits_accessor: TileVisitAccessor, config: Config
201
- ) -> None:
204
+ def compute_tile_evolution(tile_state: TileState, config: Config) -> None:
202
205
  for zoom in config.explorer_zoom_levels:
203
206
  _compute_cluster_evolution(
204
- tile_visits_accessor.histories[zoom],
205
- tile_visits_accessor.states[zoom],
207
+ tile_state["tile_history"][zoom],
208
+ tile_state["evolution_state"][zoom],
206
209
  zoom,
207
210
  )
208
211
  _compute_square_history(
209
- tile_visits_accessor.histories[zoom],
210
- tile_visits_accessor.states[zoom],
212
+ tile_state["tile_history"][zoom],
213
+ tile_state["evolution_state"][zoom],
211
214
  zoom,
212
215
  )
213
216
 
214
- tile_visits_accessor.save()
215
-
216
217
 
217
218
  def _compute_cluster_evolution(
218
219
  tiles: pd.DataFrame, s: TileEvolutionState, zoom: int
@@ -61,7 +61,9 @@ class ActivityController:
61
61
 
62
62
  new_tiles = {
63
63
  zoom: sum(
64
- self._tile_visit_accessor.histories[zoom]["activity_id"]
64
+ self._tile_visit_accessor.tile_state["tile_history"][zoom][
65
+ "activity_id"
66
+ ]
65
67
  == activity["id"]
66
68
  )
67
69
  for zoom in [14, 17]
@@ -100,7 +102,9 @@ class ActivityController:
100
102
  time_series = privacy_zone.filter_time_series(time_series)
101
103
  if len(time_series) == 0:
102
104
  time_series = self._repository.get_time_series(id)
103
- return make_sharepic(activity, time_series)
105
+ return make_sharepic(
106
+ activity, time_series, self._config.sharepic_suppressed_fields
107
+ )
104
108
 
105
109
  def render_day(self, year: int, month: int, day: int) -> dict:
106
110
  meta = self._repository.meta
@@ -411,7 +415,11 @@ def pixels_in_bounds(bounds: PixelBounds) -> int:
411
415
  return (bounds.x_max - bounds.x_min) * (bounds.y_max - bounds.y_min)
412
416
 
413
417
 
414
- def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
418
+ def make_sharepic(
419
+ activity: ActivityMeta,
420
+ time_series: pd.DataFrame,
421
+ sharepic_suppressed_fields: list[str],
422
+ ) -> bytes:
415
423
  lat_lon_data = np.array([time_series["latitude"], time_series["longitude"]]).T
416
424
 
417
425
  geo_bounds = get_bounds(lat_lon_data)
@@ -448,19 +456,27 @@ def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
448
456
  draw.line(yx, fill="red", width=4)
449
457
 
450
458
  draw.rectangle([0, img.height - 70, img.width, img.height], fill=(0, 0, 0, 128))
451
- facts = [
452
- f"{activity['kind']}",
453
- f"{activity['start'].date()}",
454
- f"{activity['equipment']}",
455
- f"\n{activity['distance_km']:.1f} km",
456
- re.sub(r"^0 days ", "", f"{activity['elapsed_time']}"),
457
- ]
459
+
460
+ facts = {
461
+ "kind": f"{activity['kind']}",
462
+ "start": f"{activity['start'].date()}",
463
+ "equipment": f"{activity['equipment']}",
464
+ "distance_km": f"\n{activity['distance_km']:.1f} km",
465
+ "elapsed_time": re.sub(r"^0 days ", "", f"{activity['elapsed_time']}"),
466
+ }
467
+
458
468
  if activity.get("calories", 0) and not pd.isna(activity["calories"]):
459
- facts.append(f"{activity['calories']:.0f} kcal")
469
+ facts["calories"] = f"{activity['calories']:.0f} kcal"
460
470
  if activity.get("steps", 0) and not pd.isna(activity["steps"]):
461
- facts.append(f"{activity['steps']:.0f} steps")
471
+ facts["steps"] = f"{activity['steps']:.0f} steps"
472
+
473
+ facts = {
474
+ key: value
475
+ for key, value in facts.items()
476
+ if not key in sharepic_suppressed_fields
477
+ }
462
478
 
463
- draw.text((35, img.height - 70 + 10), " ".join(facts), font_size=20)
479
+ draw.text((35, img.height - 70 + 10), " ".join(facts.values()), font_size=20)
464
480
 
465
481
  # img_array = np.array(img) / 255
466
482
 
@@ -111,11 +111,7 @@
111
111
  </div>
112
112
 
113
113
  {% if heartrate_time_plot is defined %}
114
- <div class="row mb-3">
115
- <div class="col">
116
- <h2>Heart rate</h2>
117
- </div>
118
- </div>
114
+ <h2>Heart rate</h2>
119
115
 
120
116
  <div class="row mb-3">
121
117
  <div class="col-md-4">
@@ -133,13 +129,12 @@
133
129
  </div>
134
130
  {% endif %}
135
131
 
136
- <div class="row mb-3">
137
- <div class="col">
138
- <h2>Share picture</h2>
132
+ <h2>Share picture</h2>
139
133
 
140
- <img src="{{ url_for('.sharepic', id=activity.id) }}" />
141
- </div>
142
- </div>
134
+ <p><img src="{{ url_for('.sharepic', id=activity.id) }}" /></p>
135
+
136
+ <p>Not happy with the displayed data? <a href="{{ url_for('settings.sharepic') }}">Change share picture
137
+ settings</a>.</p>
143
138
 
144
139
  {% if similar_activites|length > 0 %}
145
140
  <div class="row mb-3">
@@ -22,6 +22,8 @@ from .summary.blueprint import make_summary_blueprint
22
22
  from .tile.blueprint import make_tile_blueprint
23
23
  from .upload.blueprint import make_upload_blueprint
24
24
  from geo_activity_playground.core.config import ConfigAccessor
25
+ from geo_activity_playground.webui.auth.blueprint import make_auth_blueprint
26
+ from geo_activity_playground.webui.authenticator import Authenticator
25
27
  from geo_activity_playground.webui.settings.blueprint import make_settings_blueprint
26
28
 
27
29
 
@@ -71,9 +73,13 @@ def web_ui_main(
71
73
  app.config["UPLOAD_FOLDER"] = "Activities"
72
74
  app.secret_key = get_secret_key()
73
75
 
76
+ authenticator = Authenticator(config_accessor())
77
+
74
78
  route_search(app, repository)
75
79
  route_start(app, repository)
76
80
 
81
+ app.register_blueprint(make_auth_blueprint(authenticator), url_prefix="/auth")
82
+
77
83
  app.register_blueprint(
78
84
  make_activity_blueprint(
79
85
  repository,
@@ -97,7 +103,7 @@ def web_ui_main(
97
103
  make_heatmap_blueprint(repository, tile_visit_accessor), url_prefix="/heatmap"
98
104
  )
99
105
  app.register_blueprint(
100
- make_settings_blueprint(config_accessor),
106
+ make_settings_blueprint(config_accessor, authenticator),
101
107
  url_prefix="/settings",
102
108
  )
103
109
  app.register_blueprint(
@@ -110,7 +116,9 @@ def web_ui_main(
110
116
  )
111
117
  app.register_blueprint(make_tile_blueprint(), url_prefix="/tile")
112
118
  app.register_blueprint(
113
- make_upload_blueprint(repository, tile_visit_accessor, config_accessor()),
119
+ make_upload_blueprint(
120
+ repository, tile_visit_accessor, config_accessor(), authenticator
121
+ ),
114
122
  url_prefix="/upload",
115
123
  )
116
124
 
@@ -0,0 +1,27 @@
1
+ from flask import Blueprint
2
+ from flask import redirect
3
+ from flask import render_template
4
+ from flask import request
5
+ from flask import url_for
6
+
7
+ from geo_activity_playground.webui.authenticator import Authenticator
8
+
9
+
10
+ def make_auth_blueprint(authenticator: Authenticator) -> Blueprint:
11
+ blueprint = Blueprint("auth", __name__, template_folder="templates")
12
+
13
+ @blueprint.route("/", methods=["GET", "POST"])
14
+ def index():
15
+ if request.method == "POST":
16
+ authenticator.authenticate(request.form["password"])
17
+ return render_template(
18
+ "auth/index.html.j2",
19
+ is_authenticated=authenticator.is_authenticated(),
20
+ )
21
+
22
+ @blueprint.route("/logout")
23
+ def logout():
24
+ authenticator.logout()
25
+ return redirect(url_for(".index"))
26
+
27
+ return blueprint
@@ -0,0 +1,21 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <h1>Authentication</h1>
5
+
6
+ {% if is_authenticated %}
7
+ <p>You are either logged in or don't have a password set. You can do everything.</p>
8
+
9
+ <a class="btn btn-primary" href="{{ url_for('.logout') }}">Log Out</a>
10
+ {% else %}
11
+ <form method="POST">
12
+ <div class="mb-3">
13
+ <label for="password" class="form-label">Password</label>
14
+ <input type="password" class="form-control" id="password" name="password" />
15
+ </div>
16
+
17
+ <button type="submit" class="btn btn-primary">Log In</button>
18
+ </form>
19
+ {% endif %}
20
+
21
+ {% endblock %}
@@ -0,0 +1,49 @@
1
+ import functools
2
+ from typing import Callable
3
+
4
+ from flask import flash
5
+ from flask import redirect
6
+ from flask import session
7
+ from flask import url_for
8
+
9
+ from geo_activity_playground.core.config import Config
10
+
11
+
12
+ class Authenticator:
13
+ def __init__(self, config: Config) -> None:
14
+ self._config = config
15
+
16
+ def is_authenticated(self) -> bool:
17
+ print(
18
+ f"Password={self._config.upload_password}, Session={session.get('is_authenticated', False)}"
19
+ )
20
+ return not self._config.upload_password or session.get(
21
+ "is_authenticated", False
22
+ )
23
+
24
+ def authenticate(self, password: str) -> None:
25
+ if password == self._config.upload_password:
26
+ session["is_authenticated"] = True
27
+ session.permanent = True
28
+ flash("Login successful.", category="success")
29
+ else:
30
+ flash("Incorrect password.", category="warning")
31
+
32
+ def logout(self) -> None:
33
+ session["is_authenticated"] = False
34
+ flash("Logout successful.", category="success")
35
+
36
+
37
+ def needs_authentication(authenticator: Authenticator) -> Callable:
38
+ def decorator(route: Callable) -> Callable:
39
+ @functools.wraps(route)
40
+ def wrapped_route(*args, **kwargs):
41
+ if authenticator.is_authenticated():
42
+ return route(*args, **kwargs)
43
+ else:
44
+ flash("You need to be logged in to view that site.", category="Warning")
45
+ return redirect(url_for("auth.index"))
46
+
47
+ return wrapped_route
48
+
49
+ return decorator
@@ -103,7 +103,8 @@ class EquipmentController:
103
103
  }
104
104
 
105
105
  for equipment, offset in self._config.equipment_offsets.items():
106
- equipment_summary.loc[equipment, "total_distance_km"] += offset
106
+ if equipment in equipment_summary.index:
107
+ equipment_summary.loc[equipment, "total_distance_km"] += offset
107
108
 
108
109
  return {
109
110
  "equipment_variables": equipment_variables,
@@ -54,9 +54,9 @@ class ExplorerController:
54
54
  if zoom not in self._config_accessor().explorer_zoom_levels:
55
55
  return {"zoom_level_not_generated": zoom}
56
56
 
57
- tile_evolution_states = self._tile_visit_accessor.states
58
- tile_visits = self._tile_visit_accessor.visits
59
- tile_histories = self._tile_visit_accessor.histories
57
+ tile_evolution_states = self._tile_visit_accessor.tile_state["evolution_state"]
58
+ tile_visits = self._tile_visit_accessor.tile_state["tile_visits"]
59
+ tile_histories = self._tile_visit_accessor.tile_state["tile_history"]
60
60
 
61
61
  medians = tile_histories[zoom].median()
62
62
  median_lat, median_lon = get_tile_upper_left_lat_lon(
@@ -34,10 +34,14 @@ class HeatmapController:
34
34
  self._repository = repository
35
35
  self._tile_visit_accessor = tile_visit_accessor
36
36
 
37
- self.tile_histories = self._tile_visit_accessor.histories
38
- self.tile_evolution_states = self._tile_visit_accessor.states
39
- self.tile_visits = self._tile_visit_accessor.visits
40
- self.activities_per_tile = self._tile_visit_accessor.activities_per_tile
37
+ self.tile_histories = self._tile_visit_accessor.tile_state["tile_history"]
38
+ self.tile_evolution_states = self._tile_visit_accessor.tile_state[
39
+ "evolution_state"
40
+ ]
41
+ self.tile_visits = self._tile_visit_accessor.tile_state["tile_visits"]
42
+ self.activities_per_tile = self._tile_visit_accessor.tile_state[
43
+ "activities_per_tile"
44
+ ]
41
45
 
42
46
  def render(self, kinds: list[str] = []) -> dict:
43
47
  zoom = 14
@@ -74,7 +78,14 @@ class HeatmapController:
74
78
  tile_pixels = (OSM_TILE_SIZE, OSM_TILE_SIZE)
75
79
  tile_count_cache_path = pathlib.Path(f"Cache/Heatmap/{kind}/{z}/{x}/{y}.npy")
76
80
  if tile_count_cache_path.exists():
77
- tile_counts = np.load(tile_count_cache_path)
81
+ try:
82
+ tile_counts = np.load(tile_count_cache_path)
83
+ except ValueError:
84
+ logger.warning(
85
+ f"Heatmap count file {tile_count_cache_path} is corrupted, deleting."
86
+ )
87
+ tile_count_cache_path.unlink()
88
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
78
89
  else:
79
90
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
80
91
  tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -110,7 +121,9 @@ class HeatmapController:
110
121
  draw.line(pixels, fill=1, width=max(3, 6 * (z - 17)))
111
122
  aim = np.array(im)
112
123
  tile_counts += aim
113
- np.save(tile_count_cache_path, tile_counts)
124
+ tmp_path = tile_count_cache_path.with_suffix(".tmp.npy")
125
+ np.save(tmp_path, tile_counts)
126
+ tmp_path.rename(tile_count_cache_path)
114
127
  return tile_counts
115
128
 
116
129
  def _render_tile_image(
@@ -8,6 +8,8 @@ from flask import request
8
8
  from flask import url_for
9
9
 
10
10
  from geo_activity_playground.core.config import ConfigAccessor
11
+ from geo_activity_playground.webui.authenticator import Authenticator
12
+ from geo_activity_playground.webui.authenticator import needs_authentication
11
13
  from geo_activity_playground.webui.settings.controller import SettingsController
12
14
 
13
15
 
@@ -19,15 +21,29 @@ def int_or_none(s: str) -> Optional[int]:
19
21
  flash(f"Cannot parse integer from {s}: {e}", category="danger")
20
22
 
21
23
 
22
- def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
24
+ def make_settings_blueprint(
25
+ config_accessor: ConfigAccessor, authenticator: Authenticator
26
+ ) -> Blueprint:
23
27
  settings_controller = SettingsController(config_accessor)
24
28
  blueprint = Blueprint("settings", __name__, template_folder="templates")
25
29
 
26
30
  @blueprint.route("/")
31
+ @needs_authentication(authenticator)
27
32
  def index():
28
33
  return render_template("settings/index.html.j2")
29
34
 
35
+ @blueprint.route("/admin-password", methods=["GET", "POST"])
36
+ @needs_authentication(authenticator)
37
+ def admin_password():
38
+ if request.method == "POST":
39
+ settings_controller.save_admin_password(request.form["password"])
40
+ return render_template(
41
+ "settings/admin-password.html.j2",
42
+ **settings_controller.render_admin_password(),
43
+ )
44
+
30
45
  @blueprint.route("/equipment-offsets", methods=["GET", "POST"])
46
+ @needs_authentication(authenticator)
31
47
  def equipment_offsets():
32
48
  if request.method == "POST":
33
49
  equipments = request.form.getlist("equipment")
@@ -39,6 +55,7 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
39
55
  )
40
56
 
41
57
  @blueprint.route("/heart-rate", methods=["GET", "POST"])
58
+ @needs_authentication(authenticator)
42
59
  def heart_rate():
43
60
  if request.method == "POST":
44
61
  birth_year = int_or_none(request.form["birth_year"])
@@ -54,6 +71,7 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
54
71
  )
55
72
 
56
73
  @blueprint.route("/kinds-without-achievements", methods=["GET", "POST"])
74
+ @needs_authentication(authenticator)
57
75
  def kinds_without_achievements():
58
76
  if request.method == "POST":
59
77
  kinds = request.form.getlist("kind")
@@ -64,6 +82,7 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
64
82
  )
65
83
 
66
84
  @blueprint.route("/metadata-extraction", methods=["GET", "POST"])
85
+ @needs_authentication(authenticator)
67
86
  def metadata_extraction():
68
87
  if request.method == "POST":
69
88
  regexes = request.form.getlist("regex")
@@ -74,6 +93,7 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
74
93
  )
75
94
 
76
95
  @blueprint.route("/privacy-zones", methods=["GET", "POST"])
96
+ @needs_authentication(authenticator)
77
97
  def privacy_zones():
78
98
  if request.method == "POST":
79
99
  zone_names = request.form.getlist("zone_name")
@@ -84,7 +104,19 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
84
104
  **settings_controller.render_privacy_zones(),
85
105
  )
86
106
 
107
+ @blueprint.route("/sharepic", methods=["GET", "POST"])
108
+ @needs_authentication(authenticator)
109
+ def sharepic():
110
+ if request.method == "POST":
111
+ names = request.form.getlist("name")
112
+ settings_controller.save_sharepic(names)
113
+ return render_template(
114
+ "settings/sharepic.html.j2",
115
+ **settings_controller.render_sharepic(),
116
+ )
117
+
87
118
  @blueprint.route("/strava", methods=["GET", "POST"])
119
+ @needs_authentication(authenticator)
88
120
  def strava():
89
121
  if request.method == "POST":
90
122
  strava_client_id = request.form["strava_client_id"]
@@ -98,6 +130,7 @@ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
98
130
  )
99
131
 
100
132
  @blueprint.route("/strava-callback")
133
+ @needs_authentication(authenticator)
101
134
  def strava_callback():
102
135
  code = request.args.get("code", type=str)
103
136
  settings_controller.save_strava_code(code)
@@ -10,10 +10,32 @@ from geo_activity_playground.core.config import ConfigAccessor
10
10
  from geo_activity_playground.core.heart_rate import HeartRateZoneComputer
11
11
 
12
12
 
13
+ SHAREPIC_FIELDS = {
14
+ "calories": "Calories",
15
+ "distance_km": "Distance",
16
+ "elapsed_time": "Elapsed time",
17
+ "equipment": "Equipment",
18
+ "kind": "Kind",
19
+ "name": "Name",
20
+ "start": "Date",
21
+ "Steps": "Steps",
22
+ }
23
+
24
+
13
25
  class SettingsController:
14
26
  def __init__(self, config_accessor: ConfigAccessor) -> None:
15
27
  self._config_accessor = config_accessor
16
28
 
29
+ def render_admin_password(self) -> dict:
30
+ return {
31
+ "password": self._config_accessor().upload_password,
32
+ }
33
+
34
+ def save_admin_password(self, password: str) -> None:
35
+ self._config_accessor().upload_password = password
36
+ self._config_accessor.save()
37
+ flash("Updated admin password.", category="success")
38
+
17
39
  def render_equipment_offsets(self) -> dict:
18
40
  return {
19
41
  "equipment_offsets": self._config_accessor().equipment_offsets,
@@ -184,6 +206,27 @@ class SettingsController:
184
206
  self._config_accessor.save()
185
207
  flash("Updated privacy zones.", category="success")
186
208
 
209
+ def render_sharepic(self) -> dict:
210
+
211
+ return {
212
+ "names": [
213
+ (
214
+ name,
215
+ label,
216
+ name not in self._config_accessor().sharepic_suppressed_fields,
217
+ )
218
+ for name, label in SHAREPIC_FIELDS.items()
219
+ ]
220
+ }
221
+
222
+ def save_sharepic(self, names: list[str]) -> None:
223
+ self._config_accessor().sharepic_suppressed_fields = list(
224
+ set(SHAREPIC_FIELDS) - set(names)
225
+ )
226
+ self._config_accessor.save()
227
+ flash("Updated sharepic preferences.", category="success")
228
+ pass
229
+
187
230
  def render_strava(self) -> dict:
188
231
  return {
189
232
  "strava_client_id": self._config_accessor().strava_client_id,
@@ -0,0 +1,19 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Admin Password</h1>
6
+
7
+ <p>To protect the settings and the upload functionality, you may specify a password.</p>
8
+
9
+ <form method="POST">
10
+ <div class="mb-3">
11
+ <label for="password" class="form-label">Password</label>
12
+ <input type="text" class="form-control" id="password" name="password" value="{{ password }}" />
13
+ </div>
14
+
15
+ <button type="submit" class="btn btn-primary">Save</button>
16
+ </form>
17
+
18
+
19
+ {% endblock %}
@@ -11,6 +11,15 @@
11
11
 
12
12
  <div class="row mb-3">
13
13
  <div class="row row-cols-1 row-cols-md-3 g-3">
14
+ <div class="col">
15
+ <div class="card">
16
+ <div class="card-body">
17
+ <h5 class="card-title">Admin password</h5>
18
+ <p class="card-text">Running a public instance and want to protect it?</p>
19
+ <a href="{{ url_for('.admin_password') }}" class="btn btn-primary">Set up admin password</a>
20
+ </div>
21
+ </div>
22
+ </div>
14
23
  <div class="col">
15
24
  <div class="card">
16
25
  <div class="card-body">
@@ -60,6 +69,15 @@
60
69
  </div>
61
70
  </div>
62
71
  </div>
72
+ <div class="col">
73
+ <div class="card">
74
+ <div class="card-body">
75
+ <h5 class="card-title">Share picture</h5>
76
+ <p class="card-text">Select which metadata shall be displayed on share pictures.</p>
77
+ <a href="{{ url_for('.sharepic') }}" class="btn btn-primary">Set up share picture</a>
78
+ </div>
79
+ </div>
80
+ </div>
63
81
  <div class="col">
64
82
  <div class="card">
65
83
  <div class="card-body">
@@ -0,0 +1,22 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Share Picture</h1>
6
+
7
+ <p>What data fields shall be displayed on the share picture?</p>
8
+
9
+ <form method="POST">
10
+ {% for name, label, checked in names %}
11
+ <div class="form-check form-check-inline form-switch">
12
+ <input class="form-check-input" type="checkbox" role="switch" id="{{ name }}" name="name" value="{{ name }}"
13
+ {{ 'checked' if checked else '' }} />
14
+ <label class="form-check-label" for="{{ name }}">{{ label }}</label>
15
+ </div>
16
+ {% endfor %}
17
+ <div class="clearfix mb-3"></div>
18
+ <button type="submit" class="btn btn-primary">Save</button>
19
+ </form>
20
+
21
+
22
+ {% endblock %}
@@ -18,7 +18,7 @@ class SquarePlannerController:
18
18
  self._repository = repository
19
19
  self._tile_visit_accessor = tile_visit_accessor
20
20
 
21
- self._tile_visits = self._tile_visit_accessor.visits
21
+ self._tile_visits = self._tile_visit_accessor.tile_state["tile_visits"]
22
22
 
23
23
  def action_planner(
24
24
  self, zoom: int, square_x: int, square_y: int, square_size: int
@@ -52,7 +52,7 @@
52
52
  <div class="container">
53
53
  <nav class="navbar navbar-expand-lg bg-body-secondary mb-3">
54
54
  <div class="container-fluid">
55
- <a class="navbar-brand" href="{{ url_for('index') }}">Geo Actvitity Playground</a>
55
+ <a class="navbar-brand" href="{{ url_for('index') }}">Geo Activity Playground</a>
56
56
  <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
57
57
  data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
58
58
  aria-expanded="false" aria-label="Toggle navigation">
@@ -5,32 +5,39 @@ from ...core.activities import ActivityRepository
5
5
  from ...explorer.tile_visits import TileVisitAccessor
6
6
  from .controller import UploadController
7
7
  from geo_activity_playground.core.config import Config
8
+ from geo_activity_playground.webui.authenticator import Authenticator
9
+ from geo_activity_playground.webui.authenticator import needs_authentication
8
10
 
9
11
 
10
12
  def make_upload_blueprint(
11
13
  repository: ActivityRepository,
12
14
  tile_visit_accessor: TileVisitAccessor,
13
15
  config: Config,
16
+ authenticator: Authenticator,
14
17
  ) -> Blueprint:
15
18
  blueprint = Blueprint("upload", __name__, template_folder="templates")
16
19
 
17
20
  upload_controller = UploadController(repository, tile_visit_accessor, config)
18
21
 
19
22
  @blueprint.route("/")
23
+ @needs_authentication(authenticator)
20
24
  def index():
21
25
  return render_template(
22
26
  "upload/index.html.j2", **upload_controller.render_form()
23
27
  )
24
28
 
25
29
  @blueprint.route("/receive", methods=["POST"])
30
+ @needs_authentication(authenticator)
26
31
  def receive():
27
32
  return upload_controller.receive()
28
33
 
29
34
  @blueprint.route("/refresh")
35
+ @needs_authentication(authenticator)
30
36
  def reload():
31
37
  return render_template("upload/reload.html.j2")
32
38
 
33
39
  @blueprint.route("/execute-reload")
40
+ @needs_authentication(authenticator)
34
41
  def execute_reload():
35
42
  return upload_controller.execute_reload()
36
43
 
@@ -14,7 +14,7 @@ from geo_activity_playground.core.activities import build_activity_meta
14
14
  from geo_activity_playground.core.config import Config
15
15
  from geo_activity_playground.core.enrichment import enrich_activities
16
16
  from geo_activity_playground.explorer.tile_visits import compute_tile_evolution
17
- from geo_activity_playground.explorer.tile_visits import compute_tile_visits
17
+ from geo_activity_playground.explorer.tile_visits import compute_tile_visits_new
18
18
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
19
19
  from geo_activity_playground.importers.directory import get_file_hash
20
20
  from geo_activity_playground.importers.directory import import_from_directory
@@ -45,7 +45,6 @@ class UploadController:
45
45
  directories.sort()
46
46
  return {
47
47
  "directories": directories,
48
- "has_upload": self._config.upload_password,
49
48
  }
50
49
 
51
50
  def receive(self) -> Response:
@@ -54,10 +53,6 @@ class UploadController:
54
53
  flash("No file could be found. Did you select a file?", "warning")
55
54
  return redirect("/upload")
56
55
 
57
- if request.form["password"] != self._config.upload_password:
58
- flash("Incorrect upload password!", "danger")
59
- return redirect("/upload")
60
-
61
56
  file = request.files["file"]
62
57
  # If the user does not select a file, the browser submits an
63
58
  # empty file without a filename.
@@ -120,5 +115,6 @@ def scan_for_activities(
120
115
  repository.reload()
121
116
 
122
117
  if len(repository) > 0:
123
- compute_tile_visits(repository, tile_visit_accessor)
124
- compute_tile_evolution(tile_visit_accessor, config)
118
+ compute_tile_visits_new(repository, tile_visit_accessor)
119
+ compute_tile_evolution(tile_visit_accessor.tile_state, config)
120
+ tile_visit_accessor.save()
@@ -1,37 +1,21 @@
1
1
  {% extends "page.html.j2" %}
2
2
 
3
3
  {% block container %}
4
- <div class="row mb-1">
5
- <div class="col">
6
- <h1>Upload Activity</h1>
4
+ <h1>Upload Activity</h1>
7
5
 
8
- {% if has_upload %}
9
- <form method="post" enctype="multipart/form-data" action="{{ url_for(".receive") }}">
10
- <div class="mb-3">
11
- <label for="file1" class="form-label">Activity file</label>
12
- <input type="file" name="file" id="file1" class="form-control">
13
- </div>
14
- <div class="mb-3">
15
- <label for="directory" class="form-label">Target directory</label>
16
- <select name="directory" id="directory" class="form-select" aria-label="Default select example">
17
- {% for directory in directories %}
18
- <option>{{ directory }}</option>
19
- {% endfor %}
20
- </select>
21
- </div>
22
- <div class="mb-3">
23
- <label for="password" class="form-label">Password</label>
24
- <input type="password" name="password" id="password" class="form-control">
25
- </div>
26
- <button type="submit" class="btn btn-primary">Upload</button>
27
- </form>
28
- {% else %}
29
- <p>You don't have an upload password set. In order to use this feature, add the following to your configuration
30
- file:</p>
31
- <code><pre>[upload]
32
- password = "your unique password here"</pre></code>
33
- {% endif %}
6
+ <form method="post" enctype="multipart/form-data" action="{{ url_for('.receive') }}">
7
+ <div class="mb-3">
8
+ <label for="file1" class="form-label">Activity file</label>
9
+ <input type="file" name="file" id="file1" class="form-control">
34
10
  </div>
35
- </div>
36
-
11
+ <div class="mb-3">
12
+ <label for="directory" class="form-label">Target directory</label>
13
+ <select name="directory" id="directory" class="form-select" aria-label="Default select example">
14
+ {% for directory in directories %}
15
+ <option>{{ directory }}</option>
16
+ {% endfor %}
17
+ </select>
18
+ </div>
19
+ <button type="submit" class="btn btn-primary">Upload</button>
20
+ </form>
37
21
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.27.1
3
+ Version: 0.28.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding
@@ -1,8 +1,8 @@
1
1
  geo_activity_playground/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- geo_activity_playground/__main__.py,sha256=0feB3cw3-U9q0Dkrn5co76gTPpwGRWyfI5mfWNSZ_lU,4063
2
+ geo_activity_playground/__main__.py,sha256=MBZn9K1m3PofiPNTtpsSTVCyB_Gz95TjVP-nb9v_-JE,3989
3
3
  geo_activity_playground/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- geo_activity_playground/core/activities.py,sha256=Z81Itv3qrokxE1-YnPrDJ0QteyBZYjQYvTP-tHvR9j4,6606
5
- geo_activity_playground/core/config.py,sha256=Ogv_nPJD3ihlgBDZsCv5XsWAUXIKKhzWR6cu-KMNMWU,4414
4
+ geo_activity_playground/core/activities.py,sha256=yDjf3aVOC3LvnAwAvQUl_e7S3NXq_vKgccq0hGuFdLI,6609
5
+ geo_activity_playground/core/config.py,sha256=uys4O2OfXC6yNAsgbgm72WGIhy903zomr_SOwk4GNmA,4498
6
6
  geo_activity_playground/core/coordinates.py,sha256=tDfr9mlXhK6E_MMIJ0vYWVCoH0Lq8uyuaqUgaa8i0jg,966
7
7
  geo_activity_playground/core/enrichment.py,sha256=CwZhW-svgPAYbdx3n9kvKlTgcsiCaeuJfSRCC4JxX6g,7411
8
8
  geo_activity_playground/core/heart_rate.py,sha256=IwMt58TpjOYqpAxtsj07zP2ttpN_J3GZeiv-qGhYyJc,1598
@@ -10,14 +10,14 @@ geo_activity_playground/core/heatmap.py,sha256=bRLQHzmTEsQbX8XWeg85x_lRGk272UoYR
10
10
  geo_activity_playground/core/paths.py,sha256=AiYUJv46my_FGYbHZmSs5ZrqeE65GNdWEMmXZgunZrk,2150
11
11
  geo_activity_playground/core/privacy_zones.py,sha256=4TumHsVUN1uW6RG3ArqTXDykPVipF98DCxVBe7YNdO8,512
12
12
  geo_activity_playground/core/similarity.py,sha256=Jo8jRViuORCxdIGvyaflgsQhwu9S_jn10a450FRL18A,3159
13
- geo_activity_playground/core/tasks.py,sha256=k_DHzY--V5EHqMh-aNFh-VsLiQntvAsGbmK5zZJ0YPI,2909
13
+ geo_activity_playground/core/tasks.py,sha256=f3C2gCJiiqMS1eRQ-Hp5hxRbkwl60rotrYfzKsWdpSU,2937
14
14
  geo_activity_playground/core/test_tiles.py,sha256=zce1FxNfsSpOQt66jMehdQRVoNdl-oiFydx6iVBHZXM,764
15
15
  geo_activity_playground/core/test_time_conversion.py,sha256=Sh6nZA3uCTOdZTZa3yOijtR0m74QtZu2mcWXsDNnyQI,984
16
16
  geo_activity_playground/core/tiles.py,sha256=KpzD-h3kNzZ2ieLt6f2xHilSF3lHyfaEXPnrGvlIAz0,3379
17
17
  geo_activity_playground/core/time_conversion.py,sha256=9J6aTlqJhWvsknQkoECNL-CIG-8BKs6ZatJJ9XJnTsg,367
18
18
  geo_activity_playground/explorer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  geo_activity_playground/explorer/grid_file.py,sha256=k6j6KBEk2a2BY-onE8SV5TJsERGGyOrlY4as__meWpA,3304
20
- geo_activity_playground/explorer/tile_visits.py,sha256=p0MK0V4fHhroR-FN8S9m5nW0vT7uO39Euo-_mOw0Q8k,13586
20
+ geo_activity_playground/explorer/tile_visits.py,sha256=IqkaGPq-xrmR81Ze31VL1v4NUxQ2YGQeQawvsjYe25s,12776
21
21
  geo_activity_playground/explorer/video.py,sha256=ROAmV9shfJyqTgnXVD41KFORiwnRgVpEWenIq4hMCRM,4389
22
22
  geo_activity_playground/importers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
23
  geo_activity_playground/importers/activity_parsers.py,sha256=m2SpvGlTZ8F3gG6YB24_ZFrlOAbtqbfWi-GIYspeUco,10593
@@ -31,12 +31,15 @@ geo_activity_playground/importers/test_strava_api.py,sha256=4vX7wDr1a9aRh8myxNrI
31
31
  geo_activity_playground/webui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  geo_activity_playground/webui/activity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  geo_activity_playground/webui/activity/blueprint.py,sha256=upQzZa5sKApj_Fmu6PziFDboi7SBL5Zsi-tNSSNPlEE,1759
34
- geo_activity_playground/webui/activity/controller.py,sha256=VJrbUD2oofESV2imNr7LBrx6jhSA8miuwnrERPsUuQc,17573
34
+ geo_activity_playground/webui/activity/controller.py,sha256=YaWHXIfSYSeybEFY706s_m4uHbfJ9FHxqIYtqwR5sGQ,17955
35
35
  geo_activity_playground/webui/activity/templates/activity/day.html.j2,sha256=r3qKl9uTzOko4R-ZzyYAZt1j61JSevYP4g0Yi06HHPg,2702
36
36
  geo_activity_playground/webui/activity/templates/activity/lines.html.j2,sha256=5gB1aDjRgi_RventenRfC10_FtMT4ch_VuWvA9AMlBY,1121
37
37
  geo_activity_playground/webui/activity/templates/activity/name.html.j2,sha256=RDLEt6ip8_ngmdLgaC5jg92Dk-F2umGwKkd8cWmvVko,2400
38
- geo_activity_playground/webui/activity/templates/activity/show.html.j2,sha256=3g-Hab06uWBPJo6DmTk4lM2OlozE1EyrQK6RgTgTuwU,5521
39
- geo_activity_playground/webui/app.py,sha256=xKPP0WDyHMAbkpJsuEz53skIjOMRBMvueD6LpRdYJE0,4119
38
+ geo_activity_playground/webui/activity/templates/activity/show.html.j2,sha256=6k3_MC_AR_sSWVYvH53E5o6YFtE9HZD62NlPf90Rj8Q,5509
39
+ geo_activity_playground/webui/app.py,sha256=Vn8G6wf2YSKc_n7JBkulry51Gl57_UjrAW0TftXLQgM,4456
40
+ geo_activity_playground/webui/auth/blueprint.py,sha256=Lx-ZvMnfHLC1CMre1xPQI3k_pCtQoZvgRhtmafULzoE,812
41
+ geo_activity_playground/webui/auth/templates/auth/index.html.j2,sha256=ILQ5HvTEYc3OrtOAIFt1VrqWorVD70V9DC342znmP70,579
42
+ geo_activity_playground/webui/authenticator.py,sha256=jXwWJFuinSE-7sQxdi7s7WO9ifvERmeXAM5-hsAPmwQ,1546
40
43
  geo_activity_playground/webui/calendar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
44
  geo_activity_playground/webui/calendar/blueprint.py,sha256=rlnhgU2DWAcdLMRq7m77NzrM_aDyp4s3kuuQHuzjHhg,782
42
45
  geo_activity_playground/webui/calendar/controller.py,sha256=QpSAkR2s1sbLSu6P_fNNTccgGglOzEH2PIv1XwKxeVY,2778
@@ -49,29 +52,31 @@ geo_activity_playground/webui/eddington/templates/eddington/index.html.j2,sha256
49
52
  geo_activity_playground/webui/entry_controller.py,sha256=n9v4MriyL8kDR91LE9eeqc2tAvxyzFgoNMMXpr0qh4g,1906
50
53
  geo_activity_playground/webui/equipment/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
54
  geo_activity_playground/webui/equipment/blueprint.py,sha256=_NIhRJuJNbXpEd_nEPo01AqnUqPgo1vawFn7E3yoeng,636
52
- geo_activity_playground/webui/equipment/controller.py,sha256=_iLkQeeu7UYMg7FGNemTrhm8e0pf409f9aUXnGfNsCI,4022
55
+ geo_activity_playground/webui/equipment/controller.py,sha256=Sx9i2RCK7m4W6FgpDfRMewcH64VBQfUhHJdTSCwMqOU,4079
53
56
  geo_activity_playground/webui/equipment/templates/equipment/index.html.j2,sha256=FEfxB4XwVYELAOdjVlSlprjJH_kLmE-pNWEEXdPqc6I,1778
54
57
  geo_activity_playground/webui/explorer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
58
  geo_activity_playground/webui/explorer/blueprint.py,sha256=EKnBs8llqT6Wy1uac18dF2epp3TebF9p3iGlSbj6Vl0,2337
56
- geo_activity_playground/webui/explorer/controller.py,sha256=M3FdPLrcP9GZiCjuRVY0ug1DtTqLvIbEDCzRHf6QuOE,11555
59
+ geo_activity_playground/webui/explorer/controller.py,sha256=-iMsuB05JgUYKahJxiOPZeNVmzr_Uw2psHwmaFiptkI,11614
57
60
  geo_activity_playground/webui/explorer/templates/explorer/index.html.j2,sha256=cm9pWY0vB84DtkTH-LBvSzfLU1FnmxQ2ECyw3Bl7dTo,6945
58
61
  geo_activity_playground/webui/heatmap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
62
  geo_activity_playground/webui/heatmap/blueprint.py,sha256=bjQu-HL3QN5UpJ6tHOifhcLGlPr_hIKvaRu78md4JqM,1470
60
- geo_activity_playground/webui/heatmap/heatmap_controller.py,sha256=i376qEqTJrhezGsdPyKR8VsRY2N0JdEra7_kY7NmwW8,6822
63
+ geo_activity_playground/webui/heatmap/heatmap_controller.py,sha256=Bn7FO_ciMEXghSwz2tEQ8fJsbK8AXaHTPNgnGuQ7an8,7353
61
64
  geo_activity_playground/webui/heatmap/templates/heatmap/index.html.j2,sha256=YLeu6P4djl8G4qAXR6DhetseqrbOodN7aN4coocknc4,1875
62
65
  geo_activity_playground/webui/search_controller.py,sha256=PzMf7b8tiJKZIZoPvQ9A2hOrzoKV9cS3jq05w2fK94c,532
63
- geo_activity_playground/webui/settings/blueprint.py,sha256=1u16oRlauOSPVBGBzjf08Ja9jxbqEKnwcDISWl7CmJU,4113
64
- geo_activity_playground/webui/settings/controller.py,sha256=ixuYgpMGggg9kmcIVTgL2gzAPhYjMpqWEPxS7YXlmnA,7902
66
+ geo_activity_playground/webui/settings/blueprint.py,sha256=boKeKkG4bbSA7HvgpaqlUXzrcCnOPwys6KWw-Dp5sAE,5416
67
+ geo_activity_playground/webui/settings/controller.py,sha256=v14oKvI1QzWn0g41Sm4NA_g9q4SUVZ_bO9SUOZuPAaY,9121
68
+ geo_activity_playground/webui/settings/templates/settings/admin-password.html.j2,sha256=VYwddpObD1RpeTH5Dm4y7VtmT7kwURDCIjxyzJeq08c,495
65
69
  geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2,sha256=ltaYwFe8S8Mi72ddmIp1vwqlu8MEXXjBGfbpN2WBTC4,1728
66
70
  geo_activity_playground/webui/settings/templates/settings/heart-rate.html.j2,sha256=UPT3MegRgSeff36lhCo0l3ZwhqNSIg5gM6h2s32GkCY,4255
67
- geo_activity_playground/webui/settings/templates/settings/index.html.j2,sha256=26VEC_MbdIyd7vbFokDFtJelcuK3YFb1_3WaFLGv_bk,3183
71
+ geo_activity_playground/webui/settings/templates/settings/index.html.j2,sha256=rXKq2v42J0eW5OSWx7VU6EFT9jZkbzKTW600Y0OqBtY,4035
68
72
  geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2,sha256=IdUfXon1Pu8zX3NirKb28ypshLHOvZRpz2T4bJrzrak,1067
69
73
  geo_activity_playground/webui/settings/templates/settings/metadata-extraction.html.j2,sha256=Ppa8O-zRJznbeCsF4YQj37_HM9nOW8fyTi66jvWvHmA,2285
70
74
  geo_activity_playground/webui/settings/templates/settings/privacy-zones.html.j2,sha256=7BxFvCaVJOEqbImyK5vxCmhh-NGSFaRa9ARhqjZeYJ0,3093
75
+ geo_activity_playground/webui/settings/templates/settings/sharepic.html.j2,sha256=qZkfEpd4CtKKMaSSVadqvNEgMRYLV-0X-pw5-nJvukk,678
71
76
  geo_activity_playground/webui/settings/templates/settings/strava.html.j2,sha256=FrXgT-m1PgvsQWo9kMKpk8QenKeifSDBCZFqKgsHRxQ,1827
72
77
  geo_activity_playground/webui/square_planner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
78
  geo_activity_playground/webui/square_planner/blueprint.py,sha256=r2VkSM547chX85g6c1BQ8NC-tkdqGdYp-2ZALBiiDTc,1320
74
- geo_activity_playground/webui/square_planner/controller.py,sha256=wYcNEviDgqyYxSrnwMD_5LnYXIazVH9plGX8RxG6oco,3464
79
+ geo_activity_playground/webui/square_planner/controller.py,sha256=vV7Wd4Kt1YPOI-UVoqLtIqUb3nke9PJQwWynlA31Lto,3483
75
80
  geo_activity_playground/webui/square_planner/templates/square_planner/index.html.j2,sha256=aIB0ql5qW4HXfp0ENksYYOk9vTgBitwyHJX5W7bqkeY,6512
76
81
  geo_activity_playground/webui/static/android-chrome-192x192.png,sha256=yxZgo8Jw4hrgOgrn3tvi9G0AXWGFD29kjCuxC07WoT4,17610
77
82
  geo_activity_playground/webui/static/android-chrome-384x384.png,sha256=bgeqAdyvDZBMch7rVi3qSawf0Zr4Go0EG8Ws_B8NApY,49297
@@ -90,18 +95,18 @@ geo_activity_playground/webui/summary/blueprint.py,sha256=kzQ6MDOycQKfDcVoEUmL7H
90
95
  geo_activity_playground/webui/summary/controller.py,sha256=ZOrwfrKjpc8hecUYImBvesKXZi06obfR1yhQkVTeWzw,8981
91
96
  geo_activity_playground/webui/summary/templates/summary/index.html.j2,sha256=rsII1eMY-xNugh8A9SecnEcDZqkEOWYIfiHAGroQYuM,4442
92
97
  geo_activity_playground/webui/templates/home.html.j2,sha256=FjEwr9kt_3qu_evIHpa7F_oGAINN8W2Z1T_j56ugJ5c,2406
93
- geo_activity_playground/webui/templates/page.html.j2,sha256=sqerdJLOIcgMtQy533kHPD9uVIxF3v6ItLQSjuoCqlI,9589
98
+ geo_activity_playground/webui/templates/page.html.j2,sha256=8DkXaXx_cc9FvfI6eo9z3fbeNDRcvJC2NXcHXs8ytSU,9588
94
99
  geo_activity_playground/webui/templates/search.html.j2,sha256=FvNRoDfUlSzXjM_tqZY_fDhuhUDgbPaY73q56gdvF1A,1130
95
100
  geo_activity_playground/webui/tile/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
96
101
  geo_activity_playground/webui/tile/blueprint.py,sha256=cK0o2Z3BrLycgF9zw0F8s9qF-JaYDbF5Gog-GXDtUZ8,943
97
102
  geo_activity_playground/webui/tile/controller.py,sha256=PISh4vKs27b-LxFfTARtr5RAwHFresA1Kw1MDcERSRU,1221
98
103
  geo_activity_playground/webui/upload/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
99
- geo_activity_playground/webui/upload/blueprint.py,sha256=qU6ZKOrhB8DA8GSbOECVu2fPKd2VtBFp0mEFzjJAgIA,1083
100
- geo_activity_playground/webui/upload/controller.py,sha256=8Xv9JMeTyPe6mhYiDBW_nv9W_Hp7yz4Cgv_lXbM_JfY,4271
101
- geo_activity_playground/webui/upload/templates/upload/index.html.j2,sha256=hfXkEXaz_MkM46rU56423D6V6WtK-EAXPszSY-_-Tx8,1471
104
+ geo_activity_playground/webui/upload/blueprint.py,sha256=xX9scEmleN_dL03jfhWRh5yI1WsFyhxUFiS_Ul2HWy4,1428
105
+ geo_activity_playground/webui/upload/controller.py,sha256=disRtrlvPiqsIFt9UaAokgtRtXCvosg7bXkAnN_qaxk,4102
106
+ geo_activity_playground/webui/upload/templates/upload/index.html.j2,sha256=I1Ix8tDS3YBdi-HdaNfjkzYXVVCjfUTe5PFTnap1ydc,775
102
107
  geo_activity_playground/webui/upload/templates/upload/reload.html.j2,sha256=YZWX5eDeNyqKJdQAywDBcU8DZBm22rRBbZqFjrFrCvQ,556
103
- geo_activity_playground-0.27.1.dist-info/LICENSE,sha256=4RpAwKO8bPkfXH2lnpeUW0eLkNWglyG4lbrLDU_MOwY,1070
104
- geo_activity_playground-0.27.1.dist-info/METADATA,sha256=Wm7WbQhD76zAw6Vf127jOm7ZeYPy0hllY9DZTKeh8oE,1665
105
- geo_activity_playground-0.27.1.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
106
- geo_activity_playground-0.27.1.dist-info/entry_points.txt,sha256=pbNlLI6IIZIp7nPYCfAtiSiz2oxJSCl7DODD6SPkLKk,81
107
- geo_activity_playground-0.27.1.dist-info/RECORD,,
108
+ geo_activity_playground-0.28.0.dist-info/LICENSE,sha256=4RpAwKO8bPkfXH2lnpeUW0eLkNWglyG4lbrLDU_MOwY,1070
109
+ geo_activity_playground-0.28.0.dist-info/METADATA,sha256=0ZheRNfyebSkkcFUph4A3Wo3EheMmw0c0U2fi9C-MWc,1665
110
+ geo_activity_playground-0.28.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
111
+ geo_activity_playground-0.28.0.dist-info/entry_points.txt,sha256=pbNlLI6IIZIp7nPYCfAtiSiz2oxJSCl7DODD6SPkLKk,81
112
+ geo_activity_playground-0.28.0.dist-info/RECORD,,