This is the initial push of the outline of the life dashboard
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
32
backend/app/auth.py
Normal file
32
backend/app/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return username
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql://admin:AAAbraK!!!@localhost:5432/life"
|
||||
AUTH_USERNAME: str = "will"
|
||||
AUTH_PASSWORD: str = "changeme"
|
||||
JWT_SECRET: str = "changeme-secret"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_HOURS: int = 24
|
||||
|
||||
|
||||
settings = Settings()
|
||||
19
backend/app/database.py
Normal file
19
backend/app/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
28
backend/app/main.py
Normal file
28
backend/app/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from app.routers import auth, finance, weight, workout
|
||||
|
||||
app = FastAPI(title="Life Dashboard API")
|
||||
|
||||
# API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(weight.router)
|
||||
app.include_router(workout.router)
|
||||
app.include_router(finance.router)
|
||||
|
||||
# Serve React SPA static files
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = STATIC_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
12
backend/app/models/__init__.py
Normal file
12
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from app.models.weight import WeightEntry
|
||||
from app.models.workout import Exercise, Workout, WorkoutSet
|
||||
from app.models.finance import FinanceTransaction, FinanceSnapshot
|
||||
|
||||
__all__ = [
|
||||
"WeightEntry",
|
||||
"Exercise",
|
||||
"Workout",
|
||||
"WorkoutSet",
|
||||
"FinanceTransaction",
|
||||
"FinanceSnapshot",
|
||||
]
|
||||
26
backend/app/models/finance.py
Normal file
26
backend/app/models/finance.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2))
|
||||
category: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class FinanceSnapshot(Base):
|
||||
__tablename__ = "finance_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date, unique=True)
|
||||
net_worth: Mapped[float] = mapped_column(Numeric(14, 2))
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
17
backend/app/models/weight.py
Normal file
17
backend/app/models/weight.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WeightEntry(Base):
|
||||
__tablename__ = "weight_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date, unique=True)
|
||||
weight_lbs: Mapped[float] = mapped_column(Numeric(5, 1))
|
||||
body_fat_pct: Mapped[float | None] = mapped_column(Numeric(4, 1), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
43
backend/app/models/workout.py
Normal file
43
backend/app/models/workout.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Exercise(Base):
|
||||
__tablename__ = "exercises"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text, unique=True)
|
||||
category: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
class Workout(Base):
|
||||
__tablename__ = "workouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
date: Mapped[date] = mapped_column(Date)
|
||||
name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
sets: Mapped[list["WorkoutSet"]] = relationship(
|
||||
back_populates="workout", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutSet(Base):
|
||||
__tablename__ = "workout_sets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
workout_id: Mapped[int] = mapped_column(ForeignKey("workouts.id", ondelete="CASCADE"))
|
||||
exercise_id: Mapped[int] = mapped_column(ForeignKey("exercises.id"))
|
||||
set_number: Mapped[int] = mapped_column(Integer)
|
||||
reps: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
weight_lbs: Mapped[float | None] = mapped_column(Numeric(6, 1), nullable=True)
|
||||
duration_min: Mapped[float | None] = mapped_column(Numeric(5, 1), nullable=True)
|
||||
|
||||
workout: Mapped["Workout"] = relationship(back_populates="sets")
|
||||
exercise: Mapped["Exercise"] = relationship()
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
37
backend/app/routers/auth.py
Normal file
37
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth import create_access_token, get_current_user
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest):
|
||||
if body.username != settings.AUTH_USERNAME or body.password != settings.AUTH_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(username: str = Depends(get_current_user)):
|
||||
return UserResponse(username=username)
|
||||
143
backend/app/routers/finance.py
Normal file
143
backend/app/routers/finance.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.finance import FinanceSnapshot, FinanceTransaction
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/finances", tags=["finances"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class TransactionCreate(BaseModel):
|
||||
date: date
|
||||
amount: float
|
||||
category: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TransactionRead(TransactionCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
date: date
|
||||
net_worth: float
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class SnapshotRead(SnapshotCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
category: str
|
||||
total: float
|
||||
|
||||
|
||||
# --- Transaction endpoints ---
|
||||
|
||||
|
||||
@router.get("/transactions", response_model=list[TransactionRead])
|
||||
def list_transactions(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
category: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(FinanceTransaction).order_by(FinanceTransaction.date.desc())
|
||||
if from_date:
|
||||
q = q.where(FinanceTransaction.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(FinanceTransaction.date <= to_date)
|
||||
if category:
|
||||
q = q.where(FinanceTransaction.category == category)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("/transactions", response_model=TransactionRead, status_code=201)
|
||||
def create_transaction(body: TransactionCreate, db: Session = Depends(get_db)):
|
||||
txn = FinanceTransaction(**body.model_dump())
|
||||
db.add(txn)
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return txn
|
||||
|
||||
|
||||
@router.put("/transactions/{txn_id}", response_model=TransactionRead)
|
||||
def update_transaction(
|
||||
txn_id: int, body: TransactionCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
txn = db.get(FinanceTransaction, txn_id)
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
for key, val in body.model_dump().items():
|
||||
setattr(txn, key, val)
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return txn
|
||||
|
||||
|
||||
@router.delete("/transactions/{txn_id}", status_code=204)
|
||||
def delete_transaction(txn_id: int, db: Session = Depends(get_db)):
|
||||
txn = db.get(FinanceTransaction, txn_id)
|
||||
if not txn:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
db.delete(txn)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Snapshot endpoints ---
|
||||
|
||||
|
||||
@router.get("/snapshots", response_model=list[SnapshotRead])
|
||||
def list_snapshots(db: Session = Depends(get_db)):
|
||||
return db.scalars(
|
||||
select(FinanceSnapshot).order_by(FinanceSnapshot.date.desc())
|
||||
).all()
|
||||
|
||||
|
||||
@router.post("/snapshots", response_model=SnapshotRead, status_code=201)
|
||||
def create_snapshot(body: SnapshotCreate, db: Session = Depends(get_db)):
|
||||
snap = FinanceSnapshot(**body.model_dump())
|
||||
db.add(snap)
|
||||
db.commit()
|
||||
db.refresh(snap)
|
||||
return snap
|
||||
|
||||
|
||||
# --- Summary endpoint ---
|
||||
|
||||
|
||||
@router.get("/summary", response_model=list[CategorySummary])
|
||||
def spending_summary(
|
||||
period: str = Query("month", pattern="^(month|year)$"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
today = date.today()
|
||||
if period == "month":
|
||||
start = today.replace(day=1)
|
||||
else:
|
||||
start = today.replace(month=1, day=1)
|
||||
|
||||
rows = db.execute(
|
||||
select(
|
||||
FinanceTransaction.category,
|
||||
func.sum(FinanceTransaction.amount).label("total"),
|
||||
)
|
||||
.where(FinanceTransaction.date >= start)
|
||||
.where(FinanceTransaction.amount < 0)
|
||||
.group_by(FinanceTransaction.category)
|
||||
.order_by(func.sum(FinanceTransaction.amount))
|
||||
).all()
|
||||
return [CategorySummary(category=r.category, total=float(r.total)) for r in rows]
|
||||
71
backend/app/routers/weight.py
Normal file
71
backend/app/routers/weight.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.weight import WeightEntry
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/weight", tags=["weight"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
class WeightCreate(BaseModel):
|
||||
date: date
|
||||
weight_lbs: float
|
||||
body_fat_pct: float | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WeightRead(WeightCreate):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[WeightRead])
|
||||
def list_weight(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(WeightEntry).order_by(WeightEntry.date.desc())
|
||||
if from_date:
|
||||
q = q.where(WeightEntry.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(WeightEntry.date <= to_date)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("", response_model=WeightRead, status_code=201)
|
||||
def create_weight(body: WeightCreate, db: Session = Depends(get_db)):
|
||||
entry = WeightEntry(**body.model_dump())
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.put("/{entry_id}", response_model=WeightRead)
|
||||
def update_weight(entry_id: int, body: WeightCreate, db: Session = Depends(get_db)):
|
||||
entry = db.get(WeightEntry, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
for key, val in body.model_dump().items():
|
||||
setattr(entry, key, val)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
def delete_weight(entry_id: int, db: Session = Depends(get_db)):
|
||||
entry = db.get(WeightEntry, entry_id)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
137
backend/app/routers/workout.py
Normal file
137
backend/app/routers/workout.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.workout import Exercise, Workout, WorkoutSet
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api", tags=["workouts"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class ExerciseCreate(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
|
||||
|
||||
class ExerciseRead(ExerciseCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkoutSetCreate(BaseModel):
|
||||
exercise_id: int
|
||||
set_number: int
|
||||
reps: int | None = None
|
||||
weight_lbs: float | None = None
|
||||
duration_min: float | None = None
|
||||
|
||||
|
||||
class WorkoutSetRead(WorkoutSetCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WorkoutCreate(BaseModel):
|
||||
date: date
|
||||
name: str | None = None
|
||||
notes: str | None = None
|
||||
sets: list[WorkoutSetCreate] = []
|
||||
|
||||
|
||||
class WorkoutRead(BaseModel):
|
||||
id: int
|
||||
date: date
|
||||
name: str | None
|
||||
notes: str | None
|
||||
sets: list[WorkoutSetRead]
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- Exercise endpoints ---
|
||||
|
||||
|
||||
@router.get("/exercises", response_model=list[ExerciseRead])
|
||||
def list_exercises(db: Session = Depends(get_db)):
|
||||
return db.scalars(select(Exercise).order_by(Exercise.name)).all()
|
||||
|
||||
|
||||
@router.post("/exercises", response_model=ExerciseRead, status_code=201)
|
||||
def create_exercise(body: ExerciseCreate, db: Session = Depends(get_db)):
|
||||
ex = Exercise(**body.model_dump())
|
||||
db.add(ex)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
# --- Workout endpoints ---
|
||||
|
||||
|
||||
@router.get("/workouts", response_model=list[WorkoutRead])
|
||||
def list_workouts(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = (
|
||||
select(Workout)
|
||||
.options(selectinload(Workout.sets))
|
||||
.order_by(Workout.date.desc())
|
||||
)
|
||||
if from_date:
|
||||
q = q.where(Workout.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(Workout.date <= to_date)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@router.post("/workouts", response_model=WorkoutRead, status_code=201)
|
||||
def create_workout(body: WorkoutCreate, db: Session = Depends(get_db)):
|
||||
sets_data = body.sets
|
||||
workout = Workout(
|
||||
date=body.date,
|
||||
name=body.name,
|
||||
notes=body.notes,
|
||||
sets=[WorkoutSet(**s.model_dump()) for s in sets_data],
|
||||
)
|
||||
db.add(workout)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@router.put("/workouts/{workout_id}", response_model=WorkoutRead)
|
||||
def update_workout(
|
||||
workout_id: int, body: WorkoutCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
workout = db.get(Workout, workout_id, options=[selectinload(Workout.sets)])
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
workout.date = body.date
|
||||
workout.name = body.name
|
||||
workout.notes = body.notes
|
||||
# Replace sets
|
||||
workout.sets.clear()
|
||||
for s in body.sets:
|
||||
workout.sets.append(WorkoutSet(**s.model_dump()))
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@router.delete("/workouts/{workout_id}", status_code=204)
|
||||
def delete_workout(workout_id: int, db: Session = Depends(get_db)):
|
||||
workout = db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
db.delete(workout)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user