geo-activity-playground 0.41.0__py3-none-any.whl → 0.43.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 (37) 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 +7 -15
  5. geo_activity_playground/core/datamodel.py +91 -85
  6. geo_activity_playground/core/enrichment.py +15 -6
  7. geo_activity_playground/core/meta_search.py +78 -34
  8. geo_activity_playground/core/missing_values.py +16 -0
  9. geo_activity_playground/core/paths.py +2 -0
  10. geo_activity_playground/core/test_missing_values.py +19 -0
  11. geo_activity_playground/explorer/tile_visits.py +1 -1
  12. geo_activity_playground/webui/app.py +22 -8
  13. geo_activity_playground/webui/blueprints/activity_blueprint.py +18 -10
  14. geo_activity_playground/webui/blueprints/photo_blueprint.py +198 -0
  15. geo_activity_playground/webui/blueprints/settings_blueprint.py +32 -0
  16. geo_activity_playground/webui/search_util.py +23 -7
  17. geo_activity_playground/webui/templates/activity/edit.html.j2 +15 -0
  18. geo_activity_playground/webui/templates/activity/show.html.j2 +56 -12
  19. geo_activity_playground/webui/templates/eddington/distance.html.j2 +1 -2
  20. geo_activity_playground/webui/templates/eddington/elevation_gain.html.j2 +1 -2
  21. geo_activity_playground/webui/templates/elevation_eddington/index.html.j2 +18 -15
  22. geo_activity_playground/webui/templates/heatmap/index.html.j2 +1 -2
  23. geo_activity_playground/webui/templates/page.html.j2 +8 -0
  24. geo_activity_playground/webui/templates/photo/map.html.j2 +45 -0
  25. geo_activity_playground/webui/templates/photo/new.html.j2 +13 -0
  26. geo_activity_playground/webui/templates/search/index.html.j2 +1 -2
  27. geo_activity_playground/webui/templates/search_form.html.j2 +47 -22
  28. geo_activity_playground/webui/templates/settings/index.html.j2 +9 -0
  29. geo_activity_playground/webui/templates/settings/tags-edit.html.j2 +17 -0
  30. geo_activity_playground/webui/templates/settings/tags-list.html.j2 +19 -0
  31. geo_activity_playground/webui/templates/settings/tags-new.html.j2 +17 -0
  32. geo_activity_playground/webui/templates/summary/index.html.j2 +12 -10
  33. {geo_activity_playground-0.41.0.dist-info → geo_activity_playground-0.43.0.dist-info}/METADATA +3 -1
  34. {geo_activity_playground-0.41.0.dist-info → geo_activity_playground-0.43.0.dist-info}/RECORD +37 -28
  35. {geo_activity_playground-0.41.0.dist-info → geo_activity_playground-0.43.0.dist-info}/WHEEL +1 -1
  36. {geo_activity_playground-0.41.0.dist-info → geo_activity_playground-0.43.0.dist-info}/LICENSE +0 -0
  37. {geo_activity_playground-0.41.0.dist-info → geo_activity_playground-0.43.0.dist-info}/entry_points.txt +0 -0
@@ -1,10 +1,4 @@
1
- """${message}
2
1
 
3
- Revision ID: ${up_revision}
4
- Revises: ${down_revision | comma,n}
5
- Create Date: ${create_date}
6
-
7
- """
8
2
  from typing import Sequence, Union
9
3
 
10
4
  from alembic import op
@@ -0,0 +1,40 @@
1
+ from typing import Sequence
2
+ from typing import Union
3
+
4
+ import sqlalchemy as sa
5
+ from alembic import op
6
+
7
+
8
+ # revision identifiers, used by Alembic.
9
+ revision: str = "da2cba03b71d"
10
+ down_revision: Union[str, None] = "38882503dc7c"
11
+ branch_labels: Union[str, Sequence[str], None] = None
12
+ depends_on: Union[str, Sequence[str], None] = None
13
+
14
+
15
+ def upgrade() -> None:
16
+ # ### commands auto generated by Alembic - please adjust! ###
17
+ op.create_table(
18
+ "photos",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("filename", sa.String(), nullable=False),
21
+ sa.Column("time", sa.DateTime(), nullable=False),
22
+ sa.Column("latitude", sa.Float(), nullable=False),
23
+ sa.Column("longitude", sa.Float(), nullable=False),
24
+ sa.Column("activity_id", sa.Integer(), nullable=False),
25
+ sa.ForeignKeyConstraint(["activity_id"], ["activities.id"], name="activity_id"),
26
+ sa.PrimaryKeyConstraint("id"),
27
+ )
28
+ with op.batch_alter_table("tags", schema=None) as batch_op:
29
+ batch_op.create_unique_constraint("tags_tag", ["tag"])
30
+
31
+ # ### end Alembic commands ###
32
+
33
+
34
+ def downgrade() -> None:
35
+ # ### commands auto generated by Alembic - please adjust! ###
36
+ with op.batch_alter_table("tags", schema=None) as batch_op:
37
+ batch_op.drop_constraint("tags_tag", type_="unique")
38
+
39
+ op.drop_table("photos")
40
+ # ### end Alembic commands ###
@@ -1,4 +1,10 @@
1
+ """${message}
1
2
 
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
2
8
  from typing import Sequence, Union
3
9
 
4
10
  from alembic import op
@@ -2,6 +2,7 @@ import datetime
2
2
  import functools
3
3
  import logging
4
4
  from collections.abc import Callable
5
+ from collections.abc import Sequence
5
6
  from typing import Any
6
7
  from typing import Optional
7
8
 
@@ -21,20 +22,11 @@ from geo_activity_playground.core.datamodel import query_activity_meta
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
24
- def make_activity_meta() -> ActivityMeta:
25
- return ActivityMeta(
26
- calories=None,
27
- commute=False,
28
- consider_for_achievements=True,
29
- equipment="Unknown",
30
- kind="Unknown",
31
- steps=None,
32
- )
33
-
34
-
35
25
  class ActivityRepository:
36
26
  def __len__(self) -> int:
37
- return len(self.get_activity_ids())
27
+ return DB.session.scalars(
28
+ sqlalchemy.select(sqlalchemy.func.count()).select_from(Activity)
29
+ ).one()
38
30
 
39
31
  def has_activity(self, activity_id: int) -> bool:
40
32
  return bool(
@@ -52,14 +44,14 @@ class ActivityRepository:
52
44
  else:
53
45
  return None
54
46
 
55
- def get_activity_ids(self, only_achievements: bool = False) -> list[int]:
47
+ def get_activity_ids(self, only_achievements: bool = False) -> Sequence[int]:
56
48
  query = sqlalchemy.select(Activity.id)
57
49
  if only_achievements:
58
50
  query = query.where(Kind.consider_for_achievements)
59
- result = DB.session.scalars(query).all()
51
+ result = DB.session.scalars(query.order_by(Activity.start)).all()
60
52
  return result
61
53
 
62
- def iter_activities(self, new_to_old=True, drop_na=False) -> list[Activity]:
54
+ def iter_activities(self, new_to_old=True, drop_na=False) -> Sequence[Activity]:
63
55
  query = sqlalchemy.select(Activity)
64
56
  if drop_na:
65
57
  query = query.where(Activity.start.is_not(None))
@@ -1,6 +1,7 @@
1
1
  import datetime
2
2
  import json
3
3
  import logging
4
+ import pathlib
4
5
  from typing import Any
5
6
  from typing import Optional
6
7
  from typing import TypedDict
@@ -117,8 +118,9 @@ class Activity(DB.Model):
117
118
  secondary=activity_tag_association_table, back_populates="activities"
118
119
  )
119
120
 
120
- def __getitem__(self, item) -> Any:
121
- return self.to_dict()[item]
121
+ photos: Mapped[list["Photo"]] = relationship(
122
+ back_populates="activity", cascade="all, delete-orphan"
123
+ )
122
124
 
123
125
  def __str__(self) -> str:
124
126
  return f"{self.start} {self.name}"
@@ -127,11 +129,15 @@ class Activity(DB.Model):
127
129
  def average_speed_moving_kmh(self) -> Optional[float]:
128
130
  if self.moving_time:
129
131
  return self.distance_km / (self.moving_time.total_seconds() / 3_600)
132
+ else:
133
+ return None
130
134
 
131
135
  @property
132
136
  def average_speed_elapsed_kmh(self) -> Optional[float]:
133
137
  if self.elapsed_time:
134
138
  return self.distance_km / (self.elapsed_time.total_seconds() / 3_600)
139
+ else:
140
+ return None
135
141
 
136
142
  @property
137
143
  def raw_time_series(self) -> pd.DataFrame:
@@ -154,38 +160,6 @@ class Activity(DB.Model):
154
160
  else:
155
161
  return self.raw_time_series
156
162
 
157
- def to_dict(self) -> ActivityMeta:
158
- equipment = self.equipment.name if self.equipment is not None else "Unknown"
159
- kind = self.kind.name if self.kind is not None else "Unknown"
160
- consider_for_achievements = (
161
- self.kind.consider_for_achievements if self.kind is not None else True
162
- )
163
- return ActivityMeta(
164
- id=self.id,
165
- name=self.name,
166
- path=self.path,
167
- distance_km=self.distance_km,
168
- start=self.start,
169
- elapsed_time=self.elapsed_time,
170
- moving_time=self.moving_time,
171
- start_latitude=self.start_latitude,
172
- start_longitude=self.start_longitude,
173
- end_latitude=self.end_latitude,
174
- end_longitude=self.end_longitude,
175
- elevation_gain=self.elevation_gain,
176
- start_elevation=self.start_elevation,
177
- end_elevation=self.end_elevation,
178
- calories=self.calories,
179
- steps=self.steps,
180
- num_new_tiles_14=self.num_new_tiles_14,
181
- num_new_tiles_17=self.num_new_tiles_17,
182
- equipment=equipment,
183
- kind=kind,
184
- average_speed_moving_kmh=self.average_speed_moving_kmh,
185
- average_speed_elapsed_kmh=self.average_speed_elapsed_kmh,
186
- consider_for_achievements=consider_for_achievements,
187
- )
188
-
189
163
 
190
164
  class Tag(DB.Model):
191
165
  __tablename__ = "tags"
@@ -199,7 +173,18 @@ class Tag(DB.Model):
199
173
  )
200
174
 
201
175
 
202
- def query_activity_meta() -> pd.DataFrame:
176
+ def get_or_make_tag(tag: str) -> Tag:
177
+ tags = DB.session.scalars(sqlalchemy.select(Tag).where(Tag.tag == tag)).all()
178
+ if tags:
179
+ assert len(tags) == 1, f"There must be only one tag with name '{tag}'."
180
+ return tags[0]
181
+ else:
182
+ tag = Tag(tag=tag)
183
+ DB.session.add(tag)
184
+ return tag
185
+
186
+
187
+ def query_activity_meta(clauses: list = []) -> pd.DataFrame:
203
188
  rows = DB.session.execute(
204
189
  sqlalchemy.select(
205
190
  Activity.id,
@@ -226,31 +211,33 @@ def query_activity_meta() -> pd.DataFrame:
226
211
  )
227
212
  .join(Activity.equipment)
228
213
  .join(Activity.kind)
214
+ .where(*clauses)
229
215
  .order_by(Activity.start)
230
216
  ).all()
231
217
  df = pd.DataFrame(rows)
232
218
 
233
- for old, new in [
234
- ("elapsed_time", "average_speed_elapsed_kmh"),
235
- ("moving_time", "average_speed_moving_kmh"),
236
- ]:
237
- df[new] = pd.NA
238
- mask = df[old].dt.total_seconds() > 0
239
- df.loc[mask, new] = df.loc[mask, "distance_km"] / (
240
- df.loc[mask, old].dt.total_seconds() / 3_600
241
- )
242
-
243
- df["date"] = df["start"].dt.date
244
- df["year"] = df["start"].dt.year
245
- df["month"] = df["start"].dt.month
246
- df["day"] = df["start"].dt.day
247
- df["week"] = df["start"].dt.isocalendar().week
248
- df["day_of_week"] = df["start"].dt.day_of_week
249
- df["iso_year"] = df["start"].dt.isocalendar().year
250
- df["hours"] = df["elapsed_time"].dt.total_seconds() / 3_600
251
- df["hours_moving"] = df["moving_time"].dt.total_seconds() / 3_600
252
-
253
- df.index = df["id"]
219
+ if len(df):
220
+ for old, new in [
221
+ ("elapsed_time", "average_speed_elapsed_kmh"),
222
+ ("moving_time", "average_speed_moving_kmh"),
223
+ ]:
224
+ df[new] = pd.NA
225
+ mask = df[old].dt.total_seconds() > 0
226
+ df.loc[mask, new] = df.loc[mask, "distance_km"] / (
227
+ df.loc[mask, old].dt.total_seconds() / 3_600
228
+ )
229
+
230
+ df["date"] = df["start"].dt.date
231
+ df["year"] = df["start"].dt.year
232
+ df["month"] = df["start"].dt.month
233
+ df["day"] = df["start"].dt.day
234
+ df["week"] = df["start"].dt.isocalendar().week
235
+ df["day_of_week"] = df["start"].dt.day_of_week
236
+ df["iso_year"] = df["start"].dt.isocalendar().year
237
+ df["hours"] = df["elapsed_time"].dt.total_seconds() / 3_600
238
+ df["hours_moving"] = df["moving_time"].dt.total_seconds() / 3_600
239
+
240
+ df.index = df["id"]
254
241
 
255
242
  return df
256
243
 
@@ -276,6 +263,23 @@ class Equipment(DB.Model):
276
263
  __table_args__ = (sa.UniqueConstraint("name", name="equipments_name"),)
277
264
 
278
265
 
266
+ def get_or_make_equipment(name: str, config: Config) -> Equipment:
267
+ equipments = DB.session.scalars(
268
+ sqlalchemy.select(Equipment).where(Equipment.name == name)
269
+ ).all()
270
+ if equipments:
271
+ assert (
272
+ len(equipments) == 1
273
+ ), f"There must be only one equipment with name '{name}'."
274
+ return equipments[0]
275
+ else:
276
+ equipment = Equipment(
277
+ name=name, offset_km=config.equipment_offsets.get(name, 0)
278
+ )
279
+ DB.session.add(equipment)
280
+ return equipment
281
+
282
+
279
283
  class Kind(DB.Model):
280
284
  __tablename__ = "kinds"
281
285
 
@@ -299,20 +303,6 @@ class Kind(DB.Model):
299
303
  __table_args__ = (sa.UniqueConstraint("name", name="kinds_name"),)
300
304
 
301
305
 
302
- class SquarePlannerBookmark(DB.Model):
303
- __tablename__ = "square_planner_bookmarks"
304
-
305
- id: Mapped[int] = mapped_column(primary_key=True)
306
-
307
- zoom: Mapped[int] = mapped_column(sa.Integer, nullable=False)
308
- x: Mapped[int] = mapped_column(sa.Integer, nullable=False)
309
- y: Mapped[int] = mapped_column(sa.Integer, nullable=False)
310
- size: Mapped[int] = mapped_column(sa.Integer, nullable=False)
311
- name: Mapped[str] = mapped_column(sa.String, nullable=False)
312
-
313
- __table_args__ = (sa.UniqueConstraint("zoom", "x", "y", "size", name="kinds_name"),)
314
-
315
-
316
306
  def get_or_make_kind(name: str, config: Config) -> Kind:
317
307
  kinds = DB.session.scalars(sqlalchemy.select(Kind).where(Kind.name == name)).all()
318
308
  if kinds:
@@ -327,21 +317,18 @@ def get_or_make_kind(name: str, config: Config) -> Kind:
327
317
  return kind
328
318
 
329
319
 
330
- def get_or_make_equipment(name: str, config: Config) -> Equipment:
331
- equipments = DB.session.scalars(
332
- sqlalchemy.select(Equipment).where(Equipment.name == name)
333
- ).all()
334
- if equipments:
335
- assert (
336
- len(equipments) == 1
337
- ), f"There must be only one equipment with name '{name}'."
338
- return equipments[0]
339
- else:
340
- equipment = Equipment(
341
- name=name, offset_km=config.equipment_offsets.get(name, 0)
342
- )
343
- DB.session.add(equipment)
344
- return equipment
320
+ class SquarePlannerBookmark(DB.Model):
321
+ __tablename__ = "square_planner_bookmarks"
322
+
323
+ id: Mapped[int] = mapped_column(primary_key=True)
324
+
325
+ zoom: Mapped[int] = mapped_column(sa.Integer, nullable=False)
326
+ x: Mapped[int] = mapped_column(sa.Integer, nullable=False)
327
+ y: Mapped[int] = mapped_column(sa.Integer, nullable=False)
328
+ size: Mapped[int] = mapped_column(sa.Integer, nullable=False)
329
+ name: Mapped[str] = mapped_column(sa.String, nullable=False)
330
+
331
+ __table_args__ = (sa.UniqueConstraint("zoom", "x", "y", "size", name="kinds_name"),)
345
332
 
346
333
 
347
334
  class PlotSpec(DB.Model):
@@ -383,3 +370,22 @@ class PlotSpec(DB.Model):
383
370
  return json.dumps(
384
371
  {key: getattr(self, key) for key in self.FIELDS if getattr(self, key)}
385
372
  )
373
+
374
+
375
+ class Photo(DB.Model):
376
+ __tablename__ = "photos"
377
+ id: Mapped[int] = mapped_column(primary_key=True)
378
+
379
+ filename: Mapped[str] = mapped_column(sa.String, nullable=False)
380
+ time: Mapped[datetime.datetime] = mapped_column(sa.DateTime, nullable=False)
381
+ latitude: Mapped[float] = mapped_column(sa.Float, nullable=False)
382
+ longitude: Mapped[float] = mapped_column(sa.Float, nullable=False)
383
+
384
+ activity_id: Mapped[int] = mapped_column(
385
+ ForeignKey("activities.id", name="activity_id"), nullable=False
386
+ )
387
+ activity: Mapped["Activity"] = relationship(back_populates="photos")
388
+
389
+ @property
390
+ def path(self) -> pathlib.Path:
391
+ return pathlib.Path(self.filename)
@@ -15,6 +15,7 @@ from .datamodel import ActivityMeta
15
15
  from .datamodel import DB
16
16
  from .datamodel import get_or_make_equipment
17
17
  from .datamodel import get_or_make_kind
18
+ from .missing_values import some
18
19
  from .paths import activity_extracted_meta_dir
19
20
  from .paths import activity_extracted_time_series_dir
20
21
  from .paths import time_series_dir
@@ -82,9 +83,9 @@ def populate_database_from_extracted(config: Config) -> None:
82
83
  distance_km=0,
83
84
  equipment=equipment,
84
85
  kind=kind,
85
- calories=extracted_metadata.get("calories", None),
86
- elevation_gain=extracted_metadata.get("elevation_gain", None),
87
- steps=extracted_metadata.get("steps", None),
86
+ calories=some(extracted_metadata.get("calories", None)),
87
+ elevation_gain=some(extracted_metadata.get("elevation_gain", None)),
88
+ steps=some(extracted_metadata.get("steps", None)),
88
89
  path=extracted_metadata.get("path", None),
89
90
  upstream_id=upstream_id,
90
91
  )
@@ -92,7 +93,13 @@ def populate_database_from_extracted(config: Config) -> None:
92
93
  update_via_time_series(activity, time_series)
93
94
 
94
95
  DB.session.add(activity)
95
- DB.session.commit()
96
+ try:
97
+ DB.session.commit()
98
+ except sqlalchemy.exc.StatementError:
99
+ logger.error(
100
+ f"Could not insert the following activity into the database: {vars(activity)=}"
101
+ )
102
+ raise
96
103
 
97
104
  enriched_time_series_path = time_series_dir() / f"{activity.id}.parquet"
98
105
  time_series.to_parquet(enriched_time_series_path)
@@ -101,8 +108,10 @@ def populate_database_from_extracted(config: Config) -> None:
101
108
  def update_via_time_series(
102
109
  activity: Activity, time_series: pd.DataFrame
103
110
  ) -> ActivityMeta:
104
- activity.start = time_series["time"].iloc[0]
105
- activity.elapsed_time = time_series["time"].iloc[-1] - time_series["time"].iloc[0]
111
+ activity.start = some(time_series["time"].iloc[0])
112
+ activity.elapsed_time = some(
113
+ time_series["time"].iloc[-1] - time_series["time"].iloc[0]
114
+ )
106
115
  activity.distance_km = (
107
116
  time_series["distance_km"].iloc[-1] - time_series["distance_km"].iloc[0]
108
117
  )
@@ -2,21 +2,33 @@ import dataclasses
2
2
  import datetime
3
3
  import re
4
4
  import urllib.parse
5
+ from collections.abc import Sequence
5
6
  from typing import Optional
6
7
 
7
8
  import dateutil.parser
8
9
  import numpy as np
9
10
  import pandas as pd
11
+ import sqlalchemy
12
+
13
+ from .datamodel import Activity
14
+ from .datamodel import DB
15
+ from .datamodel import Equipment
16
+ from .datamodel import Kind
17
+ from .datamodel import query_activity_meta
18
+ from .datamodel import Tag
10
19
 
11
20
 
12
21
  @dataclasses.dataclass
13
22
  class SearchQuery:
14
- equipment: list[str] = dataclasses.field(default_factory=list)
15
- kind: list[str] = dataclasses.field(default_factory=list)
23
+ equipment: list[Equipment] = dataclasses.field(default_factory=list)
24
+ kind: list[Kind] = dataclasses.field(default_factory=list)
25
+ tag: list[Tag] = dataclasses.field(default_factory=list)
16
26
  name: Optional[str] = None
17
27
  name_case_sensitive: bool = False
18
28
  start_begin: Optional[datetime.date] = None
19
29
  start_end: Optional[datetime.date] = None
30
+ distance_km_min: Optional[float] = None
31
+ distance_km_max: Optional[float] = None
20
32
 
21
33
  def __str__(self) -> str:
22
34
  bits = []
@@ -25,10 +37,14 @@ class SearchQuery:
25
37
  if self.equipment:
26
38
  bits.append(
27
39
  "equipment is "
28
- + (" or ".join(f"“{equipment}”" for equipment in self.equipment))
40
+ + (" or ".join(f"“{equipment.name}”" for equipment in self.equipment))
29
41
  )
30
42
  if self.kind:
31
- bits.append("kind is " + (" or ".join(f"“{kind}”" for kind in self.kind)))
43
+ bits.append(
44
+ "kind is " + (" or ".join(f"“{kind.name}”" for kind in self.kind))
45
+ )
46
+ if self.tag:
47
+ bits.append("tag is " + (" or ".join(f"“{tag.tag}”" for tag in self.tag)))
32
48
  if self.start_begin:
33
49
  bits.append(f"after “{self.start_begin.isoformat()}”")
34
50
  if self.start_end:
@@ -43,27 +59,38 @@ class SearchQuery:
43
59
  or self.name
44
60
  or self.start_begin
45
61
  or self.start_end
62
+ or self.tag
63
+ or self.distance_km_min
64
+ or self.distance_km_max
46
65
  )
47
66
 
48
67
  def to_primitives(self) -> dict:
49
68
  return {
50
- "equipment": self.equipment,
51
- "kind": self.kind,
69
+ "equipment": [equipment.id for equipment in self.equipment],
70
+ "kind": [kind.id for kind in self.kind],
71
+ "tag": [tag.id for tag in self.tag],
52
72
  "name": self.name or "",
53
73
  "name_case_sensitive": self.name_case_sensitive,
54
74
  "start_begin": _format_optional_date(self.start_begin),
55
75
  "start_end": _format_optional_date(self.start_end),
76
+ "distance_km_min": self.distance_km_min,
77
+ "distance_km_max": self.distance_km_max,
56
78
  }
57
79
 
58
80
  @classmethod
59
81
  def from_primitives(cls, d: dict) -> "SearchQuery":
60
82
  return cls(
61
- equipment=d.get("equipment", []),
62
- kind=d.get("kind", []),
83
+ equipment=[
84
+ DB.session.get_one(Equipment, id) for id in d.get("equipment", [])
85
+ ],
86
+ kind=[DB.session.get_one(Kind, id) for id in d.get("kind", [])],
87
+ tag=[DB.session.get_one(Tag, id) for id in d.get("tag", [])],
63
88
  name=d.get("name", None),
64
89
  name_case_sensitive=d.get("name_case_sensitive", False),
65
90
  start_begin=_parse_date_or_none(d.get("start_begin", None)),
66
91
  start_end=_parse_date_or_none(d.get("start_end", None)),
92
+ distance_km_min=d.get("distance_km_min", None),
93
+ distance_km_max=d.get("distance_km_max", None),
67
94
  )
68
95
 
69
96
  def to_jinja(self) -> dict:
@@ -74,9 +101,11 @@ class SearchQuery:
74
101
  def to_url_str(self) -> str:
75
102
  variables = []
76
103
  for equipment in self.equipment:
77
- variables.append(("equipment", equipment))
104
+ variables.append(("equipment", equipment.id))
78
105
  for kind in self.kind:
79
- variables.append(("kind", kind))
106
+ variables.append(("kind", kind.id))
107
+ for tag in self.tag:
108
+ variables.append(("tag", tag.id))
80
109
  if self.name:
81
110
  variables.append(("name", self.name))
82
111
  if self.name_case_sensitive:
@@ -85,6 +114,10 @@ class SearchQuery:
85
114
  variables.append(("start_begin", self.start_begin.isoformat()))
86
115
  if self.start_end:
87
116
  variables.append(("start_end", self.start_end.isoformat()))
117
+ if self.distance_km_min:
118
+ variables.append(("distance_km_min", self.distance_km_min))
119
+ if self.distance_km_max:
120
+ variables.append(("distance_km_max", self.distance_km_max))
88
121
 
89
122
  return "&".join(
90
123
  f"{key}={urllib.parse.quote_plus(value)}" for key, value in variables
@@ -94,36 +127,47 @@ class SearchQuery:
94
127
  def apply_search_query(
95
128
  activity_meta: pd.DataFrame, search_query: SearchQuery
96
129
  ) -> pd.DataFrame:
97
- mask = _make_mask(activity_meta.index, True)
130
+
131
+ filter_clauses = []
98
132
 
99
133
  if search_query.equipment:
100
- mask &= _filter_column(activity_meta["equipment"], search_query.equipment)
134
+ filter_clauses.append(
135
+ sqlalchemy.or_(
136
+ *[
137
+ Activity.equipment == equipment
138
+ for equipment in search_query.equipment
139
+ ]
140
+ )
141
+ )
142
+
101
143
  if search_query.kind:
102
- mask &= _filter_column(activity_meta["kind"], search_query.kind)
103
- if search_query.name:
104
- mask &= pd.Series(
105
- [
106
- bool(
107
- re.search(
108
- search_query.name,
109
- activity_name,
110
- 0 if search_query.name_case_sensitive else re.IGNORECASE,
111
- )
112
- )
113
- for activity_name in activity_meta["name"]
114
- ],
115
- index=activity_meta.index,
144
+ filter_clauses.append(
145
+ sqlalchemy.or_(*[Activity.kind == kind for kind in search_query.kind])
146
+ )
147
+
148
+ if search_query.tag:
149
+ filter_clauses.append(
150
+ sqlalchemy.or_(*[Activity.tags.contains(tag) for tag in search_query.tag])
116
151
  )
117
- if search_query.start_begin is not None:
118
- start_begin = datetime.datetime.combine(
119
- search_query.start_begin, datetime.time.min
152
+
153
+ if search_query.name:
154
+ filter_clauses.append(
155
+ Activity.name.contains(search_query.name)
156
+ if search_query.name_case_sensitive
157
+ else Activity.name.icontains(search_query.name)
120
158
  )
121
- mask &= start_begin <= activity_meta["start"]
122
- if search_query.start_end is not None:
123
- start_end = datetime.datetime.combine(search_query.start_end, datetime.time.max)
124
- mask &= activity_meta["start"] <= start_end
125
159
 
126
- return activity_meta.loc[mask]
160
+ if search_query.start_begin:
161
+ filter_clauses.append(Activity.start <= search_query.start_begin)
162
+ if search_query.start_end:
163
+ filter_clauses.append(Activity.start < search_query.start_end)
164
+
165
+ if search_query.distance_km_min:
166
+ filter_clauses.append(Activity.distance_km >= search_query.distance_km_min)
167
+ if search_query.distance_km_max:
168
+ filter_clauses.append(Activity.distance_km <= search_query.distance_km_max)
169
+
170
+ return query_activity_meta(filter_clauses)
127
171
 
128
172
 
129
173
  def _format_optional_date(date: Optional[datetime.date]) -> str:
@@ -0,0 +1,16 @@
1
+ from typing import Any
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ def some(value: Any) -> Optional[Any]:
9
+ if value is None:
10
+ return None
11
+ elif np.isnan(value):
12
+ return None
13
+ elif pd.isna(value):
14
+ return None
15
+ else:
16
+ return value
@@ -53,6 +53,7 @@ _strava_last_activity_date_path = _cache_dir / "strava-last-activity-date.json"
53
53
  _new_config_file = pathlib.Path("config.json")
54
54
  _activity_meta_override_dir = pathlib.Path("Metadata Override")
55
55
  _time_series_dir = pathlib.Path("Time Series")
56
+ _photos_dir = pathlib.Path("Photos")
56
57
 
57
58
 
58
59
  cache_dir = dir_wrapper(_cache_dir)
@@ -65,6 +66,7 @@ tiles_per_time_series = dir_wrapper(_tiles_per_time_series)
65
66
  strava_api_dir = dir_wrapper(_strava_api_dir)
66
67
  activity_meta_override_dir = dir_wrapper(_activity_meta_override_dir)
67
68
  time_series_dir = dir_wrapper(_time_series_dir)
69
+ PHOTOS_DIR = dir_wrapper(_photos_dir)
68
70
 
69
71
  activities_file = file_wrapper(_activities_file)
70
72
  strava_dynamic_config_path = file_wrapper(_strava_dynamic_config_path)
@@ -0,0 +1,19 @@
1
+ import numpy as np
2
+
3
+ from .missing_values import some
4
+
5
+
6
+ def test_none() -> None:
7
+ assert some(None) == None
8
+
9
+
10
+ def test_nan() -> None:
11
+ assert some(np.nan) == None
12
+
13
+
14
+ def test_float() -> None:
15
+ assert some(1.0) == 1.0
16
+
17
+
18
+ def test_integer() -> None:
19
+ assert some(1) == 1
@@ -176,7 +176,7 @@ def _process_activity(
176
176
  activity_tiles["time"],
177
177
  zip(activity_tiles["tile_x"], activity_tiles["tile_y"]),
178
178
  ):
179
- if activity["consider_for_achievements"]:
179
+ if activity.kind.consider_for_achievements:
180
180
  if tile not in tile_state["tile_visits"][zoom]:
181
181
  new_tile_history_soa["activity_id"].append(activity_id)
182
182
  new_tile_history_soa["time"].append(time)