brestok commited on
Commit
63e1f68
·
1 Parent(s): ed39658

Add support for anonymous call purchases and account registration

Browse files

- Introduced `AccountStatus.PENDING` to manage pending account states.
- Added `PurchaseAnonymousCallRequest` schema for anonymous call purchases.
- Implemented `create_anonymous_account_obj` to create accounts with pending status.
- Updated `create_call_obj` to handle both regular and anonymous calls.
- Enhanced email notifications for anonymous bookings.
- Added endpoint for completing account registration, updating status to active.

cbh/api/account/dto.py CHANGED
@@ -22,6 +22,7 @@ class AccountStatus(Enum):
22
 
23
  ACTIVE = 1
24
  INACTIVE = 2
 
25
 
26
 
27
  class CoachOpportunity(Enum):
 
22
 
23
  ACTIVE = 1
24
  INACTIVE = 2
25
+ PENDING = 3
26
 
27
 
28
  class CoachOpportunity(Enum):
cbh/api/account/models.py CHANGED
@@ -62,6 +62,6 @@ class AccountShorten(MongoBaseShortenModel):
62
 
63
  name: str | None = None
64
  email: str
65
- pictureUrl: str | None = None
66
 
67
  accountType: AccountType
 
62
 
63
  name: str | None = None
64
  email: str
65
+ status: AccountStatus
66
 
67
  accountType: AccountType
cbh/api/calls/db_requests.py CHANGED
@@ -18,40 +18,34 @@ from cbh.api.common.db_requests import get_all_objs
18
  from cbh.api.common.schemas import FilterRequest
19
  from cbh.api.events.dto import EventType
20
  from cbh.api.events.models import EventModel, EventShorten
 
21
  from cbh.core.config import settings
22
 
23
 
24
  async def create_call_obj(
25
- event: EventModel,
26
  coach: AccountShorten,
27
  customer: AccountModel,
28
  ) -> CallModel:
29
  """
30
  Create a new call.
31
  """
32
- call = CallModel(
33
- event=EventShorten(**event.model_dump()),
34
- customer=AccountShorten(**customer.model_dump()),
35
- coach=coach,
36
- )
37
- await settings.DB_CLIENT.calls.insert_one(call.to_mongo())
38
- return call
39
-
40
-
41
- async def create_event_obj(
42
- request: PurchaseCallRequest, coach: AccountShorten
43
- ) -> EventModel:
44
- """
45
- Create a new event.
46
- """
47
  event = EventModel(
48
  type=EventType.CALL,
49
  startDate=request.startDate,
50
  endDate=request.endDate,
51
  coach=coach,
52
  )
53
- await settings.DB_CLIENT.events.insert_one(event.to_mongo())
54
- return event
 
 
 
 
 
 
 
 
55
 
56
 
57
  async def disable_call(call: CallModel) -> CallModel:
@@ -360,4 +354,22 @@ async def check_call_payment(call_id: str) -> StripeCheckResponse:
360
  raise HTTPException(status_code=404, detail="Call not found")
361
  is_processed = True if call["status"] == CallStatus.SCHEDULED.value else False
362
  success = call["event"]["isActive"]
363
- return StripeCheckResponse(isProcessed=is_processed, success=success)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  from cbh.api.common.schemas import FilterRequest
19
  from cbh.api.events.dto import EventType
20
  from cbh.api.events.models import EventModel, EventShorten
21
+ from cbh.api.security.db_requests import check_unique_fields_existence
22
  from cbh.core.config import settings
23
 
24
 
25
  async def create_call_obj(
26
+ request: PurchaseCallRequest,
27
  coach: AccountShorten,
28
  customer: AccountModel,
29
  ) -> CallModel:
30
  """
31
  Create a new call.
32
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  event = EventModel(
34
  type=EventType.CALL,
35
  startDate=request.startDate,
36
  endDate=request.endDate,
37
  coach=coach,
38
  )
39
+ call = CallModel(
40
+ event=EventShorten(**event.model_dump()),
41
+ customer=AccountShorten(**customer.model_dump()),
42
+ coach=coach,
43
+ )
44
+ await asyncio.gather(
45
+ settings.DB_CLIENT.events.insert_one(event.to_mongo()),
46
+ settings.DB_CLIENT.calls.insert_one(call.to_mongo()),
47
+ )
48
+ return call
49
 
50
 
51
  async def disable_call(call: CallModel) -> CallModel:
 
354
  raise HTTPException(status_code=404, detail="Call not found")
355
  is_processed = True if call["status"] == CallStatus.SCHEDULED.value else False
356
  success = call["event"]["isActive"]
357
+ return StripeCheckResponse(
358
+ isProcessed=is_processed,
359
+ success=success,
360
+ isRequireRegistration=call["customer"]["status"] == AccountStatus.PENDING,
361
+ )
362
+
363
+
364
+ async def create_anonymous_account_obj(email: str) -> AccountModel:
365
+ """
366
+ Create a new anonymous account.
367
+ """
368
+ await check_unique_fields_existence("email", email)
369
+ account = AccountModel(
370
+ email=email,
371
+ status=AccountStatus.PENDING,
372
+ accountType=AccountType.USER,
373
+ )
374
+ await settings.DB_CLIENT.accounts.insert_one(account.to_mongo())
375
+ return account
cbh/api/calls/schemas.py CHANGED
@@ -24,6 +24,18 @@ class PurchaseCallRequest(BaseModel):
24
  discountCode: str | None = None
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  class CallsFilter(BaseModel):
28
  """
29
  Calls filter.
@@ -53,6 +65,7 @@ class StripeCheckResponse(BaseModel):
53
 
54
  isProcessed: bool
55
  success: bool
 
56
 
57
 
58
  class DailyInitializeResponse(BaseModel):
 
24
  discountCode: str | None = None
25
 
26
 
27
+ class PurchaseAnonymousCallRequest(BaseModel):
28
+ """
29
+ Purchase anonymous call request.
30
+ """
31
+
32
+ email: str
33
+ startDate: datetime
34
+ endDate: datetime
35
+ coachId: str
36
+ discountCode: str | None = None
37
+
38
+
39
  class CallsFilter(BaseModel):
40
  """
41
  Calls filter.
 
65
 
66
  isProcessed: bool
67
  success: bool
68
+ isRequireRegistration: bool
69
 
70
 
71
  class DailyInitializeResponse(BaseModel):
cbh/api/calls/services/stripe.py CHANGED
@@ -1,7 +1,8 @@
1
  import time
 
2
 
3
- import stripe
4
  import pydash
 
5
  from fastapi import HTTPException, Request
6
 
7
  from cbh.api.calls.db_requests import disable_call, enable_call
@@ -11,7 +12,9 @@ from cbh.api.common.db_requests import get_obj_by_id
11
  from cbh.core.config import settings
12
 
13
 
14
- async def create_stripe_session(call: CallModel, code: str | None) -> str:
 
 
15
  """
16
  Create a stripe session.
17
  """
@@ -19,6 +22,10 @@ async def create_stripe_session(call: CallModel, code: str | None) -> str:
19
  "callId": call.id,
20
  }
21
 
 
 
 
 
22
  checkout_session = await settings.STRIPE_CLIENT.v1.checkout.sessions.create_async(
23
  params={
24
  "customer_email": call.customer.email,
@@ -36,8 +43,8 @@ async def create_stripe_session(call: CallModel, code: str | None) -> str:
36
  },
37
  ],
38
  "mode": "payment",
39
- "success_url": f"{settings.Audience}/payment/success?callId={call.id}",
40
- "cancel_url": f"{settings.Audience}/payment/cancel?callId={call.id}",
41
  "metadata": metadata,
42
  "discounts": [{"promotion_code": code}] if code else None,
43
  "expires_at": int(time.time()) + 1800,
 
1
  import time
2
+ from urllib.parse import urlencode
3
 
 
4
  import pydash
5
+ import stripe
6
  from fastapi import HTTPException, Request
7
 
8
  from cbh.api.calls.db_requests import disable_call, enable_call
 
12
  from cbh.core.config import settings
13
 
14
 
15
+ async def create_stripe_session(
16
+ call: CallModel, code: str | None, is_anonymous: bool = False
17
+ ) -> str:
18
  """
19
  Create a stripe session.
20
  """
 
22
  "callId": call.id,
23
  }
24
 
25
+ query_params = {"callId": call.id}
26
+ if is_anonymous:
27
+ query_params["accountId"] = call.customer.id
28
+
29
  checkout_session = await settings.STRIPE_CLIENT.v1.checkout.sessions.create_async(
30
  params={
31
  "customer_email": call.customer.email,
 
43
  },
44
  ],
45
  "mode": "payment",
46
+ "success_url": f"{settings.Audience}/payment/success?{urlencode(query_params)}",
47
+ "cancel_url": f"{settings.Audience}/payment/cancel?{urlencode(query_params)}",
48
  "metadata": metadata,
49
  "discounts": [{"promotion_code": code}] if code else None,
50
  "expires_at": int(time.time()) + 1800,
cbh/api/calls/utils.py CHANGED
@@ -192,3 +192,28 @@ async def send_session_reminder_coach_email(call: CallModel) -> None:
192
  f"Your session with {call.customer.name} is starting in 1 hour.",
193
  template_content,
194
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  f"Your session with {call.customer.name} is starting in 1 hour.",
193
  template_content,
194
  )
195
+
196
+
197
+ async def send_anonymous_booking_email(call: CallModel) -> None:
198
+ """
199
+ Send a anonymous booking email.
200
+ """
201
+ templates_path = settings.BASE_DIR / "cbh" / "templates" / "emails"
202
+ env = Environment(
203
+ loader=FileSystemLoader(templates_path),
204
+ autoescape=select_autoescape(["html", "xml"]),
205
+ )
206
+ template = env.get_template("anonymousBookingConfirmation.html")
207
+ template_content = template.render(
208
+ coach_name=call.coach.name,
209
+ start_date=call.event.startDate.strftime("%d %B %Y, %H:%M"),
210
+ join_link=f"{settings.Audience}/calls/{call.id}",
211
+ duration=(call.event.endDate - call.event.startDate).total_seconds() // 60,
212
+ registration_link=f"{settings.Audience}/signup/complete?accountId={call.customer.id}",
213
+ )
214
+
215
+ await settings.EMAIL_CLIENT.send_email(
216
+ call.customer.email,
217
+ f"You're all set! Session with {call.coach.name} confirmed!",
218
+ template_content,
219
+ )
cbh/api/calls/views.py CHANGED
@@ -3,15 +3,15 @@ from datetime import datetime
3
 
4
  from fastapi import Depends, Query, Request
5
 
6
- from cbh.api.account.dto import AccountType
7
  from cbh.api.account.models import AccountModel, AccountShorten
8
  from cbh.api.calls import calls_router
9
  from cbh.api.calls.db_requests import (
10
  calculate_call_availabilities,
11
  cancel_call_obj,
12
  check_call_payment,
 
13
  create_call_obj,
14
- create_event_obj,
15
  filter_calls_objs,
16
  )
17
  from cbh.api.calls.models import CallModel
@@ -19,6 +19,7 @@ from cbh.api.calls.schemas import (
19
  CallAvailabilityResponse,
20
  CallsFilter,
21
  DailyInitializeResponse,
 
22
  PurchaseCallRequest,
23
  StripeCheckResponse,
24
  StripeSessionResponse,
@@ -33,6 +34,7 @@ from cbh.api.calls.services import (
33
  from cbh.api.calls.services.daily import create_daily_token
34
  from cbh.api.calls.utils import (
35
  can_edit_call,
 
36
  send_coach_booking_email,
37
  send_customer_booking_email,
38
  send_cancel_customer_email,
@@ -73,7 +75,7 @@ async def get_availabilities(
73
  startDate: datetime = Query(...),
74
  endDate: datetime = Query(...),
75
  discountCode: str | None = Query(None),
76
- _: AccountModel = Depends(PermissionDependency([AccountType.USER])),
77
  ) -> CbhResponseWrapper[CallAvailabilityResponse]:
78
  """
79
  Get availabilities.
@@ -130,12 +132,30 @@ async def purchase_call(
130
  ),
131
  verify_discount_code(request.discountCode),
132
  )
133
- event = await create_event_obj(request, coach)
134
- call = await create_call_obj(event, coach, account)
135
  session_url = await create_stripe_session(call, promotion_code)
136
  return CbhResponseWrapper(data=StripeSessionResponse(sessionUrl=session_url))
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  @calls_router.post("/stripe/callback")
140
  async def stripe_callback(request: Request) -> CbhResponseWrapper:
141
  """
@@ -144,17 +164,22 @@ async def stripe_callback(request: Request) -> CbhResponseWrapper:
144
  payload = await request.body()
145
  event = verify_stripe_webhook(request, payload)
146
  call = await manage_stripe_event(event)
147
- # asyncio.create_task(send_customer_booking_email(call))
148
- # asyncio.create_task(send_coach_booking_email(call))
149
- # asyncio.create_task(send_session_reminder_email(call))
150
- # asyncio.create_task(send_session_reminder_coach_email(call))
 
 
 
 
 
151
  return CbhResponseWrapper(data=None)
152
 
153
 
154
  @calls_router.get("/stripe/check/{callId}")
155
  async def check_stripe_payment(
156
  callId: str,
157
- _: AccountModel = Depends(PermissionDependency()),
158
  ) -> CbhResponseWrapper[StripeCheckResponse]:
159
  """
160
  Check stripe payment.
 
3
 
4
  from fastapi import Depends, Query, Request
5
 
6
+ from cbh.api.account.dto import AccountStatus, AccountType
7
  from cbh.api.account.models import AccountModel, AccountShorten
8
  from cbh.api.calls import calls_router
9
  from cbh.api.calls.db_requests import (
10
  calculate_call_availabilities,
11
  cancel_call_obj,
12
  check_call_payment,
13
+ create_anonymous_account_obj,
14
  create_call_obj,
 
15
  filter_calls_objs,
16
  )
17
  from cbh.api.calls.models import CallModel
 
19
  CallAvailabilityResponse,
20
  CallsFilter,
21
  DailyInitializeResponse,
22
+ PurchaseAnonymousCallRequest,
23
  PurchaseCallRequest,
24
  StripeCheckResponse,
25
  StripeSessionResponse,
 
34
  from cbh.api.calls.services.daily import create_daily_token
35
  from cbh.api.calls.utils import (
36
  can_edit_call,
37
+ send_anonymous_booking_email,
38
  send_coach_booking_email,
39
  send_customer_booking_email,
40
  send_cancel_customer_email,
 
75
  startDate: datetime = Query(...),
76
  endDate: datetime = Query(...),
77
  discountCode: str | None = Query(None),
78
+ _: AccountModel = Depends(PermissionDependency([AccountType.USER], required=False)),
79
  ) -> CbhResponseWrapper[CallAvailabilityResponse]:
80
  """
81
  Get availabilities.
 
132
  ),
133
  verify_discount_code(request.discountCode),
134
  )
135
+ call = await create_call_obj(request, coach, account)
 
136
  session_url = await create_stripe_session(call, promotion_code)
137
  return CbhResponseWrapper(data=StripeSessionResponse(sessionUrl=session_url))
138
 
139
 
140
+ @calls_router.post("/purchase/anonymous")
141
+ async def purchase_call_anonymous(
142
+ request: PurchaseAnonymousCallRequest,
143
+ ) -> CbhResponseWrapper[StripeSessionResponse]:
144
+ """
145
+ Purchase a call.
146
+ """
147
+ coach, promotion_code, account = await asyncio.gather(
148
+ get_obj_by_id(
149
+ AccountShorten, request.coachId, projection=AccountShorten.to_mongo_fields()
150
+ ),
151
+ verify_discount_code(request.discountCode),
152
+ create_anonymous_account_obj(request.email),
153
+ )
154
+ call = await create_call_obj(request, coach, account)
155
+ session_url = await create_stripe_session(call, promotion_code, is_anonymous=True)
156
+ return CbhResponseWrapper(data=StripeSessionResponse(sessionUrl=session_url))
157
+
158
+
159
  @calls_router.post("/stripe/callback")
160
  async def stripe_callback(request: Request) -> CbhResponseWrapper:
161
  """
 
164
  payload = await request.body()
165
  event = verify_stripe_webhook(request, payload)
166
  call = await manage_stripe_event(event)
167
+
168
+ if call.customer.status == AccountStatus.PENDING:
169
+ asyncio.create_task(send_anonymous_booking_email(call))
170
+ else:
171
+ asyncio.create_task(send_customer_booking_email(call))
172
+
173
+ asyncio.create_task(send_coach_booking_email(call))
174
+ asyncio.create_task(send_session_reminder_email(call))
175
+ asyncio.create_task(send_session_reminder_coach_email(call))
176
  return CbhResponseWrapper(data=None)
177
 
178
 
179
  @calls_router.get("/stripe/check/{callId}")
180
  async def check_stripe_payment(
181
  callId: str,
182
+ _: AccountModel = Depends(PermissionDependency(required=False)),
183
  ) -> CbhResponseWrapper[StripeCheckResponse]:
184
  """
185
  Check stripe payment.
cbh/api/security/db_requests.py CHANGED
@@ -14,7 +14,9 @@ from cbh.api.availability.models import AvailabilityModel
14
  from cbh.api.security.schemas import (
15
  LoginAccountRequest,
16
  RegisterAccountRequest,
 
17
  )
 
18
  from cbh.core.config import settings
19
  from cbh.core.security import verify_password
20
 
@@ -105,3 +107,40 @@ async def get_account_by_email(
105
  elif account is None:
106
  return None
107
  return AccountModel.from_mongo(account)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from cbh.api.security.schemas import (
15
  LoginAccountRequest,
16
  RegisterAccountRequest,
17
+ RegisterCompleteRequest,
18
  )
19
+ from cbh.api.common.db_requests import get_obj_by_id
20
  from cbh.core.config import settings
21
  from cbh.core.security import verify_password
22
 
 
107
  elif account is None:
108
  return None
109
  return AccountModel.from_mongo(account)
110
+
111
+
112
+ async def complete_account_registration(
113
+ accountId: str,
114
+ request: RegisterCompleteRequest,
115
+ ) -> AccountModel:
116
+ """
117
+ Complete account registration.
118
+ """
119
+ account = await get_obj_by_id(AccountModel, accountId)
120
+ if account is None:
121
+ raise HTTPException(status_code=404, detail="Account not found")
122
+ elif account.status != AccountStatus.PENDING:
123
+ raise HTTPException(status_code=400, detail="Account already registered")
124
+ account.name = f"{request.firstName} {request.lastName}"
125
+ account.password = request.password
126
+ account.status = AccountStatus.ACTIVE
127
+
128
+ account_shorten = AccountShorten(
129
+ id=account.id,
130
+ name=account.name,
131
+ email=account.email,
132
+ status=account.status,
133
+ accountType=account.accountType,
134
+ )
135
+ await asyncio.gather(
136
+ settings.DB_CLIENT.accounts.update_one(
137
+ {"email": request.email}, {"$set": account.to_mongo()}
138
+ ),
139
+ settings.DB_CLIENT.calls.update_one(
140
+ {
141
+ "customer.email": request.email,
142
+ },
143
+ {"$set": {"customer": account_shorten.to_mongo()}},
144
+ ),
145
+ )
146
+ return account
cbh/api/security/schemas.py CHANGED
@@ -22,6 +22,16 @@ class RegisterAccountRequest(BaseModel):
22
  accountType: AccountType
23
 
24
 
 
 
 
 
 
 
 
 
 
 
25
  class LoginAccountRequest(BaseModel):
26
  """
27
  Request model for account login.
 
22
  accountType: AccountType
23
 
24
 
25
+ class RegisterCompleteRequest(BaseModel):
26
+ """
27
+ Request model for account registration completion.
28
+ """
29
+
30
+ firstName: str
31
+ lastName: str
32
+ password: str
33
+
34
+
35
  class LoginAccountRequest(BaseModel):
36
  """
37
  Request model for account login.
cbh/api/security/views.py CHANGED
@@ -9,6 +9,7 @@ from cbh.api.account.models import AccountModel
9
  from cbh.api.common.db_requests import get_obj_by_id
10
  from cbh.api.security import security_router
11
  from cbh.api.security.db_requests import (
 
12
  save_account,
13
  authenticate_account,
14
  )
@@ -17,6 +18,7 @@ from cbh.api.security.schemas import (
17
  RegisterAccountRequest,
18
  LoginAccountRequest,
19
  LoginAccountResponse,
 
20
  )
21
  from cbh.core.security import PermissionDependency, create_access_token
22
  from cbh.core.wrappers import CbhResponseWrapper
@@ -78,3 +80,15 @@ async def login_as_user(
78
  account=account,
79
  )
80
  return CbhResponseWrapper(data=response)
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from cbh.api.common.db_requests import get_obj_by_id
10
  from cbh.api.security import security_router
11
  from cbh.api.security.db_requests import (
12
+ complete_account_registration,
13
  save_account,
14
  authenticate_account,
15
  )
 
18
  RegisterAccountRequest,
19
  LoginAccountRequest,
20
  LoginAccountResponse,
21
+ RegisterCompleteRequest,
22
  )
23
  from cbh.core.security import PermissionDependency, create_access_token
24
  from cbh.core.wrappers import CbhResponseWrapper
 
80
  account=account,
81
  )
82
  return CbhResponseWrapper(data=response)
83
+
84
+
85
+ @security_router.post("/register/{accountId}/complete")
86
+ async def register_complete(
87
+ accountId: str,
88
+ request: RegisterCompleteRequest,
89
+ ) -> CbhResponseWrapper[AccountModel]:
90
+ """
91
+ Signup complete.
92
+ """
93
+ account = await complete_account_registration(accountId, request)
94
+ return CbhResponseWrapper(data=account)