geo-activity-playground 0.24.1__py3-none-any.whl → 0.25.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 (34) hide show
  1. geo_activity_playground/__main__.py +0 -2
  2. geo_activity_playground/core/activities.py +71 -149
  3. geo_activity_playground/core/enrichment.py +164 -0
  4. geo_activity_playground/core/paths.py +34 -15
  5. geo_activity_playground/core/tasks.py +26 -3
  6. geo_activity_playground/explorer/tile_visits.py +78 -42
  7. geo_activity_playground/{core → importers}/activity_parsers.py +7 -14
  8. geo_activity_playground/importers/directory.py +36 -27
  9. geo_activity_playground/importers/strava_api.py +45 -38
  10. geo_activity_playground/importers/strava_checkout.py +24 -16
  11. geo_activity_playground/webui/activity/controller.py +2 -2
  12. geo_activity_playground/webui/activity/templates/activity/show.html.j2 +2 -0
  13. geo_activity_playground/webui/app.py +11 -31
  14. geo_activity_playground/webui/entry_controller.py +5 -5
  15. geo_activity_playground/webui/equipment/controller.py +80 -39
  16. geo_activity_playground/webui/equipment/templates/equipment/index.html.j2 +14 -3
  17. geo_activity_playground/webui/heatmap/heatmap_controller.py +6 -0
  18. geo_activity_playground/webui/strava/__init__.py +0 -0
  19. geo_activity_playground/webui/strava/blueprint.py +33 -0
  20. geo_activity_playground/webui/strava/controller.py +47 -0
  21. geo_activity_playground/webui/{templates/strava-connect.html.j2 → strava/templates/strava/client-id.html.j2} +3 -7
  22. geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +14 -0
  23. geo_activity_playground/webui/summary/controller.py +11 -8
  24. geo_activity_playground/webui/templates/home.html.j2 +5 -0
  25. geo_activity_playground/webui/templates/page.html.j2 +3 -0
  26. geo_activity_playground/webui/templates/settings.html.j2 +15 -0
  27. geo_activity_playground/webui/upload/controller.py +12 -16
  28. {geo_activity_playground-0.24.1.dist-info → geo_activity_playground-0.25.0.dist-info}/METADATA +1 -1
  29. {geo_activity_playground-0.24.1.dist-info → geo_activity_playground-0.25.0.dist-info}/RECORD +32 -28
  30. geo_activity_playground/core/cache_migrations.py +0 -133
  31. geo_activity_playground/webui/strava_controller.py +0 -27
  32. {geo_activity_playground-0.24.1.dist-info → geo_activity_playground-0.25.0.dist-info}/LICENSE +0 -0
  33. {geo_activity_playground-0.24.1.dist-info → geo_activity_playground-0.25.0.dist-info}/WHEEL +0 -0
  34. {geo_activity_playground-0.24.1.dist-info → geo_activity_playground-0.25.0.dist-info}/entry_points.txt +0 -0
@@ -18,11 +18,12 @@ from .explorer.blueprint import make_explorer_blueprint
18
18
  from .heatmap.blueprint import make_heatmap_blueprint
19
19
  from .search_controller import SearchController
20
20
  from .square_planner.blueprint import make_square_planner_blueprint
21
- from .strava_controller import StravaController
21
+ from .strava.blueprint import make_strava_blueprint
22
22
  from .summary.blueprint import make_summary_blueprint
23
23
  from .tile.blueprint import make_tile_blueprint
24
24
  from .upload.blueprint import make_upload_blueprint
25
25
  from geo_activity_playground.core.privacy_zones import PrivacyZone
26
+ from geo_activity_playground.webui.strava.controller import StravaController
26
27
 
27
28
 
28
29
  def route_search(app: Flask, repository: ActivityRepository) -> None:
@@ -45,35 +46,10 @@ def route_start(app: Flask, repository: ActivityRepository) -> None:
45
46
  return render_template("home.html.j2", **entry_controller.render())
46
47
 
47
48
 
48
- def route_strava(app: Flask, host: str, port: int) -> None:
49
- strava_controller = StravaController()
50
-
51
- @app.route("/strava/connect")
52
- def strava_connect():
53
- return render_template(
54
- "strava-connect.html.j2",
55
- host=host,
56
- port=port,
57
- **strava_controller.action_connect()
58
- )
59
-
60
- @app.route("/strava/authorize")
61
- def strava_authorize():
62
- client_id = request.form["client_id"]
63
- client_secret = request.form["client_secret"]
64
- return redirect(
65
- strava_controller.action_authorize(host, port, client_id, client_secret)
66
- )
67
-
68
- @app.route("/strava/callback")
69
- def strava_callback():
70
- code = request.args.get("code", type=str)
71
- return render_template(
72
- "strava-connect.html.j2",
73
- host=host,
74
- port=port,
75
- **strava_controller.action_connect()
76
- )
49
+ def route_settings(app: Flask) -> None:
50
+ @app.route("/settings")
51
+ def settings():
52
+ return render_template("settings.html.j2")
77
53
 
78
54
 
79
55
  def get_secret_key():
@@ -99,7 +75,7 @@ def webui_main(
99
75
 
100
76
  route_search(app, repository)
101
77
  route_start(app, repository)
102
- route_strava(app, host, port)
78
+ route_settings(app)
103
79
 
104
80
  app.config["UPLOAD_FOLDER"] = "Activities"
105
81
  app.secret_key = get_secret_key()
@@ -136,6 +112,10 @@ def webui_main(
136
112
  make_summary_blueprint(repository),
137
113
  url_prefix="/summary",
138
114
  )
115
+ app.register_blueprint(
116
+ make_strava_blueprint(host, port),
117
+ url_prefix="/strava",
118
+ )
139
119
  app.register_blueprint(make_tile_blueprint(), url_prefix="/tile")
140
120
  app.register_blueprint(
141
121
  make_upload_blueprint(repository, tile_visit_accessor, config),
@@ -13,12 +13,12 @@ class EntryController:
13
13
  self._repository = repository
14
14
 
15
15
  def render(self) -> dict:
16
- result = {
17
- "distance_last_30_days_plot": distance_last_30_days_meta_plot(
16
+ result = {"latest_activities": []}
17
+
18
+ if len(self._repository):
19
+ result["distance_last_30_days_plot"] = distance_last_30_days_meta_plot(
18
20
  self._repository.meta
19
- ),
20
- "latest_activities": [],
21
- }
21
+ )
22
22
 
23
23
  for activity in itertools.islice(
24
24
  self._repository.iter_activities(dropna=True), 15
@@ -10,64 +10,105 @@ class EquipmentController:
10
10
  self._repository = repository
11
11
 
12
12
  def render(self) -> dict:
13
- total_distances = (
14
- self._repository.meta.groupby("equipment")
15
- .apply(
16
- lambda group: pd.DataFrame(
17
- {
18
- "time": group["start"],
19
- "total_distance_km": group["distance_km"].cumsum(),
20
- }
21
- ),
22
- include_groups=False,
23
- )
24
- .reset_index()
25
- )
26
-
27
- plot = (
28
- alt.Chart(
29
- total_distances,
30
- height=250,
31
- width=250,
32
- title="Equipment Usage over Time",
33
- )
34
- .mark_line(interpolate="step-after")
35
- .encode(
36
- alt.X("time", title="Date"),
37
- alt.Y("total_distance_km", title="Cumulative distance / km"),
38
- )
39
- .facet("equipment", columns=4, title="Equipment")
40
- .resolve_scale(y="independent")
41
- .resolve_axis(x="independent")
42
- .interactive()
43
- .to_json(format="vega")
13
+ kind_per_equipment = self._repository.meta.groupby("equipment").apply(
14
+ lambda group: group.groupby("kind")
15
+ .apply(lambda group2: sum(group2["distance_km"]), include_groups=False)
16
+ .idxmax(),
17
+ include_groups=False,
44
18
  )
45
19
 
46
20
  equipment_summary = (
47
21
  self._repository.meta.groupby("equipment")
48
22
  .apply(
49
- lambda group: pd.DataFrame(
23
+ lambda group: pd.Series(
50
24
  {
51
25
  "total_distance_km": group["distance_km"].sum(),
52
26
  "first_use": group["start"].iloc[0],
53
27
  "last_use": group["start"].iloc[-1],
54
28
  },
55
- index=[0],
56
29
  ),
57
30
  include_groups=False,
58
31
  )
59
- .reset_index()
60
32
  .sort_values("last_use", ascending=False)
61
33
  )
62
34
 
35
+ equipment_summary["primarily_used_for"] = None
36
+ for equipment, kind in kind_per_equipment.items():
37
+ equipment_summary.loc[equipment, "primarily_used_for"] = kind
38
+
39
+ equipment_variables = {}
40
+ for equipment in equipment_summary.index:
41
+ selection = self._repository.meta.loc[
42
+ self._repository.meta["equipment"] == equipment
43
+ ]
44
+ total_distances = pd.DataFrame(
45
+ {
46
+ "time": selection["start"],
47
+ "total_distance_km": selection["distance_km"].cumsum(),
48
+ }
49
+ )
50
+
51
+ total_distances_plot = (
52
+ alt.Chart(
53
+ total_distances,
54
+ height=300,
55
+ width=300,
56
+ title="Usage over Time",
57
+ )
58
+ .mark_line(interpolate="step-after")
59
+ .encode(
60
+ alt.X("time", title="Date"),
61
+ alt.Y("total_distance_km", title="Cumulative distance / km"),
62
+ )
63
+ .interactive()
64
+ .to_json(format="vega")
65
+ )
66
+
67
+ yearly_distance_plot = (
68
+ alt.Chart(
69
+ selection,
70
+ height=300,
71
+ title="Yearly distance",
72
+ )
73
+ .mark_bar()
74
+ .encode(
75
+ alt.X("year(start):O", title="Year"),
76
+ alt.Y("sum(distance_km)", title="Distance / km"),
77
+ )
78
+ .to_json(format="vega")
79
+ )
80
+
81
+ usages_plot = (
82
+ alt.Chart(
83
+ selection,
84
+ height=300,
85
+ title="Kinds",
86
+ )
87
+ .mark_bar()
88
+ .encode(
89
+ alt.X("kind", title="Year"),
90
+ alt.Y("sum(distance_km)", title="Distance / km"),
91
+ )
92
+ .to_json(format="vega")
93
+ )
94
+
95
+ equipment_variables[equipment] = {
96
+ "total_distances_plot": total_distances_plot,
97
+ "total_distances_plot_id": f"total_distances_plot_{hash(equipment)}",
98
+ "yearly_distance_plot": yearly_distance_plot,
99
+ "yearly_distance_plot_id": f"yearly_distance_plot_{hash(equipment)}",
100
+ "usages_plot": usages_plot,
101
+ "usages_plot_id": f"usages_plot_{hash(equipment)}",
102
+ }
103
+
63
104
  config = get_config()
64
105
  if "offsets" in config:
65
106
  for equipment, offset in config["offsets"].items():
66
- equipment_summary.loc[
67
- equipment_summary["equipment"] == equipment, "total_distance_km"
68
- ] += offset
107
+ equipment_summary.loc[equipment, "total_distance_km"] += offset
69
108
 
70
109
  return {
71
- "total_distances_plot": plot,
72
- "equipment_summary": equipment_summary.to_dict(orient="records"),
110
+ "equipment_variables": equipment_variables,
111
+ "equipment_summary": equipment_summary.reset_index().to_dict(
112
+ orient="records"
113
+ ),
73
114
  }
@@ -13,6 +13,7 @@
13
13
  <thead>
14
14
  <tr>
15
15
  <th>Equipment</th>
16
+ <th>Primarily used for</th>
16
17
  <th style="text-align: right;">Distance / km</th>
17
18
  <th style="text-align: right;">First use</th>
18
19
  <th style="text-align: right;">Last use</th>
@@ -22,6 +23,7 @@
22
23
  {% for equipment in equipment_summary %}
23
24
  <tr>
24
25
  <td>{{ equipment.equipment }}</td>
26
+ <td>{{ equipment.primarily_used_for }}</td>
25
27
  <td style="text-align: right;">{{ equipment.total_distance_km|int }}</td>
26
28
  <td style="text-align: right;">{{ equipment.first_use.date() }}</td>
27
29
  <td style="text-align: right;">{{ equipment.last_use.date() }}</td>
@@ -34,13 +36,22 @@
34
36
 
35
37
  <div class="row mb-3">
36
38
  <div class="col">
37
- <h2>Usage over time</h2>
39
+ <h2>Details for each equipment</h2>
38
40
  </div>
39
41
  </div>
40
42
 
43
+ {% for equipment, data in equipment_variables.items() %}
44
+ <h3>{{ equipment }}</h3>
41
45
  <div class="row mb-3">
42
- <div class="col">
43
- {{ vega_direct("total_distances_plot", total_distances_plot) }}
46
+ <div class="col-md-4">
47
+ {{ vega_direct(data.total_distances_plot_id, data.total_distances_plot) }}
48
+ </div>
49
+ <div class="col-md-4">
50
+ {{ vega_direct(data.yearly_distance_plot_id, data.yearly_distance_plot) }}
51
+ </div>
52
+ <div class="col-md-4">
53
+ {{ vega_direct(data.usages_plot_id, data.usages_plot) }}
44
54
  </div>
45
55
  </div>
56
+ {% endfor %}
46
57
  {% endblock %}
@@ -83,6 +83,12 @@ class HeatmapController:
83
83
  with work_tracker(
84
84
  tile_count_cache_path.with_suffix(".json")
85
85
  ) as parsed_activities:
86
+ if parsed_activities - activity_ids:
87
+ logger.warning(
88
+ f"Resetting heatmap cache for {kind=}/{x=}/{y=}/{z=} because activities have been removed."
89
+ )
90
+ tile_counts = np.zeros(tile_pixels, dtype=np.int32)
91
+ parsed_activities.clear()
86
92
  for activity_id in activity_ids:
87
93
  if activity_id in parsed_activities:
88
94
  continue
File without changes
@@ -0,0 +1,33 @@
1
+ from flask import Blueprint
2
+ from flask import redirect
3
+ from flask import render_template
4
+ from flask import request
5
+
6
+ from .controller import StravaController
7
+
8
+
9
+ def make_strava_blueprint(host: str, port: int) -> Blueprint:
10
+ strava_controller = StravaController(host, port)
11
+ blueprint = Blueprint("strava", __name__, template_folder="templates")
12
+
13
+ @blueprint.route("/setup")
14
+ def setup():
15
+ return render_template(
16
+ "strava/client-id.html.j2", **strava_controller.set_client_id()
17
+ )
18
+
19
+ @blueprint.route("/post-client-id", methods=["POST"])
20
+ def post_client_id():
21
+ client_id = request.form["client_id"]
22
+ client_secret = request.form["client_secret"]
23
+ url = strava_controller.save_client_id(client_id, client_secret)
24
+ return redirect(url)
25
+
26
+ @blueprint.route("/callback")
27
+ def strava_callback():
28
+ code = request.args.get("code", type=str)
29
+ return render_template(
30
+ "strava/connected.html.j2", **strava_controller.save_code(code)
31
+ )
32
+
33
+ return blueprint
@@ -0,0 +1,47 @@
1
+ import json
2
+ import urllib.parse
3
+ from typing import Optional
4
+
5
+ from geo_activity_playground.core.paths import strava_dynamic_config_path
6
+
7
+
8
+ class StravaController:
9
+ def __init__(self, host: str, port: int) -> None:
10
+ self._host = host
11
+ self._port = port
12
+
13
+ self._client_secret: Optional[str] = None
14
+
15
+ def set_client_id(self) -> dict:
16
+ return {"host": self._host, "port": self._port}
17
+
18
+ def save_client_id(self, client_id: str, client_secret: str) -> str:
19
+ self._client_id = client_id
20
+ self._client_secret = client_secret
21
+
22
+ payload = {
23
+ "client_id": client_id,
24
+ "redirect_uri": f"http://{self._host}:{self._port}/strava/callback",
25
+ "response_type": "code",
26
+ "scope": "activity:read_all",
27
+ }
28
+
29
+ arg_string = "&".join(
30
+ f"{key}={urllib.parse.quote(value)}" for key, value in payload.items()
31
+ )
32
+ return f"https://www.strava.com/oauth/authorize?{arg_string}"
33
+
34
+ def save_code(self, code: str) -> dict:
35
+ self._code = code
36
+
37
+ with open(strava_dynamic_config_path(), "w") as f:
38
+ json.dump(
39
+ {
40
+ "client_id": self._client_id,
41
+ "client_secret": self._client_secret,
42
+ "code": self._code,
43
+ },
44
+ f,
45
+ )
46
+
47
+ return {}
@@ -6,21 +6,17 @@
6
6
 
7
7
  <div class="row mb-3">
8
8
  <div class="col-md-4">
9
- <form action="/strava/authorize" method="POST">
9
+ <form action="/strava/post-client-id" method="POST">
10
10
  <div class="mb-3">
11
11
  <label for="client_id" class="form-label">Client ID</label>
12
- <input type="text" class="form-control" id="client_id" name="client_id" placeholder="814722" />
12
+ <input type="text" class="form-control" id="client_id" name="client_id" value="131693" />
13
13
  </div>
14
14
  <div class="mb-3">
15
15
  <label for="client_secret" class="form-label">Client Secret</label>
16
16
  <input type="text" class="form-control" id="client_secret" name="client_secret"
17
- placeholder="ed9d766e135d95c79dbfca4379d09661a72ebdfd" />
17
+ value="0ccc0100a2c218512a7ef0cea3b0e322fb4b4365" />
18
18
  </div>
19
19
 
20
-
21
- <input type="hidden" name="redirect_uri" value="http://{{ host }}:{{ port }}/strava/callback" />
22
- <input type="hidden" name="response_type" value="code" />
23
- <input type="hidden" name="scope" value="activity:read_all" />
24
20
  <input type="submit" value="Connect to Strava" />
25
21
  </form>
26
22
  </div>
@@ -0,0 +1,14 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Strava Connect</h1>
6
+
7
+ <div class="row mb-3">
8
+ <div class="col-md-4">
9
+ <p>You're now connected to Strava. Please restart the webserver to start loading actvities.</p>
10
+ </div>
11
+ </div>
12
+
13
+
14
+ {% endblock %}
@@ -61,11 +61,13 @@ def nominate_activities(meta: pd.DataFrame) -> dict[int, list[str]]:
61
61
  i = subset["elapsed_time"].idxmax()
62
62
  nominations[i].append(f"Longest elapsed time: {meta.loc[i].elapsed_time}")
63
63
 
64
- i = subset["calories"].idxmax()
65
- nominations[i].append(f"Most calories burnt: {meta.loc[i].calories:.0f} kcal")
64
+ if "calories" in subset.columns:
65
+ i = subset["calories"].idxmax()
66
+ nominations[i].append(f"Most calories burnt: {meta.loc[i].calories:.0f} kcal")
66
67
 
67
- i = subset["steps"].idxmax()
68
- nominations[i].append(f"Most steps: {meta.loc[i].steps:.0f}")
68
+ if "steps" in subset:
69
+ i = subset["steps"].idxmax()
70
+ nominations[i].append(f"Most steps: {meta.loc[i].steps:.0f}")
69
71
 
70
72
  for kind, group in meta.groupby("kind"):
71
73
  for key, text in [
@@ -83,10 +85,11 @@ def nominate_activities(meta: pd.DataFrame) -> dict[int, list[str]]:
83
85
  ),
84
86
  ("steps", lambda row: f"Most steps for {row.kind}: {row.steps:.0f}"),
85
87
  ]:
86
- series = group[key]
87
- i = series.idxmax()
88
- if not pd.isna(i):
89
- nominations[i].append(text(meta.loc[i]))
88
+ if key in group.columns:
89
+ series = group[key]
90
+ i = series.idxmax()
91
+ if not pd.isna(i):
92
+ nominations[i].append(text(meta.loc[i]))
90
93
 
91
94
  return nominations
92
95
 
@@ -4,6 +4,7 @@
4
4
  <div class="row mb-3">
5
5
  </div>
6
6
 
7
+ {% if latest_activities %}
7
8
  <div class="row mb-3">
8
9
  <div class="col-md-9">
9
10
  <h2>Last 30 days</h2>
@@ -63,5 +64,9 @@
63
64
  {% endfor %}
64
65
  </div>
65
66
  {% endfor %}
67
+ {% else %}
68
+ <p>You don't have activities yet. Either put some files into a directory <tt>Activities</tt> or <a
69
+ href="{{ url_for('strava.setup') }}">set up Strava API</a>.</p>
70
+ {% endif %}
66
71
 
67
72
  {% endblock %}
@@ -90,6 +90,9 @@
90
90
  <a class="nav-link active" aria-current="page"
91
91
  href="{{ url_for('upload.index') }}">Upload</a>
92
92
  </li>
93
+ <li class="nav-item">
94
+ <a class="nav-link active" aria-current="page" href="{{ url_for('settings') }}">Settings</a>
95
+ </li>
93
96
  </ul>
94
97
  </div>
95
98
  </div>
@@ -0,0 +1,15 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+ <div class="row mb-3">
5
+ <div class="col">
6
+ <h1>Settings</h1>
7
+ </div>
8
+ </div>
9
+
10
+ <div class="row mb-3">
11
+ <div class="col">
12
+ <p><a href="{{ url_for('strava.setup') }}">Set up Strava API</a>.</p>
13
+ </div>
14
+ </div>
15
+ {% endblock %}
@@ -10,7 +10,9 @@ from flask import Response
10
10
  from werkzeug.utils import secure_filename
11
11
 
12
12
  from geo_activity_playground.core.activities import ActivityRepository
13
- from geo_activity_playground.core.activities import embellish_time_series
13
+ from geo_activity_playground.core.activities import build_activity_meta
14
+ from geo_activity_playground.core.enrichment import enrich_activities
15
+ from geo_activity_playground.core.paths import _strava_dynamic_config_path
14
16
  from geo_activity_playground.explorer.tile_visits import compute_tile_evolution
15
17
  from geo_activity_playground.explorer.tile_visits import compute_tile_visits
16
18
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
@@ -94,22 +96,16 @@ def scan_for_activities(
94
96
  skip_strava: bool = False,
95
97
  ) -> None:
96
98
  if pathlib.Path("Activities").exists():
97
- import_from_directory(
98
- repository,
99
- config.get("kind", {}),
100
- config.get("metadata_extraction_regexes", []),
101
- )
99
+ import_from_directory(config.get("metadata_extraction_regexes", []))
102
100
  if pathlib.Path("Strava Export").exists():
103
101
  import_from_strava_checkout(repository)
104
- if "strava" in config and not skip_strava:
105
- import_from_strava_api(repository)
102
+ if (_strava_dynamic_config_path.exists() or "strava" in config) and not skip_strava:
103
+ import_from_strava_api()
106
104
 
107
- if len(repository) == 0:
108
- logger.error(
109
- f"No activities found. You need to either add activity files (GPX, FIT, …) to {pathlib.Path('Activities')} or set up the Strava API. Starting without any activities is unfortunately not supported."
110
- )
111
- sys.exit(1)
105
+ enrich_activities(config.get("kind", {}))
106
+ build_activity_meta()
107
+ repository.reload()
112
108
 
113
- embellish_time_series(repository)
114
- compute_tile_visits(repository, tile_visit_accessor)
115
- compute_tile_evolution(tile_visit_accessor)
109
+ if len(repository) > 0:
110
+ compute_tile_visits(repository, tile_visit_accessor)
111
+ compute_tile_evolution(tile_visit_accessor)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-activity-playground
3
- Version: 0.24.1
3
+ Version: 0.25.0
4
4
  Summary: Analysis of geo data activities like rides, runs or hikes.
5
5
  License: MIT
6
6
  Author: Martin Ueding