Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #83 - Step 3 - Configure which fields should be reported to slack, per activity. #84

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions config/app-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ fitbit:
activities:
history_days: 180 # how far to look back to report new records of best times/durations/calories/etc.
daily_report_time: "23:50" # Time of day (HH:mm)to post daily reports to slack.
default_report:
daily: false
realtime: true
fields:
- activity_count
- distance
- calories
- duration
- fat_burn_minutes
- cardio_minutes
- peak_minutes
- out_of_zone_minutes

activity_types:
# Configuration specific to activity types.
Expand All @@ -38,15 +50,20 @@ fitbit:
# report_realtime: whether a report should be posted to slack for this activity type as soon as we receive it from fitbit
- name: Treadmill
id: 90019
report_daily: true
report_realtime: false
report:
daily: true
realtime: false
fields:
- distance

- name: Spinning
id: 55001
report_daily: false
report_realtime: true
report:
daily: false
realtime: true

- name: Walk
id: 90013
report_daily: false
report_realtime: true
report:
daily: false
realtime: true
2 changes: 2 additions & 0 deletions slackhealthbot/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Container(containers.DeclarativeContainer):
"slackhealthbot.domain.usecases.fitbit.usecase_process_daily_activity",
"slackhealthbot.domain.usecases.fitbit.usecase_process_new_activity",
"slackhealthbot.domain.usecases.slack.usecase_post_user_logged_out",
"slackhealthbot.domain.usecases.slack.usecase_post_activity",
"slackhealthbot.domain.usecases.slack.usecase_post_daily_activity",
"slackhealthbot.oauth.fitbitconfig",
"slackhealthbot.oauth.withingsconfig",
"slackhealthbot.remoteservices.api.fitbit.activityapi",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
from slackhealthbot.domain.usecases.fitbit import usecase_get_last_activity
from slackhealthbot.domain.usecases.slack import usecase_post_activity
from slackhealthbot.settings import Settings
from slackhealthbot.settings import ActivityType, Settings


@inject
Expand Down Expand Up @@ -74,10 +74,12 @@ async def do( # noqa: PLR0913 deal with this later
activity=new_activity_data,
)

if (
new_activity_data.type_id
not in settings.app_settings.fitbit_realtime_activity_type_ids
):
activity_type: ActivityType = (
settings.app_settings.fitbit.activities.get_activity_type(
id=new_activity_data.type_id
)
)
if activity_type is None or not activity_type.report.realtime:
# This activity isn't to be posted in realtime to slack.
# We're done for now.
return
Expand Down Expand Up @@ -121,10 +123,9 @@ async def _is_new_valid_activity(
log_id: int,
settings: Settings,
) -> bool:
return (
type_id in settings.app_settings.fitbit_activity_type_ids
and not await repo.get_activity_by_user_and_log_id(
fitbit_userid=fitbit_userid,
log_id=log_id,
)
return settings.app_settings.fitbit.activities.get_activity_type(
id=type_id
) and not await repo.get_activity_by_user_and_log_id(
fitbit_userid=fitbit_userid,
log_id=log_id,
)
25 changes: 22 additions & 3 deletions slackhealthbot/domain/usecases/slack/usecase_post_activity.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from dependency_injector.wiring import Provide, inject
from fastapi import Depends

from slackhealthbot.containers import Container
from slackhealthbot.domain.models.activity import ActivityHistory
from slackhealthbot.domain.remoterepository.remoteslackrepository import (
RemoteSlackRepository,
Expand All @@ -9,6 +13,7 @@
get_activity_minutes_change_icon,
get_ranking_text,
)
from slackhealthbot.settings import ReportField, Settings


async def do(
Expand All @@ -24,11 +29,13 @@ async def do(
await repo.post_message(message.strip())


@inject
def create_message(
slack_alias: str,
activity_name: str,
activity_history: ActivityHistory,
record_history_days: int,
settings: Settings = Depends(Provide[Container.settings]),
):
activity = activity_history.new_activity_data
zone_icons = {}
Expand Down Expand Up @@ -116,12 +123,23 @@ def create_message(
recent_top_value,
record_history_days=record_history_days,
)
report_settings = settings.app_settings.fitbit.activities.get_report(
activity_type_id=activity_history.new_activity_data.type_id
)

message = f"""
New {activity_name} activity from <@{slack_alias}>:
• Duration: {activity.total_minutes} minutes {duration_icon} {duration_record_text}
• Calories: {activity.calories} {calories_icon} {calories_record_text}
"""
if activity.distance_km:

if ReportField.duration in report_settings.fields:
message += f""" • Duration: {activity.total_minutes} minutes {duration_icon} {duration_record_text}
"""

if ReportField.calories in report_settings.fields:
message += f""" • Calories: {activity.calories} {calories_icon} {calories_record_text}
"""

if ReportField.distance in report_settings.fields and activity.distance_km:
message += f""" • Distance: {activity.distance_km:.3f} km {distance_km_icon} {distance_km_record_text}
"""
message += "\n".join(
Expand All @@ -131,6 +149,7 @@ def create_message(
+ zone_icons.get(zone_minutes.zone, "")
+ f" {zone_record_texts.get(zone_minutes.zone, '')}"
for zone_minutes in activity.zone_minutes
if f"{zone_minutes.zone}_minutes" in report_settings.fields
]
)
return message
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from dependency_injector.wiring import Provide, inject
from fastapi import Depends

from slackhealthbot.containers import Container
from slackhealthbot.domain.models.activity import DailyActivityHistory
from slackhealthbot.domain.remoterepository.remoteslackrepository import (
RemoteSlackRepository,
Expand All @@ -8,6 +12,7 @@
get_activity_minutes_change_icon,
get_ranking_text,
)
from slackhealthbot.settings import ReportField, Settings


async def do(
Expand All @@ -26,11 +31,13 @@ async def do(
await repo.post_message(message.strip())


@inject
def create_message(
slack_alias: str,
activity_name: str,
history: DailyActivityHistory,
record_history_days: int,
settings: Settings = Depends(Provide[Container.settings]),
) -> str:
if history.previous_daily_activity_stats:
calories_icon = (
Expand Down Expand Up @@ -148,25 +155,53 @@ def create_message(
record_history_days=record_history_days,
)

report_settings = settings.app_settings.fitbit.activities.get_report(
activity_type_id=history.new_daily_activity_stats.type_id
)

message = f"""
New daily {activity_name} activity from <@{slack_alias}>:
• Activity count: {history.new_daily_activity_stats.count_activities}
• Total duration: {history.new_daily_activity_stats.sum_total_minutes} minutes {total_minutes_icon} {total_minutes_record_text}
• Total calories: {history.new_daily_activity_stats.sum_calories} {calories_icon} {calories_record_text}
"""
if history.new_daily_activity_stats.sum_distance_km:

if ReportField.activity_count in report_settings.fields:
message += f""" • Activity count: {history.new_daily_activity_stats.count_activities}
"""

if ReportField.duration in report_settings.fields:
message += f""" • Total duration: {history.new_daily_activity_stats.sum_total_minutes} minutes {total_minutes_icon} {total_minutes_record_text}
"""

if ReportField.calories in report_settings.fields:
message += f""" • Total calories: {history.new_daily_activity_stats.sum_calories} {calories_icon} {calories_record_text}
"""
if (
ReportField.distance in report_settings.fields
and history.new_daily_activity_stats.sum_distance_km
):
message += f""" • Distance: {history.new_daily_activity_stats.sum_distance_km:.3f} km {distance_km_icon} {distance_km_record_text}
"""
if history.new_daily_activity_stats.sum_fat_burn_minutes:
if (
ReportField.fat_burn_minutes in report_settings.fields
and history.new_daily_activity_stats.sum_fat_burn_minutes
):
message += f""" • Total fat burn minutes: {history.new_daily_activity_stats.sum_fat_burn_minutes} {fat_burn_minutes_icon} {fat_burn_minutes_record_text}
"""
if history.new_daily_activity_stats.sum_cardio_minutes:
if (
ReportField.cardio_minutes in report_settings.fields
and history.new_daily_activity_stats.sum_cardio_minutes
):
message += f""" • Total cardio minutes: {history.new_daily_activity_stats.sum_cardio_minutes} {cardio_minutes_icon} {cardio_minutes_record_text}
"""
if history.new_daily_activity_stats.sum_peak_minutes:
if (
ReportField.peak_minutes in report_settings.fields
and history.new_daily_activity_stats.sum_peak_minutes
):
message += f""" • Total peak minutes: {history.new_daily_activity_stats.sum_peak_minutes} {peak_minutes_icon} {peak_minutes_record_text}
"""
if history.new_daily_activity_stats.sum_out_of_zone_minutes:
if (
ReportField.out_of_zone_minutes in report_settings.fields
and history.new_daily_activity_stats.sum_out_of_zone_minutes
):
message += f""" • Total out of zone minutes: {history.new_daily_activity_stats.sum_out_of_zone_minutes} {out_of_zone_minutes_icon} {out_of_zone_minutes_record_text}
"""

Expand Down
7 changes: 5 additions & 2 deletions slackhealthbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ async def lifespan(_app: FastAPI):
initial_delay_s=10,
)
daily_activity_task: Task | None = None
if settings.app_settings.fitbit_daily_activity_type_ids:
daily_activity_type_ids = (
settings.app_settings.fitbit.activities.daily_activity_type_ids
)
if daily_activity_type_ids:
daily_activity_task = await post_daily_activities(
local_fitbit_repo_factory=fitbit_repository_factory(),
activity_type_ids=set(settings.app_settings.fitbit_daily_activity_type_ids),
activity_type_ids=set(daily_activity_type_ids),
slack_repo=get_slack_repository(),
post_time=settings.app_settings.fitbit.activities.daily_report_time,
)
Expand Down
84 changes: 66 additions & 18 deletions slackhealthbot/settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import dataclasses
import datetime as dt
import enum
import os
from copy import deepcopy
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Optional

import yaml
from pydantic import AnyHttpUrl, BaseModel, HttpUrl
Expand Down Expand Up @@ -37,17 +40,78 @@ class Poll(BaseModel):
interval_seconds: int = 3600


class ReportField(enum.StrEnum):
activity_count = enum.auto()
distance = enum.auto()
calories = enum.auto()
duration = enum.auto()
fat_burn_minutes = enum.auto()
cardio_minutes = enum.auto()
peak_minutes = enum.auto()
out_of_zone_minutes = enum.auto()


class Report(BaseModel):
daily: bool
realtime: bool
fields: Optional[list[ReportField]] = None


class ActivityType(BaseModel):
name: str
id: int
report_daily: bool = False
report_realtime: bool = True
report: Report | None = None


class Activities(BaseModel):
daily_report_time: dt.time = dt.time(hour=23, second=50)
history_days: int = 180
activity_types: list[ActivityType]
default_report: Report = Report(
daily=False,
realtime=True,
fields=[x for x in ReportField],
)

def get_activity_type(self, id: int) -> ActivityType | None:
return next((x for x in self.activity_types if x.id == id), None)

def get_report(self, activity_type_id: int) -> Report | None:
"""
Get the report configuration for the given activity type.
If the activity type doesn't have an explicit report configuration,
fallback to the default report configuration.

If the activity type report configuration is missing some attributes,
fill them in with the default report configuration. This applies to the
following attributes:
- fields

:return None: If the activity type id is unknown
"""
activity_type = self.get_activity_type(id=activity_type_id)
if not activity_type:
return None

if activity_type.report is None:
return self.default_report

report = deepcopy(activity_type.report)
if not report.fields:
report.fields = self.default_report.fields

return report

@property
def daily_activity_type_ids(self) -> list[int]:
return [
x.id
for x in self.activity_types
if (
(x.report and x.report.daily)
or (x.report is None and self.default_report.daily)
)
]


class Fitbit(BaseModel):
Expand Down Expand Up @@ -117,22 +181,6 @@ def settings_customise_sources(
)
return (env_settings, yaml_settings_source)

@property
def fitbit_realtime_activity_type_ids(self) -> list[int]:
return [
x.id for x in self.fitbit.activities.activity_types if x.report_realtime
]

@property
def fitbit_daily_activity_type_ids(self) -> list[int]:
return [x.id for x in self.fitbit.activities.activity_types if x.report_daily]

@property
def fitbit_activity_type_ids(self) -> list[int]:
return (
self.fitbit_realtime_activity_type_ids + self.fitbit_daily_activity_type_ids
)


class SecretSettings(BaseSettings):
withings_client_secret: str
Expand Down
Loading
Loading