Deploy Stride with encrypted manual and scheduled Garmin sync
This commit is contained in:
+109
-2
@@ -26,7 +26,7 @@ from garmin_sync.service import SyncError, atomic_write
|
||||
|
||||
from .analytics import analyse, pace_text
|
||||
from .extensions import db, limiter
|
||||
from .models import Activity, Friendship, User
|
||||
from .models import Activity, Friendship, GarminConnection, User
|
||||
from .services import activity_data, import_cache, import_upload
|
||||
|
||||
web = Blueprint("web", __name__)
|
||||
@@ -73,6 +73,17 @@ def are_friends(first, second):
|
||||
)
|
||||
|
||||
|
||||
def latest_mapped_run(rows):
|
||||
return next(
|
||||
(
|
||||
row
|
||||
for row in rows
|
||||
if row.sport == "running" and any(sample.get("point") for sample in row.samples)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def overview(rows, timezone="Australia/Sydney"):
|
||||
total_distance = sum(r.distance_m for r in rows)
|
||||
total_time = sum(r.duration_s for r in rows)
|
||||
@@ -208,6 +219,7 @@ def dashboard():
|
||||
stats=overview(rows, current_user.timezone),
|
||||
profile=current_user,
|
||||
personal=True,
|
||||
mapped_run=latest_mapped_run(rows),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,6 +302,7 @@ def profile(username):
|
||||
stats=overview(rows, user.timezone),
|
||||
profile=user,
|
||||
personal=False,
|
||||
mapped_run=latest_mapped_run(rows),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,6 +321,52 @@ def data(activity_id):
|
||||
return jsonify(activity_data(row, owner))
|
||||
|
||||
|
||||
@web.post("/activities/<int:activity_id>/share")
|
||||
@login_required
|
||||
def share_activity(activity_id):
|
||||
row, _ = get_activity(activity_id, True)
|
||||
if not row.share_token:
|
||||
# Conditional update keeps simultaneous copy requests on the same link.
|
||||
db.session.execute(
|
||||
db.update(Activity)
|
||||
.where(Activity.id == row.id, Activity.share_token.is_(None))
|
||||
.values(share_token=secrets.token_urlsafe(32))
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.refresh(row)
|
||||
return jsonify(url=url_for("web.shared_activity", token=row.share_token, _external=True))
|
||||
|
||||
|
||||
@web.post("/activities/<int:activity_id>/share/revoke")
|
||||
@login_required
|
||||
def revoke_activity_share(activity_id):
|
||||
row, _ = get_activity(activity_id, True)
|
||||
row.share_token = None
|
||||
db.session.commit()
|
||||
flash("Public link disabled. Previously copied links no longer work.")
|
||||
return redirect(url_for("web.activity", activity_id=row.id))
|
||||
|
||||
|
||||
@web.get("/shared/runs/<token>")
|
||||
def shared_activity(token):
|
||||
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
|
||||
return render_template(
|
||||
"activity.html",
|
||||
activity=row,
|
||||
owner_view=False,
|
||||
public_view=True,
|
||||
analysis=analyse(row),
|
||||
pace_text=pace_text,
|
||||
data_url=url_for("web.shared_activity_data", token=token),
|
||||
)
|
||||
|
||||
|
||||
@web.get("/shared/runs/<token>/data")
|
||||
def shared_activity_data(token):
|
||||
row = db.first_or_404(db.select(Activity).filter_by(share_token=token))
|
||||
return jsonify(activity_data(row, False))
|
||||
|
||||
|
||||
@web.post("/activities/<int:activity_id>/edit")
|
||||
@login_required
|
||||
def edit_activity(activity_id):
|
||||
@@ -334,7 +393,55 @@ def settings():
|
||||
db.session.commit()
|
||||
flash("Profile saved.")
|
||||
return redirect(url_for("web.settings"))
|
||||
return render_template("settings.html")
|
||||
connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=current_user.id))
|
||||
return render_template("settings.html", connection=connection)
|
||||
|
||||
|
||||
@web.post("/garmin/<action>")
|
||||
@login_required
|
||||
@limiter.limit("10 per hour")
|
||||
def garmin_action(action):
|
||||
from filelock import Timeout
|
||||
|
||||
from .sync import connect, session_lock
|
||||
|
||||
if action not in {"connect", "sync", "schedule", "disconnect"}:
|
||||
abort(404)
|
||||
try:
|
||||
if action == "connect":
|
||||
file = request.files.get("tokens")
|
||||
if not file:
|
||||
raise SyncError("Choose your exported Garmin session JSON file.")
|
||||
connect(current_user, file.read(65537))
|
||||
flash("Garmin connected. Your first sync is queued.")
|
||||
else:
|
||||
with session_lock(current_user.id):
|
||||
connection = db.session.scalar(
|
||||
db.select(GarminConnection).filter_by(user_id=current_user.id)
|
||||
)
|
||||
if not connection:
|
||||
raise SyncError("Connect Garmin first.")
|
||||
if action == "sync":
|
||||
connection.pending = True
|
||||
connection.message = "Manual sync queued."
|
||||
elif action == "schedule":
|
||||
connection.scheduled = request.form.get("enabled") == "yes"
|
||||
connection.next_sync = datetime.now(UTC)
|
||||
else:
|
||||
db.session.delete(connection)
|
||||
current_user.local_account_key = None
|
||||
db.session.commit()
|
||||
flash("Garmin sync settings updated.")
|
||||
except Timeout:
|
||||
db.session.rollback()
|
||||
flash("A sync is running. Please retry shortly.", "error")
|
||||
except SyncError as error:
|
||||
db.session.rollback()
|
||||
flash(str(error), "error")
|
||||
except Exception: # noqa: BLE001 -- suppress provider credentials at boundary
|
||||
db.session.rollback()
|
||||
flash("Garmin connection failed. Check your session file and try again.", "error")
|
||||
return redirect(url_for("web.settings"))
|
||||
|
||||
|
||||
@web.post("/imports/cache")
|
||||
|
||||
Reference in New Issue
Block a user