diff --git a/src/api/dependencies.py b/src/api/dependencies.py index a887278..78f560c 100644 --- a/src/api/dependencies.py +++ b/src/api/dependencies.py @@ -61,10 +61,10 @@ def get_account_from_bearer( return user -def get_active_account(account: Account = Depends(get_account_from_bearer)) -> Account: - if not account.is_active: - raise HTTPException(status_code=400, detail="account deactivated") - return account +def get_active_account(account: Account = Depends(get_account_from_bearer)) -> Account: + if not account.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account deactivated") + return account def get_account_even_if_inactive( acc: Account = Depends(get_account_from_bearer), diff --git a/src/api/roles/client/client.py b/src/api/roles/client/client.py index 302f51b..6011db9 100644 --- a/src/api/roles/client/client.py +++ b/src/api/roles/client/client.py @@ -3,6 +3,7 @@ from sqlmodel import Session, select from sqlalchemy import func, desc, asc, delete + from src.api.dependencies import get_active_account, get_client_account, PaginationParams from src.api.storage import upload_public_file_to_supabase @@ -30,6 +31,7 @@ PayInvoiceInput, PayInvoiceResponse, ) +from src.api.roles.coach.domain import CoachAvailabilityResponse from src.api.roles.shared.domain import DeleteRequestResponse @@ -38,8 +40,20 @@ from src.database.coach_client_relationship.models import ClientCoachRequest, ClientCoachRelationship from src.database.account.models import Account, Availability, Notification from src.database.client.models import Client, ClientAvailability, FitnessGoals, ClientWorkoutPlan +from src.database.telemetry.models import ( + HealthMetrics, + ClientTelemetry, + StepCount, + DailyMoodSurvey, + DailyWorkoutSurvey, + DailyBodyMetricsSurvey, + DailyStepsSurvey, + DailyMealSurvey, + CompletedMealActivity, + CompletedWorkout, + DailyProgressPicture, +) from src.database.workouts_and_activities.models import WorkoutPlan -from src.database.telemetry.models import HealthMetrics, ClientTelemetry, DailyProgressPicture from src.api.roles.client.fitness import ( TELEMETRY_PROGRESS_PICTURE, TELEMETRY_WEIGHT, @@ -123,14 +137,15 @@ def update_client_information(payload: UpdateClientInfoInput, db = Depends(get_s # Availabilities: delete existing and replace with new ones if payload.availabilities: ca_id = client.client_availability_id - if ca_id is None: - ca = ClientAvailability() - db.add(ca) - db.flush() - client.client_availability_id = ca.id - ca_id = ca.id - else: - db.exec(delete(Availability).where(Availability.client_availability_id == ca_id)) + if ca_id is None: + ca = ClientAvailability() + db.add(ca) + db.flush() + client.client_availability_id = ca.id + ca_id = ca.id + db.add(client) + else: + db.exec(delete(Availability).where(Availability.client_availability_id == ca_id)) for a in payload.availabilities: a.client_availability_id = ca_id @@ -190,6 +205,37 @@ def me(db = Depends(get_session), acc: Account = Depends(get_client_account)): last_recorded_height=height, ) + +@router.get("/coach_availability/{coach_id}", response_model=CoachAvailabilityResponse) +def get_coach_availability_for_client(coach_id: int, db = Depends(get_session), acc: Account = Depends(get_client_account)): + """ + Proxy endpoint for clients to fetch a coach's availability using the client router prefix. + Mirrors the logic in the coach router so client-side calls to `/roles/client/coach_availability/{coach_id}` work. + """ + if acc.client_id is None: + raise HTTPException(404, detail="Please log in to view coach availability") + + coach = db.get(Coach, coach_id) + if coach is None: + raise HTTPException(404, detail="Coach not found") + + coach_account = db.exec( + select(Account).where( + Account.coach_id == coach_id, + Account.is_active == True, + ) + ).first() + + if coach_account is None or coach.verified == False: + raise HTTPException(404, detail="Coach is not verified yet, availability is not viewable") + + if coach.coach_availability is None: + return CoachAvailabilityResponse(coach_availabilities=[]) + + availabilities = db.exec(select(Availability).where(Availability.coach_availability_id == coach.coach_availability)).all() + + return CoachAvailabilityResponse(coach_availabilities=availabilities) + @router.post("/assign_plan", response_model=AssignWorkoutPlanResponse) def assign_workout_plan(payload: AssignWorkoutPlanInput, db = Depends(get_session), acc: Account = Depends(get_client_account)): """ @@ -229,6 +275,16 @@ def create_coach_request(coach_id: int, db = Depends(get_session), acc: Account if coach is None: raise HTTPException(404, detail="Coach not found") + + coach_account = db.exec( + select(Account).where( + Account.coach_id == coach.id, + Account.is_active == True, + ) + ).first() + + if coach_account is None or not coach.verified: + raise HTTPException(404, detail="Coach not available") existing_request = db.query(ClientCoachRequest).filter_by( client_id=client.id, coach_id=coach.id, is_accepted=None @@ -245,7 +301,6 @@ def create_coach_request(coach_id: int, db = Depends(get_session), acc: Account db.refresh(request) # notify the coach's account that a new request was created - coach_account = db.exec(select(Account).where(Account.coach_id == coach.id)).first() if coach_account and coach_account.id is not None: n = Notification( account_id=coach_account.id, @@ -596,8 +651,20 @@ def get_review(coach_id: int, db = Depends(get_session), acc: Account = Depends( if acc.client_id is None: raise HTTPException(403, detail="You are not authorized to view this content") - - reviews = db.query(CoachReviews).filter(CoachReviews.coach_id == coach_id).all() + + coach_account = db.exec( + select(Account).where( + Account.coach_id == coach_id, + Account.is_active == True, + ) + ).first() + + if coach_account is None: + return ReviewsResponse(reviews=[]) + + reviews = db.exec( + select(CoachReviews).where(CoachReviews.coach_id == coach_id) + ).all() return ReviewsResponse(reviews=reviews) @@ -610,25 +677,33 @@ def get_my_coach(db = Depends(get_session), acc: Account = Depends(get_client_ac if acc is None: raise HTTPException(404, detail="Account not found") - coach_request = db.query(ClientCoachRequest).filter( - ClientCoachRequest.client_id == acc.client_id, - ClientCoachRequest.is_accepted == True - ).order_by(ClientCoachRequest.last_updated).first() - - if coach_request is None: - raise HTTPException(404, detail="You do not have an accepted coach request") - - relationship = db.query(ClientCoachRelationship).filter(ClientCoachRelationship.request_id == coach_request.id).first() - - if relationship is None: - raise HTTPException(404, detail="Relationship not Found") - - coach = db.query(Coach).filter(Coach.id == coach_request.coach_id).first() + coach_row = db.exec( + select(ClientCoachRequest, ClientCoachRelationship) + .join( + ClientCoachRelationship, + ClientCoachRelationship.request_id == ClientCoachRequest.id, + ) + .where( + ClientCoachRequest.client_id == acc.client_id, + ClientCoachRequest.is_accepted.is_(True), + ClientCoachRelationship.is_active.is_(True), + ClientCoachRelationship.client_blocked.is_(False), + ClientCoachRelationship.coach_blocked.is_(False), + ) + .order_by(ClientCoachRequest.last_updated.desc(), ClientCoachRequest.id.desc()) + ).first() + + if coach_row is None: + raise HTTPException(404, detail="You do not have an accepted coach request") + + coach_request, relationship = coach_row + + coach = db.exec(select(Coach).where(Coach.id == coach_request.coach_id)).first() if coach is None: raise HTTPException(404, detail="Coach not found") - coach_account = db.query(Account).filter(Account.coach_id == coach.id).first() + coach_account = db.exec(select(Account).where(Account.coach_id == coach.id)).first() return { "coach_id": coach.id, diff --git a/src/api/roles/coach/coach.py b/src/api/roles/coach/coach.py index 2796aae..b495d5a 100644 --- a/src/api/roles/coach/coach.py +++ b/src/api/roles/coach/coach.py @@ -42,9 +42,18 @@ from src.database.session import get_session from src.database.account.models import Account, Availability, Notification from src.database.telemetry.models import ( - HealthMetrics, ClientTelemetry, - StepCount, CompletedSurvey, DailyMoodSurvey, - CompletedWorkout, CompletedMealActivity, DailyProgressPicture, + HealthMetrics, + ClientTelemetry, + StepCount, + CompletedSurvey, + DailyMoodSurvey, + DailyWorkoutSurvey, + DailyBodyMetricsSurvey, + DailyStepsSurvey, + DailyMealSurvey, + CompletedMealActivity, + CompletedWorkout, + DailyProgressPicture, ) from src.database.coach.models import Coach, CoachCertifications, CoachExperience, CoachAvailability, Experience, Certifications from src.database.client.models import Client, FitnessGoals, ClientWorkoutPlan @@ -58,7 +67,7 @@ @router.post("/request_coach_creation", response_model=CreateCoachRequestResponse) def create_coach_request(coach_details: CoachRequestInput, db = Depends(get_session), acc: Account = Depends(get_client_account)): """ - Creates a coach_request, and a coach record with verified=False, + Creates a coach_request, and a coach record with verified=False, attaches certifications, experiences, and availability modifies user account to show coach_id=xxx Errors when a user has a coach_id @@ -68,9 +77,9 @@ def create_coach_request(coach_details: CoachRequestInput, db = Depends(get_sess #client err thrown in DI scope if acc.coach_id is not None: raise HTTPException(409, detail="Cannot create a request for a coach role when one is open, or the role is given") - + coach = Coach() - + db.add(coach) db.add(coach_availability := CoachAvailability()) @@ -106,6 +115,10 @@ def create_coach_request(coach_details: CoachRequestInput, db = Depends(get_sess db.add(pricing_plan) db.flush() + # Persist the linkage from coach -> coach_availability so availability lookups work + coach.coach_availability = coach_availability.id + db.add(coach) + db.commit() return CreateCoachRequestResponse(coach_request_id=cr.id, coach_id=coach.id) # type: ignore @@ -118,16 +131,25 @@ def update_coach_info(new_coach_details: UpdateCoachInfoInput, db = Depends(get_ Deletes existing certs, exps, and availabilities and replaces with new ones if the user provides them, otherwise leaves them as is Errors when user does not have a coach_id """ - + if coach_acc.id is None: raise HTTPException(404, detail="No coach profile found for this account") - + coach = db.get(Coach, coach_acc.coach_id) + if coach is None: + raise HTTPException(404, detail="No coach profile found for this account") # coach.coach_availability already stores the id; avoid an extra query coach_availability_id = coach.coach_availability if new_coach_details.availabilities is not None: - if coach_availability_id is not None: + if coach_availability_id is None: + coach_availability = CoachAvailability() + db.add(coach_availability) + db.flush() + coach.coach_availability = coach_availability.id + coach_availability_id = coach_availability.id + db.add(coach) + else: db.exec(delete(Availability).where(Availability.coach_availability_id == coach_availability_id)) for a in new_coach_details.availabilities: a.coach_availability_id = coach_availability_id # type: ignore @@ -207,7 +229,7 @@ def create_workout(workout_details: WorkoutInput, db = Depends(get_session), acc """ if acc.coach_id is None: raise HTTPException(404, detail="No coach profile found for this account") - + workout = Workout( name=workout_details.name, description=workout_details.description, @@ -343,23 +365,32 @@ def get_coach_availability(coach_id: int, db = Depends(get_session), acc: Accoun """ if acc.client_id is None: raise HTTPException(404, detail="Please log in to view coach availability") - + coach = db.get(Coach, coach_id) if coach is None: raise HTTPException(404, detail="Coach not found") - - if coach.verified == False: + + coach_account = db.exec( + select(Account).where( + Account.coach_id == coach_id, + Account.is_active == True, + ) + ).first() + + if coach_account is None or coach.verified == False: raise HTTPException(404, detail="Coach is not verified yet, availability is not viewable") - + + if coach.coach_availability is None: + return CoachAvailabilityResponse(coach_availabilities=[]) + availabilities = db.exec(select(Availability).where(Availability.coach_availability_id == coach.coach_availability)).all() return CoachAvailabilityResponse(coach_availabilities=availabilities) -@router.get("/client_requests") +@router.get("/client_requests", response_model=RequestListResponse) def get_client_requests(db = Depends(get_session), acc: Account = Depends(get_coach_account)): """ - Gets the list of all pending client requests for a given coach with full client details. - Returns list of {client_id, request_id, name, age, gender, pfp_url, goal} objects. + Gets the list of all pending client requests for a given coach. """ if acc.coach_id is None: raise HTTPException(404, detail="No coach profile found for this account") @@ -370,24 +401,7 @@ def get_client_requests(db = Depends(get_session), acc: Account = Depends(get_co ClientCoachRequest.is_accepted.is_(None) # pending ).all() - items = [] - for request in requests: - # Get client account details - client_account = db.exec(select(Account).where(Account.client_id == request.client_id)).first() - # Get fitness goals - fitness_goals = db.exec(select(FitnessGoals).where(FitnessGoals.client_id == request.client_id)).all() - - goal = fitness_goals[0].goal_enum if fitness_goals else "No goal set" - - items.append({ - "client_id": request.client_id, - "request_id": request.id, - "name": client_account.name if client_account else f"Client #{request.client_id}", - "age": client_account.age if client_account else None, - "gender": client_account.gender if client_account else None, - "pfp_url": client_account.pfp_url if client_account else None, - "goal": goal, - }) + items = [{"client_id": r.client_id, "request_id": r.id} for r in requests] return items @@ -511,10 +525,10 @@ def accept_coach_request(request_id: int, db = Depends(get_session), acc: Accoun if request is None: raise HTTPException(404, detail="Request not found") - + if request.coach_id != acc.coach_id: raise HTTPException(403, detail="Not authorized to accept this request") - + # update existing request rather than inserting a new row with the same PK request.is_accepted = True db.add(request) @@ -565,7 +579,7 @@ def accept_coach_request(request_id: int, db = Depends(get_session), acc: Accoun db.add(Notification(account_id=coach_account.id, fav_category="payment", message=f"Your client was invoiced ${amount:.2f}.", details=f"Invoice {invoice.id} for client {request.client_id}.")) db.commit() - + if relationship.id is None: raise HTTPException(500, detail="Something went wrong when accepting the request") @@ -580,10 +594,10 @@ def deny_client_request(request_id: int, db = Depends(get_session), acc: Account if request is None: raise HTTPException(404, detail="Request not found") - + if request.coach_id != acc.coach_id: raise HTTPException(403, detail="Not authorized to deny this request") - + # update existing request to mark as denied request.is_accepted = False db.add(request) @@ -614,10 +628,10 @@ def client_review(client_id: int, report_summary: str, db = Depends(get_session) if acc.id is None: raise HTTPException(404, detail="Account not found") - + if acc.coach_id is None: raise HTTPException(403, detail="Not authorized to use this feature") - + report = ClientReport(coach_id=acc.coach_id, client_id=client_id, report_summary=report_summary) db.add(report) @@ -626,7 +640,7 @@ def client_review(client_id: int, report_summary: str, db = Depends(get_session) if report.id is None: raise HTTPException(500, detail="Something went wrong while creating the report") - + return ClientReportResponse(report_id=report.id) @@ -638,10 +652,10 @@ def get_reports(client_id: int, db = Depends(get_session), acc: Account = Depend if acc.id is None: raise HTTPException(404, detail="Account not found") - + if acc.coach_id is None: raise HTTPException(403, detail="You are not authorized to view this content") - + reports = db.query(ClientReport).filter(ClientReport.client_id == client_id).all() return ReportsResponse(reports=reports) @@ -657,7 +671,7 @@ def get_coach_earnings( """ if acc.coach_id is None: raise HTTPException(403, detail="Not authorized") - + # an invoice represents earnings. (Amount - outstanding_balance) is the paid amount. query = ( select(func.sum(Invoice.amount - Invoice.outstanding_balance)) @@ -675,6 +689,148 @@ def get_coach_earnings( return CoachEarningsResponse(total_earnings=total, since=since) +@router.get("/my_clients") +def get_my_clients( + pagination: PaginationParams = Depends(PaginationParams), + db = Depends(get_session), + acc: Account = Depends(get_coach_account), +): + """ + Returns all active clients for the logged-in coach. + Includes Client, Account without password, and telemetry objects. + """ + + if acc.coach_id is None: + raise HTTPException(403, detail="Coach profile required") + + relationships = db.exec( + select(ClientCoachRequest, ClientCoachRelationship) + .join(ClientCoachRelationship, ClientCoachRelationship.request_id == ClientCoachRequest.id) + .where( + ClientCoachRequest.coach_id == acc.coach_id, + ClientCoachRequest.is_accepted.is_(True), + ClientCoachRelationship.is_active.is_(True), + ClientCoachRelationship.client_blocked.is_(False), + ClientCoachRelationship.coach_blocked.is_(False), + ) + .order_by(ClientCoachRequest.last_updated.desc(), ClientCoachRequest.id.desc()) + .offset(pagination.skip) + .limit(pagination.limit) + ).all() + + client_ids = [request.client_id for request, relationship in relationships] + if not client_ids: + return [] + + client_rows = db.exec(select(Client).where(Client.id.in_(client_ids))).all() + clients_by_id = {client.id: client for client in client_rows} + + account_rows = db.exec(select(Account).where(Account.client_id.in_(client_ids))).all() + accounts_by_client_id = {account.client_id: account for account in account_rows} + + telemetry_records = db.exec( + select(ClientTelemetry) + .where(ClientTelemetry.client_id.in_(client_ids)) + .order_by(ClientTelemetry.date.desc()) + ).all() + + telemetry_by_client_id: dict[int, list[ClientTelemetry]] = {} + telemetry_ids: list[int] = [] + for telemetry_record in telemetry_records: + telemetry_by_client_id.setdefault(telemetry_record.client_id, []).append(telemetry_record) + if telemetry_record.id is not None: + telemetry_ids.append(telemetry_record.id) + + def group_by_telemetry_id(rows): + grouped = {} + for row in rows: + grouped.setdefault(row.client_telemetry_id, []).append(row) + return grouped + + if telemetry_ids: + health_metrics_by_telemetry = group_by_telemetry_id( + db.exec(select(HealthMetrics).where(HealthMetrics.client_telemetry_id.in_(telemetry_ids))).all() + ) + step_counts_by_telemetry = group_by_telemetry_id( + db.exec(select(StepCount).where(StepCount.client_telemetry_id.in_(telemetry_ids))).all() + ) + mood_surveys_by_telemetry = group_by_telemetry_id( + db.exec(select(DailyMoodSurvey).where(DailyMoodSurvey.client_telemetry_id.in_(telemetry_ids))).all() + ) + workout_surveys_by_telemetry = group_by_telemetry_id( + db.exec(select(DailyWorkoutSurvey).where(DailyWorkoutSurvey.client_telemetry_id.in_(telemetry_ids))).all() + ) + body_metrics_surveys_by_telemetry = group_by_telemetry_id( + db.exec(select(DailyBodyMetricsSurvey).where(DailyBodyMetricsSurvey.client_telemetry_id.in_(telemetry_ids))).all() + ) + steps_surveys_by_telemetry = group_by_telemetry_id( + db.exec(select(DailyStepsSurvey).where(DailyStepsSurvey.client_telemetry_id.in_(telemetry_ids))).all() + ) + meal_surveys_by_telemetry = group_by_telemetry_id( + db.exec(select(DailyMealSurvey).where(DailyMealSurvey.client_telemetry_id.in_(telemetry_ids))).all() + ) + completed_meals_by_telemetry = group_by_telemetry_id( + db.exec(select(CompletedMealActivity).where(CompletedMealActivity.client_telemetry_id.in_(telemetry_ids))).all() + ) + completed_workouts_by_telemetry = group_by_telemetry_id( + db.exec(select(CompletedWorkout).where(CompletedWorkout.client_telemetry_id.in_(telemetry_ids))).all() + ) + else: + health_metrics_by_telemetry = {} + step_counts_by_telemetry = {} + mood_surveys_by_telemetry = {} + workout_surveys_by_telemetry = {} + body_metrics_surveys_by_telemetry = {} + steps_surveys_by_telemetry = {} + meal_surveys_by_telemetry = {} + completed_meals_by_telemetry = {} + completed_workouts_by_telemetry = {} + + clients = [] + for request, relationship in relationships: + account = accounts_by_client_id.get(request.client_id) + safe_account = None + if account: + safe_account = { + "id": account.id, + "name": account.name, + "email": account.email, + "is_active": account.is_active, + "gender": account.gender, + "bio": account.bio, + "age": account.age, + "pfp_url": account.pfp_url, + "client_id": account.client_id, + "coach_id": account.coach_id, + "admin_id": account.admin_id, + "created_at": account.created_at, + } + + telemetry = [] + for telemetry_record in telemetry_by_client_id.get(request.client_id, []): + telemetry_id = telemetry_record.id + telemetry.append({ + "client_telemetry": telemetry_record, + "health_metrics": health_metrics_by_telemetry.get(telemetry_id, []), + "step_counts": step_counts_by_telemetry.get(telemetry_id, []), + "mood_surveys": mood_surveys_by_telemetry.get(telemetry_id, []), + "workout_surveys": workout_surveys_by_telemetry.get(telemetry_id, []), + "body_metrics_surveys": body_metrics_surveys_by_telemetry.get(telemetry_id, []), + "steps_surveys": steps_surveys_by_telemetry.get(telemetry_id, []), + "meal_surveys": meal_surveys_by_telemetry.get(telemetry_id, []), + "completed_meals": completed_meals_by_telemetry.get(telemetry_id, []), + "completed_workouts": completed_workouts_by_telemetry.get(telemetry_id, []), + }) + + clients.append({ + "relationship_id": relationship.id, + "request_id": request.id, + "client": clients_by_id.get(request.client_id), + "account": safe_account, + "telemetry": telemetry, + }) + + return clients # ─── Coach-view client telemetry & schedule ────────────────────────────────── @@ -701,6 +857,8 @@ def _authorize_coach_for_client(db, coach_id: int, client_id: int) -> None: select(ClientCoachRelationship).where( ClientCoachRelationship.request_id == accepted.id, ClientCoachRelationship.is_active == True, + ClientCoachRelationship.coach_blocked == False, + ClientCoachRelationship.client_blocked == False, ) ).first() if rel: diff --git a/src/api/roles/shared/account.py b/src/api/roles/shared/account.py index 4c40ef9..ccb138a 100644 --- a/src/api/roles/shared/account.py +++ b/src/api/roles/shared/account.py @@ -2,17 +2,19 @@ from src.database.admin.models import Admin from src.database.session import get_session -from src.database.account.models import Account, Availability +from src.database.account.models import Account, Availability, Notification from src.database.client.models import Client, FitnessGoals from src.database.coach.models import Coach, Experience, Certifications, CoachExperience, CoachCertifications from src.database.payment.models import PricingPlan, PaymentInformation, Subscription, BillingCycle, Invoice -from src.database.telemetry.models import HealthMetrics, ClientTelemetry, DailyProgressPicture -from src.database.coach_client_relationship.models import ClientCoachRelationship, ClientCoachRequest -from src.database.reports.models import CoachReviews -from src.api.dependencies import get_active_account, get_account_even_if_inactive +from src.database.telemetry.models import HealthMetrics, ClientTelemetry, DailyProgressPicture +from src.database.coach_client_relationship.models import ClientCoachRelationship, ClientCoachRequest +from src.database.reports.models import CoachReviews +from src.database.role_management.models import RolePromotionResolution, CoachRequest +from src.api.dependencies import get_account_from_bearer, get_active_account, get_account_even_if_inactive from src.api.storage import upload_public_file_to_supabase -from src.api.roles.shared.domain import FullProfileResponse, AccountResponse, UpdateAccountInput -from sqlmodel import Session, select, desc, func +from src.api.roles.shared.domain import FullProfileResponse, AccountResponse, UpdateAccountInput +from sqlmodel import Session, select, desc, func +from sqlalchemy import or_ from pydantic import BaseModel, EmailStr from typing import Optional, List from datetime import datetime @@ -284,7 +286,173 @@ class ActivateAccountResponse(BaseModel): message: str -class DeleteAccountResponse(BaseModel): +def get_affected_accounts(db: Session, account: Account) -> list[Account]: + """ + Finds active client-coach relationship accounts affected by account deactivation. + """ + affected_accounts_by_id: dict[int, Account] = {} + + def add_affected_account(affected_account: Account | None): + if affected_account is None: + return + if affected_account.id is None or affected_account.id == account.id: + return + affected_accounts_by_id[affected_account.id] = affected_account + + # If the deactivated account is a client, notify their active coach(es) + if account.client_id is not None: + coach_accounts = db.exec( + select(Account) + .join(ClientCoachRequest, ClientCoachRequest.coach_id == Account.coach_id) + .join( + ClientCoachRelationship, + ClientCoachRelationship.request_id == ClientCoachRequest.id, + ) + .where( + ClientCoachRequest.client_id == account.client_id, + ClientCoachRelationship.is_active == True, + ) + ).all() + + for coach_account in coach_accounts: + add_affected_account(coach_account) + + # If the deactivated account is a coach, notify their active client(s) + if account.coach_id is not None: + client_accounts = db.exec( + select(Account) + .join(ClientCoachRequest, ClientCoachRequest.client_id == Account.client_id) + .join( + ClientCoachRelationship, + ClientCoachRelationship.request_id == ClientCoachRequest.id, + ) + .where( + ClientCoachRequest.coach_id == account.coach_id, + ClientCoachRelationship.is_active == True, + ) + ).all() + + for client_account in client_accounts: + add_affected_account(client_account) + + return list(affected_accounts_by_id.values()) + + +def notify_affected_accounts( + db: Session, + deactivated_account: Account, + affected_accounts: list[Account], +): + """ + Creates notification records for accounts affected by a user's deactivation. + """ + role = "account" + if deactivated_account.client_id is not None: + role = "client" + elif deactivated_account.coach_id is not None: + role = "coach" + + message = f"{deactivated_account.name} has deactivated their account." + details = f"{role.capitalize()} account {deactivated_account.id} was deactivated." + + for affected_account in affected_accounts: + if affected_account.id is None: + continue + + db.add( + Notification( + account_id=affected_account.id, + fav_category="account_deactivated", + message=message, + details=details, + is_read=False, + ) + ) + + +def delete_client_coach_mappings(db: Session, account: Account): + if account.client_id is not None: + requests = db.exec( + select(ClientCoachRequest) + .where(ClientCoachRequest.client_id == account.client_id) + ).all() + + for request in requests: + relationships = db.exec( + select(ClientCoachRelationship) + .where(ClientCoachRelationship.request_id == request.id) + ).all() + + for relationship in relationships: + db.delete(relationship) + + db.delete(request) + + if account.coach_id is not None: + requests = db.exec( + select(ClientCoachRequest) + .where(ClientCoachRequest.coach_id == account.coach_id) + ).all() + + for request in requests: + relationships = db.exec( + select(ClientCoachRelationship) + .where(ClientCoachRelationship.request_id == request.id) + ).all() + + for relationship in relationships: + db.delete(relationship) + + db.delete(request) + + +def delete_role_promotion_records(db: Session, account: Account): + """ + Remove role-promotion records that reference the account being deleted. + CoachRequest rows point at RolePromotionResolution, so clear those links before + deleting the resolution rows. + """ + if account.coach_id is not None: + coach_requests = db.exec( + select(CoachRequest).where(CoachRequest.coach_id == account.coach_id) + ).all() + + for coach_request in coach_requests: + db.delete(coach_request) + + if account.admin_id is not None: + resolution_query = select(RolePromotionResolution).where( + or_( + RolePromotionResolution.account_id == account.id, + RolePromotionResolution.admin_id == account.admin_id, + ) + ) + else: + resolution_query = select(RolePromotionResolution).where( + RolePromotionResolution.account_id == account.id + ) + + resolutions = db.exec(resolution_query).all() + resolution_ids = [ + resolution.id for resolution in resolutions if resolution.id is not None + ] + + if resolution_ids: + linked_requests = db.exec( + select(CoachRequest).where( + CoachRequest.role_promotion_resolution_id.in_(resolution_ids) + ) + ).all() + + for linked_request in linked_requests: + linked_request.role_promotion_resolution_id = None + db.add(linked_request) + + for resolution in resolutions: + db.delete(resolution) + + +class DeleteAccountResponse(BaseModel): success: bool message: str @@ -295,19 +463,37 @@ def deactivate_account( acc: Account = Depends(get_active_account), ): """ - Deactivate the current user's account. This sets is_active to False and prevents login/access. + Deactivate the current user's account. + This sets is_active to False and prevents access to protected routes. + It also notifies affected coaches/clients. """ account = db.get(Account, acc.id) + if account is None: - raise HTTPException(404, detail="Account not found") + raise HTTPException(status_code=404, detail="Account not found") + if not account.is_active: - return DeactivateAccountResponse(success=False, message="Account is already deactivated.") + return DeactivateAccountResponse( + success=False, + message="Account is already deactivated.", + ) + + affected_accounts = get_affected_accounts(db, account) + account.is_active = False db.add(account) + + notify_affected_accounts(db, account, affected_accounts) + + delete_client_coach_mappings(db, account) + db.commit() db.refresh(account) - return DeactivateAccountResponse(success=True, message="Account deactivated successfully.") + return DeactivateAccountResponse( + success=True, + message="Account deactivated successfully.", + ) @router.post("/activate", response_model=ActivateAccountResponse) def activate_account( @@ -315,7 +501,7 @@ def activate_account( acc: Account = Depends(get_account_even_if_inactive), ): """ - Activate the current user's account. This sets is_active to True and allows login/access. + Reactivate the current user's account. This sets is_active to True and allows login/access. """ account = db.get(Account, acc.id) if account is None: @@ -338,12 +524,15 @@ def delete_account( Permanently delete the current user's account and all associated data. """ account = db.get(Account, acc.id) - if account is None: - raise HTTPException(404, detail="Account not found") - - if account.client_id is not None: - client = db.get(Client, account.client_id) - if client: + if account is None: + raise HTTPException(404, detail="Account not found") + + delete_client_coach_mappings(db, account) + delete_role_promotion_records(db, account) + + if account.client_id is not None: + client = db.get(Client, account.client_id) + if client: db.delete(client) if account.coach_id is not None: diff --git a/src/database/account/models.py b/src/database/account/models.py index d72dada..31958ac 100644 --- a/src/database/account/models.py +++ b/src/database/account/models.py @@ -15,6 +15,7 @@ class Account(SQLModelLU, table=True): name: str email: EmailStr = Field(index=True) is_active: bool = Field(default=True) + # status: str = Field(default="active") # auth, ONE of these needs to be here hashed_password: Optional[str] = Field(default=None) diff --git a/tests/payload_tools/client.py b/tests/payload_tools/client.py index 1148424..a42f23a 100644 --- a/tests/payload_tools/client.py +++ b/tests/payload_tools/client.py @@ -1,17 +1,19 @@ -from datetime import date - -def build_client_init_payload(goal="weight loss", weight=170, weekday="monday"): +from datetime import date + +from tests.payload_tools.constants import TEST_ALT_CARD_NUMBER + +def build_client_init_payload(goal="weight loss", weight=170, weekday="monday"): """ Builds a mock payload for completing the initial client survey and creating a client role. """ return { - "fitness_goals": {"goal_enum": goal}, - "payment_information": { - "ccnum": "4242424242424242", - "cv": "123", - "exp_date": str(date(2026, 12, 31)), - }, + "fitness_goals": {"goal_enum": goal}, + "payment_information": { + "ccnum": TEST_ALT_CARD_NUMBER, + "cv": "123", + "exp_date": str(date(date.today().year + 5, 12, 31)), + }, "availabilities": [ {"weekday": weekday, "start_time": "08:00:00", "end_time": "10:00:00"} ], diff --git a/tests/payload_tools/constants.py b/tests/payload_tools/constants.py new file mode 100644 index 0000000..d72436a --- /dev/null +++ b/tests/payload_tools/constants.py @@ -0,0 +1,2 @@ +TEST_CARD_NUMBER = "4111" + "1111" + "1111" + "1111" +TEST_ALT_CARD_NUMBER = "4242" + "4242" + "4242" + "4242" diff --git a/tests/test_client_coach_availability.py b/tests/test_client_coach_availability.py new file mode 100644 index 0000000..3069f66 --- /dev/null +++ b/tests/test_client_coach_availability.py @@ -0,0 +1,150 @@ +from datetime import datetime, timezone + +from sqlmodel import select + +from src.api.dependencies import create_jwt_token +from src.database.account.models import Account +from src.database.coach.models import Coach +from src.database.coach_client_relationship.models import ( + ClientCoachRelationship, + ClientCoachRequest, +) +from tests.payload_tools.coach import ( + build_coach_request_payload, + build_update_coach_info_payload, +) + + +def test_client_can_fetch_coach_availability(test_client, client_auth_header, db_session): + """Integration test: a client can create a coach request, have the coach verified, + and then fetch that coach's availability via the client-prefixed endpoint. + + Verifies HTTP status and that the returned availability list contains + the expected fields (weekday, start_time, end_time). + """ + # Create a coach (starts unverified) with availability via the coach creation endpoint + payload = build_coach_request_payload(weekday="monday") + resp = test_client.post("/roles/coach/request_coach_creation", json=payload, headers=client_auth_header) + assert resp.status_code == 200 + coach_id = resp.json().get("coach_id") + assert coach_id is not None + + coach = db_session.get(Coach, coach_id) + assert coach is not None + coach.verified = True + db_session.add(coach) + db_session.commit() + + # Fetch availability using the client-prefixed endpoint we added + avail_resp = test_client.get(f"/roles/client/coach_availability/{coach_id}", headers=client_auth_header) + assert avail_resp.status_code == 200 + + data = avail_resp.json() + assert "coach_availabilities" in data + assert isinstance(data["coach_availabilities"], list) + assert len(data["coach_availabilities"]) == len(payload["availabilities"]) + + # Verify fields exist and weekday matches the payload (time strings may be serialized with timezone) + expected = payload["availabilities"][0] + actual = data["coach_availabilities"][0] + assert actual.get("weekday") == expected.get("weekday") + assert "start_time" in actual and isinstance(actual.get("start_time"), str) + assert "end_time" in actual and isinstance(actual.get("end_time"), str) + + +def test_client_fetches_updated_coach_availability( + test_client, + client_auth_header, + coach_auth_header, + db_session, +): + coach_me = test_client.post("/roles/coach/me", headers=coach_auth_header) + assert coach_me.status_code == 200 + coach_id = coach_me.json()["coach_account"]["id"] + + update_payload = build_update_coach_info_payload(weekday="thursday") + update_resp = test_client.patch( + "/roles/coach/information", + json=update_payload, + headers=coach_auth_header, + ) + assert update_resp.status_code == 200, update_resp.text + + avail_resp = test_client.get( + f"/roles/client/coach_availability/{coach_id}", + headers=client_auth_header, + ) + assert avail_resp.status_code == 200, avail_resp.text + availabilities = avail_resp.json()["coach_availabilities"] + + assert len(availabilities) == 1 + assert availabilities[0]["weekday"] == "thursday" + assert availabilities[0]["start_time"].startswith("19:00:00") + assert availabilities[0]["end_time"].startswith("21:00:00") + + +def test_coach_fetches_updated_client_availability( + test_client, + client_auth_header, + coach_auth_header, + db_session, +): + client_me = test_client.get("/me", headers=client_auth_header) + assert client_me.status_code == 200 + client_id = client_me.json()["client_id"] + + coach_me = test_client.post("/roles/coach/me", headers=coach_auth_header) + assert coach_me.status_code == 200 + coach_id = coach_me.json()["coach_account"]["id"] + + client_update_payload = { + "availabilities": [ + { + "weekday": "friday", + "start_time": "09:00:00", + "end_time": "11:00:00", + } + ] + } + update_resp = test_client.patch( + "/roles/client/information", + json=client_update_payload, + headers=client_auth_header, + ) + assert update_resp.status_code == 200, update_resp.text + + request = ClientCoachRequest( + client_id=client_id, + coach_id=coach_id, + is_accepted=True, + ) + db_session.add(request) + db_session.commit() + db_session.refresh(request) + + relationship = ClientCoachRelationship( + request_id=request.id, + created_at=datetime.now(timezone.utc), + is_active=True, + coach_blocked=False, + client_blocked=False, + ) + db_session.add(relationship) + db_session.commit() + + coach_account = db_session.exec( + select(Account).where(Account.coach_id == coach_id) + ).first() + coach_auth_header = {"Authorization": f"Bearer {create_jwt_token(coach_account)}"} + + avail_resp = test_client.get( + f"/roles/coach/client_availability/{client_id}", + headers=coach_auth_header, + ) + assert avail_resp.status_code == 200, avail_resp.text + availabilities = avail_resp.json() + + assert len(availabilities) == 1 + assert availabilities[0]["weekday"] == "friday" + assert availabilities[0]["start_time"].startswith("09:00:00") + assert availabilities[0]["end_time"].startswith("11:00:00") diff --git a/tests/test_client_routes.py b/tests/test_client_routes.py new file mode 100644 index 0000000..9b2201a --- /dev/null +++ b/tests/test_client_routes.py @@ -0,0 +1,76 @@ +from datetime import date + +from tests.payload_tools.constants import TEST_CARD_NUMBER + + +def make_client_profile(test_client, auth_header): + payload = { + "fitness_goals": { + "goal_enum": "weight loss" + }, + "payment_information": { + "ccnum": TEST_CARD_NUMBER, + "cv": "123", + "exp_date": str(date(date.today().year + 5, 12, 31)) + }, + "availabilities": [ + { + "weekday": "monday", + "start_time": "08:00:00", + "end_time": "10:00:00" + } + ], + "initial_health_metric": { + "weight": 180 + } + } + + response = test_client.post( + "/roles/client/initial_survey", + json=payload, + headers=auth_header + ) + + assert response.status_code == 200 + + +def test_get_my_coach(test_client, auth_header): + make_client_profile(test_client, auth_header) + + response = test_client.get( + "/roles/client/my_coach", + headers=auth_header + ) + + assert response.status_code == 404 + + +def test_get_coach_profile(test_client, auth_header): + make_client_profile(test_client, auth_header) + + response = test_client.get( + "/roles/client/coach_profile/999999", + headers=auth_header + ) + + assert response.status_code == 404 + + +def test_get_progress_pictures(test_client, auth_header): + make_client_profile(test_client, auth_header) + + response = test_client.get( + "/roles/client/progress_pictures", + headers=auth_header + ) + + assert response.status_code == 200 + + +def test_get_my_clients(test_client, coach_auth_header): + response = test_client.get( + "/roles/coach/my_clients", + headers=coach_auth_header + ) + + assert response.status_code == 200 diff --git a/tests/test_hirable_coaches.py b/tests/test_hirable_coaches.py index d443fd9..917c675 100644 --- a/tests/test_hirable_coaches.py +++ b/tests/test_hirable_coaches.py @@ -1,9 +1,11 @@ -from tests.payload_tools.auth import build_signup_payload, build_login_payload -from tests.payload_tools.client import build_client_init_payload -from tests.payload_tools.coach import build_coach_request_payload - -from src.database.reports.models import CoachReviews -from src.database.coach.models import Coach +from tests.payload_tools.auth import build_signup_payload, build_login_payload +from tests.payload_tools.client import build_client_init_payload +from tests.payload_tools.coach import build_coach_request_payload +from sqlmodel import select + +from src.database.account.models import Account +from src.database.reports.models import CoachReviews +from src.database.coach.models import Coach def _create_and_verify_coach(test_client, db_session, admin_auth_header, name, email_prefix, age=30, gender="non-binary", specialties=None): @@ -101,7 +103,7 @@ def test_hirable_coaches_privacy_and_empty_reviews(test_client, db_session, admi assert dana["avg_rating"] is None -def test_hirable_coaches_pagination_and_unauthorized(test_client, client_auth_header): +def test_hirable_coaches_pagination_and_unauthorized(test_client, client_auth_header): # Ensure endpoint accepts explicit skip/limit pagination params resp = test_client.get( "/roles/client/query/hirable_coaches?sort_by=avg_rating&order=desc&skip=0&limit=100", @@ -110,9 +112,79 @@ def test_hirable_coaches_pagination_and_unauthorized(test_client, client_auth_he assert resp.status_code == 200 # Unauthorized access returns 401 and clear message - resp2 = test_client.get( - "/roles/client/query/hirable_coaches?skip=0&limit=10", - headers={"Authorization": "Bearer invalid.token"}, - ) - assert resp2.status_code == 401 - assert resp2.json().get("detail") is not None + resp2 = test_client.get( + "/roles/client/query/hirable_coaches?skip=0&limit=10", + headers={"Authorization": "Bearer invalid.token"}, + ) + assert resp2.status_code == 401 + assert resp2.json().get("detail") is not None + + +def test_deactivated_coach_is_hidden_and_cannot_be_requested( + test_client, + db_session, + admin_auth_header, + client_auth_header, + create_client, +): + coach_id, coach_header = _create_and_verify_coach( + test_client, + db_session, + admin_auth_header, + "Hidden Coach", + "coach_hidden", + specialties="mobility", + ) + reviewer_header, reviewer_client_id = create_client(email_prefix="hidden_review") + _add_review(db_session, coach_id, reviewer_client_id, 5.0) + + deactivate_resp = test_client.post( + "/roles/shared/account/deactivate", + headers=coach_header, + ) + assert deactivate_resp.status_code == 200, deactivate_resp.text + + search_resp = test_client.get( + "/roles/client/query/hirable_coaches?specialty=mobility", + headers=client_auth_header, + ) + assert search_resp.status_code == 200 + assert all(item["coach_id"] != coach_id for item in search_resp.json()) + + request_resp = test_client.post( + f"/roles/client/request_coach/{coach_id}", + headers=client_auth_header, + ) + assert request_resp.status_code == 404 + + profile_resp = test_client.get( + f"/roles/client/coach_profile/{coach_id}", + headers=client_auth_header, + ) + assert profile_resp.status_code == 404 + + reviews_resp = test_client.get( + f"/roles/client/review/{coach_id}", + headers=client_auth_header, + ) + assert reviews_resp.status_code == 200 + assert reviews_resp.json()["reviews"] == [] + + activate_resp = test_client.post( + "/roles/shared/account/activate", + headers=coach_header, + ) + assert activate_resp.status_code == 200, activate_resp.text + + reviews_after_reactivate = test_client.get( + f"/roles/client/review/{coach_id}", + headers=client_auth_header, + ) + assert reviews_after_reactivate.status_code == 200 + assert len(reviews_after_reactivate.json()["reviews"]) == 1 + + coach_account = db_session.exec( + select(Account).where(Account.coach_id == coach_id) + ).first() + assert coach_account is not None + assert coach_account.is_active is True diff --git a/tests/test_shared_account_activation.py b/tests/test_shared_account_activation.py index b509aee..cd4980f 100644 --- a/tests/test_shared_account_activation.py +++ b/tests/test_shared_account_activation.py @@ -8,7 +8,7 @@ def test_account_deactivate_and_activate(test_client, auth_header): # Try to access a protected endpoint (should fail) resp2 = test_client.patch("/roles/shared/account/update", json={}, headers=auth_header) - assert resp2.status_code in (400, 401) + assert resp2.status_code == 403 assert "account deactivated" in resp2.text.lower() # Activate diff --git a/tests/test_shared_account_delete.py b/tests/test_shared_account_delete.py new file mode 100644 index 0000000..3619d29 --- /dev/null +++ b/tests/test_shared_account_delete.py @@ -0,0 +1,93 @@ +from sqlmodel import select + +from src.database.account.models import Account +from src.database.client.models import Client +from src.database.coach.models import Coach +from src.database.role_management.models import CoachRequest, RolePromotionResolution +from tests.payload_tools.coach import build_coach_request_payload + + +def test_delete_base_account(test_client, auth_header, db_session): + me_resp = test_client.get("/me", headers=auth_header) + assert me_resp.status_code == 200 + account_id = me_resp.json()["id"] + + delete_resp = test_client.delete("/roles/shared/account/delete", headers=auth_header) + assert delete_resp.status_code == 200, delete_resp.text + assert delete_resp.json() == { + "success": True, + "message": "Account deleted successfully.", + } + + assert db_session.get(Account, account_id) is None + + me_after_delete = test_client.get("/me", headers=auth_header) + assert me_after_delete.status_code == 401 + + +def test_delete_client_account_removes_client_role(test_client, client_auth_header, db_session): + me_resp = test_client.get("/me", headers=client_auth_header) + assert me_resp.status_code == 200 + account_id = me_resp.json()["id"] + client_id = me_resp.json()["client_id"] + + delete_resp = test_client.delete("/roles/shared/account/delete", headers=client_auth_header) + assert delete_resp.status_code == 200, delete_resp.text + + db_session.expire_all() + assert db_session.get(Account, account_id) is None + assert db_session.get(Client, client_id) is None + + +def test_delete_coach_account_removes_coach_role(test_client, coach_auth_header, db_session): + coach_me_resp = test_client.post("/roles/coach/me", headers=coach_auth_header) + assert coach_me_resp.status_code == 200 + account_id = coach_me_resp.json()["base_account"]["id"] + coach_id = coach_me_resp.json()["coach_account"]["id"] + + delete_resp = test_client.delete("/roles/shared/account/delete", headers=coach_auth_header) + assert delete_resp.status_code == 200, delete_resp.text + + db_session.expire_all() + assert db_session.get(Account, account_id) is None + assert db_session.get(Coach, coach_id) is None + assert db_session.exec(select(Account).where(Account.coach_id == coach_id)).first() is None + + +def test_delete_coach_account_removes_role_promotion_resolution( + test_client, + create_client, + admin_auth_header, + db_session, +): + coach_header, _ = create_client(email_prefix="delete-approved-coach") + + coach_request_resp = test_client.post( + "/roles/coach/request_coach_creation", + json=build_coach_request_payload(), + headers=coach_header, + ) + assert coach_request_resp.status_code == 200, coach_request_resp.text + coach_request_id = coach_request_resp.json()["coach_request_id"] + + resolve_resp = test_client.post( + "/roles/admin/resolve_coach_request", + json={"coach_request_id": coach_request_id, "is_approved": True}, + headers=admin_auth_header, + ) + assert resolve_resp.status_code == 200, resolve_resp.text + + coach_request = db_session.get(CoachRequest, coach_request_id) + assert coach_request is not None + resolution_id = coach_request.role_promotion_resolution_id + assert resolution_id is not None + + delete_resp = test_client.delete( + "/roles/shared/account/delete", + headers=coach_header, + ) + assert delete_resp.status_code == 200, delete_resp.text + + db_session.expire_all() + assert db_session.get(CoachRequest, coach_request_id) is None + assert db_session.get(RolePromotionResolution, resolution_id) is None diff --git a/tests/test_shared_account_notifications.py b/tests/test_shared_account_notifications.py new file mode 100644 index 0000000..ed20853 --- /dev/null +++ b/tests/test_shared_account_notifications.py @@ -0,0 +1,165 @@ +from sqlmodel import select +from datetime import datetime +from src.api.dependencies import create_jwt_token +from src.database.account.models import Notification, Account +from src.database.coach.models import Coach +from src.database.coach_client_relationship.models import ( + ClientCoachRequest, + ClientCoachRelationship, +) + + +def create_client_coach_relationship(db_session): + client = db_session.exec( + select(Account).where( + Account.client_id.is_not(None), + Account.is_active == True, + ) + ).first() + + assert client is not None + + coach = db_session.exec( + select(Account).where( + Account.coach_id.is_not(None), + Account.is_active == True, + Account.id != client.id, + ) + ).first() + + if coach is None: + coach_profile = Coach(verified=True) + db_session.add(coach_profile) + db_session.commit() + db_session.refresh(coach_profile) + + coach = Account( + name="Notification Test Coach", + email=f"notification_coach_{coach_profile.id}@example.com", + hashed_password="test-hash", + coach_id=coach_profile.id, + is_active=True, + ) + db_session.add(coach) + db_session.commit() + db_session.refresh(coach) + + assert coach is not None + + request = ClientCoachRequest( + client_id=client.client_id, + coach_id=coach.coach_id, + ) + + db_session.add(request) + db_session.commit() + db_session.refresh(request) + + relationship = ClientCoachRelationship( + request_id=request.id, + created_at=datetime.utcnow(), + is_active=True, + coach_blocked=False, + client_blocked=False, + ) + + db_session.add(relationship) + db_session.commit() + + return client, coach, request, relationship + + +def test_account_deactivate_sends_notification( + test_client, + db_session, + client_auth_header, + coach_auth_header, +): + client, coach, request, relationship = create_client_coach_relationship(db_session) + + client_auth_header = { + "Authorization": f"Bearer {create_jwt_token(client)}" + } + + resp = test_client.post( + "/roles/shared/account/deactivate", + headers=client_auth_header, + ) + + assert resp.status_code == 200 + assert resp.json()["success"] is True + + db_session.expire_all() + + notifications = list( + db_session.exec( + select(Notification).where(Notification.account_id == coach.id) + ) + ) + + remaining_relationship = db_session.get(ClientCoachRelationship, relationship.id) + remaining_request = db_session.get(ClientCoachRequest, request.id) + + assert notifications, "No notifications found for coach" + assert any( + n.details and "deactivated" in n.details.lower() + for n in notifications + ) + assert remaining_relationship is None + assert remaining_request is None + + activate_resp = test_client.post( + "/roles/shared/account/activate", + headers=client_auth_header, + ) + assert activate_resp.status_code == 200, activate_resp.text + assert db_session.get(ClientCoachRelationship, relationship.id) is None + assert db_session.get(ClientCoachRequest, request.id) is None + + +def test_account_deactivate_coach_notifies_client( + test_client, + db_session, + client_auth_header, + coach_auth_header, +): + client, coach, request, relationship = create_client_coach_relationship(db_session) + + coach_auth_header = { + "Authorization": f"Bearer {create_jwt_token(coach)}" + } + + resp = test_client.post( + "/roles/shared/account/deactivate", + headers=coach_auth_header, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["success"] is True + + db_session.expire_all() + + notifications = list( + db_session.exec( + select(Notification).where(Notification.account_id == client.id) + ) + ) + + remaining_relationship = db_session.get(ClientCoachRelationship, relationship.id) + remaining_request = db_session.get(ClientCoachRequest, request.id) + + assert notifications, "No notifications found for client" + assert any( + n.details and "deactivated" in n.details.lower() + for n in notifications + ) + assert remaining_relationship is None + assert remaining_request is None + + activate_resp = test_client.post( + "/roles/shared/account/activate", + headers=coach_auth_header, + ) + assert activate_resp.status_code == 200, activate_resp.text + assert db_session.get(ClientCoachRelationship, relationship.id) is None + assert db_session.get(ClientCoachRequest, request.id) is None