brestok commited on
Commit
cd46ce5
·
0 Parent(s):
Files changed (50) hide show
  1. .gitignore +22 -0
  2. WeeklyProgress.md +56 -0
  3. cbh/__init__.py +74 -0
  4. cbh/api/account/__init__.py +14 -0
  5. cbh/api/account/db_requests.py +76 -0
  6. cbh/api/account/dto.py +25 -0
  7. cbh/api/account/models.py +69 -0
  8. cbh/api/account/schemas.py +21 -0
  9. cbh/api/account/utils.py +28 -0
  10. cbh/api/account/views.py +188 -0
  11. cbh/api/availability/__init__.py +5 -0
  12. cbh/api/availability/dto.py +24 -0
  13. cbh/api/availability/models.py +15 -0
  14. cbh/api/availability/schemas.py +22 -0
  15. cbh/api/availability/views.py +46 -0
  16. cbh/api/calls/__init__.py +5 -0
  17. cbh/api/calls/db_requests.py +65 -0
  18. cbh/api/calls/dto.py +14 -0
  19. cbh/api/calls/models.py +25 -0
  20. cbh/api/calls/schemas.py +21 -0
  21. cbh/api/calls/services/__init__.py +3 -0
  22. cbh/api/calls/services/stripe.py +73 -0
  23. cbh/api/calls/utils.py +0 -0
  24. cbh/api/calls/views.py +44 -0
  25. cbh/api/common/db_requests.py +230 -0
  26. cbh/api/common/dto.py +146 -0
  27. cbh/api/common/schemas.py +70 -0
  28. cbh/api/common/utils.py +84 -0
  29. cbh/api/events/__init__.py +5 -0
  30. cbh/api/events/db_requests.py +21 -0
  31. cbh/api/events/dto.py +10 -0
  32. cbh/api/events/models.py +33 -0
  33. cbh/api/events/schemas.py +15 -0
  34. cbh/api/events/views.py +22 -0
  35. cbh/api/security/__init__.py +11 -0
  36. cbh/api/security/db_requests.py +105 -0
  37. cbh/api/security/dto.py +14 -0
  38. cbh/api/security/schemas.py +37 -0
  39. cbh/api/security/utils.py +122 -0
  40. cbh/api/security/views.py +80 -0
  41. cbh/api/timezone/__init__.py +7 -0
  42. cbh/api/timezone/models.py +10 -0
  43. cbh/api/timezone/views.py +26 -0
  44. cbh/core/config.py +110 -0
  45. cbh/core/database.py +194 -0
  46. cbh/core/email_client.py +50 -0
  47. cbh/core/security.py +199 -0
  48. cbh/core/wrappers.py +119 -0
  49. main.py +10 -0
  50. test_main.http +11 -0
.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ env/
3
+ venv/
4
+ .venv/
5
+ .idea/
6
+ *.log
7
+ *.egg-info/
8
+ pip-wheel-EntityData/
9
+ .env
10
+ .DS_Store
11
+ static/
12
+ test.py
13
+ rsa_key.p8
14
+ rsa_key.pub
15
+ aws.pem
16
+ .vscode/
17
+ data/
18
+ *.csv
19
+ test.json
20
+ voiceagentcbh.pem
21
+ *.pem
22
+ download
WeeklyProgress.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Weekly Progress Report - Backend Development
2
+
3
+ ## Project Overview
4
+ Successfully initiated and advanced the backend development for ClipboardHealthAI, a health and fitness platform connecting coaches with users through scheduled calls and training sessions.
5
+
6
+ ## Major Accomplishments
7
+
8
+ ### 1. **Core Infrastructure Setup**
9
+ - **FastAPI Framework**: Implemented a robust FastAPI application with proper middleware configuration
10
+ - **Database Layer**: Established MongoDB integration with custom Pydantic models for seamless data serialization
11
+ - **Authentication System**: Built comprehensive JWT-based authentication with role-based access control
12
+ - **Error Handling**: Implemented standardized response wrappers and exception handling
13
+
14
+ ### 2. **User Management System**
15
+ - **Complete CRUD Operations**: Full account management including creation, retrieval, updates, and deletion
16
+ - **Role-Based Permissions**: Implemented ADMIN, USER, and COACH user types with appropriate access controls
17
+ - **Profile Management**: Added profile picture upload functionality with S3 integration
18
+ - **User Registration & Login**: Secure authentication endpoints with token generation and verification
19
+
20
+ ### 3. **Call Scheduling & Payment Integration**
21
+ - **Stripe Integration**: Implemented payment processing for call bookings with webhook handling
22
+ - **Call Management**: Built call creation and management system linking coaches and users
23
+ - **Event System**: Developed event creation functionality for scheduling training sessions
24
+
25
+ ### 4. **Coach Availability System**
26
+ - **Time Slot Management**: Implemented coach availability tracking and management
27
+ - **Availability Queries**: Built endpoints for retrieving coach schedules for users and admins
28
+ - **Coach-Specific Features**: Role-based availability updates restricted to coach accounts
29
+
30
+ ### 5. **Supporting Infrastructure**
31
+ - **File Storage**: Integrated AWS S3 for secure profile picture storage
32
+ - **Timezone Support**: Added timezone search functionality for global user support
33
+ - **Health Monitoring**: Implemented health check endpoints for system monitoring
34
+ - **CORS Configuration**: Configured cross-origin resource sharing for frontend integration
35
+
36
+ ## Technical Implementation Highlights
37
+
38
+ - **Modular Architecture**: Clean separation of concerns with dedicated modules for each domain (account, calls, availability, security, events)
39
+ - **Database Design**: Custom MongoDB models with proper serialization and enum handling
40
+ - **Security**: Comprehensive permission system with dependency injection for endpoint protection
41
+ - **Payment Processing**: Production-ready Stripe integration with webhook verification
42
+ - **API Design**: RESTful endpoints with consistent request/response patterns
43
+
44
+ ## System Capabilities Delivered
45
+
46
+ ✅ User registration and authentication
47
+ ✅ Account management with profile pictures
48
+ ✅ Role-based access control (Admin/User/Coach)
49
+ ✅ Coach availability management
50
+ ✅ Call booking system with Stripe payments
51
+ ✅ Event scheduling functionality
52
+ ✅ Timezone support for global users
53
+ ✅ File upload and storage integration
54
+ ✅ Health monitoring and error handling
55
+
56
+ The backend foundation is now established with core business logic implemented and ready for frontend integration and further feature development.
cbh/__init__.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pylint: disable=C0415
2
+ """
3
+ ClipboardHealthAI application package.
4
+ """
5
+ import asyncio
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from starlette.exceptions import HTTPException as StarletteHTTPException
10
+
11
+ from cbh.core.wrappers import CbhResponseWrapper, ErrorCbhResponse
12
+
13
+
14
+ def create_app() -> FastAPI:
15
+ """
16
+ Create and configure the FastAPI application.
17
+ """
18
+ app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json")
19
+
20
+ from cbh.api.account import account_router
21
+
22
+ app.include_router(account_router, tags=["account"])
23
+
24
+ from cbh.api.availability import availability_router
25
+
26
+ app.include_router(availability_router, tags=["availability"])
27
+
28
+ from cbh.api.calls import calls_router
29
+
30
+ app.include_router(calls_router, tags=["calls"])
31
+
32
+ from cbh.api.security import security_router
33
+
34
+ app.include_router(security_router, tags=["security"])
35
+
36
+ # TODO написать скрипт миграци сценариев
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origin_regex=r"https?://([a-z0-9-]+\.)?(localhost|trainwitharena|cbhexp\.com)(:\d+)?",
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ @app.exception_handler(StarletteHTTPException)
46
+ async def http_exception_handler(_, exc):
47
+ """
48
+ Handle HTTP exceptions and convert them to standardized error responses.
49
+ """
50
+ return CbhResponseWrapper(
51
+ data=None, successful=False, error=ErrorCbhResponse(message=str(exc.detail))
52
+ ).response(exc.status_code)
53
+
54
+ @app.on_event("startup")
55
+ async def startup_event():
56
+ """
57
+ Execute startup tasks when the application starts.
58
+ """
59
+
60
+ @app.get("/api/health")
61
+ async def health():
62
+ """
63
+ Health check endpoint for container orchestration and monitoring.
64
+ """
65
+ return {"status": "healthy"}
66
+
67
+ @app.get("/")
68
+ async def root():
69
+ """
70
+ Root endpoint for the application.
71
+ """
72
+ return {"message": "hi!"}
73
+
74
+ return app
cbh/api/account/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account module initialization.
3
+
4
+ This module defines the FastAPI router for account API endpoints
5
+ and imports related views for account management.
6
+ """
7
+
8
+ from fastapi import APIRouter
9
+
10
+ account_router = APIRouter(
11
+ prefix="/api/account",
12
+ )
13
+
14
+ from . import views # noqa # pylint: disable=C0413
cbh/api/account/db_requests.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account database requests.
3
+ """
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ import re
8
+ from cbh.api.account.models import AccountModel
9
+ from cbh.api.account.schemas import AccountFilter, UpdateAccountRequest, AccountResponse
10
+ from cbh.api.account.dto import AccountType
11
+ from cbh.api.account.utils import (
12
+ add_teams_session_reports_to_accounts,
13
+ prepare_additional_filter,
14
+ )
15
+ from cbh.api.common.db_requests import get_all_objs, get_obj_by_id
16
+ from cbh.api.common.schemas import FilterRequest
17
+ from cbh.core.config import settings
18
+
19
+
20
+ async def update_own_account_obj(
21
+ request: UpdateAccountRequest, account: AccountModel
22
+ ) -> AccountModel:
23
+ """
24
+ Update own account.
25
+ """
26
+ account.name = request.name
27
+ account.email = request.email
28
+ account.datetimeUpdated = datetime.now()
29
+ await settings.DB_CLIENT.accounts.update_one(
30
+ {"id": account.id},
31
+ {"$set": account.to_mongo()},
32
+ )
33
+ return account
34
+
35
+
36
+ async def update_account_picture_obj(
37
+ account: AccountModel, picture_s3: str
38
+ ) -> AccountModel:
39
+ """
40
+ Update account picture.
41
+ """
42
+ account.pictureUrl = picture_s3
43
+ account.datetimeUpdated = datetime.now()
44
+ await settings.DB_CLIENT.accounts.update_one(
45
+ {"id": account.id}, {"$set": account.to_mongo()}
46
+ )
47
+ return AccountModel(**account.to_mongo())
48
+
49
+
50
+ async def filter_accounts_objs(
51
+ account: AccountModel, request: FilterRequest[AccountFilter]
52
+ ) -> tuple[list[AccountModel], int]:
53
+ """
54
+ Filter accounts.
55
+ """
56
+ filters = prepare_additional_filter(account)
57
+ if request.filter.accountTypes:
58
+ filters["accountType"] = {"$in": request.filter.accountTypes}
59
+ if request.filter.searchTerm:
60
+ content = f"^{re.escape(request.filter.searchTerm)}"
61
+ filters["$or"] = [
62
+ {"name": {"$regex": content, "$options": "i"}},
63
+ {"email": {"$regex": content, "$options": "i"}},
64
+ ]
65
+ if request.sortBy:
66
+ sort = (request.sortBy.name, request.sortBy.order.value)
67
+ else:
68
+ sort = ("id", -1)
69
+ accounts, total_count = await get_all_objs(
70
+ AccountModel,
71
+ request.pageSize,
72
+ request.pageIndex,
73
+ additional_filter=filters,
74
+ sort=sort,
75
+ )
76
+ return accounts, total_count
cbh/api/account/dto.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account DTOs.
3
+ """
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class AccountType(Enum):
9
+ """
10
+ Enum for account types.
11
+ """
12
+
13
+ USER = 1
14
+ COACH = 2
15
+ ADMIN = 3
16
+
17
+
18
+ class AccountStatus(Enum):
19
+ """
20
+ Enum for account statuses.
21
+ """
22
+
23
+ ACTIVE = 1
24
+ BLOCKED = 2
25
+ PENDING_INVITATION = 3
cbh/api/account/models.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account models.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from passlib.context import CryptContext
8
+ from pydantic import Field, field_validator
9
+
10
+ from cbh.api.account.dto import AccountStatus, AccountType
11
+ from cbh.core.database import MongoBaseModel, MongoBaseShortenModel
12
+
13
+
14
+ class AccountModel(MongoBaseModel):
15
+ """
16
+ Account model class.
17
+
18
+ This class represents a user account in the system.
19
+ It includes fields for email, password, and timestamps for creation and update.
20
+ """
21
+
22
+ name: str | None = None
23
+ email: str
24
+ pictureUrl: str | None = None
25
+ password: str | None = Field(exclude=True, default=None)
26
+
27
+ status: AccountStatus = AccountStatus.ACTIVE
28
+ accountType: AccountType = Field(default=AccountType.USER)
29
+
30
+ datetimeInserted: datetime = Field(default_factory=datetime.now)
31
+ datetimeUpdated: datetime = Field(default_factory=datetime.now)
32
+
33
+ @field_validator("password", mode="before", check_fields=False)
34
+ @classmethod
35
+ def set_password_hash(cls, v: str | None) -> str | None:
36
+ """
37
+ Set the password hash.
38
+
39
+ Args:
40
+ v (str): The password to hash.
41
+
42
+ Returns:
43
+ str: The hashed password.
44
+ """
45
+ if isinstance(v, str) and not v.startswith("$2b$"):
46
+ return CryptContext(schemes=["bcrypt"], deprecated="auto").hash(v)
47
+ return v
48
+
49
+ class Config: # pylint: disable=R0903
50
+ """
51
+ Config for the AccountModel class.
52
+ """
53
+
54
+ arbitrary_types_allowed = True
55
+ populate_by_name = True
56
+ json_encoders = {datetime: lambda dt: dt.isoformat()}
57
+
58
+
59
+ class AccountShorten(MongoBaseShortenModel):
60
+ """
61
+ Account shorten model.
62
+ """
63
+
64
+ id: str
65
+ name: str | None = None
66
+ email: str
67
+ pictureUrl: str | None = None
68
+
69
+ accountType: AccountType
cbh/api/account/schemas.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ from cbh.api.account.models import AccountModel
4
+
5
+
6
+ class UpdateAccountRequest(BaseModel):
7
+ """
8
+ Update account request.
9
+ """
10
+
11
+ name: str
12
+ email: str
13
+
14
+
15
+ class AccountFilter(BaseModel):
16
+ """
17
+ Account filter.
18
+ """
19
+
20
+ accountTypes: list[int] | None = None
21
+ searchTerm: str | None = None
cbh/api/account/utils.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import BytesIO
2
+
3
+ from PIL import Image, ImageOps, UnidentifiedImageError
4
+ from fastapi import HTTPException, UploadFile
5
+
6
+
7
+ def compress_image(file: UploadFile) -> bytes:
8
+ if not file.content_type or not file.content_type.startswith("image/"):
9
+ raise HTTPException(status_code=400, detail="File must be an image")
10
+ try:
11
+ file.file.seek(0)
12
+ original_bytes = file.file.read()
13
+ image = Image.open(BytesIO(original_bytes))
14
+ image = ImageOps.exif_transpose(image)
15
+ max_size = 512
16
+ image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
17
+ if image.mode not in ("RGB", "RGBA"):
18
+ image = image.convert("RGBA")
19
+ buffer = BytesIO()
20
+ image.save(buffer, format="PNG", optimize=True, compress_level=9)
21
+ return buffer.getvalue()
22
+ except (UnidentifiedImageError, OSError):
23
+ raise HTTPException(status_code=400, detail="Invalid image file")
24
+ finally:
25
+ try:
26
+ file.file.close()
27
+ except Exception:
28
+ pass
cbh/api/account/views.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Account views module.
3
+ """
4
+
5
+ from fastapi import Depends, File, Query, UploadFile
6
+
7
+ from cbh.api.account import account_router
8
+ from cbh.api.account.db_requests import (
9
+ filter_accounts_objs,
10
+ update_account_picture_obj,
11
+ update_own_account_obj,
12
+ )
13
+ from cbh.api.account.dto import AccountType
14
+ from cbh.api.account.models import AccountModel
15
+ from cbh.api.account.schemas import (
16
+ AccountFilter,
17
+ UpdateAccountRequest,
18
+ )
19
+ from cbh.api.account.utils import compress_image, prepare_additional_filter
20
+ from cbh.api.common.db_requests import (
21
+ get_all_objs,
22
+ get_obj_by_id,
23
+ delete_obj,
24
+ search_objs,
25
+ )
26
+ from cbh.api.common.dto import Paging
27
+ from cbh.api.common.schemas import AllObjectsResponse, FilterRequest, SearchRequest
28
+ from cbh.core.config import settings
29
+ from cbh.core.security import PermissionDependency
30
+ from cbh.core.wrappers import CbhResponseWrapper
31
+
32
+
33
+ @account_router.get("/all")
34
+ async def get_all_accounts(
35
+ pageSize: int = Query( # pylint: disable=C0103
36
+ default=10, ge=0, le=100, description="The number of accounts to return"
37
+ ),
38
+ pageIndex: int = Query( # pylint: disable=C0103
39
+ default=0, ge=0, description="The page number to return"
40
+ ),
41
+ account: AccountModel = Depends(PermissionDependency([AccountType.ADMIN])),
42
+ ) -> CbhResponseWrapper[AllObjectsResponse[AccountModel]]:
43
+ """
44
+ Get all accounts.
45
+ """
46
+ filter_ = prepare_additional_filter(account)
47
+ accounts, total_count = await get_all_objs(
48
+ AccountModel,
49
+ pageSize,
50
+ pageIndex,
51
+ additional_filter=filter_,
52
+ )
53
+ return CbhResponseWrapper(
54
+ data=AllObjectsResponse(
55
+ paging=Paging(
56
+ pageSize=pageSize, pageIndex=pageIndex, totalCount=total_count
57
+ ),
58
+ data=accounts,
59
+ )
60
+ )
61
+
62
+
63
+ @account_router.post("/search")
64
+ async def search_accounts(
65
+ request: SearchRequest,
66
+ account: AccountModel = Depends(PermissionDependency([AccountType.ADMIN])),
67
+ ) -> CbhResponseWrapper[AllObjectsResponse[AccountModel]]:
68
+ """
69
+ Search for accounts based on specified criteria.
70
+ """
71
+ filter_ = prepare_additional_filter(account)
72
+ accounts, total = await search_objs(
73
+ AccountModel,
74
+ request,
75
+ additional_filter=filter_,
76
+ )
77
+ return CbhResponseWrapper(
78
+ data=AllObjectsResponse(
79
+ data=accounts,
80
+ paging=Paging(
81
+ totalCount=total, pageSize=request.pageSize, pageIndex=request.pageIndex
82
+ ),
83
+ )
84
+ )
85
+
86
+
87
+ @account_router.post(
88
+ "/filter", description="Used by admin to filter accounts in the teams page"
89
+ )
90
+ async def filter_accounts(
91
+ request: FilterRequest[AccountFilter],
92
+ account: AccountModel = Depends(PermissionDependency([AccountType.ADMIN])),
93
+ ) -> CbhResponseWrapper[AllObjectsResponse[AccountModel]]:
94
+ """
95
+ Filter accounts.
96
+ """
97
+ accounts, total_count = await filter_accounts_objs(account, request)
98
+ return CbhResponseWrapper(
99
+ data=AllObjectsResponse(
100
+ data=accounts,
101
+ paging=Paging(
102
+ pageSize=request.pageSize,
103
+ pageIndex=request.pageIndex,
104
+ totalCount=total_count,
105
+ ),
106
+ )
107
+ )
108
+
109
+
110
+ @account_router.get("/{accountId}")
111
+ async def get_account(
112
+ accountId: str, # pylint: disable=C0103
113
+ account: AccountModel = Depends(
114
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
115
+ ),
116
+ ) -> CbhResponseWrapper[AccountModel]:
117
+ """
118
+ Get an account by ID.
119
+ """
120
+ filter_ = prepare_additional_filter(account)
121
+ account = await get_obj_by_id(AccountModel, accountId, additional_filter=filter_)
122
+ return CbhResponseWrapper(data=account)
123
+
124
+
125
+ @account_router.delete("/{accountId}")
126
+ async def delete_account(
127
+ accountId: str,
128
+ account: AccountModel = Depends(PermissionDependency([AccountType.ADMIN])),
129
+ ) -> CbhResponseWrapper[AccountModel]:
130
+ """
131
+ Delete an account.
132
+ """
133
+ await delete_obj(
134
+ AccountModel,
135
+ accountId,
136
+ additional_filter={
137
+ "organization.id": account.organization.id,
138
+ "accountType": AccountType.USER.value,
139
+ },
140
+ )
141
+ return CbhResponseWrapper()
142
+
143
+
144
+ @account_router.get("")
145
+ async def get_own_account(
146
+ account: AccountModel = Depends(
147
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
148
+ ),
149
+ ) -> CbhResponseWrapper[AccountModel]:
150
+ """
151
+ Get own account.
152
+ """
153
+ return CbhResponseWrapper(data=account)
154
+
155
+
156
+ @account_router.put("")
157
+ async def update_own_account(
158
+ request: UpdateAccountRequest,
159
+ account: AccountModel = Depends(
160
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
161
+ ),
162
+ ) -> CbhResponseWrapper[AccountModel]:
163
+ """
164
+ Update own account.
165
+ """
166
+ account = await update_own_account_obj(request, account)
167
+ return CbhResponseWrapper(data=account)
168
+
169
+
170
+ @account_router.post("/picture/upload")
171
+ async def upload_own_account_picture(
172
+ picture: UploadFile = File(..., description="The picture to upload"),
173
+ account: AccountModel = Depends(
174
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
175
+ ),
176
+ ) -> CbhResponseWrapper[AccountModel]:
177
+ """
178
+ Upload own account picture.
179
+ """
180
+ picture = compress_image(picture)
181
+ if account.pictureUrl:
182
+ settings.S3_CLIENT.update_file(picture, account.pictureUrl, "pictures")
183
+ else:
184
+ picture_s3 = settings.S3_CLIENT.upload_file(
185
+ picture, f"{account.id}.png", "pictures"
186
+ )
187
+ account = await update_account_picture_obj(account, picture_s3)
188
+ return CbhResponseWrapper(data=account)
cbh/api/availability/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ availability_router = APIRouter(prefix="/availability", tags=["availability"])
4
+
5
+ from . import views
cbh/api/availability/dto.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from datetime import time
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class DayOfWeek(Enum):
8
+ MONDAY = 1
9
+ TUESDAY = 2
10
+ WEDNESDAY = 3
11
+ THURSDAY = 4
12
+ FRIDAY = 5
13
+ SATURDAY = 6
14
+ SUNDAY = 7
15
+
16
+
17
+ class TimeSlot(BaseModel):
18
+ startTime: time
19
+ endTime: time
20
+
21
+
22
+ class DaySchedule(BaseModel):
23
+ dayOfWeek: DayOfWeek
24
+ slots: list[TimeSlot] | None = None
cbh/api/availability/models.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.api.availability.dto import DaySchedule
2
+ from cbh.api.timezone.models import TimezoneModel
3
+ from cbh.core.database import MongoBaseModel
4
+ from cbh.api.account.models import AccountShorten
5
+
6
+
7
+ class AvailabilityModel(MongoBaseModel):
8
+ """
9
+ Availability model.
10
+ """
11
+
12
+ coach: AccountShorten
13
+ timezone: TimezoneModel
14
+
15
+ weeklySchedule: list[DaySchedule] | None = None
cbh/api/availability/schemas.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from cbh.api.availability.dto import DaySchedule
3
+ from cbh.api.availability.models import AvailabilityModel
4
+ from cbh.api.events.models import EventShorten
5
+
6
+
7
+ class UpdateAvailabilityRequest(BaseModel):
8
+ """
9
+ Update availability request.
10
+ """
11
+
12
+ timezoneId: str
13
+ weeklySchedule: list[DaySchedule]
14
+
15
+
16
+ class ExtendedAvailabilityResponse(BaseModel):
17
+ """
18
+ Extended availability response.
19
+ """
20
+
21
+ availability: AvailabilityModel
22
+ events: list[EventShorten]
cbh/api/availability/views.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends
2
+ from cbh.api.account.models import AccountModel
3
+ from cbh.api.availability.schemas import (
4
+ UpdateAvailabilityRequest,
5
+ ExtendedAvailabilityResponse,
6
+ )
7
+ from cbh.core.security import PermissionDependency
8
+ from cbh.api.account.dto import AccountType
9
+ from cbh.core.wrappers import CbhResponseWrapper
10
+ from cbh.api.availability.models import AvailabilityModel
11
+ from cbh.api.availability import availability_router
12
+
13
+
14
+ @availability_router.get("")
15
+ async def get_own_availability(
16
+ account: AccountModel = Depends(PermissionDependency([AccountType.COACH])),
17
+ ) -> CbhResponseWrapper[AvailabilityModel]:
18
+
19
+ availability = await get_availability_obj(account)
20
+ return CbhResponseWrapper(data=availability)
21
+
22
+
23
+ @availability_router.get("/{coachId}")
24
+ async def get_extended_availability(
25
+ coachId: str,
26
+ account: AccountModel = Depends(
27
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
28
+ ),
29
+ ) -> CbhResponseWrapper[ExtendedAvailabilityResponse]:
30
+ """
31
+ Get availability by coach ID.
32
+ """
33
+ availability = await get_extended_availability_obj(coachId)
34
+ return CbhResponseWrapper(data=availability)
35
+
36
+
37
+ @availability_router.put("")
38
+ async def update_own_availability(
39
+ availability: UpdateAvailabilityRequest,
40
+ account: AccountModel = Depends(PermissionDependency([AccountType.COACH])),
41
+ ) -> CbhResponseWrapper[AvailabilityModel]:
42
+ """
43
+ Update own availability.
44
+ """
45
+ availability = await update_own_availability_obj(availability, account)
46
+ return CbhResponseWrapper(data=availability)
cbh/api/calls/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ calls_router = APIRouter(prefix="/calls", tags=["calls"])
4
+
5
+ from . import views
cbh/api/calls/db_requests.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ from cbh.api.account.models import AccountModel, AccountShorten
4
+ from cbh.api.calls.dto import CallStatus
5
+ from cbh.api.calls.models import CallModel
6
+ from cbh.api.calls.schemas import PurchaseCallRequest
7
+ from cbh.api.events.dto import EventType
8
+ from cbh.api.events.models import EventModel, EventShorten
9
+ from cbh.core.config import settings
10
+
11
+
12
+ async def create_call_obj(
13
+ event: EventModel,
14
+ coach: AccountShorten,
15
+ customer: AccountModel,
16
+ ) -> CallModel:
17
+ """
18
+ Create a new call.
19
+ """
20
+ call = CallModel(
21
+ event=EventShorten(**event.model_dump()),
22
+ customer=AccountShorten(**customer.model_dump()),
23
+ coach=coach,
24
+ )
25
+ await settings.DB_CLIENT.calls.insert_one(call.to_mongo())
26
+ return call
27
+
28
+
29
+ async def create_event_obj(
30
+ request: PurchaseCallRequest, coach: AccountShorten
31
+ ) -> EventModel:
32
+ """
33
+ Create a new event.
34
+ """
35
+ event = EventModel(
36
+ type=EventType.CALL,
37
+ startDate=request.startDate,
38
+ endDate=request.endDate,
39
+ coach=coach,
40
+ )
41
+ await settings.DB_CLIENT.events.insert_one(event.to_mongo())
42
+ return event
43
+
44
+
45
+ async def disable_call(call: CallModel) -> None:
46
+ """
47
+ Disable a call.
48
+ """
49
+ call.status = CallStatus.DISABLED
50
+ call.event.isActive = False
51
+ await asyncio.gather(
52
+ settings.DB_CLIENT.calls.update_one({"id": call.id}, {"$set": call.to_mongo()}),
53
+ settings.DB_CLIENT.events.update_one(
54
+ {"id": call.event.id}, {"$set": {"isActive": False}}
55
+ ),
56
+ )
57
+
58
+
59
+ async def enable_call(call: CallModel) -> None:
60
+ """
61
+ Enable a call.
62
+ """
63
+ await settings.DB_CLIENT.calls.update_one(
64
+ {"id": call.id}, {"$set": {"status": CallStatus.SCHEDULED}}
65
+ )
cbh/api/calls/dto.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+
4
+ class CallStatus(Enum):
5
+ """
6
+ Call status.
7
+ """
8
+
9
+ CREATED = 0
10
+ DISABLED = 1
11
+ SCHEDULED = 2
12
+ COMPLETED = 3
13
+ CANCELLED_BY_CUSTOMER = 4
14
+ CANCELLED_BY_COACH = 5
cbh/api/calls/models.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from pydantic import Field
4
+
5
+ from cbh.api.account.models import AccountShorten
6
+ from cbh.api.calls.dto import CallStatus
7
+ from cbh.api.events.models import EventShorten
8
+ from cbh.core.database import MongoBaseModel
9
+
10
+
11
+ class CallModel(MongoBaseModel):
12
+ """
13
+ Call model.
14
+ """
15
+
16
+ event: EventShorten
17
+
18
+ customer: AccountShorten
19
+ coach: AccountShorten
20
+
21
+ duration: int | None = None
22
+
23
+ status: CallStatus = CallStatus.CREATED
24
+
25
+ datetimeInserted: datetime = Field(default_factory=datetime.now)
cbh/api/calls/schemas.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class StripeSessionResponse(BaseModel):
7
+ """
8
+ Stripe session response.
9
+ """
10
+
11
+ sessionUrl: str
12
+
13
+
14
+ class PurchaseCallRequest(BaseModel):
15
+ """
16
+ Purchase call request.
17
+ """
18
+
19
+ coachId: str
20
+ startDate: datetime
21
+ endDate: datetime
cbh/api/calls/services/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .stripe import create_stripe_session, verify_stripe_webhook, manage_stripe_event
2
+
3
+ __all__ = ["create_stripe_session", "verify_stripe_webhook", "manage_stripe_event"]
cbh/api/calls/services/stripe.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import pydash
4
+ import stripe
5
+ from fastapi import HTTPException, Request
6
+
7
+ from cbh.api.calls.db_requests import disable_call, enable_call
8
+ from cbh.api.calls.models import CallModel
9
+ from cbh.api.common.db_requests import get_obj_by_id
10
+ from cbh.core.config import settings
11
+
12
+
13
+ async def create_stripe_session(call: CallModel) -> str:
14
+ """
15
+ Create a stripe session.
16
+ """
17
+ metadata = {
18
+ "callId": call.id,
19
+ }
20
+
21
+ checkout_session = await settings.STRIPE_CLIENT.checkout.sessions.create_async(
22
+ payment_method_types=["card", "ideal"],
23
+ line_items=[
24
+ {
25
+ "price_data": {
26
+ "currency": "usd",
27
+ "unit_amount": 15,
28
+ "product_data": {
29
+ "name": f"Call ({call.duration} minutes)",
30
+ "description": f"Payment for a call of {call.duration} minutes",
31
+ },
32
+ },
33
+ "quantity": 1,
34
+ },
35
+ ],
36
+ mode="payment",
37
+ success_url=f"{settings.Audience}",
38
+ cancel_url=f"{settings.Audience}",
39
+ metadata=metadata,
40
+ expires_at=int(time.time()) + 1800,
41
+ )
42
+ return checkout_session.url
43
+
44
+
45
+ async def verify_stripe_webhook(request: Request, payload: bytes) -> dict:
46
+ """
47
+ Verify a stripe webhook.
48
+ """
49
+ sig_header = request.headers.get("stripe-signature")
50
+
51
+ try:
52
+ event = stripe.Webhook.construct_event(
53
+ payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
54
+ )
55
+ return event
56
+ except stripe.error.SignatureVerificationError as e:
57
+ raise HTTPException(status_code=400, detail="Invalid request")
58
+
59
+
60
+ async def manage_stripe_event(event: dict) -> str:
61
+ """
62
+ Manage a stripe event.
63
+ """
64
+ event_type = event["type"]
65
+ call_id = pydash.get(event, "data.object.metadata.callId")
66
+ call = await get_obj_by_id(CallModel, call_id)
67
+
68
+ if event_type != "checkout.session.completed":
69
+ await disable_call(call)
70
+ return f"{settings.Audience}/stripe/error?event_type={event_type}"
71
+
72
+ await enable_call(call)
73
+ return f"{settings.Audience}/stripe/success"
cbh/api/calls/utils.py ADDED
File without changes
cbh/api/calls/views.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, Request
2
+ from fastapi.responses import RedirectResponse
3
+
4
+ from cbh.api.account.dto import AccountType
5
+ from cbh.api.account.models import AccountModel, AccountShorten
6
+ from cbh.api.calls import calls_router
7
+ from cbh.api.calls.db_requests import create_call_obj, create_event_obj
8
+ from cbh.api.calls.schemas import PurchaseCallRequest, StripeSessionResponse
9
+ from cbh.api.calls.services import (
10
+ create_stripe_session,
11
+ manage_stripe_event,
12
+ verify_stripe_webhook,
13
+ )
14
+ from cbh.api.common.db_requests import get_obj_by_id
15
+ from cbh.core.security import PermissionDependency
16
+ from cbh.core.wrappers import CbhResponseWrapper
17
+
18
+
19
+ @calls_router.post("/stripe/purchase")
20
+ async def purchase_call(
21
+ request: PurchaseCallRequest,
22
+ account: AccountModel = Depends(PermissionDependency([AccountType.USER])),
23
+ ) -> CbhResponseWrapper[StripeSessionResponse]:
24
+ """
25
+ Purchase a call.
26
+ """
27
+ coach = await get_obj_by_id(
28
+ AccountShorten, request.coachId, projection=AccountShorten.to_mongo_fields()
29
+ )
30
+ event = await create_event_obj(request, coach)
31
+ call = await create_call_obj(event, coach, account)
32
+ session_url = await create_stripe_session(call)
33
+ return CbhResponseWrapper(data=StripeSessionResponse(sessionUrl=session_url))
34
+
35
+
36
+ @calls_router.post("/stripe/callback")
37
+ async def stripe_callback(request: Request) -> RedirectResponse:
38
+ """
39
+ Stripe callback.
40
+ """
41
+ payload = await request.body()
42
+ event = await verify_stripe_webhook(request, payload)
43
+ redirect_url = await manage_stripe_event(event)
44
+ return RedirectResponse(redirect_url)
cbh/api/common/db_requests.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common database requests.
3
+ """
4
+
5
+ import asyncio
6
+ import re
7
+ from datetime import timedelta, datetime
8
+ from typing import TypeVar
9
+
10
+ from fastapi import HTTPException
11
+ from pydantic import BaseModel
12
+
13
+ from cbh.api.common.schemas import (
14
+ SearchRequest,
15
+ )
16
+ from cbh.core.config import settings
17
+
18
+ T = TypeVar("T", bound=BaseModel)
19
+
20
+ collection_map = {
21
+ "AccountModel": "accounts",
22
+ "AccountShorten": "accounts",
23
+ "ScenarioModel": "scenarios",
24
+ "ScenarioShorten": "scenarios",
25
+ "SessionReportModel": "sessionreports",
26
+ "SessionReportShorten": "sessionreports",
27
+ "TeamModel": "teams",
28
+ "TeamModelShorten": "teams",
29
+ "OrganizationModel": "organizations",
30
+ "OrganizationShorten": "organizations",
31
+ "OrganizationDocumentModel": "organizationdocuments",
32
+ "OrganizationSettingsModel": "organizationsettings",
33
+ "UserInsightModel": "userinsights",
34
+ "UserInsightShorten": "userinsights",
35
+ "VoiceModel": "voices",
36
+ "VoiceModelShorten": "voices",
37
+ "ActivityLogModel": "activitylogs",
38
+ "AccountNotificationModel": "accountnotifications",
39
+ "FeedbackModel": "generalfeedbacks",
40
+ "FeedbackExtendedModel": "extendedfeedbacks",
41
+ "ScenarioChangeModel": "scenariochanges",
42
+ "WaitlistModel": "waitlists",
43
+ "NotificationModel": "notifications",
44
+ "NotificationModelShorten": "notifications",
45
+ }
46
+
47
+
48
+ async def get_obj_by_id(
49
+ model: T,
50
+ obj_id: str | None,
51
+ additional_filter: dict | None = None,
52
+ projection: dict | None = None,
53
+ exception: bool = True,
54
+ ) -> T | None:
55
+ """
56
+ Get an object by ID.
57
+ """
58
+ filter_ = {"id": obj_id} if obj_id else {}
59
+ if additional_filter:
60
+ filter_.update(additional_filter)
61
+ obj = await settings.DB_CLIENT[collection_map[model.__name__]].find_one(
62
+ filter_, projection
63
+ )
64
+ if obj is None:
65
+ if exception:
66
+ raise HTTPException(status_code=404, detail="Object not found.")
67
+ else:
68
+ return None
69
+ return model.from_mongo(obj)
70
+
71
+
72
+ async def get_all_objs(
73
+ model: T,
74
+ page_size: int,
75
+ page_index: int,
76
+ sort: tuple[str, int] = ("id", -1),
77
+ additional_filter: dict | None = None,
78
+ projection: dict | None = None,
79
+ ) -> tuple[list[T], int]:
80
+ """
81
+ Get all objects.
82
+ """
83
+ filter_ = additional_filter if additional_filter else {}
84
+ skip = page_index * page_size
85
+ objs, total_count = await asyncio.gather(
86
+ settings.DB_CLIENT[collection_map[model.__name__]]
87
+ .find(filter_, projection)
88
+ .sort(*sort)
89
+ .skip(skip)
90
+ .limit(page_size)
91
+ .to_list(page_size),
92
+ settings.DB_CLIENT[collection_map[model.__name__]].count_documents(filter_),
93
+ )
94
+ return [model.from_mongo(obj) for obj in objs], total_count
95
+
96
+
97
+ async def delete_obj(
98
+ model: T, obj_id: str | None = None, additional_filter: dict | None = None
99
+ ) -> T:
100
+ """
101
+ Delete an object.
102
+ """
103
+ filter_ = {"id": obj_id} if obj_id else {}
104
+ if additional_filter:
105
+ filter_.update(additional_filter)
106
+ obj = await settings.DB_CLIENT[collection_map[model.__name__]].find_one(filter_)
107
+ if obj is None:
108
+ raise HTTPException(status_code=404, detail="Object not found.")
109
+ await settings.DB_CLIENT[collection_map[model.__name__]].delete_one(filter_)
110
+ return model.from_mongo(obj)
111
+
112
+
113
+ async def search_objs(
114
+ model: T,
115
+ data: SearchRequest,
116
+ additional_filter: dict | None = None,
117
+ projection: dict | None = None,
118
+ ) -> tuple[list[T], int]:
119
+ """
120
+ Search for objects in a specified collection based on search filters.
121
+ """
122
+ filters = []
123
+ date_filters = {}
124
+
125
+ for search_filter in data.filter:
126
+ if isinstance(search_filter.value, str):
127
+ date_match = re.fullmatch(
128
+ r"^(\d{4}-\d{2}-\d{2});([+-]\d{1,2})$", search_filter.value
129
+ )
130
+
131
+ if date_match:
132
+ if search_filter.name not in date_filters:
133
+ date_filters[search_filter.name] = []
134
+
135
+ date_filters[search_filter.name].append(
136
+ {
137
+ "date": datetime.strptime(date_match.group(1), "%Y-%m-%d"),
138
+ "timezone_offset": int(date_match.group(2)),
139
+ }
140
+ )
141
+ else:
142
+ filters.append(
143
+ {
144
+ search_filter.name: {
145
+ "$regex": f"^{re.escape(search_filter.value)}",
146
+ "$options": "i",
147
+ }
148
+ }
149
+ )
150
+ else:
151
+ filters.append({search_filter.name: search_filter.value})
152
+
153
+ for field_name, dates in date_filters.items():
154
+ if len(dates) == 1:
155
+ date_info = dates[0]
156
+ user_local_day_start = date_info["date"]
157
+ user_local_day_end = user_local_day_start + timedelta(days=1)
158
+ filters.append(
159
+ {
160
+ field_name: {
161
+ "$gte": (
162
+ user_local_day_start
163
+ - timedelta(hours=date_info["timezone_offset"])
164
+ ).isoformat(),
165
+ "$lt": (
166
+ user_local_day_end
167
+ - timedelta(hours=date_info["timezone_offset"])
168
+ ).isoformat(),
169
+ }
170
+ }
171
+ )
172
+ elif len(dates) == 2:
173
+ start_date = min(dates, key=lambda x: x["date"])
174
+ end_date = max(dates, key=lambda x: x["date"])
175
+
176
+ start_datetime = start_date["date"] - timedelta(
177
+ hours=start_date["timezone_offset"]
178
+ )
179
+ end_datetime = (
180
+ end_date["date"]
181
+ + timedelta(days=1)
182
+ - timedelta(hours=end_date["timezone_offset"])
183
+ )
184
+
185
+ filters.append(
186
+ {
187
+ field_name: {
188
+ "$gte": start_datetime.isoformat(),
189
+ "$lt": end_datetime.isoformat(),
190
+ }
191
+ }
192
+ )
193
+ elif len(dates) > 2:
194
+ dates_sorted = sorted(dates, key=lambda x: x["date"])
195
+ start_date = dates_sorted[0]
196
+ end_date = dates_sorted[-1]
197
+
198
+ start_datetime = start_date["date"] - timedelta(
199
+ hours=start_date["timezone_offset"]
200
+ )
201
+ end_datetime = (
202
+ end_date["date"]
203
+ + timedelta(days=1)
204
+ - timedelta(hours=end_date["timezone_offset"])
205
+ )
206
+
207
+ filters.append(
208
+ {
209
+ field_name: {
210
+ "$gte": start_datetime.isoformat(),
211
+ "$lt": end_datetime.isoformat(),
212
+ }
213
+ }
214
+ )
215
+
216
+ if additional_filter:
217
+ filters.append(additional_filter)
218
+ regex_filter = {"$and": filters} if filters else {}
219
+ objects, total_count = await asyncio.gather(
220
+ settings.DB_CLIENT[collection_map[model.__name__]]
221
+ .find(regex_filter, projection)
222
+ .sort("id", -1)
223
+ .skip(data.pageSize * data.pageIndex)
224
+ .limit(data.pageSize)
225
+ .to_list(length=data.pageSize),
226
+ settings.DB_CLIENT[collection_map[model.__name__]].count_documents(
227
+ regex_filter
228
+ ),
229
+ )
230
+ return [model.from_mongo(obj) for obj in objects], total_count
cbh/api/common/dto.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common DTOs.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from cbh.api.account.models import AccountShorten
11
+
12
+
13
+ class Paging(BaseModel):
14
+ """
15
+ Pagination model for API responses.
16
+ """
17
+
18
+ pageSize: int
19
+ pageIndex: int
20
+ totalCount: int
21
+
22
+
23
+ class SearchFilter(BaseModel):
24
+ """
25
+ Search filter model for constructing database queries.
26
+
27
+ Attributes:
28
+ name (str): Field name to filter on
29
+ value (str | int): Value to search for
30
+ """
31
+
32
+ name: str
33
+ value: str | int
34
+
35
+
36
+ class Scores(BaseModel):
37
+ """
38
+ Scores for the recording.
39
+ """
40
+
41
+ communication: int = Field(
42
+ description="Speech professionalism assessment: clarity of presentation, absence of filler words, literacy, 1-100",
43
+ )
44
+
45
+ activeListening: int = Field(
46
+ description="Active listening skills: ability to ask clarifying questions, respond to client signals, 1-100",
47
+ )
48
+
49
+ conversation: int = Field(
50
+ description="Conversation control: ability to direct conversation, adhere to sales structure, 1-100",
51
+ )
52
+
53
+ objection: int = Field(
54
+ description="Objection handling: ability to correctly respond to client doubts and overcome obstacles, 1-100",
55
+ )
56
+
57
+ empathy: int = Field(
58
+ description="Emotional intelligence: adaptation to client mood, empathy, building rapport, 1-100",
59
+ )
60
+
61
+ final: int = Field(
62
+ description="Overall score: score of completion of conversation, 1-100",
63
+ )
64
+
65
+ def __sub__(self, other: "Scores") -> "Scores":
66
+ return Scores(
67
+ communication=self.communication - other.communication,
68
+ activeListening=self.activeListening - other.activeListening,
69
+ conversation=self.conversation - other.conversation,
70
+ objection=self.objection - other.objection,
71
+ empathy=self.empathy - other.empathy,
72
+ final=self.final - other.final,
73
+ )
74
+
75
+ def __mod__(self, other: "Scores") -> "Scores":
76
+ def calc_percentage_diff(current: int, previous: int) -> int:
77
+ if previous == 0:
78
+ return 0
79
+ return int(((current - previous) / previous) * 100)
80
+
81
+ return Scores(
82
+ communication=calc_percentage_diff(self.communication, other.communication),
83
+ activeListening=calc_percentage_diff(
84
+ self.activeListening, other.activeListening
85
+ ),
86
+ conversation=calc_percentage_diff(self.conversation, other.conversation),
87
+ objection=calc_percentage_diff(self.objection, other.objection),
88
+ empathy=calc_percentage_diff(self.empathy, other.empathy),
89
+ final=calc_percentage_diff(self.final, other.final),
90
+ )
91
+
92
+
93
+ class DateValue(BaseModel):
94
+ """
95
+ Date value.
96
+ """
97
+
98
+ date: datetime
99
+ value: int
100
+
101
+
102
+ class ValueDelta(BaseModel):
103
+ """
104
+ Value delta.
105
+ """
106
+
107
+ value: int
108
+ delta: int
109
+
110
+
111
+ class IDName(BaseModel):
112
+ id: str | int
113
+ name: str
114
+
115
+
116
+ class OrderType(Enum):
117
+ """
118
+ Order type.
119
+ """
120
+
121
+ ASCENDING = 1
122
+ DESCENDING = -1
123
+
124
+
125
+ class SortBy(BaseModel):
126
+ """
127
+ Sort by.
128
+ """
129
+
130
+ name: str
131
+ order: OrderType
132
+
133
+
134
+ class TokenUsage(BaseModel):
135
+ inputTokens: int
136
+ outputTokens: int
137
+
138
+
139
+ class LeaderboardPosition(BaseModel):
140
+ """
141
+ Leaderboard position model.
142
+ """
143
+
144
+ user: AccountShorten
145
+ finalScore: int
146
+ attempts: int
cbh/api/common/schemas.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common schemas.
3
+ """
4
+
5
+ from typing import TypeVar, Generic
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from cbh.api.common.dto import Paging, SearchFilter, SortBy
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ class AllObjectsResponse(BaseModel, Generic[T]):
15
+ """
16
+ Response model for all objects.
17
+ """
18
+
19
+ paging: Paging
20
+ data: list[T]
21
+
22
+
23
+ class SearchRequest(BaseModel):
24
+ """
25
+ Request schema for searching calls or statistics.
26
+
27
+ Attributes:
28
+ filter (list[SearchFilter]): List of filters to apply
29
+ pageSize (int): Number of items to return per page
30
+ pageIndex (int): Page index to retrieve
31
+ """
32
+
33
+ filter: list[SearchFilter]
34
+ pageSize: int
35
+ pageIndex: int
36
+
37
+
38
+ class FilterRequest(BaseModel, Generic[T]):
39
+ """
40
+ Filter request.
41
+ """
42
+
43
+ filter: T
44
+ sortBy: SortBy | None = None
45
+ pageSize: int = 10
46
+ pageIndex: int = 0
47
+
48
+
49
+ class PlainTextResponse(BaseModel):
50
+ """
51
+ Response model for plain text.
52
+ """
53
+
54
+ text: str
55
+
56
+
57
+ class BatchIdsRequest(BaseModel):
58
+ """
59
+ Batch ids request.
60
+ """
61
+
62
+ ids: list[str]
63
+
64
+
65
+ class EmailRequest(BaseModel):
66
+ """
67
+ Email request.
68
+ """
69
+
70
+ email: str
cbh/api/common/utils.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common utilities.
3
+ """
4
+
5
+ import asyncio
6
+ import base64
7
+ import re
8
+
9
+ import httpx
10
+
11
+ from cbh.api.account.dto import AccountType
12
+ from cbh.api.account.models import AccountModel
13
+ from cbh.api.scenario.dto import AssigneesType
14
+ from cbh.core.config import settings
15
+
16
+
17
+ def form_additional_scenario_filter(account: AccountModel):
18
+ filter_ = {"owner.organization.id": account.organization.id}
19
+ if account.accountType == AccountType.USER:
20
+ filter_.update(
21
+ {
22
+ "$or": [
23
+ {"assignees": {"$size": 0}},
24
+ {
25
+ "assignees": {
26
+ "$elemMatch": {
27
+ "type": AssigneesType.USER.value,
28
+ "account.id": account.id,
29
+ }
30
+ }
31
+ },
32
+ {
33
+ "assignees": {
34
+ "$elemMatch": {
35
+ "type": AssigneesType.TEAM.value,
36
+ "team.members": {"$elemMatch": {"id": account.id}},
37
+ }
38
+ }
39
+ },
40
+ ],
41
+ }
42
+ )
43
+ return filter_
44
+
45
+
46
+ async def convert_document_to_text(file: bytes, filename: str) -> str:
47
+ filename = re.sub(r"[^\w\s.-]", "", filename)
48
+ base64_file = base64.b64encode(file).decode("utf-8")
49
+ headers = {"Content-Type": "application/json"}
50
+ data = {
51
+ "apikey": settings.CONVERTIO_API_KEY,
52
+ "input": "base64",
53
+ "file": base64_file,
54
+ "filename": filename,
55
+ "outputformat": "txt",
56
+ }
57
+ async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=120)) as client:
58
+ response = await client.post(
59
+ "https://api.convertio.co/convert", json=data, headers=headers
60
+ )
61
+ response = response.json()
62
+ if response["code"] == 200:
63
+ conversion_id = response["data"]["id"]
64
+ status = ""
65
+ attempt = 0
66
+ while status != "finish":
67
+ if attempt > 50:
68
+ raise Exception("Please, try again")
69
+ get_status_response = await client.get(
70
+ f"https://api.convertio.co/convert/{conversion_id}/status"
71
+ )
72
+ get_status_response = get_status_response.json()
73
+ if get_status_response["code"] != 200:
74
+ raise Exception("Please, try again")
75
+ else:
76
+ status = get_status_response["data"]["step"]
77
+ await asyncio.sleep(1)
78
+ attempt += 1
79
+ file_url = get_status_response["data"]["output"]["url"]
80
+ response = await client.get(file_url)
81
+ response.raise_for_status()
82
+ return response.content.decode("utf-8", errors="ignore")
83
+ else:
84
+ return ""
cbh/api/events/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ events_router = APIRouter(prefix="/events", tags=["events"])
4
+
5
+ from . import views
cbh/api/events/db_requests.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.api.account.models import AccountModel, AccountShorten
2
+ from cbh.api.events.models import EventModel
3
+ from cbh.api.events.schemas import CreateEventRequest
4
+ from cbh.core.config import settings
5
+
6
+
7
+ async def create_event_obj(
8
+ event: CreateEventRequest, account: AccountModel
9
+ ) -> EventModel:
10
+ """
11
+ Create a new event.
12
+ """
13
+ event = EventModel(
14
+ reason=event.reason,
15
+ type=event.type,
16
+ startDate=event.startDate,
17
+ endDate=event.endDate,
18
+ coach=AccountShorten(**account.model_dump()),
19
+ )
20
+ await settings.DB_CLIENT.events.insert_one(event.to_mongo())
21
+ return event
cbh/api/events/dto.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+
4
+ class EventType(Enum):
5
+ """
6
+ Event type.
7
+ """
8
+
9
+ CALL = 1
10
+ CUSTOM = 2
cbh/api/events/models.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from pydantic import Field
3
+ from cbh.api.events.dto import EventType
4
+ from cbh.core.database import MongoBaseModel, MongoBaseShortenModel
5
+ from cbh.api.account.models import AccountShorten
6
+
7
+
8
+ class EventModel(MongoBaseModel):
9
+ """
10
+ Event model.
11
+ """
12
+
13
+ reason: str | None = None
14
+ type: EventType
15
+
16
+ startDate: datetime
17
+ endDate: datetime
18
+
19
+ coach: AccountShorten
20
+
21
+ isActive: bool = True
22
+ datetimeInserted: datetime = Field(default_factory=datetime.now)
23
+
24
+
25
+ class EventShorten(MongoBaseShortenModel):
26
+ """
27
+ Event shorten model.
28
+ """
29
+
30
+ startDate: datetime
31
+ endDate: datetime
32
+
33
+ isActive: bool
cbh/api/events/schemas.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from pydantic import BaseModel
3
+ from cbh.api.events.dto import EventType
4
+
5
+
6
+ class CreateEventRequest(BaseModel):
7
+ """
8
+ Create event request.
9
+ """
10
+
11
+ reason: str | None = None
12
+ type: EventType
13
+
14
+ startDate: datetime
15
+ endDate: datetime
cbh/api/events/views.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends
2
+
3
+ from cbh.api.events import events_router
4
+ from cbh.api.events.models import EventModel
5
+ from cbh.api.account.models import AccountModel
6
+ from cbh.core.security import PermissionDependency
7
+ from cbh.api.account.dto import AccountType
8
+ from cbh.core.wrappers import CbhResponseWrapper
9
+ from cbh.api.events.schemas import CreateEventRequest
10
+ from cbh.api.events.db_requests import create_event_obj
11
+
12
+
13
+ @events_router.post("/")
14
+ async def create_event(
15
+ event: CreateEventRequest,
16
+ account: AccountModel = Depends(PermissionDependency([AccountType.COACH])),
17
+ ) -> CbhResponseWrapper[EventModel]:
18
+ """
19
+ Create a new event.
20
+ """
21
+ event = await create_event_obj(event, account)
22
+ return CbhResponseWrapper(data=event)
cbh/api/security/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security module initialization.
3
+ """
4
+
5
+ from fastapi import APIRouter
6
+
7
+ security_router = APIRouter(
8
+ prefix="/api/security",
9
+ )
10
+
11
+ from . import views # pylint: disable=C0413 # noqa: E402,F401
cbh/api/security/db_requests.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database requests module for security functionality.
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from fastapi import HTTPException
8
+ from passlib.context import CryptContext
9
+ from pydantic import EmailStr
10
+
11
+ from cbh.api.account.dto import AccountStatus
12
+ from cbh.api.account.models import AccountModel, AccountShorten
13
+ from cbh.api.security.schemas import (
14
+ LoginAccountRequest,
15
+ RegisterAccountRequest,
16
+ )
17
+ from cbh.core.config import settings
18
+ from cbh.core.security import verify_password
19
+
20
+
21
+ async def check_unique_fields_existence(
22
+ name: str, new_value: EmailStr | str, current_value: str | None = None
23
+ ) -> None:
24
+ """
25
+ Check if a field value already exists in the database to ensure uniqueness.
26
+ """
27
+ if new_value == current_value or not new_value:
28
+ return
29
+ account = await settings.DB_CLIENT.accounts.find_one(
30
+ {name: {"$regex": f"^{str(new_value)}$", "$options": "i"}}
31
+ )
32
+ if account:
33
+ detail = f'Account with {name} "{new_value}" already exists.'
34
+ raise HTTPException(status_code=400, detail=detail)
35
+
36
+
37
+ async def save_account(data: RegisterAccountRequest) -> AccountModel:
38
+ """
39
+ Create a new user account in the database.
40
+ """
41
+ await check_unique_fields_existence("email", data.email)
42
+ account = AccountModel(
43
+ name=f"{data.firstName} {data.lastName}",
44
+ email=data.email,
45
+ password=data.password,
46
+ status=AccountStatus.ACTIVE,
47
+ datetimeUpdated=datetime.now(),
48
+ )
49
+ await settings.DB_CLIENT.accounts.insert_one(account.to_mongo())
50
+ return account
51
+
52
+
53
+ async def authenticate_account(data: LoginAccountRequest) -> AccountModel:
54
+ """
55
+ Authenticate a user account using mail and password.
56
+ """
57
+ account = await settings.DB_CLIENT.accounts.find_one(
58
+ {"email": {"$regex": f"^{data.email}$", "$options": "i"}}
59
+ )
60
+ if account is None:
61
+ raise HTTPException(status_code=404, detail="Invalid email or password.")
62
+
63
+ account = AccountModel.from_mongo(account)
64
+
65
+ if not verify_password(data.password, account.password):
66
+ raise HTTPException(status_code=401, detail="Invalid email or password")
67
+
68
+ if account.status == AccountStatus.PENDING_INVITATION:
69
+ raise HTTPException(
70
+ status_code=403,
71
+ detail="Account not activated. Please accept your invitation",
72
+ )
73
+
74
+ if account.status == AccountStatus.BLOCKED:
75
+ raise HTTPException(status_code=423, detail="Account is blocked")
76
+
77
+ return account
78
+
79
+
80
+ async def get_account_by_email(
81
+ email: EmailStr, raise_exception: bool = True
82
+ ) -> AccountModel | None:
83
+ """
84
+ Verify if an account exists.
85
+ """
86
+ account = await settings.DB_CLIENT.accounts.find_one(
87
+ {"email": {"$regex": f"^{email}$", "$options": "i"}}
88
+ )
89
+ if account is None and raise_exception:
90
+ raise HTTPException(status_code=404, detail="Account not found")
91
+ elif account is None:
92
+ return None
93
+ return AccountModel.from_mongo(account)
94
+
95
+
96
+ async def reset_password_obj(account: AccountShorten, password: str) -> AccountModel:
97
+ """
98
+ Reset a password object.
99
+ """
100
+ password = CryptContext(schemes=["bcrypt"], deprecated="auto").hash(password)
101
+ await settings.DB_CLIENT.accounts.update_one(
102
+ {"id": account.id},
103
+ {"$set": {"password": password}},
104
+ )
105
+ return account
cbh/api/security/dto.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data Transfer Objects (DTOs) for security functionality.
3
+ """
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class AccessToken(BaseModel):
9
+ """
10
+ Access token model for authentication.
11
+ """
12
+
13
+ type: str = "Bearer"
14
+ value: str
cbh/api/security/schemas.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schema definitions for security API endpoints.
3
+ """
4
+
5
+ from pydantic import BaseModel, EmailStr
6
+
7
+ from cbh.api.account.models import AccountModel
8
+ from cbh.api.security.dto import AccessToken
9
+
10
+
11
+ class RegisterAccountRequest(BaseModel):
12
+ """
13
+ Request model for account registration.
14
+ """
15
+
16
+ firstName: str
17
+ lastName: str
18
+ email: str
19
+ password: str
20
+
21
+
22
+ class LoginAccountRequest(BaseModel):
23
+ """
24
+ Request model for account login.
25
+ """
26
+
27
+ email: EmailStr
28
+ password: str
29
+
30
+
31
+ class LoginAccountResponse(BaseModel):
32
+ """
33
+ Response model for successful login.
34
+ """
35
+
36
+ accessToken: AccessToken | None = None
37
+ account: AccountModel
cbh/api/security/utils.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities module.
3
+ """
4
+
5
+ import asyncio
6
+
7
+ from fastapi import HTTPException
8
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
9
+
10
+ from cbh.api.account.dto import RegistrationType
11
+ from cbh.api.account.models import AccountModel, AccountShorten
12
+ from cbh.api.security.models import VerificationCodeModel
13
+ from cbh.core.config import settings
14
+
15
+
16
+ def check_account_to_reset(
17
+ account_obj: AccountModel, account: AccountModel | None = None
18
+ ) -> None:
19
+ """
20
+ Check if the account can be reset.
21
+ """
22
+ if not account and account_obj.registrationType != RegistrationType.ORGANIC:
23
+ raise HTTPException(
24
+ status_code=422,
25
+ detail="Please sign in with social providers",
26
+ )
27
+ elif account and account_obj.registrationType != RegistrationType.ORGANIC:
28
+ raise HTTPException(
29
+ status_code=422,
30
+ detail="Password reset is not available for social login accounts",
31
+ )
32
+
33
+
34
+ async def send_password_reset_email(
35
+ code: str, account_obj: AccountModel, account: AccountModel | None = None
36
+ ) -> None:
37
+ """
38
+ Send a password reset email.
39
+ """
40
+ templates_path = settings.BASE_DIR / "cbh" / "templates" / "emails"
41
+ env = Environment(
42
+ loader=FileSystemLoader(templates_path),
43
+ autoescape=select_autoescape(["html", "xml"]),
44
+ )
45
+ template = env.get_template("resetPassword.html")
46
+
47
+ audience_link = settings.Domain
48
+ if account_obj.organization:
49
+ audience_link = f"{account_obj.organization.slug}.{settings.Domain}"
50
+
51
+ link = f"{settings.Audience}/reset?code={code}"
52
+ if account:
53
+ link = f"https://{account.organization.slug}.{settings.Domain}/settings/change-password?code={code}"
54
+
55
+ template_content = template.render(
56
+ link=link,
57
+ audience_link=audience_link,
58
+ )
59
+ await settings.EMAIL_CLIENT.send_email(
60
+ account_obj.email,
61
+ "You requested a password reset in Arena",
62
+ template_content,
63
+ )
64
+
65
+
66
+ def _prepare_joins_str(org_accounts: list[AccountShorten]):
67
+ if not org_accounts:
68
+ return None
69
+ if len(org_accounts) == 1:
70
+ return f"{org_accounts[0].name} has joined"
71
+ elif len(org_accounts) == 2:
72
+ return f"{org_accounts[0].name} and {org_accounts[1].name} have joined"
73
+ return f"{org_accounts[0].name} and {len(org_accounts) - 1} others have joined"
74
+
75
+
76
+ async def send_invite_email(
77
+ admin_account: AccountModel,
78
+ code: VerificationCodeModel,
79
+ org_accounts: list[AccountShorten],
80
+ email: str,
81
+ message: str | None = None,
82
+ ) -> None:
83
+ """
84
+ Send an invite email.
85
+ """
86
+ templates_path = settings.BASE_DIR / "cbh" / "templates" / "emails"
87
+ env = Environment(
88
+ loader=FileSystemLoader(templates_path),
89
+ autoescape=select_autoescape(["html", "xml"]),
90
+ )
91
+ template = env.get_template("inviteToOrg.html")
92
+
93
+ icons = [
94
+ org_account.pictureUrl for org_account in org_accounts if org_account.pictureUrl
95
+ ][:3]
96
+ joins_str = _prepare_joins_str(org_accounts)
97
+
98
+ template_content = template.render(
99
+ admin_name=admin_account.name,
100
+ organization_name=admin_account.organization.name,
101
+ link=f"{settings.Audience}/signup?code={code.id}",
102
+ icons=icons if icons else None,
103
+ joins_str=joins_str,
104
+ audience_link=f"{admin_account.organization.slug}.{settings.Domain}",
105
+ message=message,
106
+ )
107
+
108
+ await settings.EMAIL_CLIENT.send_email(
109
+ email,
110
+ f"{admin_account.name.title()} invited you to train with them in Arena",
111
+ template_content,
112
+ )
113
+
114
+
115
+ async def delete_account():
116
+ await settings.DB_CLIENT.accounts.delete_one(
117
+ {"email": "maksimshymanouski@gmail.com"}
118
+ )
119
+
120
+
121
+ if __name__ == "__main__":
122
+ asyncio.run(delete_account())
cbh/api/security/views.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security API views module.
3
+ """
4
+
5
+ from fastapi import Depends, HTTPException
6
+
7
+ from cbh.api.account.dto import AccountType
8
+ 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
+ )
15
+ from cbh.api.security.dto import AccessToken
16
+ 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
23
+
24
+
25
+ @security_router.post("/register")
26
+ async def register_user(
27
+ data: RegisterAccountRequest,
28
+ ) -> CbhResponseWrapper[AccountModel]:
29
+ """
30
+ Register a new user account.
31
+ """
32
+ account = await save_account(data)
33
+ return CbhResponseWrapper(data=account)
34
+
35
+
36
+ @security_router.post("/login")
37
+ async def login(data: LoginAccountRequest) -> CbhResponseWrapper[LoginAccountResponse]:
38
+ """
39
+ Authenticate a user and generate an access token.
40
+ """
41
+ account = await authenticate_account(data)
42
+ access_token = create_access_token(
43
+ account.email, str(account.id), account.accountType
44
+ )
45
+ response = LoginAccountResponse(
46
+ accessToken=AccessToken(value=access_token),
47
+ account=account,
48
+ )
49
+ return CbhResponseWrapper(data=response)
50
+
51
+
52
+ @security_router.post("/verify")
53
+ async def verify(
54
+ account: AccountModel = Depends(
55
+ PermissionDependency([AccountType.ADMIN, AccountType.USER])
56
+ ),
57
+ ) -> CbhResponseWrapper[AccountModel]:
58
+ """
59
+ Verify a user's authentication token.
60
+ """
61
+ return CbhResponseWrapper(data=account)
62
+
63
+
64
+ @security_router.post("/login/as/user")
65
+ async def login_as_user(
66
+ accountId: str,
67
+ # _: AccountModel = Depends(PermissionDependency([AccountType.ADMIN])),
68
+ ) -> CbhResponseWrapper[LoginAccountResponse]:
69
+ """
70
+ Login as a user.
71
+ """
72
+ account = await get_obj_by_id(AccountModel, accountId)
73
+ if account is None:
74
+ raise HTTPException(status_code=404, detail="User not found")
75
+ token = create_access_token(account.email, str(account.id), account.accountType)
76
+ response = LoginAccountResponse(
77
+ accessToken=AccessToken(value=token),
78
+ account=account,
79
+ )
80
+ return CbhResponseWrapper(data=response)
cbh/api/timezone/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ timezone_router = APIRouter(
4
+ prefix="/api/timezone",
5
+ )
6
+
7
+ from . import views
cbh/api/timezone/models.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.core.database import MongoBaseModel
2
+
3
+
4
+ class TimezoneModel(MongoBaseModel):
5
+ """
6
+ Timezone model.
7
+ """
8
+
9
+ name: str
10
+ offset: str
cbh/api/timezone/views.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends
2
+
3
+ from cbh.api.account.models import AccountModel
4
+ from cbh.api.common.db_requests import search_objs
5
+ from cbh.api.common.dto import Paging
6
+ from cbh.api.common.schemas import AllObjectsResponse, SearchRequest
7
+ from cbh.api.timezone import timezone_router
8
+ from cbh.api.timezone.models import TimezoneModel
9
+ from cbh.core.security import PermissionDependency
10
+ from cbh.core.wrappers import CbhResponseWrapper
11
+
12
+
13
+ @timezone_router.post("/search")
14
+ async def search_industries(
15
+ request: SearchRequest,
16
+ _: AccountModel = Depends(PermissionDependency()),
17
+ ) -> CbhResponseWrapper[AllObjectsResponse[TimezoneModel]]:
18
+ industries, total = await search_objs(TimezoneModel, request)
19
+ return CbhResponseWrapper(
20
+ data=AllObjectsResponse(
21
+ data=industries,
22
+ paging=Paging(
23
+ pageSize=request.pageSize, pageIndex=request.pageIndex, totalCount=total
24
+ ),
25
+ )
26
+ )
cbh/core/config.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration module for ClipboardHealthAI application.
3
+ """
4
+
5
+ import os
6
+ import pathlib
7
+ from functools import lru_cache
8
+ from typing import Optional, Type
9
+
10
+ from dotenv import load_dotenv
11
+ from langchain_core.runnables import Runnable
12
+ from langchain_openai import ChatOpenAI, OpenAIEmbeddings
13
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
14
+ from pydantic import BaseModel
15
+ from stripe import StripeClient
16
+
17
+ load_dotenv()
18
+
19
+
20
+ class BaseConfig:
21
+ """
22
+ Base configuration class containing common settings for all environments.
23
+ """
24
+
25
+ BASE_DIR: pathlib.Path = pathlib.Path(__file__).parent.parent.parent
26
+ SECRET_KEY: str = os.getenv("SECRET", "")
27
+
28
+ DB_CLIENT: AsyncIOMotorDatabase = AsyncIOMotorClient(
29
+ os.getenv("MONGO_DB_URL")
30
+ ).spark
31
+
32
+ STRIPE_CLIENT = StripeClient(api_key=os.getenv("STRIPE_API_KEY"))
33
+ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
34
+
35
+ @staticmethod
36
+ def get_headers(api_key: str) -> dict:
37
+ """
38
+ Generate HTTP headers for API requests.
39
+ """
40
+ return {
41
+ "Authorization": f"Bearer {api_key}",
42
+ "Content-Type": "application/json",
43
+ "Accept": "application/json",
44
+ }
45
+
46
+ @staticmethod
47
+ @lru_cache()
48
+ def get_llm(
49
+ model: str = "gpt-4.1-mini",
50
+ temperature: float = 0.0,
51
+ reasoning: str = "none",
52
+ schema: Optional[Type[BaseModel]] = None,
53
+ is_json: bool = False,
54
+ ) -> Runnable:
55
+ """
56
+ Get a configured LLM instance.
57
+ """
58
+ kwargs = {"model": model, "temperature": temperature}
59
+ if model.startswith("gpt-5"):
60
+ kwargs["reasoning_effort"] = reasoning
61
+ kwargs["temperature"] = 1
62
+ if schema:
63
+ return ChatOpenAI(**kwargs).with_structured_output(schema)
64
+ if is_json:
65
+ return ChatOpenAI(**kwargs).with_structured_output(method="json_mode")
66
+ return ChatOpenAI(**kwargs)
67
+
68
+ @staticmethod
69
+ @lru_cache()
70
+ def get_embedding_client(
71
+ model: str = "text-embedding-3-small", dimensions: int = 384
72
+ ) -> OpenAIEmbeddings:
73
+ return OpenAIEmbeddings(model=model, dimensions=dimensions)
74
+
75
+
76
+ class DevelopmentConfig(BaseConfig):
77
+ """
78
+ Development environment configuration settings.
79
+ """
80
+
81
+ Issuer = "https://trainwitharena.com"
82
+ Audience = "https://trainwitharena.com"
83
+ Domain = "trainwitharena.com"
84
+
85
+
86
+ class ProductionConfig(BaseConfig):
87
+ """
88
+ Production environment configuration settings.
89
+ """
90
+
91
+ Issuer = "https://trainwitharena.com"
92
+ Audience = "https://trainwitharena.com"
93
+ Domain = "trainwitharena.com"
94
+
95
+
96
+ @lru_cache()
97
+ def get_settings() -> DevelopmentConfig | ProductionConfig:
98
+ """
99
+ Get the appropriate configuration based on the current environment.
100
+ """
101
+ config_cls_dict = {
102
+ "development": DevelopmentConfig,
103
+ "production": ProductionConfig,
104
+ }
105
+ config_name = os.getenv("FASTAPI_CONFIG", default="development")
106
+ config_cls = config_cls_dict[config_name]
107
+ return config_cls() # type: ignore
108
+
109
+
110
+ settings = get_settings()
cbh/core/database.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database utilities for ClipboardHealthAI application.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ import re
8
+ from typing import Any, Dict, Type, TypeVar
9
+
10
+ from bson import ObjectId
11
+ from pydantic import AnyUrl, BaseModel, Field, GetCoreSchemaHandler
12
+ from pydantic.json_schema import JsonSchemaValue
13
+ from pydantic_core import core_schema
14
+
15
+ T = TypeVar("T", bound=BaseModel)
16
+
17
+
18
+ class PyObjectId:
19
+ """
20
+ Custom type for handling MongoDB ObjectId in Pydantic models.
21
+ """
22
+
23
+ @classmethod
24
+ def __get_pydantic_core_schema__(
25
+ cls, _source: type, _handler: GetCoreSchemaHandler
26
+ ) -> core_schema.CoreSchema:
27
+ """
28
+ Define the core schema for Pydantic validation.
29
+ """
30
+ return core_schema.with_info_after_validator_function(
31
+ cls.validate, core_schema.str_schema() # type: ignore
32
+ )
33
+
34
+ @classmethod
35
+ def __get_pydantic_json_schema__(
36
+ cls, _schema: core_schema.CoreSchema, _handler: GetCoreSchemaHandler
37
+ ) -> JsonSchemaValue:
38
+ """
39
+ Define the JSON schema representation.
40
+ """
41
+ return {"type": "string"}
42
+
43
+ @classmethod
44
+ def validate(cls, value: str) -> ObjectId:
45
+ """
46
+ Validate and convert a string to MongoDB ObjectId.
47
+ """
48
+ if not ObjectId.is_valid(value):
49
+ raise ValueError(f"Invalid ObjectId: {value}")
50
+ return ObjectId(value)
51
+
52
+ def __getattr__(self, item):
53
+ """
54
+ Delegate attribute access to the wrapped ObjectId.
55
+ """
56
+ return getattr(self.__dict__["value"], item)
57
+
58
+ def __init__(self, value: str | None = None):
59
+ """
60
+ Initialize with a string value or create a new ObjectId.
61
+ """
62
+ if value is None:
63
+ self.value = ObjectId()
64
+ else:
65
+ self.value = self.validate(value)
66
+
67
+ def __str__(self):
68
+ """
69
+ Convert to string representation.
70
+ """
71
+ return str(self.value)
72
+
73
+
74
+ class MongoBaseModel(BaseModel):
75
+ """
76
+ Base model for MongoDB documents with serialization support.
77
+ """
78
+
79
+ id: str = Field(default_factory=lambda: str(PyObjectId()))
80
+
81
+ class Config: # pylint: disable=R0903
82
+ """
83
+ Configuration for the model.
84
+ """
85
+
86
+ arbitrary_types_allowed = True
87
+ extra = "ignore"
88
+ populate_by_name = True
89
+
90
+ @staticmethod
91
+ def serialize_s3_url(value: Any) -> Any:
92
+ """
93
+ Serialize an S3 URL.
94
+ """
95
+ if (
96
+ value
97
+ and isinstance(value, str)
98
+ and "AWSAccessKeyId" in value
99
+ and "Expires" in value
100
+ ):
101
+ match = re.search(r"s3\.amazonaws\.com/([^?]+)", value)
102
+ if match:
103
+ return match.group(1)
104
+ return value
105
+
106
+ def to_mongo(self) -> Dict[str, Any]:
107
+ """
108
+ Convert the model instance to a MongoDB-compatible dictionary.
109
+ """
110
+
111
+ def model_to_dict(model: BaseModel) -> Dict[str, Any]:
112
+ doc = {}
113
+ for name in model.__fields__.keys():
114
+ value = getattr(model, name)
115
+ key = model.__fields__[name].alias or name
116
+
117
+ if isinstance(value, BaseModel):
118
+ doc[key] = model_to_dict(value)
119
+ elif isinstance(value, list) and all(
120
+ isinstance(i, BaseModel) for i in value
121
+ ):
122
+ doc[key] = [model_to_dict(item) for item in value] # type: ignore
123
+ elif value and isinstance(value, Enum):
124
+ doc[key] = value.value
125
+ elif isinstance(value, datetime):
126
+ doc[key] = value.isoformat() # type: ignore
127
+ elif value and isinstance(value, AnyUrl):
128
+ doc[key] = str(value) # type: ignore
129
+ else:
130
+ doc[key] = self.serialize_s3_url(value)
131
+
132
+ return doc
133
+
134
+ result = model_to_dict(self)
135
+ return result
136
+
137
+ @classmethod
138
+ def from_mongo(cls, data: Dict[str, Any]):
139
+ """
140
+ Create a model instance from MongoDB document data.
141
+ """
142
+
143
+ def restore_enums(inst: Any, model_cls: Type[BaseModel]) -> None:
144
+ for name, field in model_cls.__fields__.items(): # type: ignore
145
+ value = getattr(inst, name)
146
+ if (
147
+ field
148
+ and isinstance(field.annotation, type)
149
+ and issubclass(field.annotation, Enum)
150
+ ):
151
+ setattr(inst, name, field.annotation(value))
152
+ elif isinstance(value, BaseModel):
153
+ restore_enums(value, value.__class__)
154
+ elif isinstance(value, list):
155
+ for i, item in enumerate(value):
156
+ if isinstance(item, BaseModel):
157
+ restore_enums(item, item.__class__)
158
+ elif isinstance(field.annotation, type) and issubclass(
159
+ field.annotation, Enum
160
+ ):
161
+ value[i] = field.annotation(item)
162
+ elif isinstance(value, dict):
163
+ for k, v in value.items():
164
+ if isinstance(v, BaseModel):
165
+ restore_enums(v, v.__class__)
166
+ elif isinstance(field.annotation, type) and issubclass(
167
+ field.annotation, Enum
168
+ ):
169
+ value[k] = field.annotation(v)
170
+
171
+ if data is None:
172
+ return None
173
+ instance = cls(**data)
174
+ restore_enums(instance, instance.__class__)
175
+ return instance
176
+
177
+
178
+ class MongoBaseShortenModel(BaseModel):
179
+ """
180
+ Base model for MongoDB documents with serialization support.
181
+ """
182
+
183
+ id: str
184
+
185
+ @classmethod
186
+ def to_mongo_fields(self) -> dict:
187
+ result = {field: 1 for field in self.__annotations__ if field != "_id"}
188
+ result["_id"] = 0
189
+ result["id"] = 1
190
+ return result
191
+
192
+ @classmethod
193
+ def from_mongo(cls, mongo_obj: Dict[str, Any]) -> T:
194
+ return cls(**mongo_obj)
cbh/core/email_client.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import asyncio
3
+ import boto3
4
+
5
+
6
+ class EmailClient:
7
+ def __init__(
8
+ self,
9
+ region: str,
10
+ sender_email: str,
11
+ sender_name: str,
12
+ profile_name: str | None = None,
13
+ ):
14
+ self.sender_email = sender_email
15
+ self.sender_name = sender_name
16
+ if profile_name:
17
+ session = boto3.Session(profile_name=profile_name)
18
+ self.ses_client = session.client("ses", region_name=region)
19
+ else:
20
+ self.ses_client = boto3.client("ses", region_name=region)
21
+
22
+ @staticmethod
23
+ def _strip_html(html_body: str) -> str:
24
+ text_body = re.sub("<[^<]+?>", "", html_body)
25
+ return text_body.replace("&nbsp;", " ").strip()
26
+
27
+ async def send_email(
28
+ self, recipient_email: str, subject: str, html_body: str
29
+ ) -> None:
30
+ text_body = self._strip_html(html_body)
31
+
32
+ def _send():
33
+ try:
34
+ return self.ses_client.send_email(
35
+ Source=f"{self.sender_name} <{self.sender_email}>",
36
+ Destination={"ToAddresses": [recipient_email]},
37
+ Message={
38
+ "Subject": {"Data": subject, "Charset": "UTF-8"},
39
+ "Body": {
40
+ "Text": {"Data": text_body, "Charset": "UTF-8"},
41
+ "Html": {"Data": html_body, "Charset": "UTF-8"},
42
+ },
43
+ },
44
+ ReplyToAddresses=[self.sender_email],
45
+ )
46
+ except Exception as exc:
47
+ print(f"Failed to send email: {exc}")
48
+ return None
49
+
50
+ await asyncio.to_thread(_send)
cbh/core/security.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities for ClipboardHealthAI application.
3
+
4
+ This module provides authentication and authorization functionality, including:
5
+ - Password verification
6
+ - JWT token creation and validation
7
+ - Permission-based endpoint protection using FastAPI dependencies
8
+ """
9
+
10
+ from datetime import datetime, timedelta
11
+
12
+ import anyio
13
+ from fastapi import Depends, HTTPException
14
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
15
+ from jose import JWTError, jwt
16
+ from passlib.context import CryptContext
17
+
18
+ from cbh.api.account.dto import AccountType
19
+ from cbh.api.account.models import AccountModel
20
+ from cbh.core.config import settings
21
+
22
+
23
+ def verify_password(plain_password, hashed_password) -> bool:
24
+ """
25
+ Verify a password against its hashed version.
26
+
27
+ Args:
28
+ plain_password: The plain text password to verify
29
+ hashed_password: The hashed password to check against
30
+
31
+ Returns:
32
+ bool: True if the password matches, False otherwise
33
+ """
34
+ result = CryptContext(schemes=["bcrypt"], deprecated="auto").verify(
35
+ plain_password, hashed_password
36
+ )
37
+ return result
38
+
39
+
40
+ def create_access_token(email: str, account_id: str, account_type: AccountType):
41
+ """
42
+ Create a JWT access token for a user.
43
+
44
+ Args:
45
+ email: User's email address
46
+ account_id: User's account ID
47
+ account_type: User's account type
48
+
49
+ Returns:
50
+ str: Encoded JWT token
51
+ """
52
+ payload = {
53
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": email,
54
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": account_id,
55
+ "accountId": account_id,
56
+ "accountType": account_type.value,
57
+ "iss": settings.Issuer,
58
+ "aud": settings.Audience,
59
+ "exp": datetime.utcnow() + timedelta(days=30),
60
+ }
61
+ encoded_jwt = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
62
+ return encoded_jwt
63
+
64
+
65
+ class PermissionDependency:
66
+ """
67
+ FastAPI dependency for protecting endpoints with authentication.
68
+
69
+ This class implements the callable interface required for FastAPI dependencies
70
+ and validates JWT tokens for protected endpoints.
71
+ """
72
+
73
+ def __init__(
74
+ self, account_type: list[AccountType] | None = None, required: bool = True
75
+ ):
76
+ self.account_types = account_type
77
+ self.required = required
78
+
79
+ def __call__(
80
+ self,
81
+ credentials: HTTPAuthorizationCredentials | None = Depends(
82
+ HTTPBearer(auto_error=False)
83
+ ),
84
+ ) -> AccountModel | None:
85
+ """
86
+ Validate authorization credentials and return account details.
87
+
88
+ This method is called by FastAPI when the dependency is used.
89
+
90
+ Args:
91
+ credentials: The HTTP authorization credentials from the request
92
+
93
+ Returns:
94
+ AccountModel: The account details if authentication is successful
95
+
96
+ Raises:
97
+ HTTPException: If authentication fails
98
+ """
99
+ try:
100
+ if not credentials and self.required:
101
+ raise HTTPException(status_code=401, detail="Unauthorized")
102
+ elif not credentials and not self.required:
103
+ return None
104
+ account_id = self.authenticate_jwt_token(credentials.credentials)
105
+ account_data = anyio.from_thread.run(self.get_account_by_id, account_id)
106
+ self.check_account_health(account_data)
107
+ return AccountModel.from_mongo(account_data)
108
+
109
+ except JWTError as e:
110
+ raise HTTPException( # pylint: disable=W0707
111
+ status_code=403, detail="Permission denied"
112
+ )
113
+ except Exception as e:
114
+ if isinstance(e, HTTPException) and e.status_code == 401:
115
+ raise e
116
+ raise HTTPException( # pylint: disable=W0707
117
+ status_code=403, detail="Permission denied"
118
+ )
119
+
120
+ @staticmethod
121
+ async def get_account_by_id(account_id: str) -> dict:
122
+ """
123
+ Retrieve account data from the database by ID.
124
+
125
+ Args:
126
+ account_id: The account ID to look up
127
+
128
+ Returns:
129
+ dict: Account data from the database
130
+ """
131
+ account = await settings.DB_CLIENT.accounts.find_one({"id": account_id})
132
+ if not account:
133
+ raise HTTPException(status_code=403, detail="Permission denied")
134
+ return account
135
+
136
+ def check_account_health(self, account: dict):
137
+ """
138
+ Verify account data is valid and active.
139
+
140
+ Args:
141
+ account: Account data dictionary
142
+
143
+ Raises:
144
+ HTTPException: If the account is not valid
145
+ """
146
+ if not account:
147
+ raise HTTPException(status_code=403, detail="Permission denied")
148
+ if (
149
+ self.account_types
150
+ and AccountType(account["accountType"]) not in self.account_types
151
+ ):
152
+ raise HTTPException(status_code=403, detail="Permission denied")
153
+
154
+ @staticmethod
155
+ def authenticate_jwt_token(token: str) -> str:
156
+ """
157
+ Validate a JWT token and extract the account ID.
158
+
159
+ Args:
160
+ token: JWT token string
161
+
162
+ Returns:
163
+ str: Account ID from the token
164
+
165
+ Raises:
166
+ HTTPException: If token validation fails
167
+ """
168
+ payload = jwt.decode(
169
+ token, settings.SECRET_KEY, algorithms="HS256", audience=settings.Audience
170
+ )
171
+ email: str | None = payload.get(
172
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
173
+ )
174
+ account_id: str | None = payload.get("accountId")
175
+
176
+ if email is None or account_id is None:
177
+ raise HTTPException(status_code=403, detail="Permission denied")
178
+
179
+ return account_id
180
+
181
+
182
+ def check_account_token(token: str) -> dict | None:
183
+ try:
184
+ payload = jwt.decode(
185
+ token, settings.SECRET_KEY, algorithms="HS256", audience=settings.Audience
186
+ )
187
+ email: str | None = payload.get(
188
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
189
+ )
190
+ account_id: str | None = payload.get("accountId")
191
+ if email is None or account_id is None:
192
+ return None
193
+ return {
194
+ "email": email,
195
+ "account_id": account_id,
196
+ "account_type": payload.get("accountType"),
197
+ }
198
+ except Exception as _:
199
+ return None
cbh/core/wrappers.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Response wrappers and utility decorators for ClipboardHealthAI application.
3
+
4
+ This module provides:
5
+ - Standardized response wrappers for API endpoints
6
+ - Exception handling decorators
7
+ - OpenAI API request wrapper
8
+ - Background task decorator
9
+ """
10
+
11
+ from functools import wraps
12
+ from typing import Generic, Optional, TypeVar
13
+
14
+ from fastapi import HTTPException
15
+ from pydantic import BaseModel
16
+ from starlette.responses import JSONResponse
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class ErrorCbhResponse(BaseModel):
22
+ """
23
+ Error response model for standardized error formatting.
24
+
25
+ Attributes:
26
+ message: Error message describing what went wrong
27
+ """
28
+
29
+ message: str
30
+
31
+
32
+ class CbhResponseWrapper(BaseModel, Generic[T]):
33
+ """
34
+ Standard response wrapper for all API endpoints.
35
+
36
+ This class provides a consistent structure for all API responses,
37
+ including data, success status, and error information.
38
+
39
+ Attributes:
40
+ data: The response data (optional)
41
+ successful: Whether the request was successful
42
+ error: Error details if the request failed
43
+ """
44
+
45
+ data: Optional[T] = None
46
+ successful: bool = True
47
+ error: Optional[ErrorCbhResponse] = None
48
+
49
+ def response(self, status_code: int):
50
+ """
51
+ Create a JSONResponse with proper status code and formatting.
52
+
53
+ Args:
54
+ status_code: HTTP status code for the response
55
+
56
+ Returns:
57
+ JSONResponse: Formatted API response
58
+ """
59
+ return JSONResponse(
60
+ status_code=status_code,
61
+ content={
62
+ "data": self.data,
63
+ "successful": self.successful,
64
+ "error": self.error.dict() if self.error else None,
65
+ },
66
+ )
67
+
68
+
69
+ def exception_wrapper(http_error: int, error_message: str):
70
+ """
71
+ Decorator for handling exceptions in route handlers.
72
+
73
+ Catches any exceptions and converts them to a proper HTTP exception
74
+ with specified status code and error message.
75
+
76
+ Args:
77
+ http_error: HTTP status code to use for the exception
78
+ error_message: Error message to include
79
+
80
+ Returns:
81
+ Decorator function that wraps route handlers
82
+ """
83
+
84
+ def decorator(func):
85
+ @wraps(func)
86
+ async def wrapper(*args, **kwargs):
87
+ try:
88
+ return await func(*args, **kwargs)
89
+ except Exception as e:
90
+ raise HTTPException(status_code=http_error, detail=error_message) from e
91
+
92
+ return wrapper
93
+
94
+ return decorator
95
+
96
+
97
+ def background_task():
98
+ """
99
+ Decorator for background tasks that should not crash the application.
100
+
101
+ Wraps a function to catch and suppress any exceptions, preventing
102
+ background task failures from affecting the main application.
103
+
104
+ Returns:
105
+ Decorator function for background tasks
106
+ """
107
+
108
+ def decorator(func):
109
+ @wraps(func)
110
+ async def wrapper(*args, **kwargs) -> str | None:
111
+ try:
112
+ result = await func(*args, **kwargs)
113
+ return result
114
+ except Exception: # pylint: disable=W0718
115
+ return None
116
+
117
+ return wrapper
118
+
119
+ return decorator
main.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application entry point for ClipboardHealthAI.
3
+
4
+ This file creates and launches the FastAPI application by importing the create_app
5
+ function from the cbh package.
6
+ """
7
+
8
+ from cbh import create_app
9
+
10
+ app = create_app()
test_main.http ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Test your FastAPI endpoints
2
+
3
+ GET http://127.0.0.1:8000/
4
+ Accept: application/json
5
+
6
+ ###
7
+
8
+ GET http://127.0.0.1:8000/hello/User
9
+ Accept: application/json
10
+
11
+ ###