Coverage for apps/appointments/models.py: 65%

212 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2024-05-23 11:03 -0600

1from datetime import date, datetime, time, timedelta 

2from functools import cached_property 

3 

4import pytz 

5from django.contrib.postgres.fields import ArrayField 

6from django.db import models 

7from django.db.models import F, Sum 

8from django.template.loader import render_to_string 

9from django_fsm import RETURN_VALUE, FSMField, transition 

10from djmoney.models.fields import MoneyField 

11from djmoney.money import Money 

12from simple_history.models import HistoricalRecords 

13 

14from apps.accounting.models import PositiveMovement 

15from apps.payments.models import Payment, PaymentAllocation 

16from base.models import RandomSlugModel, TimeStampedModel 

17 

18 

19class AppointmentType(RandomSlugModel): 

20 """ 

21 Model to represent appointment type 

22 """ 

23 

24 practitioner = models.ForeignKey( 

25 "practitioners.Practitioner", on_delete=models.CASCADE, related_name="appointment_types" 

26 ) 

27 products = models.ManyToManyField("products.Product", through="appointments.AppointmentTypeProduct") 

28 duration = models.DurationField(default=timedelta(hours=1), null=True) 

29 is_active = models.BooleanField(default=True) 

30 

31 name = models.CharField(max_length=512) 

32 

33 class Meta: 

34 ordering = ["name"] 

35 

36 def __str__(self): 

37 return self.name 

38 

39 

40class AppointmentTypeProduct(models.Model): 

41 appointment_type = models.ForeignKey("appointments.AppointmentType", on_delete=models.CASCADE) 

42 product = models.ForeignKey("products.Product", on_delete=models.CASCADE) 

43 is_binding = models.BooleanField(default=True) 

44 

45 

46class Appointment(RandomSlugModel, TimeStampedModel): 

47 """ 

48 Model to represent the appointment of patients 

49 """ 

50 

51 organization = models.ForeignKey( 

52 "organizations.Organization", on_delete=models.PROTECT, related_name="appointments" 

53 ) 

54 patient = models.ForeignKey("patients.Patient", on_delete=models.PROTECT, related_name="appointments") 

55 type_of = models.ForeignKey( 

56 "appointments.AppointmentType", on_delete=models.PROTECT, related_name="appointments", null=True, blank=True 

57 ) 

58 comments = models.TextField(blank=True) 

59 

60 date = models.DateField(default=date.today) 

61 start_time = models.TimeField(null=True, blank=True) 

62 end_time = models.TimeField(null=True, blank=True) 

63 arrival_timestamp = models.DateTimeField(null=True, blank=True) 

64 start_timestamp = models.DateTimeField(null=True, blank=True) 

65 end_timestamp = models.DateTimeField(null=True, blank=True) 

66 departure_timestamp = models.DateTimeField(null=True, blank=True) 

67 is_cancellable = models.BooleanField(default=True) 

68 is_confirmed = models.BooleanField(default=False) 

69 is_past = models.BooleanField(default=False) 

70 

71 class StatusChoices(models.TextChoices): 

72 SCHEDULED = "SCHEDULED", "Agendada" 

73 CONFIRMED = "CONFIRMED", "Confirmada" 

74 WAITING = "WAITING", "Esperando" 

75 ONGOING = "ONGOING", "En Curso" 

76 FINISHED = "FINISHED", "Finalizada" # The doctor ended the appointment 

77 OVERDUE = "OVERDUE", "Con Adeudo" 

78 CLOSED = "CLOSED", "Concluida" 

79 CANCELED = "CANCELED", "Cancelada" 

80 

81 status = FSMField( 

82 max_length=16, 

83 choices=StatusChoices.choices, 

84 default=StatusChoices.SCHEDULED, 

85 ) 

86 

87 class Meta: 

88 verbose_name = "Cita" 

89 verbose_name_plural = "Cita" 

90 ordering = ["date", "start_time"] 

91 

92 def __str__(self, *args, **kwargs): 

93 type_of = getattr(getattr(self, "type_of", None), "name", None) 

94 return f"{type_of or 'Cita'} de {self.patient.full_name} del {self.date}" 

95 

96 def owner_practitioners(self): 

97 from apps.practitioners.models import Practitioner 

98 

99 return Practitioner.objects.filter(appointments__appointment=self) 

100 

101 @transition(field=status, source=StatusChoices.SCHEDULED, target=StatusChoices.CONFIRMED) 

102 def patient_confirmed(self): 

103 pass 

104 

105 @transition(field=status, source=[StatusChoices.SCHEDULED, StatusChoices.CONFIRMED], target=StatusChoices.CANCELED) 

106 def patient_canceled(self): 

107 pass 

108 

109 @transition(field=status, source=[StatusChoices.SCHEDULED, StatusChoices.CONFIRMED], target=StatusChoices.WAITING) 

110 def patient_arrived(self): 

111 from .services import PractitionerCommunication 

112 

113 for practitioner in self.owner_practitioners(): 

114 pc = PractitionerCommunication(practitioner=practitioner).send_patient_arrived(self) 

115 

116 @transition(field=status, source=StatusChoices.WAITING, target=StatusChoices.ONGOING) 

117 def patient_entered(self): 

118 pass 

119 

120 @transition(field=status, source=StatusChoices.ONGOING, target=StatusChoices.FINISHED) 

121 def patient_exited(self): 

122 pass 

123 

124 @transition( 

125 field=status, 

126 source=[StatusChoices.ONGOING, StatusChoices.FINISHED], 

127 target=RETURN_VALUE(StatusChoices.OVERDUE, StatusChoices.CLOSED), 

128 ) 

129 def patient_departed(self): 

130 from .services import PatientCommunication, PractitionerCommunication 

131 

132 for practitioner in self.owner_practitioners(): 

133 pc = PractitionerCommunication(practitioner=practitioner).send_patient_left(self) 

134 """ 

135 PatientCommunication(patient=self.patient).send_payments_email 

136 """ 

137 

138 patcom1 = PatientCommunication(patient=self.patient).send_thankyou_message(self) 

139 patcom2 = PatientCommunication(patient=self.patient).send_payments_summary(self) 

140 

141 return self.StatusChoices.OVERDUE if self.pending_amount.amount > 0 else self.StatusChoices.CLOSED 

142 

143 @transition(field=status, source=StatusChoices.OVERDUE, target=StatusChoices.CLOSED) 

144 def close_appointment(self): 

145 pass 

146 

147 @transition(field=status, source=StatusChoices.CLOSED, target=StatusChoices.OVERDUE) 

148 def overdue_appointment(self): 

149 pass 

150 

151 @cached_property 

152 def total_price(self): 

153 agg = self.charges.aggregate(total_price=Sum(F("price") * F("quantity"))).get("total_price") or 0 

154 return Money(agg or 0, "MXN") 

155 

156 @cached_property 

157 def total_shared_cost(self): 

158 agg = ( 

159 self.charges.aggregate(total_shared_cost=Sum(F("shared_cost") * F("quantity"))).get("total_shared_cost") 

160 or 0 

161 ) 

162 return Money(agg or 0, "MXN") 

163 

164 @cached_property 

165 def total_hard_cost(self): 

166 agg = self.charges.aggregate(total_hard_cost=Sum(F("hard_cost") * F("quantity"))).get("total_hard_cost") or 0 

167 return Money(agg or 0, "MXN") 

168 

169 @cached_property 

170 def paid_amount(self): 

171 agg = ( 

172 PaymentAllocation.objects.filter(appointment_charge__appointment=self) 

173 .aggregate(allocated_amount=Sum("amount")) 

174 .get("allocated_amount") 

175 or 0 

176 ) 

177 return Money(agg or 0, "MXN") 

178 

179 @cached_property 

180 def pending_amount(self): 

181 return self.total_price - self.paid_amount 

182 

183 

184class AppointmentPractitioner(RandomSlugModel, TimeStampedModel): 

185 practitioner = models.ForeignKey( 

186 "practitioners.Practitioner", on_delete=models.CASCADE, related_name="appointments" 

187 ) 

188 appointment = models.ForeignKey("appointments.Appointment", on_delete=models.CASCADE, related_name="practitioners") 

189 

190 is_owner = models.BooleanField(default=False) 

191 is_accepted = models.BooleanField(default=False) 

192 

193 class Meta: 

194 unique_together = ["practitioner", "appointment"] 

195 ordering = ["appointment", "created_at"] 

196 

197 

198class GoogleCalendarEvent(models.Model): 

199 appointment = models.OneToOneField( 

200 "appointments.Appointment", on_delete=models.CASCADE, null=True, blank=True, related_name="googleevent" 

201 ) 

202 calendar = models.ForeignKey( 

203 "practitioners.PractitionerGoogleCalendar", on_delete=models.CASCADE, null=True, blank=True 

204 ) 

205 event_id = models.CharField(max_length=128, blank=True, null=True) 

206 

207 def get_summary(self): 

208 status_mapping = { 

209 Appointment.StatusChoices.SCHEDULED: "⚪️", 

210 Appointment.StatusChoices.CONFIRMED: "🟩", 

211 Appointment.StatusChoices.WAITING: "🟧", 

212 Appointment.StatusChoices.ONGOING: "🟨", 

213 Appointment.StatusChoices.FINISHED: "🟦", 

214 Appointment.StatusChoices.OVERDUE: "🚨", 

215 Appointment.StatusChoices.CLOSED: "✅", 

216 Appointment.StatusChoices.CANCELED: "⛔️", 

217 "default": "", 

218 } 

219 icon = status_mapping.get(self.appointment.status, "default") 

220 type_of = self.appointment.type_of or "" 

221 string = f"{icon} {self.appointment.patient.full_name.upper()} ({type_of})" 

222 return string 

223 

224 def get_description(self): 

225 charges_list = self.appointment.charges.all() 

226 # payments_list = Payment.objects.filter(appointment=self.appointment) 

227 payments_list = appointment.payments.all() 

228 data = { 

229 "charges": charges_list, 

230 "payments": payments_list, 

231 "object": self, 

232 "appointment": self.appointment, 

233 } 

234 # html = render_to_string('appointments/appointment_body.html', data) 

235 html = render_to_string("appointments/appointment_body.txt", data) 

236 

237 return html 

238 

239 def get_start(self): 

240 if self.appointment.start_timestamp: 

241 value = self.appointment.start_timestamp 

242 else: 

243 _date = self.appointment.date 

244 _time = self.appointment.start_time or time(9, 0, 0) 

245 tz = pytz.timezone(self.calendar.timezone) 

246 value = tz.localize(datetime.combine(_date, _time)) 

247 return value 

248 

249 def get_end(self): 

250 if self.appointment.end_timestamp: 

251 value = self.appointment.end_timestamp 

252 elif self.appointment.end_time: 

253 _date = self.appointment.date 

254 _time = self.appointment.end_time 

255 tz = pytz.timezone(self.calendar.timezone) 

256 value = tz.localize(datetime.combine(_date, _time)) 

257 else: 

258 duration = getattr(self.appointment.type_of, "duration", None) 

259 if duration: 

260 value = self.get_start() + duration 

261 else: 

262 value = self.get_start() + timedelta(hours=1) 

263 value = max(self.get_start() + timedelta(minutes=10), value) 

264 return value 

265 

266 def get_attendees(self): 

267 """ 

268 ej. [ {'email': algo@gmial.com}, 

269 {'email': algo@gmial.com}, 

270 {'email': algo@gmial.com}, 

271 {'email': algo@gmial.com}, 

272 {'email': algo@gmial.com}, ] 

273 """ 

274 email_list = [] 

275 qs = ( 

276 self.appointment.practitioners.all() 

277 .exclude(practitioner=self.calendar.practitioner) 

278 .values_list("practitioner__user__email", flat=True) 

279 ) 

280 email_list = [{"email": item} for item in qs] 

281 return email_list 

282 

283 def __str__(self): 

284 return super().__str__() 

285 

286 def get_event_dict(self): 

287 data_dict = { 

288 "summary": self.get_summary(), 

289 # 'location': '800 Howard St., San Francisco, CA 94103', 

290 "description": self.get_description(), 

291 "start": { 

292 "dateTime": self.get_start().isoformat(), 

293 "timeZone": self.calendar.timezone, 

294 }, 

295 "end": { 

296 "dateTime": self.get_end().isoformat(), 

297 "timeZone": self.calendar.timezone, 

298 }, 

299 } 

300 if self.get_attendees(): 

301 data_dict["attendees"] = self.get_attendees() 

302 

303 print("data dict", data_dict) 

304 return data_dict 

305 

306 

307class AppointmentCharge(PositiveMovement): 

308 """ 

309 Model to represent the appointment of patients 

310 """ 

311 

312 appointment = models.ForeignKey("appointments.Appointment", on_delete=models.CASCADE, related_name="charges") 

313 product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="charges") 

314 created_by = models.ForeignKey("users.User", on_delete=models.PROTECT, null=True) 

315 

316 hard_cost = MoneyField(max_digits=14, decimal_places=2, default_currency="MXN", default=0) 

317 shared_cost = MoneyField(max_digits=14, decimal_places=2, default_currency="MXN", default=0) 

318 price = MoneyField(max_digits=14, decimal_places=2, default_currency="MXN", default=0) 

319 quantity = models.DecimalField(decimal_places=2, max_digits=9, default=1) 

320 

321 @cached_property 

322 def paid_amount(self): 

323 agg = self.payments.aggregate(allocated_amount=models.Sum("amount")).get("allocated_amount") or 0 

324 return Money(agg or 0, "MXN") 

325 

326 @cached_property 

327 def total_price(self): 

328 return self.price * self.quantity 

329 

330 @cached_property 

331 def total_shared_cost(self): 

332 return self.shared_cost * self.quantity 

333 

334 @cached_property 

335 def total_hard_cost(self): 

336 return self.hard_cost * self.quantity 

337 

338 @cached_property 

339 def pending_amount(self): 

340 return self.total_price - self.paid_amount 

341 

342 class Meta: 

343 ordering = ["created_at"] 

344 verbose_name = "Cargo de Cita" 

345 verbose_name_plural = "Cargos de Citas" 

346 

347 def __str__(self): 

348 return f"Cargo {self.product} en {self.appointment}" 

349 

350 

351class AppointmentExport(RandomSlugModel, TimeStampedModel): 

352 """ 

353 Model for Appointment export "Reporte de Ventas" 

354 """ 

355 

356 organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE) 

357 practitioner = models.ForeignKey("practitioners.Practitioner", on_delete=models.CASCADE, blank=True, null=True) 

358 

359 from_date = models.DateField() 

360 to_date = models.DateField() 

361 export_file = models.FileField(upload_to="appointment/exports/", blank=True, null=True) 

362 

363 class Meta: 

364 ordering = ["organization", "practitioner", "-created_at"] 

365 verbose_name = "Reporte de Ingresos" 

366 verbose_name_plural = "Reportes de Ingresos" 

367 

368 

369class AppointmentChargeExport(RandomSlugModel, TimeStampedModel): 

370 """ 

371 Model for Appointment Charge export "Reporte de Adeudos" 

372 """ 

373 

374 organization = models.ForeignKey("organizations.Organization", on_delete=models.CASCADE) 

375 practitioner = models.ForeignKey("practitioners.Practitioner", on_delete=models.CASCADE, blank=True, null=True) 

376 

377 # from_date = models.DateField() 

378 # to_date = models.DateField() 

379 export_file = models.FileField(upload_to="appointmentcharge/exports/", blank=True, null=True) 

380 

381 class Meta: 

382 ordering = ["organization", "practitioner", "-created_at"] 

383 verbose_name = "Reporte de Adeudos" 

384 verbose_name_plural = "Reportes de Adeudos"