brestok commited on
Commit
4cd2145
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ *.vscode/*
12
+ *.history
cbh/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from starlette.exceptions import HTTPException as StarletteHTTPException
6
+
7
+ from cbh.core.wrappers import CbhResponseWrapper, ErrorCbhResponse
8
+
9
+
10
+ def create_app() -> FastAPI:
11
+ app = FastAPI()
12
+
13
+ from cbh.api.message import message_router
14
+ app.include_router(message_router, tags=['message'])
15
+
16
+ from cbh.api.chat import chat_router
17
+ app.include_router(chat_router, tags=['chat'])
18
+
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=["*"],
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+ @app.exception_handler(StarletteHTTPException)
27
+ async def http_exception_handler(_, exc):
28
+ return CbhResponseWrapper(
29
+ data=None,
30
+ successful=False,
31
+ error=ErrorCbhResponse(message=str(exc.detail))
32
+ ).response(exc.status_code)
33
+
34
+
35
+ @app.get("/")
36
+ async def read_root():
37
+ return {"report": "Hello world!"}
38
+
39
+ return app
cbh/api/chat/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastapi.routing import APIRouter
2
+
3
+ chat_router = APIRouter(
4
+ prefix="/api/chat", tags=["chat"]
5
+ )
6
+
7
+ from . import views
cbh/api/chat/db_requests.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ from cbh.api.chat.model import ChatModel
4
+ from cbh.core.config import settings
5
+
6
+
7
+ async def create_chat_obj() -> ChatModel:
8
+ chat = ChatModel()
9
+ await settings.DB_CLIENT.chats.insert_one(chat.to_mongo())
10
+ return chat
11
+
12
+
13
+ async def get_all_chats_obj(page_size: int, page_index: int) -> tuple[list[dict], int]:
14
+ skip = page_size * page_index
15
+ objects, total_count = await asyncio.gather(
16
+ settings.DB_CLIENT.chats
17
+ .find({})
18
+ .sort("_id", -1)
19
+ .skip(skip)
20
+ .limit(page_size)
21
+ .to_list(length=page_size),
22
+ settings.DB_CLIENT.chats.count_documents({}),
23
+ )
24
+ return objects, total_count
cbh/api/chat/dto.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Paging(BaseModel):
5
+ pageSize: int
6
+ pageIndex: int
7
+ totalCount: int
cbh/api/chat/model.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from pydantic import Field
4
+
5
+ from cbh.core.database import MongoBaseModel
6
+
7
+
8
+ class ChatModel(MongoBaseModel):
9
+ title: str = 'New Chat'
10
+ datetimeInserted: datetime = Field(default_factory=datetime.now)
11
+ datetimeUpdated: datetime = Field(default_factory=datetime.now)
cbh/api/chat/schemas.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ from cbh.api.chat.dto import Paging
4
+ from cbh.api.chat.model import ChatModel
5
+ from cbh.core.wrappers import CbhResponseWrapper
6
+
7
+
8
+ class AllChatResponse(BaseModel):
9
+ paging: Paging
10
+ data: list[ChatModel]
11
+
12
+
13
+ class AllChatWrapper(CbhResponseWrapper[AllChatResponse]):
14
+ pass
15
+
16
+
17
+ class ChatTitleRequest(BaseModel):
18
+ title: str
cbh/api/chat/views.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from fastapi import Query
4
+
5
+ from cbh.api.chat import chat_router
6
+ from cbh.api.chat.db_requests import get_all_chats_obj, create_chat_obj
7
+ from cbh.api.chat.dto import Paging
8
+ from cbh.api.chat.model import ChatModel
9
+ from cbh.api.chat.schemas import AllChatWrapper, AllChatResponse
10
+ from cbh.core.wrappers import CbhResponseWrapper
11
+
12
+
13
+ @chat_router.get('/all', response_model_by_alias=False, response_model=AllChatWrapper)
14
+ async def get_all_chats(
15
+ pageSize: Optional[int] = Query(10, description="Number of objects to return per page"),
16
+ pageIndex: Optional[int] = Query(0, description="Page index to retrieve"),
17
+ ):
18
+ chats, total_count = await get_all_chats_obj(pageSize, pageIndex)
19
+ response = AllChatResponse(
20
+ paging=Paging(pageSize=pageSize, pageIndex=pageIndex, totalCount=total_count),
21
+ data=chats
22
+ )
23
+ return AllChatWrapper(data=response)
24
+
25
+
26
+ @chat_router.post('', response_model_by_alias=False, response_model=CbhResponseWrapper[ChatModel])
27
+ async def create_chat():
28
+ chat = await create_chat_obj()
29
+ return CbhResponseWrapper(data=chat)
cbh/api/message/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ message_router = APIRouter(
4
+ prefix="/api/message",
5
+ )
6
+
7
+ from . import views
cbh/api/message/db_requests.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from fastapi import HTTPException
3
+ from bson import ObjectId
4
+ from cbh.api.chat.model import ChatModel
5
+ from cbh.api.message.model import MessageModel
6
+ from cbh.api.message.schemas import CreateMessageRequest
7
+ from cbh.core.config import settings
8
+
9
+
10
+ async def get_all_chat_messages_obj(chat_id: str) -> tuple[list[MessageModel], ChatModel]:
11
+ messages, chat = await asyncio.gather(
12
+ settings.DB_CLIENT.messages.find({"chatId": ObjectId(chat_id)}).to_list(length=None),
13
+ settings.DB_CLIENT.chats.find_one({"_id": ObjectId(chat_id)})
14
+ )
15
+ messages = [MessageModel.from_mongo(message) for message in messages]
16
+ if not chat:
17
+ raise HTTPException(status_code=404, detail="Chat not found")
18
+ return messages, ChatModel.from_mongo(chat)
19
+
20
+
21
+ async def create_message_obj(chat_id: str, message: CreateMessageRequest) -> MessageModel:
22
+ chat = await settings.DB_CLIENT.chats.find_one({"_id": ObjectId(chat_id)})
23
+ if not chat:
24
+ raise HTTPException(status_code=404, detail="Chat not found")
25
+ message = MessageModel(chatId=chat_id,
26
+ author=message.author,
27
+ text=message.text,
28
+ moduleResponse=message.moduleResponse)
29
+ t = message.to_mongo()
30
+ await settings.DB_CLIENT.messages.insert_one(t)
31
+ return message
cbh/api/message/dto.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+
4
+ class AgentRequestType(Enum):
5
+ WorkerSearch = 1
6
+ FacilitySearch = 2
7
+ LocationSearch = 3
8
+ ShiftStatistics = 4
9
+ AverageStatistics = 5
10
+ CountStatistics = 6
11
+ LocationStatistics = 7
12
+ General = 8
13
+
14
+
15
+
16
+ class Author(Enum):
17
+ User = "user"
18
+ Assistant = "assistant"
19
+
20
+
21
+ class EntityType(Enum):
22
+ Worker = 1
23
+ Facility = 2
24
+ Shift = 3
25
+
26
+
27
+ class LocationType(Enum):
28
+ city = 1
29
+ state = 2
cbh/api/message/model.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.api.message.dto import Author
2
+ from cbh.core.database import MongoBaseModel, PyObjectId
3
+
4
+
5
+ class MessageModel(MongoBaseModel):
6
+ chatId: str
7
+ author: Author
8
+ text: str
9
+ moduleResponse: dict = {}
cbh/api/message/schemas.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ from cbh.api.chat.dto import Paging
4
+ from cbh.api.message.dto import Author
5
+ from cbh.api.message.model import MessageModel
6
+
7
+
8
+ class CreateMessageRequest(BaseModel):
9
+ author: Author
10
+ text: str
11
+ moduleResponse: dict | None = None
12
+
13
+
14
+ class AllMessagesResponse(BaseModel):
15
+ paging: Paging
16
+ data: list[MessageModel]
cbh/api/message/utils.py ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime
3
+
4
+ from cbh.api.message.dto import AgentRequestType, EntityType, LocationType
5
+ from cbh.api.message.model import MessageModel
6
+
7
+
8
+ def build_worker_search_query(filters: dict) -> str:
9
+ query = "SELECT * FROM DIM_WORKERS WHERE 1=1"
10
+ conditions = []
11
+ if filters.get('name'):
12
+ conditions.append(f"FULL_NAME ILIKE '%{filters['name']}%'")
13
+ if filters.get('city'):
14
+ conditions.append(f"CITY ILIKE '%{filters['city']}%'")
15
+ if filters.get('state'):
16
+ conditions.append(f"STATE ILIKE '%{filters['state']}%'")
17
+ if filters.get('country'):
18
+ conditions.append(f"COUNTRY ILIKE '%{filters['country']}%'")
19
+ if filters.get('address'):
20
+ conditions.append(f"FORMATTED_ADDRESS ILIKE '%{filters['address']}%'")
21
+ if filters.get('qualification'):
22
+ conditions.append(f"QUALIFICATION ILIKE '{filters['qualification']}'")
23
+ if filters.get('postalCode'):
24
+ conditions.append(f"POSTAL_CODE ILIKE '{filters['postalCode']}'")
25
+ if conditions:
26
+ query += " AND " + " AND ".join(conditions)
27
+ query += " LIMIT 1"
28
+ return query
29
+
30
+
31
+ def build_worker_shift_count_query(worker_id: str, filters: dict) -> str:
32
+ return f"""
33
+ WITH filtered_shifts AS (
34
+ SELECT *
35
+ FROM fct_shift_logs
36
+ WHERE WORKER_ID = '{worker_id}'
37
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
38
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
39
+ ),
40
+ ranked_shifts AS (
41
+ SELECT *,
42
+ ROW_NUMBER() OVER (PARTITION BY SHIFT_ID ORDER BY ACTION_AT DESC) AS rn
43
+ FROM filtered_shifts
44
+ )
45
+ SELECT
46
+ COUNT(CASE WHEN ACTION = 'SHIFT_VERIFY' THEN 1 END) AS shiftVerified,
47
+ COUNT(CASE WHEN ACTION = 'FACILITY_CANCEL' THEN 1 END) AS facilityCancelled,
48
+ COUNT(CASE WHEN ACTION = 'SHIFT_OPEN' THEN 1 END) AS shiftOpen,
49
+ COUNT(CASE WHEN ACTION = 'SHIFT_REASSIGN' THEN 1 END) AS shiftReassign,
50
+ COUNT(CASE WHEN ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) AS shiftTimeChange,
51
+ COUNT(CASE WHEN ACTION = 'WORKER_CANCEL' THEN 1 END) AS workerCancel,
52
+ COUNT(CASE WHEN ACTION = 'SHIFT_UNASSIGN' THEN 1 END) AS shiftUnassign,
53
+ COUNT(CASE WHEN ACTION = 'SHIFT_CLAIM' THEN 1 END) AS shiftClaim,
54
+ COUNT(CASE WHEN ACTION = 'SHIFT_DELETE' THEN 1 END) AS shiftDelete,
55
+ COUNT(CASE WHEN ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) AS noCallNoShow,
56
+ COUNT(CASE WHEN ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) AS shiftPayRateChange,
57
+ COUNT(CASE WHEN ACTION = 'SHIFT_ASSIGN' THEN 1 END) AS shiftAssign,
58
+ FROM ranked_shifts
59
+ WHERE rn = 1;
60
+ """
61
+
62
+ def build_facility_search_query(filters: dict) -> str:
63
+ query = "SELECT * FROM dim_workplaces WHERE 1=1"
64
+ conditions = []
65
+ if filters.get('name'):
66
+ conditions.append(f"NAME ILIKE '%{filters['name']}%'")
67
+ if filters.get('email'):
68
+ conditions.append(f"EMAIL ILIKE '{filters['email']}'")
69
+ if filters.get('country'):
70
+ conditions.append(f"COUNTRY ILIKE '%{filters['country']}%'")
71
+ if filters.get('city'):
72
+ conditions.append(f"CITY ILIKE '%{filters['city']}%'")
73
+ if filters.get('state'):
74
+ conditions.append(f"STATE ILIKE '%{filters['state']}%'")
75
+ if filters.get('address'):
76
+ conditions.append(f"ADDRESS ILIKE '%{filters['address']}%'")
77
+ if filters.get('postalCode'):
78
+ conditions.append(f"POSTAL_CODE ILIKE '{filters['postalCode']}'")
79
+ if filters.get('type'):
80
+ conditions.append(f"TYPE ILIKE '%{filters['type']}%'")
81
+ if conditions:
82
+ query += " AND " + " AND ".join(conditions)
83
+ query += " LIMIT 1"
84
+ return query
85
+
86
+
87
+ def build_facility_shift_count_query(facility_id: str, filters: dict) -> str:
88
+ return f"""
89
+ WITH filtered_shifts AS (
90
+ SELECT SHIFT_ID, ACTION, ACTION_AT
91
+ FROM fct_shift_logs
92
+ WHERE WORKPLACE_ID = '{facility_id}'
93
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
94
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
95
+ ),
96
+ ranked_shifts AS (
97
+ SELECT SHIFT_ID, ACTION, ACTION_AT,
98
+ ROW_NUMBER() OVER (PARTITION BY SHIFT_ID ORDER BY ACTION_AT DESC) AS rn
99
+ FROM filtered_shifts
100
+ )
101
+ SELECT
102
+ COUNT(CASE WHEN ACTION = 'SHIFT_VERIFY' THEN 1 END) AS shiftVerified,
103
+ COUNT(CASE WHEN ACTION = 'FACILITY_CANCEL' THEN 1 END) AS facilityCancelled,
104
+ COUNT(CASE WHEN ACTION = 'SHIFT_OPEN' THEN 1 END) AS shiftOpen,
105
+ COUNT(CASE WHEN ACTION = 'SHIFT_REASSIGN' THEN 1 END) AS shiftReassign,
106
+ COUNT(CASE WHEN ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) AS shiftTimeChange,
107
+ COUNT(CASE WHEN ACTION = 'WORKER_CANCEL' THEN 1 END) AS workerCancel,
108
+ COUNT(CASE WHEN ACTION = 'SHIFT_UNASSIGN' THEN 1 END) AS shiftUnassign,
109
+ COUNT(CASE WHEN ACTION = 'SHIFT_CLAIM' THEN 1 END) AS shiftClaim,
110
+ COUNT(CASE WHEN ACTION = 'SHIFT_DELETE' THEN 1 END) AS shiftDelete,
111
+ COUNT(CASE WHEN ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) AS noCallNoShow,
112
+ COUNT(CASE WHEN ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) AS shiftPayRateChange,
113
+ COUNT(CASE WHEN ACTION = 'SHIFT_ASSIGN' THEN 1 END) AS shiftAssign,
114
+ FROM ranked_shifts
115
+ WHERE rn = 1;
116
+ """
117
+
118
+
119
+ def form_worker_module_response(worker: dict, shift_count: dict, request_body: dict, amount_earned: int) -> dict:
120
+ keys_needed = ['ACCOUNT_STAGE', 'ATTENDANCE_SCORE', 'AVG_WORKER_RATING', 'CITY',
121
+ 'CLIPBOARD_SCORE', 'COUNTRY', 'FULL_NAME', 'FORMATTED_ADDRESS', 'MSA', 'POSTAL_CODE',
122
+ 'QUALIFICATION', 'STATE', 'INSTANT_PAY_RATE']
123
+ worker = {k: v for k, v in worker.items() if k in keys_needed}
124
+ worker['AVG_WORKER_RATING'] = float(worker.get('AVG_WORKER_RATING') or 0)
125
+ worker[f'shiftStatistics (from {request_body["startDate"]} to {request_body["endDate"]})'] = shift_count
126
+ worker[f'amountEarned (from {request_body["startDate"]} to {request_body["endDate"]})'] = amount_earned
127
+ return worker
128
+
129
+
130
+ def form_facility_module_response(facility: dict, shift_count: dict, request_body: dict, paid_info: dict) -> dict:
131
+ keys_needed = ["TYPE", 'NAME', 'EMAIL', 'COUNTRY', 'STATE', 'CITY', 'ADDRESS',
132
+ 'POSTAL_CODE', 'HAS_PAY_ON_HOLIDAY', 'WEBSITE', 'PHONE', 'DESCRIPTION']
133
+ worker = {k: v for k, v in facility.items() if k in keys_needed}
134
+ worker[f'shiftStatistics (from {request_body["startDate"]} to {request_body["endDate"]})'] = shift_count
135
+ worker['paymentInfo (from {request_body["startDate"]} to {request_body["endDate"]})'] = paid_info
136
+ return worker
137
+
138
+
139
+ def build_shift_statistics_query(filters: dict) -> str:
140
+ if filters['entity'] == EntityType.Facility.value:
141
+ key_field = 'WORKPLACE_NAME'
142
+ first_condition = "1=1"
143
+ else:
144
+ key_field = 'WORKER_FULL_NAME'
145
+ if filters.get('qualification'):
146
+ first_condition = f"WORKER_QUALIFICATION ILIKE '%{filters.get("qualification")}%'"
147
+ else:
148
+ first_condition = "1=1"
149
+ query = f"""
150
+ WITH filtered_shifts AS (
151
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT
152
+ FROM fct_shift_logs
153
+ WHERE {first_condition}
154
+ AND ACTION = '{filters.get("shiftType")}'
155
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters.get("startDate"), "%Y-%m-%d")}')
156
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters.get("endDate"), "%Y-%m-%d")}')
157
+ ),
158
+ ranked_shifts AS (
159
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT,
160
+ ROW_NUMBER() OVER (PARTITION BY SHIFT_ID ORDER BY ACTION_AT DESC) AS rn
161
+ FROM filtered_shifts
162
+ ),
163
+ shift_dates AS (
164
+ SELECT DISTINCT DATE(SHIFT_START_AT) AS shift_date
165
+ FROM ranked_shifts
166
+ WHERE rn = 1
167
+ )
168
+ SELECT
169
+ {key_field},
170
+ COUNT(DISTINCT SHIFT_ID) AS count,
171
+ COUNT(*) / COUNT(DISTINCT shift_date)
172
+ FROM ranked_shifts
173
+ JOIN shift_dates
174
+ ON DATE(SHIFT_START_AT) = shift_dates.shift_date
175
+ WHERE rn = 1
176
+ GROUP BY {key_field}
177
+ ORDER BY count DESC
178
+ LIMIT {filters.get("length")};
179
+ """
180
+ return query
181
+
182
+
183
+ def build_avg_rating_query(filters: dict) -> str:
184
+ cond = ''
185
+ if filters.get('qualification'):
186
+ cond += f"AND QUALIFICATION ILIKE '{filters['qualification']}'\n"
187
+ if filters.get('city'):
188
+ cond += f"AND CITY ILIKE '{filters['city']}'\n"
189
+ if filters.get('country'):
190
+ cond += f"AND COUNTRY ILIKE '{filters['country']}'\n"
191
+ if filters.get('state'):
192
+ cond += f"AND STATE ILIKE '{filters['state']}'\n"
193
+ query = f"""
194
+ SELECT AVG(AVG_WORKER_RATING), AVG(CLIPBOARD_SCORE), AVG(ATTENDANCE_SCORE)
195
+ FROM DIM_WORKERS
196
+ WHERE 1=1
197
+ {cond}
198
+ AND AVG_WORKER_RATING IS NOT NULL;
199
+ """
200
+ return query
201
+
202
+
203
+ def build_avg_pay_rate_query(filters: dict) -> str:
204
+ second_cond = ''
205
+ if filters.get('qualification'):
206
+ second_cond = f"AND WORKER_QUALIFICATION ILIKE '{filters['qualification']}'"
207
+ query = f"""
208
+ SELECT AVG(NET_PAY) AS avg_net_pay, AVG(PAY) AS avg_pay
209
+ FROM fct_shifts
210
+ WHERE IS_VERIFIED = true
211
+ {second_cond}
212
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
213
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
214
+ """
215
+ return query
216
+
217
+
218
+ def build_worker_count_query(filters: dict) -> str:
219
+ additional_cond = ''
220
+ if filters.get('city'):
221
+ additional_cond += f"AND CITY ILIKE '%{filters['city']}%'\n"
222
+ if filters.get('country'):
223
+ additional_cond += f"AND COUNTRY ILIKE '%{filters['country']}%'\n"
224
+ if filters.get('state'):
225
+ additional_cond += f"AND STATE ILIKE '%{filters['state']}%'\n"
226
+
227
+ created_add_cond = ''
228
+ start_of_year = datetime(datetime.now().year, 1, 1).strftime('%Y-%m-%d')
229
+ today = datetime.now().strftime('%Y-%m-%d')
230
+ if filters['startDate'] != start_of_year and filters['endDate'] != today:
231
+ created_add_cond = f"AND CREATED_AT <= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')"
232
+ query = f"""
233
+ SELECT QUALIFICATION, COUNT(*) AS cnt
234
+ FROM DIM_WORKERS
235
+ WHERE 1=1
236
+ {additional_cond}
237
+ {created_add_cond}
238
+ GROUP BY ROLLUP(QUALIFICATION)
239
+ """
240
+ return query
241
+
242
+
243
+ def build_facility_count_query(filters: dict) -> str:
244
+ additional_cond = ''
245
+ if filters.get('city'):
246
+ additional_cond += f"AND CITY ILIKE '%{filters['city']}%'\n"
247
+ if filters.get('country'):
248
+ additional_cond += f"AND COUNTRY ILIKE '%{filters['country']}%'\n"
249
+ if filters.get('state'):
250
+ additional_cond += f"AND STATE ILIKE '%{filters['state']}%'\n"
251
+
252
+ created_add_cond = ''
253
+ start_of_year = datetime(datetime.now().year, 1, 1).strftime('%Y-%m-%d')
254
+ today = datetime.now().strftime('%Y-%m-%d')
255
+ if filters['startDate'] != start_of_year and filters['endDate'] != today:
256
+ created_add_cond = f"AND CREATED_AT <= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')"
257
+
258
+ query = f"""
259
+ SELECT TYPE, COUNT(*) AS cnt
260
+ FROM dim_workplaces
261
+ WHERE 1=1
262
+ {additional_cond}
263
+ {created_add_cond}
264
+ GROUP BY TYPE
265
+ """
266
+ return query
267
+
268
+
269
+ def build_shift_count_query(filters: dict) -> str:
270
+ return f"""
271
+ WITH filtered_shifts AS (
272
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT
273
+ FROM fct_shift_logs
274
+ WHERE SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
275
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
276
+ ),
277
+ ranked_shifts AS (
278
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT,
279
+ ROW_NUMBER() OVER (PARTITION BY SHIFT_ID ORDER BY ACTION_AT DESC) AS rn
280
+ FROM filtered_shifts
281
+ )
282
+ SELECT
283
+ COUNT(CASE WHEN ACTION = 'SHIFT_VERIFY' THEN 1 END) AS shiftVerified,
284
+ COUNT(CASE WHEN ACTION = 'FACILITY_CANCEL' THEN 1 END) AS facilityCancelled,
285
+ COUNT(CASE WHEN ACTION = 'SHIFT_OPEN' THEN 1 END) AS shiftOpen,
286
+ COUNT(CASE WHEN ACTION = 'SHIFT_REASSIGN' THEN 1 END) AS shiftReassign,
287
+ COUNT(CASE WHEN ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) AS shiftTimeChange,
288
+ COUNT(CASE WHEN ACTION = 'WORKER_CANCEL' THEN 1 END) AS workerCancel,
289
+ COUNT(CASE WHEN ACTION = 'SHIFT_UNASSIGN' THEN 1 END) AS shiftUnassign,
290
+ COUNT(CASE WHEN ACTION = 'SHIFT_CLAIM' THEN 1 END) AS shiftClaim,
291
+ COUNT(CASE WHEN ACTION = 'SHIFT_DELETE' THEN 1 END) AS shiftDelete,
292
+ COUNT(CASE WHEN ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) AS noCallNoShow,
293
+ COUNT(CASE WHEN ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) AS shiftPayRateChange,
294
+ COUNT(CASE WHEN ACTION = 'SHIFT_ASSIGN' THEN 1 END) AS shiftAssign,
295
+ FROM ranked_shifts
296
+ WHERE rn = 1;
297
+ """
298
+
299
+
300
+ def build_facility_payment_info_query(facility_id: str, filters: dict) -> str:
301
+ return f"""
302
+ SELECT WORKER_QUALIFICATION,
303
+ SUM(TOTAL_SHIFT_CHARGE) AS total_paid,
304
+ COUNT(SHIFT_ID) AS number_of_shifts
305
+ FROM fct_shifts
306
+ WHERE WORKPLACE_ID = '{facility_id}'
307
+ AND IS_VERIFIED = true
308
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
309
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
310
+ GROUP BY WORKER_QUALIFICATION
311
+ """
312
+
313
+
314
+ def build_worker_payment_query(worker_id: str, filters: dict) -> str:
315
+ return f"""
316
+ SELECT SUM(NET_PAY)
317
+ FROM fct_shifts
318
+ WHERE WORKER_ID = '{worker_id}'
319
+ AND IS_VERIFIED = true
320
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
321
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
322
+ AND NET_PAY IS NOT NULL;
323
+ """
324
+
325
+
326
+ def build_avg_shift_count_query(filters: dict) -> str:
327
+ cond = '1=1'
328
+ if filters.get('qualification'):
329
+ cond = f"WHERE WORKER_QUALIFICATION ILIKE '{filters['qualification']}'"
330
+ return f"""
331
+ WITH filtered_shifts AS (
332
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT, WORKER_QUALIFICATION
333
+ FROM fct_shift_logs
334
+ {cond}
335
+ AND SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
336
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
337
+ ),
338
+ ranked_shifts AS (
339
+ SELECT SHIFT_ID, ACTION, ACTION_AT, SHIFT_START_AT, WORKER_QUALIFICATION,
340
+ ROW_NUMBER() OVER (PARTITION BY SHIFT_ID ORDER BY ACTION_AT DESC) AS rn
341
+ FROM filtered_shifts
342
+ ),
343
+ shift_dates AS (
344
+ SELECT DISTINCT DATE(SHIFT_START_AT) AS shift_date
345
+ FROM ranked_shifts
346
+ WHERE rn = 1
347
+ )
348
+ SELECT
349
+ COUNT(CASE WHEN ACTION = 'SHIFT_VERIFY' THEN 1 END) / COUNT(DISTINCT shift_date),
350
+ COUNT(CASE WHEN ACTION = 'FACILITY_CANCEL' THEN 1 END) / COUNT(DISTINCT shift_date),
351
+ COUNT(CASE WHEN ACTION = 'SHIFT_OPEN' THEN 1 END) / COUNT(DISTINCT shift_date),
352
+ COUNT(CASE WHEN ACTION = 'SHIFT_REASSIGN' THEN 1 END) / COUNT(DISTINCT shift_date),
353
+ COUNT(CASE WHEN ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) / COUNT(DISTINCT shift_date),
354
+ COUNT(CASE WHEN ACTION = 'WORKER_CANCEL' THEN 1 END) / COUNT(DISTINCT shift_date),
355
+ COUNT(CASE WHEN ACTION = 'SHIFT_UNASSIGN' THEN 1 END) / COUNT(DISTINCT shift_date),
356
+ COUNT(CASE WHEN ACTION = 'SHIFT_CLAIM' THEN 1 END) / COUNT(DISTINCT shift_date),
357
+ COUNT(CASE WHEN ACTION = 'SHIFT_DELETE' THEN 1 END) / COUNT(DISTINCT shift_date),
358
+ COUNT(CASE WHEN ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) / COUNT(DISTINCT shift_date),
359
+ COUNT(CASE WHEN ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) / COUNT(DISTINCT shift_date),
360
+ COUNT(CASE WHEN ACTION = 'SHIFT_ASSIGN' THEN 1 END) / COUNT(DISTINCT shift_date),
361
+ FROM ranked_shifts
362
+ JOIN shift_dates
363
+ ON DATE(SHIFT_START_AT) = shift_dates.shift_date
364
+ WHERE rn = 1;
365
+ """
366
+
367
+
368
+ def build_location_workers_query(filters: dict) -> str:
369
+ worker_qualification = filters['qualification']
370
+ key_field = "CITY" if filters.get('field1') == LocationType.city.value else "STATE"
371
+ length = filters['length'] + 1 if filters.get('field1') == LocationType.city.value else filters['length']
372
+
373
+ qualification_cond = ""
374
+ if worker_qualification:
375
+ qualification_cond = f"AND QUALIFICATION ILIKE '%{worker_qualification}%'"
376
+ return f"""WITH QualifiedCities AS (
377
+ SELECT {key_field}, COUNT(*) AS worker_count
378
+ FROM DIM_WORKERS
379
+ WHERE 1=1
380
+ {qualification_cond}
381
+ GROUP BY {key_field}
382
+ ORDER BY worker_count DESC
383
+ LIMIT {length}
384
+ )
385
+ SELECT
386
+ t.{key_field} AS name,
387
+ OBJECT_CONSTRUCT(
388
+ 'totalWorkersCount', COUNT(*),
389
+ 'CNA', SUM(CASE WHEN d.QUALIFICATION = 'CNA' THEN 1 ELSE 0 END),
390
+ 'RN', SUM(CASE WHEN d.QUALIFICATION = 'RN' THEN 1 ELSE 0 END),
391
+ 'LVN', SUM(CASE WHEN d.QUALIFICATION = 'LVN' THEN 1 ELSE 0 END),
392
+ 'CAREGIVER', SUM(CASE WHEN d.QUALIFICATION = 'CAREGIVER' THEN 1 ELSE 0 END),
393
+ 'Non Clinical', SUM(CASE WHEN d.QUALIFICATION = 'Non Clinical' THEN 1 ELSE 0 END),
394
+ 'Medical Assistant', SUM(CASE WHEN d.QUALIFICATION = 'Medical Assistant' THEN 1 ELSE 0 END),
395
+ 'HOUSEKEEPER', SUM(CASE WHEN d.QUALIFICATION = 'HOUSEKEEPER' THEN 1 ELSE 0 END),
396
+ 'Dietary Aide', SUM(CASE WHEN d.QUALIFICATION = 'Dietary Aide' THEN 1 ELSE 0 END),
397
+ 'HHA', SUM(CASE WHEN d.QUALIFICATION = 'HHA' THEN 1 ELSE 0 END)
398
+ ) AS workerStatistics
399
+ FROM DIM_WORKERS d
400
+ JOIN QualifiedCities t ON d.{key_field} = t.{key_field}
401
+ GROUP BY t.{key_field};"""
402
+
403
+
404
+ def build_location_facilities_query(filters: dict) -> str:
405
+ key_field = "CITY" if filters.get('field1') == LocationType.city.value else "STATE"
406
+ type_cond = ""
407
+ if filters.get('facilityType'):
408
+ type_cond = f"AND TYPE ILIKE '%{filters['facilityType']}%'"
409
+ return f"""WITH TopCities AS (
410
+ SELECT {key_field}, COUNT(*) AS facility_count
411
+ FROM DIM_WORKPLACES
412
+ WHERE 1=1
413
+ {type_cond}
414
+ GROUP BY {key_field}
415
+ ORDER BY facility_count DESC
416
+ LIMIT {filters['length']}
417
+ )
418
+ SELECT
419
+ t.{key_field} AS name,
420
+ OBJECT_CONSTRUCT(
421
+ 'totalFacilitiesCount', COUNT(*),
422
+ 'Long Term Care', SUM(CASE WHEN d.TYPE = 'Long Term Care' THEN 1 ELSE 0 END),
423
+ 'Dental Clinic', SUM(CASE WHEN d.TYPE = 'Dental Clinic' THEN 1 ELSE 0 END),
424
+ 'Home Healthcare', SUM(CASE WHEN d.TYPE = 'Home Healthcare' THEN 1 ELSE 0 END),
425
+ 'Medical Lab', SUM(CASE WHEN d.TYPE = 'Medical Lab' THEN 1 ELSE 0 END),
426
+ 'Hospice', SUM(CASE WHEN d.TYPE = 'Hospice' THEN 1 ELSE 0 END),
427
+ 'Pharmacy', SUM(CASE WHEN d.TYPE = 'Pharmacy' THEN 1 ELSE 0 END),
428
+ 'Hospital', SUM(CASE WHEN d.TYPE = 'Hospital' THEN 1 ELSE 0 END)
429
+ ) AS facilityStatistics
430
+ FROM DIM_WORKPLACES d
431
+ JOIN TopCities t ON d.{key_field} = t.{key_field}
432
+ GROUP BY t.{key_field}"""
433
+
434
+
435
+ def build_location_shift_query(filters: dict) -> str:
436
+ key_field = "CITY" if filters.get('field1') == LocationType.city.value else "STATE"
437
+ return f"""WITH filtered_shifts AS (
438
+ SELECT WORKER_ID, WORKPLACE_ID, ACTION, SHIFT_START_AT
439
+ FROM fct_shift_logs
440
+ WHERE SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
441
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
442
+ ),
443
+ ranked_shifts AS (
444
+ SELECT WORKER_ID, WORKPLACE_ID, ACTION, SHIFT_START_AT,
445
+ ROW_NUMBER() OVER (PARTITION BY WORKER_ID ORDER BY SHIFT_START_AT DESC) AS rn
446
+ FROM filtered_shifts
447
+ ),
448
+ city_shifts AS (
449
+ SELECT fsl.WORKPLACE_ID, fw.{key_field},
450
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_VERIFY' THEN 1 END) AS SHIFT_VERIFY,
451
+ COUNT(CASE WHEN fsl.ACTION = 'FACILITY_CANCEL' THEN 1 END) AS FACILITY_CANCEL,
452
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_OPEN' THEN 1 END) AS SHIFT_OPEN,
453
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_REASSIGN' THEN 1 END) AS SHIFT_REASSIGN,
454
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) AS SHIFT_TIME_CHANGE,
455
+ COUNT(CASE WHEN fsl.ACTION = 'WORKER_CANCEL' THEN 1 END) AS WORKER_CANCEL,
456
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_UNASSIGN' THEN 1 END) AS SHIFT_UNASSIGN,
457
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_CLAIM' THEN 1 END) AS SHIFT_CLAIM,
458
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_DELETE' THEN 1 END) AS SHIFT_DELETE,
459
+ COUNT(CASE WHEN fsl.ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) AS NO_CALL_NO_SHOW,
460
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) AS SHIFT_PAY_RATE_CHANGE,
461
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_ASSIGN' THEN 1 END) AS SHIFT_ASSIGN,
462
+ FROM ranked_shifts fsl
463
+ JOIN dim_workplaces fw ON fsl.WORKPLACE_ID = fw.WORKPLACE_ID
464
+ GROUP BY fsl.WORKPLACE_ID, fw.{key_field}
465
+ )
466
+ SELECT {key_field},
467
+ SHIFT_VERIFY,
468
+ FACILITY_CANCEL,
469
+ SHIFT_OPEN,
470
+ SHIFT_REASSIGN,
471
+ SHIFT_TIME_CHANGE,
472
+ WORKER_CANCEL,
473
+ SHIFT_UNASSIGN,
474
+ SHIFT_CLAIM,
475
+ SHIFT_DELETE,
476
+ NO_CALL_NO_SHOW,
477
+ SHIFT_PAY_RATE_CHANGE,
478
+ SHIFT_ASSIGN
479
+ FROM city_shifts
480
+ ORDER BY {filters.get('shiftType') or "SHIFT_VERIFY"} DESC
481
+ FETCH FIRST {filters['length']} ROWS ONLY;"""
482
+
483
+
484
+ def build_specific_location_worker_query(filters: dict) -> str:
485
+ location = filters.get('name')
486
+ key_field = "CITY" if filters.get('locationType') == LocationType.city.value else "STATE"
487
+ return f"""SELECT
488
+ {key_field} AS name,
489
+ OBJECT_CONSTRUCT(
490
+ 'totalWorkersCount', COUNT(*),
491
+ 'CNA', SUM(CASE WHEN QUALIFICATION = 'CNA' THEN 1 ELSE 0 END),
492
+ 'RN', SUM(CASE WHEN QUALIFICATION = 'RN' THEN 1 ELSE 0 END),
493
+ 'LVN', SUM(CASE WHEN QUALIFICATION = 'LVN' THEN 1 ELSE 0 END),
494
+ 'CAREGIVER', SUM(CASE WHEN QUALIFICATION = 'CAREGIVER' THEN 1 ELSE 0 END),
495
+ 'Non Clinical', SUM(CASE WHEN QUALIFICATION = 'Non Clinical' THEN 1 ELSE 0 END),
496
+ 'Medical Assistant', SUM(CASE WHEN QUALIFICATION = 'Medical Assistant' THEN 1 ELSE 0 END),
497
+ 'HOUSEKEEPER', SUM(CASE WHEN QUALIFICATION = 'HOUSEKEEPER' THEN 1 ELSE 0 END),
498
+ 'Dietary Aide', SUM(CASE WHEN QUALIFICATION = 'Dietary Aide' THEN 1 ELSE 0 END),
499
+ 'HHA', SUM(CASE WHEN QUALIFICATION = 'HHA' THEN 1 ELSE 0 END)
500
+ ) as workerStatistics
501
+ FROM DIM_WORKERS
502
+ WHERE {key_field} ILIKE '{location}'
503
+ GROUP BY {key_field};"""
504
+
505
+
506
+ def build_specific_location_facility_query(filters: dict) -> str:
507
+ location = filters.get('name')
508
+ key_field = "CITY" if filters.get('locationType') == LocationType.city.value else "STATE"
509
+ return f"""SELECT
510
+ {key_field} AS name,
511
+ OBJECT_CONSTRUCT(
512
+ 'totalFacilitiesCount', COUNT(*),
513
+ 'Long Term Care', SUM(CASE WHEN d.TYPE = 'Long Term Care' THEN 1 ELSE 0 END),
514
+ 'Dental Clinic', SUM(CASE WHEN d.TYPE = 'Dental Clinic' THEN 1 ELSE 0 END),
515
+ 'Home Healthcare', SUM(CASE WHEN d.TYPE = 'Home Healthcare' THEN 1 ELSE 0 END),
516
+ 'Medical Lab', SUM(CASE WHEN d.TYPE = 'Medical Lab' THEN 1 ELSE 0 END),
517
+ 'Hospice', SUM(CASE WHEN d.TYPE = 'Hospice' THEN 1 ELSE 0 END),
518
+ 'Pharmacy', SUM(CASE WHEN d.TYPE = 'Pharmacy' THEN 1 ELSE 0 END),
519
+ 'Hospital', SUM(CASE WHEN d.TYPE = 'Hospital' THEN 1 ELSE 0 END)
520
+ ) AS facilityStatistics
521
+ FROM DIM_WORKPLACES d
522
+ WHERE {key_field} ILIKE '{location}'
523
+ GROUP BY {key_field}"""
524
+
525
+ def build_specific_location_shift_query(filters: dict) -> str:
526
+ key_field = "CITY" if filters.get('locationType') == LocationType.city.value else "STATE"
527
+ location_value = filters['name']
528
+
529
+ return f"""WITH filtered_shifts AS (
530
+ SELECT WORKER_ID, WORKPLACE_ID, ACTION, SHIFT_START_AT
531
+ FROM fct_shift_logs
532
+ WHERE SHIFT_START_AT >= TO_TIMESTAMP('{datetime.strptime(filters["startDate"], "%Y-%m-%d")}')
533
+ AND SHIFT_START_AT <= TO_TIMESTAMP('{datetime.strptime(filters["endDate"], "%Y-%m-%d")}')
534
+ ),
535
+ city_shifts AS (
536
+ SELECT fsl.WORKPLACE_ID, fw.{key_field},
537
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_VERIFY' THEN 1 END) AS SHIFT_VERIFY,
538
+ COUNT(CASE WHEN fsl.ACTION = 'FACILITY_CANCEL' THEN 1 END) AS FACILITY_CANCEL,
539
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_OPEN' THEN 1 END) AS SHIFT_OPEN,
540
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_REASSIGN' THEN 1 END) AS SHIFT_REASSIGN,
541
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_TIME_CHANGE' THEN 1 END) AS SHIFT_TIME_CHANGE,
542
+ COUNT(CASE WHEN fsl.ACTION = 'WORKER_CANCEL' THEN 1 END) AS WORKER_CANCEL,
543
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_UNASSIGN' THEN 1 END) AS SHIFT_UNASSIGN,
544
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_CLAIM' THEN 1 END) AS SHIFT_CLAIM,
545
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_DELETE' THEN 1 END) AS SHIFT_DELETE,
546
+ COUNT(CASE WHEN fsl.ACTION = 'NO_CALL_NO_SHOW' THEN 1 END) AS NO_CALL_NO_SHOW,
547
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_PAY_RATE_CHANGE' THEN 1 END) AS SHIFT_PAY_RATE_CHANGE,
548
+ COUNT(CASE WHEN fsl.ACTION = 'SHIFT_ASSIGN' THEN 1 END) AS SHIFT_ASSIGN
549
+ FROM filtered_shifts fsl
550
+ JOIN dim_workplaces fw ON fsl.WORKPLACE_ID = fw.WORKPLACE_ID
551
+ WHERE fw.{key_field} ILIKE '{location_value}'
552
+ GROUP BY fsl.WORKPLACE_ID, fw.{key_field}
553
+ )
554
+ SELECT {key_field},
555
+ SHIFT_VERIFY,
556
+ FACILITY_CANCEL,
557
+ SHIFT_OPEN,
558
+ SHIFT_REASSIGN,
559
+ SHIFT_TIME_CHANGE,
560
+ WORKER_CANCEL,
561
+ SHIFT_UNASSIGN,
562
+ SHIFT_CLAIM,
563
+ SHIFT_DELETE,
564
+ NO_CALL_NO_SHOW,
565
+ SHIFT_PAY_RATE_CHANGE,
566
+ SHIFT_ASSIGN
567
+ FROM city_shifts
568
+ FETCH FIRST 1 ROWS ONLY;"""
569
+
570
+
571
+ def form_shift_count_response(row: list[int]) -> dict[str, int]:
572
+ return {
573
+ "shiftVerified": row[0],
574
+ "facilityCancellations": row[1],
575
+ "shiftOpened": row[2],
576
+ "shiftReassigned": row[3],
577
+ "shiftTimeChanged": row[4],
578
+ "workerCancellations": row[5],
579
+ "shiftUnassigned": row[6],
580
+ "shiftClaimed": row[7],
581
+ "shiftDeleted": row[8],
582
+ "noCallNoShow": row[9],
583
+ "shiftPayRateChanged": row[10],
584
+ "shiftAssigned": row[11],
585
+ }
586
+
587
+
588
+ def form_worker_count_response(rows, filters: dict):
589
+ qualifications, results = dict(), dict()
590
+ total_count = 0
591
+ for qualification, count in rows:
592
+ if qualification:
593
+ qualifications[qualification] = int(count)
594
+ if qualification is None and count > total_count:
595
+ total_count = int(count)
596
+ results['totalWorkersCount'] = total_count
597
+ results['qualifications'] = qualifications
598
+ results['selectedDate'] = f"{filters['endDate']}"
599
+ return results
600
+
601
+
602
+ def form_facility_count_response(rows, filters: dict):
603
+ results = {"types": {}}
604
+ total_count = 0
605
+ for type_, count in rows:
606
+ if type_ and type_ != '-':
607
+ results["types"][type_] = int(count)
608
+ total_count += int(count)
609
+ results['totalFacilitiesCount'] = total_count
610
+ results['selectedDate'] = f"{filters['endDate']}"
611
+ return results
612
+
613
+
614
+ def form_shift_statistics_response(rows, request_body: dict):
615
+ entity = request_body.get('entity')
616
+ results = []
617
+ for row in rows:
618
+ key = "workerFullName" if entity == EntityType.Worker.value else "facilityName"
619
+ worker_data = {
620
+ key: row[0],
621
+ "totalShiftCount": row[1],
622
+ "avgShiftCountPerDay": round(float(row[2]), 2),
623
+ }
624
+ results.append(worker_data)
625
+
626
+ return {"results": results, "startDate": request_body['startDate'], "endDate": request_body['endDate']}
627
+
628
+
629
+ def check_missed_fields(request: dict, request_type: AgentRequestType):
630
+ required_fields_map = {
631
+ AgentRequestType.WorkerSearch: ['name', 'city', 'state', 'country', 'address', 'qualification', 'postalCode'],
632
+ AgentRequestType.FacilitySearch: ['name', 'email', 'country', 'state', 'city', 'address', 'postalCode', 'type'],
633
+ AgentRequestType.ShiftStatistics: ["entity", "type", 'length', 'shiftType'],
634
+ AgentRequestType.AverageStatistics: ["qualification"],
635
+ AgentRequestType.LocationStatistics: ["field1", "field2"],
636
+ AgentRequestType.CountStatistics: ["field"],
637
+ AgentRequestType.LocationSearch: ["name", "locationType"]
638
+ }
639
+ fields = required_fields_map[request_type]
640
+ if request_type in (AgentRequestType.WorkerSearch, AgentRequestType.FacilitySearch):
641
+ if not any(
642
+ [request.get('name'), request.get('email'), request.get('address')]
643
+ ) and len(list(filter(lambda field: request.get(field), fields))) < 2:
644
+ message = form_search_missed_message(request, fields)
645
+ raise ValueError(message)
646
+ elif request_type == AgentRequestType.ShiftStatistics:
647
+ general_conditions = not all([request.get('shiftType'), request.get('entity'), request.get('type')])
648
+ if general_conditions:
649
+ message = form_shift_statistics_missed_message(request)
650
+ raise ValueError(message)
651
+ elif request_type == AgentRequestType.AverageStatistics:
652
+ if not request.get('qualification'):
653
+ raise ValueError("qualification")
654
+ elif request_type == AgentRequestType.CountStatistics:
655
+ if not request.get('field'):
656
+ raise ValueError("field (worker, facility or shift type)")
657
+ elif request_type == AgentRequestType.LocationStatistics:
658
+ if not all([request.get('field1'), request.get('field2')]):
659
+ raise ValueError(
660
+ "Filter field (city or state) and result field (workers or facilities) needs to be specified")
661
+ elif request_type == AgentRequestType.LocationSearch:
662
+ if not all([request.get('name'), request.get('locationType')]):
663
+ raise ValueError(
664
+ "Location name and location type (city or state) needs to be specified"
665
+ )
666
+
667
+
668
+ def form_search_missed_message(request: dict, fields: list) -> str:
669
+ return ', '.join([field for field in fields if not request.get(field)])
670
+
671
+
672
+ def form_shift_statistics_missed_message(request: dict) -> str:
673
+ result = ''
674
+ if not request.get('shiftType'):
675
+ result += "shiftType (Verified shifts, cancelled shifts, etc.); "
676
+ if not request.get('entity'):
677
+ result += "entity (worker or facility); "
678
+ if not request.get('type'):
679
+ result += "type (most or least); "
680
+ if not request.get('qualification') and request.get('entity') == EntityType.Worker.value:
681
+ result += "worker qualification; "
682
+ if not request.get('length'):
683
+ result += "length of list;"
684
+ return result
685
+
686
+
687
+ def prepare_message_history_str(message_history: list[MessageModel], search_request: str) -> str:
688
+ results = ''
689
+ shorted_message_history = message_history[-8:]
690
+ for message in shorted_message_history:
691
+ results += f'[{message.author.name}] {message.text}\n'
692
+ results += f"[User] {search_request}"
693
+ return results
694
+
695
+
696
+ def form_location_statistics_response(rows, filters: dict):
697
+ result = []
698
+ entity = filters['field2']
699
+ for row in rows:
700
+ city_name = row[0]
701
+ if entity != EntityType.Shift.value:
702
+ entity_statistics = json.loads(row[1])
703
+
704
+ total_field = "totalFacilitiesCount" if entity == EntityType.Facility.value else "totalWorkersCount"
705
+ total_value = entity_statistics.pop(total_field)
706
+
707
+ statistics_field = "workersByQualificationCount" if entity == EntityType.Worker.value else "facilitiesByTypeCount"
708
+ result.append({
709
+ "name": city_name,
710
+ total_field: total_value,
711
+ statistics_field: entity_statistics
712
+ })
713
+ else:
714
+ result.append({
715
+ "name": city_name,
716
+ "shiftsByTypeCount": {
717
+ "shiftVerified": row[1],
718
+ "facilityCancellations": row[2],
719
+ "shiftOpened": row[3],
720
+ "shiftReassigned": row[4],
721
+ "shiftTimeChanged": row[5],
722
+ "workerCancellations": row[6],
723
+ "shiftUnassigned": row[7],
724
+ "shiftClaimed": row[8],
725
+ "shiftDeleted": row[9],
726
+ "noCallNoShow": row[10],
727
+ "shiftPayRateChanged": row[11],
728
+ "shiftAssigned": row[12],
729
+ },
730
+ "selectedDate": f"from {filters['startDate']} to {filters['endDate']}"
731
+ })
732
+ return {"results": result[:filters['length']]}
733
+
734
+
735
+ def form_prev_request(messages: list[MessageModel]) -> tuple:
736
+ prev_query = ''
737
+ prev_module_response = {}
738
+ if len(messages) >= 2:
739
+ prev_query = messages[-2].text
740
+ prev_module_response = messages[-1].moduleResponse
741
+ return prev_query, prev_module_response
742
+
743
+
744
+ def form_avg_statistics_response(rows: list) -> dict:
745
+ return {
746
+ "shiftVerified": round(float(rows[0]), 2),
747
+ "facilityCancellations": round(float(rows[1]), 2),
748
+ "shiftOpened": round(float(rows[2]), 2),
749
+ "shiftReassigned": round(float(rows[3]), 2),
750
+ "shiftTimeChanged": round(float(rows[4]), 2),
751
+ "workerCancellations": round(float(rows[5]), 2),
752
+ "shiftUnassigned": round(float(rows[6]), 2),
753
+ "shiftClaimed": round(float(rows[7]), 2),
754
+ "shiftDeleted": round(float(rows[8]), 2),
755
+ "noCallNoShow": round(float(rows[9]), 2),
756
+ "shiftPayRateChanged": round(float(rows[10]), 2),
757
+ "shiftAssigned": round(float(rows[11]), 2),
758
+ }
cbh/api/message/views.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.api.chat.dto import Paging
2
+ from cbh.api.message import message_router
3
+ from cbh.api.message.db_requests import create_message_obj, get_all_chat_messages_obj
4
+ from cbh.api.message.model import MessageModel
5
+ from cbh.api.message.schemas import CreateMessageRequest, AllMessagesResponse
6
+ from cbh.core.wrappers import CbhResponseWrapper
7
+
8
+
9
+ @message_router.get(
10
+ '/{chatId}/all', response_model_by_alias=False, response_model=CbhResponseWrapper[AllMessagesResponse]
11
+ )
12
+ async def get_all_chat_messages(chatId: str):
13
+ messages, _ = await get_all_chat_messages_obj(chatId)
14
+ response = AllMessagesResponse(
15
+ paging=Paging(pageSize=len(messages), pageIndex=0, totalCount=len(messages)),
16
+ data=messages
17
+ )
18
+ return CbhResponseWrapper(data=response)
19
+
20
+
21
+ @message_router.post('/{chatId}', response_model_by_alias=False, response_model=CbhResponseWrapper[MessageModel])
22
+ async def create_message(chatId: str, message: CreateMessageRequest):
23
+ message = await create_message_obj(chatId, message)
24
+ return CbhResponseWrapper(data=message)
cbh/core/config.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pathlib
3
+ from functools import lru_cache
4
+
5
+ import motor.motor_asyncio
6
+ from dotenv import load_dotenv
7
+ from openai import AsyncClient
8
+
9
+ load_dotenv()
10
+
11
+
12
+ class BaseConfig:
13
+ BASE_DIR: pathlib.Path = pathlib.Path(__file__).parent.parent.parent
14
+ OPENAI_CLIENT = AsyncClient(api_key=os.getenv('OPENAI_API_KEY'))
15
+ DB_CLIENT = motor.motor_asyncio.AsyncIOMotorClient(os.getenv('MONGO_DB_URL')).cbhtest
16
+
17
+
18
+ class DevelopmentConfig(BaseConfig):
19
+ Issuer = "http://localhost:8000"
20
+ Audience = "http://localhost:3000"
21
+
22
+
23
+ class ProductionConfig(BaseConfig):
24
+ Issuer = ""
25
+ Audience = ""
26
+
27
+
28
+ @lru_cache()
29
+ def get_settings() -> DevelopmentConfig | ProductionConfig:
30
+ config_cls_dict = {
31
+ 'development': DevelopmentConfig,
32
+ 'production': ProductionConfig,
33
+ }
34
+ config_name = os.getenv('FASTAPI_CONFIG', default='development')
35
+ config_cls = config_cls_dict[config_name]
36
+ return config_cls()
37
+
38
+
39
+ settings = get_settings()
cbh/core/database.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import Annotated, Dict, Any, Optional, Type
3
+
4
+ from pydantic import BeforeValidator, BaseModel, ConfigDict, Field
5
+ from bson import ObjectId
6
+
7
+ PyObjectId = Annotated[str, BeforeValidator(str)]
8
+
9
+ class MongoBaseModel(BaseModel):
10
+ id: Optional[PyObjectId] = Field(alias="_id", default=ObjectId())
11
+
12
+ model_config = ConfigDict(
13
+ populate_by_name=True,
14
+ arbitrary_types_allowed=True,
15
+ json_encoders={PyObjectId: str},
16
+ response_by_alias=False
17
+ )
18
+
19
+ def to_mongo(self):
20
+ result = self.model_dump(by_alias=True, mode='json', exclude={'id',})
21
+ result['_id'] = ObjectId(self.id)
22
+ return result
23
+
24
+ @classmethod
25
+ def from_mongo(cls, data: Dict[str, Any]):
26
+ def restore_enums(inst: Any, model_cls: Type[BaseModel]) -> None:
27
+ for name, field in model_cls.__fields__.items():
28
+ value = getattr(inst, name)
29
+ if field and isinstance(field.annotation, type) and issubclass(field.annotation, Enum):
30
+ setattr(inst, name, field.annotation(value))
31
+ elif isinstance(value, BaseModel):
32
+ restore_enums(value, value.__class__)
33
+ elif isinstance(value, list):
34
+ for i, item in enumerate(value):
35
+ if isinstance(item, BaseModel):
36
+ restore_enums(item, item.__class__)
37
+ elif isinstance(field.annotation, type) and issubclass(field.annotation, Enum):
38
+ value[i] = field.annotation(item)
39
+ elif isinstance(value, dict):
40
+ for k, v in value.items():
41
+ if isinstance(v, BaseModel):
42
+ restore_enums(v, v.__class__)
43
+ elif isinstance(field.annotation, type) and issubclass(field.annotation, Enum):
44
+ value[k] = field.annotation(v)
45
+
46
+ if data is None:
47
+ return None
48
+ instance = cls(**data)
49
+ restore_enums(instance, instance.__class__)
50
+ return instance
51
+
cbh/core/wrappers.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from functools import wraps
3
+ from typing import Generic, Optional, TypeVar, Literal
4
+
5
+ import pydash
6
+ from fastapi import HTTPException
7
+ from pydantic import BaseModel
8
+ from starlette.responses import JSONResponse
9
+
10
+ from cbh.core.config import settings
11
+
12
+ T = TypeVar('T')
13
+
14
+
15
+ class ErrorCbhResponse(BaseModel):
16
+ message: str
17
+
18
+
19
+ class CbhResponseWrapper(BaseModel, Generic[T]):
20
+ data: Optional[T] = None
21
+ successful: bool = True
22
+ error: Optional[ErrorCbhResponse] = None
23
+
24
+ def response(self, status_code: int):
25
+ return JSONResponse(
26
+ status_code=status_code,
27
+ content={
28
+ "data": self.data,
29
+ "successful": self.successful,
30
+ "error": self.error.dict() if self.error else None
31
+ }
32
+ )
33
+
34
+
35
+ def exception_wrapper(http_error: int, error_message: str):
36
+ def decorator(func):
37
+ @wraps(func)
38
+ async def wrapper(*args, **kwargs):
39
+ try:
40
+ return await func(*args, **kwargs)
41
+ except Exception as e:
42
+ raise HTTPException(status_code=http_error, detail=error_message) from e
43
+
44
+ return wrapper
45
+
46
+ return decorator
47
+
48
+
49
+ def openai_wrapper(
50
+ temperature: int | float = 0,
51
+ model: Literal["gpt-4o", "gpt-4o-mini"] = "gpt-4o",
52
+ is_json: bool = False,
53
+ return_: str = None
54
+ ):
55
+ def decorator(func):
56
+ @wraps(func)
57
+ async def wrapper(*args, **kwargs) -> str:
58
+ messages = await func(*args, **kwargs)
59
+ if not messages:
60
+ return messages
61
+ completion = await settings.OPENAI_CLIENT.chat.completions.create(
62
+ messages=messages,
63
+ temperature=temperature,
64
+ n=1,
65
+ model=model,
66
+ response_format={"type": "json_object"} if is_json else {"type": "text"}
67
+ )
68
+ response = completion.choices[0].message.content
69
+ if is_json:
70
+ response = json.loads(response)
71
+ if return_:
72
+ return pydash.get(response, return_)
73
+ return response
74
+
75
+ return wrapper
76
+
77
+ return decorator
78
+
79
+
80
+ def background_task():
81
+ def decorator(func):
82
+ @wraps(func)
83
+ async def wrapper(*args, **kwargs) -> str:
84
+ try:
85
+ result = await func(*args, **kwargs)
86
+ return result
87
+ except Exception as e:
88
+ pass
89
+
90
+ return wrapper
91
+
92
+ return decorator
main.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from cbh import create_app
2
+
3
+ app = create_app()
requirements.txt ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-types==0.7.0
2
+ anyio==4.8.0
3
+ asn1crypto==1.5.1
4
+ bcrypt==4.2.1
5
+ certifi==2025.1.31
6
+ cffi==1.17.1
7
+ charset-normalizer==3.4.1
8
+ click==8.1.8
9
+ cryptography==44.0.1
10
+ distro==1.9.0
11
+ dnspython==2.7.0
12
+ ecdsa==0.19.0
13
+ email_validator==2.2.0
14
+ fastapi==0.115.8
15
+ filelock==3.17.0
16
+ git-filter-repo==2.47.0
17
+ h11==0.14.0
18
+ httpcore==1.0.7
19
+ httptools==0.6.4
20
+ httpx==0.28.1
21
+ idna==3.10
22
+ jiter==0.8.2
23
+ motor==3.7.0
24
+ numpy==2.2.3
25
+ openai==1.63.0
26
+ packaging==24.2
27
+ pandas==2.2.3
28
+ passlib==1.7.4
29
+ platformdirs==4.3.6
30
+ pyasn1==0.4.8
31
+ pycparser==2.22
32
+ pydantic==2.10.6
33
+ pydantic_core==2.27.2
34
+ pydash==8.0.5
35
+ PyJWT==2.10.1
36
+ pymongo==4.11.1
37
+ pyOpenSSL==24.3.0
38
+ python-dateutil==2.9.0.post0
39
+ python-dotenv==1.0.1
40
+ python-jose==3.4.0
41
+ pytz==2025.1
42
+ PyYAML==6.0.2
43
+ regex==2024.11.6
44
+ requests==2.32.3
45
+ rsa==4.9
46
+ six==1.17.0
47
+ sniffio==1.3.1
48
+ snowflake-connector-python==3.13.2
49
+ sortedcontainers==2.4.0
50
+ starlette==0.45.3
51
+ tiktoken==0.9.0
52
+ tomlkit==0.13.2
53
+ tqdm==4.67.1
54
+ typing_extensions==4.12.2
55
+ tzdata==2025.1
56
+ urllib3==2.3.0
57
+ uvicorn==0.34.0
58
+ uvloop==0.21.0
59
+ watchfiles==1.0.4
60
+ websockets==14.2