MukeshKapoor25 commited on
Commit
6b8c0cb
·
1 Parent(s): b0f4909

Refactor appointment service and model; add error handling for fetching appointments and update cancelation logic

Browse files
app/__init__.py ADDED
File without changes
app/models/appointment.py CHANGED
@@ -71,7 +71,7 @@ class Appointment(BaseModel):
71
  datetime: lambda v: v.strftime('%Y-%m-%d %H:%M:%S'),
72
  date: lambda v: v.strftime('%Y-%m-%d')
73
  }
74
- schema_extra = {
75
  "example": {
76
  "merchant_id": "MERCHANT123",
77
  "merchant_name": "Merchant Name",
@@ -116,13 +116,12 @@ appointment_table = sqlalchemy.Table(
116
  metadata,
117
  sqlalchemy.Column("appointment_id", sqlalchemy.String, primary_key=True),
118
  sqlalchemy.Column("merchant_name", sqlalchemy.String, nullable=False),
119
- sqlalchemy.Column("merchant_address", sqlalchemy.JSON, nullable=False), # JSON array
120
  sqlalchemy.Column("merchant_id", sqlalchemy.String, nullable=False),
121
  sqlalchemy.Column("location_id", sqlalchemy.String, nullable=False),
122
  sqlalchemy.Column("customer_id", sqlalchemy.String, nullable=False),
123
  sqlalchemy.Column("appointment_date", sqlalchemy.Date, nullable=False),
124
  sqlalchemy.Column("appointment_time", sqlalchemy.String(20), nullable=False), # HH:MM format
125
- sqlalchemy.Column("staffs", sqlalchemy.JSON, nullable=True), # JSON array
126
  sqlalchemy.Column("status", sqlalchemy.Enum(AppointmentStatus), nullable=False),
127
  sqlalchemy.Column("services", sqlalchemy.JSON, nullable=False), # JSON array
128
  sqlalchemy.Column("notes", sqlalchemy.Text, nullable=True),
@@ -135,7 +134,7 @@ appointment_table = sqlalchemy.Table(
135
  sqlalchemy.Column("order_id", sqlalchemy.String, nullable=True), # ✅ Added order_id column
136
  sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), default=datetime.utcnow, nullable=False),
137
  sqlalchemy.Column("modified_at", sqlalchemy.DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False),
138
- sqlalchemy.Column("cancel_reason", sqlalchemy.String, nullable=True) # ✅ Added cancel_reason column
139
  )
140
 
141
 
@@ -159,6 +158,7 @@ class AppointmentResponse(BaseModel):
159
  cleared_amount: float
160
  created_at: str
161
  modified_at: str
 
162
 
163
 
164
  # Model for Pagination Metadata
 
71
  datetime: lambda v: v.strftime('%Y-%m-%d %H:%M:%S'),
72
  date: lambda v: v.strftime('%Y-%m-%d')
73
  }
74
+ json_schema_extra = {
75
  "example": {
76
  "merchant_id": "MERCHANT123",
77
  "merchant_name": "Merchant Name",
 
116
  metadata,
117
  sqlalchemy.Column("appointment_id", sqlalchemy.String, primary_key=True),
118
  sqlalchemy.Column("merchant_name", sqlalchemy.String, nullable=False),
 
119
  sqlalchemy.Column("merchant_id", sqlalchemy.String, nullable=False),
120
  sqlalchemy.Column("location_id", sqlalchemy.String, nullable=False),
121
  sqlalchemy.Column("customer_id", sqlalchemy.String, nullable=False),
122
  sqlalchemy.Column("appointment_date", sqlalchemy.Date, nullable=False),
123
  sqlalchemy.Column("appointment_time", sqlalchemy.String(20), nullable=False), # HH:MM format
124
+ sqlalchemy.Column("staffs", sqlalchemy.JSON, nullable=False), # JSON array
125
  sqlalchemy.Column("status", sqlalchemy.Enum(AppointmentStatus), nullable=False),
126
  sqlalchemy.Column("services", sqlalchemy.JSON, nullable=False), # JSON array
127
  sqlalchemy.Column("notes", sqlalchemy.Text, nullable=True),
 
134
  sqlalchemy.Column("order_id", sqlalchemy.String, nullable=True), # ✅ Added order_id column
135
  sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), default=datetime.utcnow, nullable=False),
136
  sqlalchemy.Column("modified_at", sqlalchemy.DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False),
137
+ #sqlalchemy.Column("cancel_reason", sqlalchemy.String, nullable=True) # ✅ Added cancel_reason column
138
  )
139
 
140
 
 
158
  cleared_amount: float
159
  created_at: str
160
  modified_at: str
161
+ cancel_reason: Optional[str] # ✅ Included cancel_reason
162
 
163
 
164
  # Model for Pagination Metadata
app/repositories/appointment.py CHANGED
@@ -11,6 +11,7 @@ from fastapi import HTTPException
11
  from sqlalchemy.sql import select
12
  import logging
13
  from sqlalchemy import func, insert
 
14
 
15
  from app.models.appointment import appointment_table, Appointment
16
 
@@ -280,8 +281,20 @@ async def fetch_appointments_from_db(
280
 
281
  logger.info(f"Fetching appointments from DB: {query}")
282
 
283
- appointments = await database.fetch_all(query)
284
- return [dict(appointment) for appointment in appointments], total_count
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
  except Exception as e:
287
  logger.error(f"Database error in fetch_appointments_from_db: {str(e)}")
 
11
  from sqlalchemy.sql import select
12
  import logging
13
  from sqlalchemy import func, insert
14
+ import json
15
 
16
  from app.models.appointment import appointment_table, Appointment
17
 
 
281
 
282
  logger.info(f"Fetching appointments from DB: {query}")
283
 
284
+ try:
285
+ appointments = await database.fetch_all(query)
286
+ logger.info(f"Appointments fetched: {appointments}")
287
+
288
+ appointments_dicts = [dict(appointment) for appointment in appointments]
289
+
290
+ logger.info(f"Retrieved appointments for customer ID {customer_id}: {appointments_dicts}")
291
+
292
+
293
+ except Exception as e:
294
+ logger.error(f"Database error in fetch_appointments_from_db: {str(e)}")
295
+ raise HTTPException(status_code=500, detail="Failed to fetch appointments from database.")
296
+
297
+ return appointments_dicts, total_count
298
 
299
  except Exception as e:
300
  logger.error(f"Database error in fetch_appointments_from_db: {str(e)}")
app/services/appointment.py CHANGED
@@ -201,7 +201,7 @@ async def cancel_appointment_service(appointment_id: str, cancel_reason: str):
201
 
202
 
203
  # Ensure the appointment is not already canceled
204
- if existing_appointment["status"] == "canceled":
205
  raise HTTPException(status_code=400, detail="Appointment is already canceled")
206
 
207
  # Ensure the appointment is not in the past
@@ -227,8 +227,11 @@ async def cancel_appointment_service(appointment_id: str, cancel_reason: str):
227
  await cancel_appointment(appointment_id, update_data)
228
  logger.info(f"Appointment canceled successfully: {appointment_id}")
229
 
230
- # Post message for wallet refund
231
- # Post message for email notification
 
 
 
232
 
233
  return {"message": "Appointment canceled successfully", "appointment_id": appointment_id}
234
 
@@ -266,12 +269,16 @@ async def get_appointments_by_customer_id(
266
  }
267
  status_list = status_mapping.get(status.lower()) if status else None
268
 
 
269
  appointments, total_count = await fetch_appointments_from_db(
270
  customer_id=customer_id,
271
  limit=limit,
272
  offset=offset,
273
  status=status_list
274
  )
 
 
 
275
 
276
  formatted_appointments = [
277
  AppointmentResponse(
@@ -306,6 +313,7 @@ async def get_appointments_by_customer_id(
306
  appointment["modified_at"].strftime("%Y-%m-%d %H:%M:%S")
307
  if appointment.get("modified_at") else ""
308
  ),
 
309
  )
310
  for appointment in appointments
311
  ]
 
201
 
202
 
203
  # Ensure the appointment is not already canceled
204
+ if existing_appointment["status"] == AppointmentStatus.CANCELED.value:
205
  raise HTTPException(status_code=400, detail="Appointment is already canceled")
206
 
207
  # Ensure the appointment is not in the past
 
227
  await cancel_appointment(appointment_id, update_data)
228
  logger.info(f"Appointment canceled successfully: {appointment_id}")
229
 
230
+ # Implement wallet refund logic here
231
+ #await post_wallet_refund_message(appointment_id)
232
+
233
+ # Implement email notification logic here
234
+ #await send_notification(appointment_id)
235
 
236
  return {"message": "Appointment canceled successfully", "appointment_id": appointment_id}
237
 
 
269
  }
270
  status_list = status_mapping.get(status.lower()) if status else None
271
 
272
+
273
  appointments, total_count = await fetch_appointments_from_db(
274
  customer_id=customer_id,
275
  limit=limit,
276
  offset=offset,
277
  status=status_list
278
  )
279
+
280
+
281
+ logger.info(f"Retrieved {len(appointments)} appointments for customer ID {customer_id}")
282
 
283
  formatted_appointments = [
284
  AppointmentResponse(
 
313
  appointment["modified_at"].strftime("%Y-%m-%d %H:%M:%S")
314
  if appointment.get("modified_at") else ""
315
  ),
316
+ cancel_reason=appointment.get("cancel_reason", ""),
317
  )
318
  for appointment in appointments
319
  ]
tests/__init__.py ADDED
File without changes
tests/unit/__init__.py ADDED
File without changes
tests/unit/test_appointment.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import AsyncMock, patch
3
+ from fastapi import HTTPException
4
+ from datetime import datetime, timezone, date, time
5
+ from uuid import uuid4
6
+ from app.services.appointment import (
7
+ create_new_appointment,
8
+ reschedule_appointment,
9
+ cancel_appointment_service,
10
+ get_appointment_details,
11
+ get_appointments_by_customer_id,
12
+ )
13
+ from app.models.appointment import Appointment, AppointmentStatus, PaymentMode, PaymentStatus
14
+ from app.repositories.appointment import (
15
+ create_appointment,
16
+ update_appointment,
17
+ cancel_appointment,
18
+ get_appointment_by_id,
19
+ get_order_by_id,
20
+ fetch_appointments_from_db,
21
+ )
22
+
23
+ # Mock data
24
+ MOCK_APPOINTMENT_ID = str(uuid4())
25
+ MOCK_CUSTOMER_ID = str(uuid4())
26
+ MOCK_MERCHANT_ID = str(uuid4())
27
+ MOCK_ORDER_ID = "order_123"
28
+
29
+ # Test data
30
+ TEST_APPOINTMENT = Appointment(
31
+ appointment_id=MOCK_APPOINTMENT_ID,
32
+ customer_id=MOCK_CUSTOMER_ID,
33
+ merchant_id=MOCK_MERCHANT_ID,
34
+ appointment_date="2023-12-25",
35
+ appointment_time="10:00",
36
+ payment_mode=PaymentMode.ONLINE,
37
+ order_id=MOCK_ORDER_ID,
38
+ )
39
+
40
+ # Mock functions
41
+ @pytest.fixture
42
+ def mock_create_appointment():
43
+ with patch("app.repositories.appointment.create_appointment", new_callable=AsyncMock) as mock:
44
+ yield mock
45
+
46
+ @pytest.fixture
47
+ def mock_get_order_by_id():
48
+ with patch("app.repositories.appointment.get_order_by_id", new_callable=AsyncMock) as mock:
49
+ mock.return_value = {"status": "pending"}
50
+ yield mock
51
+
52
+ @pytest.fixture
53
+ def mock_get_appointment_by_id():
54
+ with patch("app.repositories.appointment.get_appointment_by_id", new_callable=AsyncMock) as mock:
55
+ yield mock
56
+
57
+ @pytest.fixture
58
+ def mock_update_appointment():
59
+ with patch("app.repositories.appointment.update_appointment", new_callable=AsyncMock) as mock:
60
+ yield mock
61
+
62
+ @pytest.fixture
63
+ def mock_cancel_appointment():
64
+ with patch("app.repositories.appointment.cancel_appointment", new_callable=AsyncMock) as mock:
65
+ yield mock
66
+
67
+ @pytest.fixture
68
+ def mock_fetch_appointments_from_db():
69
+ with patch("app.repositories.appointment.fetch_appointments_from_db", new_callable=AsyncMock) as mock:
70
+ yield mock
71
+
72
+ # Tests
73
+ @pytest.mark.asyncio
74
+ async def test_create_new_appointment_success(mock_create_appointment, mock_get_order_by_id):
75
+ result = await create_new_appointment(TEST_APPOINTMENT)
76
+ assert result["appointment_id"] == MOCK_APPOINTMENT_ID
77
+ mock_create_appointment.assert_called_once()
78
+ mock_get_order_by_id.assert_called_once_with(MOCK_ORDER_ID)
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_create_new_appointment_invalid_order(mock_get_order_by_id):
82
+ mock_get_order_by_id.return_value = None
83
+ with pytest.raises(HTTPException) as exc_info:
84
+ await create_new_appointment(TEST_APPOINTMENT)
85
+ assert exc_info.value.status_code == 400
86
+ assert "Invalid Razorpay Order ID" in str(exc_info.value.detail)
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_reschedule_appointment_success(mock_get_appointment_by_id, mock_update_appointment):
90
+ mock_get_appointment_by_id.return_value = {
91
+ "appointment_id": MOCK_APPOINTMENT_ID,
92
+ "status": AppointmentStatus.CONFIRMED.value,
93
+ "appointment_date": "2023-12-25",
94
+ "appointment_time": "10:00:00",
95
+ }
96
+ result = await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-26", "11:00:00")
97
+ assert result["message"] == "Appointment rescheduled successfully"
98
+ mock_update_appointment.assert_called_once()
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_reschedule_appointment_past_date(mock_get_appointment_by_id):
102
+ mock_get_appointment_by_id.return_value = {
103
+ "appointment_id": MOCK_APPOINTMENT_ID,
104
+ "status": AppointmentStatus.CONFIRMED.value,
105
+ "appointment_date": "2023-12-25",
106
+ "appointment_time": "10:00:00",
107
+ }
108
+ with pytest.raises(HTTPException) as exc_info:
109
+ await reschedule_appointment(MOCK_APPOINTMENT_ID, "2023-12-24", "11:00:00")
110
+ assert exc_info.value.status_code == 400
111
+ assert "Cannot reschedule to a past time" in str(exc_info.value.detail)
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_cancel_appointment_service_success(mock_get_appointment_by_id, mock_cancel_appointment):
115
+ mock_get_appointment_by_id.return_value = {
116
+ "appointment_id": MOCK_APPOINTMENT_ID,
117
+ "status": AppointmentStatus.CONFIRMED.value,
118
+ "appointment_date": "2023-12-25",
119
+ "appointment_time": "10:00:00",
120
+ }
121
+ result = await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans")
122
+ assert result["message"] == "Appointment canceled successfully"
123
+ mock_cancel_appointment.assert_called_once()
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_cancel_appointment_service_already_canceled(mock_get_appointment_by_id):
127
+ mock_get_appointment_by_id.return_value = {
128
+ "appointment_id": MOCK_APPOINTMENT_ID,
129
+ "status": AppointmentStatus.CANCELED.value,
130
+ "appointment_date": "2023-12-25",
131
+ "appointment_time": "10:00:00",
132
+ }
133
+ with pytest.raises(HTTPException) as exc_info:
134
+ await cancel_appointment_service(MOCK_APPOINTMENT_ID, "change_of_plans")
135
+ assert exc_info.value.status_code == 400
136
+ assert "Appointment is already canceled" in str(exc_info.value.detail)
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_get_appointment_details_success(mock_get_appointment_by_id):
140
+ mock_get_appointment_by_id.return_value = {
141
+ "appointment_id": MOCK_APPOINTMENT_ID,
142
+ "status": AppointmentStatus.CONFIRMED.value,
143
+ "appointment_date": "2023-12-25",
144
+ "appointment_time": "10:00:00",
145
+ }
146
+ result = await get_appointment_details(MOCK_APPOINTMENT_ID)
147
+ assert result["appointment_id"] == MOCK_APPOINTMENT_ID
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_get_appointments_by_customer_id_success(mock_fetch_appointments_from_db):
151
+ mock_fetch_appointments_from_db.return_value = (
152
+ [
153
+ {
154
+ "appointment_id": MOCK_APPOINTMENT_ID,
155
+ "merchant_id": MOCK_MERCHANT_ID,
156
+ "merchant_name": "Test Merchant",
157
+ "merchant_address": "123 Test St",
158
+ "location_id": str(uuid4()),
159
+ "appointment_date": date(2023, 12, 25),
160
+ "appointment_time": "10:00:00",
161
+ "status": "confirmed",
162
+ "staffs": [],
163
+ "services": [],
164
+ "notes": "",
165
+ "total_amount": 100.0,
166
+ "discount": 0.0,
167
+ "payment_status": "Paid",
168
+ "payment_id": "pay_123",
169
+ "cleared_amount": 100.0,
170
+ "created_at": datetime(2023, 12, 1, 10, 0, 0),
171
+ "modified_at": datetime(2023, 12, 1, 10, 0, 0),
172
+ }
173
+ ],
174
+ 1,
175
+ )
176
+ result = await get_appointments_by_customer_id(MOCK_CUSTOMER_ID)
177
+ assert result.customer_id == MOCK_CUSTOMER_ID
178
+ assert len(result.appointments) == 1
179
+ assert result.appointments[0].appointment_id == MOCK_APPOINTMENT_ID
tests/unit/test_appointment_service.py DELETED
@@ -1,13 +0,0 @@
1
- import unittest
2
- from unittest.mock import patch
3
- from app.services.appointment import reschedule_appointment
4
-
5
- class TestAppointmentService(unittest.TestCase):
6
- @patch('app.services.appointment.logger')
7
- def test_reschedule_appointment_invalid_date(self, mock_logger):
8
- response = reschedule_appointment('e4fbbb1a-2858-4d2b-b851-f1be10b0a344', '2025-02-30', '14:30:00')
9
- self.assertEqual(response.status_code, 400)
10
- mock_logger.error.assert_called_with('Invalid date or time format for appointment e4fbbb1a-2858-4d2b-b851-f1be10b0a344: day is out of range for month')
11
-
12
- if __name__ == '__main__':
13
- unittest.main()