fusesell 1.2.4__py3-none-any.whl → 1.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fusesell might be problematic. Click here for more details.

@@ -3,14 +3,14 @@ Event Scheduler - Database-based event scheduling system
3
3
  Creates scheduled events in database for external app to handle
4
4
  """
5
5
 
6
- import logging
7
- from datetime import datetime, timedelta
8
- from typing import Dict, Any, Optional, List
9
- import pytz
10
- import json
11
- import sqlite3
12
- import uuid
13
- from pathlib import Path
6
+ import logging
7
+ from datetime import datetime, timedelta
8
+ from typing import Dict, Any, Optional, List, Union
9
+ import pytz
10
+ import json
11
+ import sqlite3
12
+ import uuid
13
+ from pathlib import Path
14
14
 
15
15
 
16
16
  class EventScheduler:
@@ -73,15 +73,53 @@ class EventScheduler:
73
73
  ON scheduled_events(scheduled_time, status)
74
74
  """)
75
75
 
76
- cursor.execute("""
77
- CREATE INDEX IF NOT EXISTS idx_scheduled_events_org_team
78
- ON scheduled_events(org_id, team_id)
79
- """)
80
-
81
- conn.commit()
82
- conn.close()
83
-
84
- self.logger.info("Scheduled events database initialized")
76
+ cursor.execute("""
77
+ CREATE INDEX IF NOT EXISTS idx_scheduled_events_org_team
78
+ ON scheduled_events(org_id, team_id)
79
+ """)
80
+
81
+ cursor.execute("""
82
+ CREATE TABLE IF NOT EXISTS reminder_task (
83
+ id TEXT PRIMARY KEY,
84
+ status TEXT NOT NULL,
85
+ task TEXT NOT NULL,
86
+ cron TEXT NOT NULL,
87
+ room_id TEXT,
88
+ tags TEXT,
89
+ customextra TEXT,
90
+ org_id TEXT,
91
+ customer_id TEXT,
92
+ task_id TEXT,
93
+ import_uuid TEXT,
94
+ scheduled_time TIMESTAMP,
95
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
96
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
97
+ executed_at TIMESTAMP,
98
+ error_message TEXT
99
+ )
100
+ """)
101
+
102
+ cursor.execute("""
103
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_status
104
+ ON reminder_task(status)
105
+ """)
106
+ cursor.execute("""
107
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_org_id
108
+ ON reminder_task(org_id)
109
+ """)
110
+ cursor.execute("""
111
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_task_id
112
+ ON reminder_task(task_id)
113
+ """)
114
+ cursor.execute("""
115
+ CREATE INDEX IF NOT EXISTS idx_reminder_task_cron
116
+ ON reminder_task(cron)
117
+ """)
118
+
119
+ conn.commit()
120
+ conn.close()
121
+
122
+ self.logger.info("Scheduled events database initialized")
85
123
 
86
124
  except Exception as e:
87
125
  self.logger.error(f"Failed to initialize scheduled events DB: {str(e)}")
@@ -122,15 +160,225 @@ class EventScheduler:
122
160
  conn.commit()
123
161
  conn.close()
124
162
 
125
- self.logger.info("Scheduling rules database initialized")
126
-
127
- except Exception as e:
128
- self.logger.error(f"Failed to initialize scheduling rules DB: {str(e)}")
129
- raise
163
+ self.logger.info("Scheduling rules database initialized")
164
+
165
+ except Exception as e:
166
+ self.logger.error(f"Failed to initialize scheduling rules DB: {str(e)}")
167
+ raise
168
+
169
+ def _format_datetime(self, value: Union[str, datetime, None]) -> str:
170
+ """
171
+ Normalize datetime-like values to ISO 8601 strings.
172
+
173
+ Args:
174
+ value: Datetime, ISO string, or None.
175
+
176
+ Returns:
177
+ ISO 8601 formatted string.
178
+ """
179
+ if isinstance(value, datetime):
180
+ return value.isoformat()
181
+ if value is None:
182
+ return datetime.utcnow().isoformat()
183
+
184
+ value_str = str(value).strip()
185
+ if not value_str:
186
+ return datetime.utcnow().isoformat()
187
+
188
+ try:
189
+ parsed = datetime.fromisoformat(value_str)
190
+ return parsed.isoformat()
191
+ except ValueError:
192
+ return value_str
193
+
194
+ def _build_reminder_payload(
195
+ self,
196
+ base_context: Dict[str, Any],
197
+ *,
198
+ event_id: str,
199
+ send_time: datetime,
200
+ email_type: str,
201
+ org_id: str,
202
+ recipient_address: str,
203
+ recipient_name: str,
204
+ draft_id: str,
205
+ customer_timezone: str,
206
+ follow_up_iteration: Optional[int] = None
207
+ ) -> Optional[Dict[str, Any]]:
208
+ """
209
+ Construct reminder_task payload mirroring server implementation.
210
+
211
+ Args:
212
+ base_context: Context data supplied by caller.
213
+ event_id: Scheduled event identifier.
214
+ send_time: Planned send time (UTC).
215
+ email_type: 'initial' or 'follow_up'.
216
+ org_id: Organization identifier.
217
+ recipient_address: Recipient email.
218
+ recipient_name: Recipient name.
219
+ draft_id: Draft identifier.
220
+ customer_timezone: Customer timezone.
221
+ follow_up_iteration: Optional follow-up iteration counter.
222
+
223
+ Returns:
224
+ Reminder payload dictionary or None if insufficient data.
225
+ """
226
+ if not base_context:
227
+ return None
228
+
229
+ context = dict(base_context)
230
+ customextra_raw = context.pop('customextra', {}) or {}
231
+ if isinstance(customextra_raw, str):
232
+ try:
233
+ customextra = json.loads(customextra_raw)
234
+ except (json.JSONDecodeError, TypeError):
235
+ customextra = {}
236
+ elif isinstance(customextra_raw, dict):
237
+ customextra = dict(customextra_raw)
238
+ else:
239
+ customextra = {}
240
+
241
+ status = context.pop('status', 'published') or 'published'
242
+ cron_value = context.pop('cron', None)
243
+ scheduled_time_value = context.pop('scheduled_time', None)
244
+ room_id = context.pop('room_id', context.pop('room', None))
245
+ tags = context.pop('tags', None)
246
+ task_label = context.pop('task', None)
247
+ org_id_override = context.pop('org_id', None) or org_id
248
+ customer_id = context.pop('customer_id', None) or customextra.get('customer_id')
249
+ task_id = context.pop('task_id', None) or context.pop('execution_id', None) or customextra.get('task_id')
250
+ customer_name = context.pop('customer_name', None)
251
+ language = context.pop('language', None)
252
+ team_id = context.pop('team_id', None)
253
+ team_name = context.pop('team_name', None)
254
+ staff_name = context.pop('staff_name', None)
255
+ import_uuid = context.pop('import_uuid', None) or customextra.get('import_uuid')
256
+
257
+ customextra.setdefault('reminder_content', 'draft_send' if email_type == 'initial' else 'follow_up')
258
+ customextra.setdefault('org_id', org_id_override)
259
+ customextra.setdefault('customer_id', customer_id)
260
+ customextra.setdefault('task_id', task_id)
261
+ customextra.setdefault('event_id', event_id)
262
+ customextra.setdefault('email_type', email_type)
263
+ customextra.setdefault('recipient_address', recipient_address)
264
+ customextra.setdefault('recipient_name', recipient_name)
265
+ customextra.setdefault('draft_id', draft_id)
266
+ customextra.setdefault('customer_timezone', customer_timezone)
267
+ customextra.setdefault('scheduled_time_utc', send_time.isoformat())
268
+
269
+ if team_id and 'team_id' not in customextra:
270
+ customextra['team_id'] = team_id
271
+ if team_name and 'team_name' not in customextra:
272
+ customextra['team_name'] = team_name
273
+ if language and 'language' not in customextra:
274
+ customextra['language'] = language
275
+ if staff_name and 'staff_name' not in customextra:
276
+ customextra['staff_name'] = staff_name
277
+ if customer_name and 'customer_name' not in customextra:
278
+ customextra['customer_name'] = customer_name
279
+
280
+ iteration = follow_up_iteration or context.pop('current_follow_up_time', None)
281
+ if iteration is not None and 'current_follow_up_time' not in customextra:
282
+ customextra['current_follow_up_time'] = iteration
283
+
284
+ if not import_uuid:
285
+ import_uuid = f"{customextra.get('org_id', '')}_{customextra.get('customer_id', '')}_{customextra.get('task_id', '')}_{event_id}"
286
+ customextra.setdefault('import_uuid', import_uuid)
287
+
288
+ if not tags:
289
+ tags = ['fusesell', 'init-outreach' if email_type == 'initial' else 'follow-up']
290
+
291
+ if not task_label:
292
+ readable_type = "Initial Outreach" if email_type == 'initial' else "Follow-up"
293
+ identifier = customextra.get('customer_name') or customextra.get('customer_id') or customer_id or 'customer'
294
+ tracking_id = customextra.get('task_id') or task_id or draft_id
295
+ task_label = f"FuseSell {readable_type} {identifier} - {tracking_id}"
296
+
297
+ cron_value = self._format_datetime(cron_value or send_time)
298
+ scheduled_time_str = self._format_datetime(scheduled_time_value or send_time)
299
+
300
+ return {
301
+ 'status': status,
302
+ 'task': task_label,
303
+ 'cron': cron_value,
304
+ 'room_id': room_id,
305
+ 'tags': tags,
306
+ 'customextra': customextra,
307
+ 'org_id': customextra.get('org_id'),
308
+ 'customer_id': customextra.get('customer_id'),
309
+ 'task_id': customextra.get('task_id'),
310
+ 'import_uuid': customextra.get('import_uuid'),
311
+ 'scheduled_time': scheduled_time_str
312
+ }
313
+
314
+ def _insert_reminder_task(self, payload: Optional[Dict[str, Any]]) -> Optional[str]:
315
+ """
316
+ Insert reminder_task record into local database.
317
+
318
+ Args:
319
+ payload: Reminder payload produced by _build_reminder_payload.
320
+
321
+ Returns:
322
+ Reminder task identifier or None on failure.
323
+ """
324
+ if not payload:
325
+ return None
326
+
327
+ try:
328
+ reminder_id = payload.get('id') or f"uuid:{str(uuid.uuid4())}"
329
+
330
+ tags_value = payload.get('tags')
331
+ if isinstance(tags_value, (list, tuple)):
332
+ tags_str = json.dumps(list(tags_value))
333
+ elif isinstance(tags_value, str):
334
+ tags_str = tags_value
335
+ else:
336
+ tags_str = json.dumps([])
337
+
338
+ customextra_value = payload.get('customextra') or {}
339
+ if isinstance(customextra_value, dict):
340
+ customextra_str = json.dumps(customextra_value)
341
+ elif isinstance(customextra_value, str):
342
+ customextra_str = customextra_value
343
+ else:
344
+ customextra_str = json.dumps({})
345
+
346
+ conn = sqlite3.connect(self.main_db_path)
347
+ cursor = conn.cursor()
348
+
349
+ cursor.execute("""
350
+ INSERT INTO reminder_task
351
+ (id, status, task, cron, room_id, tags, customextra, org_id, customer_id, task_id, import_uuid, scheduled_time)
352
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
353
+ """, (
354
+ reminder_id,
355
+ payload.get('status', 'published'),
356
+ payload.get('task') or 'FuseSell Reminder',
357
+ self._format_datetime(payload.get('cron')),
358
+ payload.get('room_id'),
359
+ tags_str,
360
+ customextra_str,
361
+ payload.get('org_id'),
362
+ payload.get('customer_id'),
363
+ payload.get('task_id'),
364
+ payload.get('import_uuid'),
365
+ self._format_datetime(payload.get('scheduled_time'))
366
+ ))
367
+
368
+ conn.commit()
369
+ conn.close()
370
+
371
+ self.logger.debug(f"Created reminder_task record {reminder_id}")
372
+ return reminder_id
373
+
374
+ except Exception as exc:
375
+ self.logger.error(f"Failed to create reminder_task record: {str(exc)}")
376
+ return None
130
377
 
131
- def schedule_email_event(self, draft_id: str, recipient_address: str, recipient_name: str,
132
- org_id: str, team_id: str = None, customer_timezone: str = None,
133
- email_type: str = 'initial', send_immediately: bool = False) -> Dict[str, Any]:
378
+ def schedule_email_event(self, draft_id: str, recipient_address: str, recipient_name: str,
379
+ org_id: str, team_id: str = None, customer_timezone: str = None,
380
+ email_type: str = 'initial', send_immediately: bool = False,
381
+ reminder_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
134
382
  """
135
383
  Schedule an email event in the database for external app to handle.
136
384
 
@@ -142,7 +390,8 @@ class EventScheduler:
142
390
  team_id: Team ID (optional)
143
391
  customer_timezone: Customer's timezone (optional)
144
392
  email_type: Type of email ('initial' or 'follow_up')
145
- send_immediately: If True, schedule for immediate sending
393
+ send_immediately: If True, schedule for immediate sending
394
+ reminder_context: Optional metadata for reminder_task mirroring server behaviour
146
395
 
147
396
  Returns:
148
397
  Event creation result with event ID and scheduled time
@@ -180,40 +429,82 @@ class EventScheduler:
180
429
  conn = sqlite3.connect(self.main_db_path)
181
430
  cursor = conn.cursor()
182
431
 
183
- cursor.execute("""
184
- INSERT INTO scheduled_events
185
- (id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
186
- recipient_address, recipient_name, customer_timezone, event_data)
187
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
432
+ cursor.execute("""
433
+ INSERT INTO scheduled_events
434
+ (id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
435
+ recipient_address, recipient_name, customer_timezone, event_data)
436
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
188
437
  """, (
189
438
  f"uuid:{str(uuid.uuid4())}", event_id, 'email_send', send_time, org_id, team_id, draft_id,
190
439
  recipient_address, recipient_name, customer_timezone, json.dumps(event_data)
191
440
  ))
192
441
 
193
- conn.commit()
194
- conn.close()
195
-
196
- # Log the scheduling
197
- self.logger.info(f"Scheduled email event {event_id} for {send_time} (draft: {draft_id})")
198
-
199
- # Schedule follow-up if this is an initial email
200
- follow_up_event_id = None
201
- if email_type == 'initial' and not send_immediately:
202
- follow_up_result = self._schedule_follow_up_event(
203
- draft_id, recipient_address, recipient_name, org_id, team_id, customer_timezone
204
- )
205
- follow_up_event_id = follow_up_result.get('event_id')
206
-
207
- return {
208
- 'success': True,
209
- 'event_id': event_id,
210
- 'scheduled_time': send_time.isoformat(),
211
- 'recipient_address': recipient_address,
212
- 'recipient_name': recipient_name,
213
- 'draft_id': draft_id,
214
- 'email_type': email_type,
215
- 'follow_up_event_id': follow_up_event_id
216
- }
442
+ conn.commit()
443
+ conn.close()
444
+
445
+ reminder_task_id = None
446
+ if reminder_context:
447
+ reminder_payload = self._build_reminder_payload(
448
+ dict(reminder_context),
449
+ event_id=event_id,
450
+ send_time=send_time,
451
+ email_type=email_type,
452
+ org_id=org_id,
453
+ recipient_address=recipient_address,
454
+ recipient_name=recipient_name,
455
+ draft_id=draft_id,
456
+ customer_timezone=customer_timezone
457
+ )
458
+ reminder_task_id = self._insert_reminder_task(reminder_payload)
459
+
460
+ # Log the scheduling
461
+ self.logger.info(f"Scheduled email event {event_id} for {send_time} (draft: {draft_id})")
462
+
463
+ # Schedule follow-up if this is an initial email
464
+ follow_up_event_id = None
465
+ follow_up_reminder_id = None
466
+ if email_type == 'initial' and not send_immediately:
467
+ follow_up_context = None
468
+ if reminder_context:
469
+ follow_up_context = dict(reminder_context)
470
+ follow_up_extra = dict(follow_up_context.get('customextra', {}) or {})
471
+ follow_up_extra['reminder_content'] = 'follow_up'
472
+ follow_up_extra.setdefault('current_follow_up_time', 1)
473
+ follow_up_context['customextra'] = follow_up_extra
474
+ follow_up_context['tags'] = follow_up_context.get('tags') or ['fusesell', 'follow-up']
475
+
476
+ follow_up_result = self._schedule_follow_up_event(
477
+ draft_id,
478
+ recipient_address,
479
+ recipient_name,
480
+ org_id,
481
+ team_id,
482
+ customer_timezone,
483
+ reminder_context=follow_up_context
484
+ )
485
+
486
+ if follow_up_result.get('success'):
487
+ follow_up_event_id = follow_up_result.get('event_id')
488
+ follow_up_reminder_id = follow_up_result.get('reminder_task_id')
489
+ else:
490
+ self.logger.warning(
491
+ "Follow-up scheduling failed for event %s: %s",
492
+ event_id,
493
+ follow_up_result.get('error', 'unknown error')
494
+ )
495
+
496
+ return {
497
+ 'success': True,
498
+ 'event_id': event_id,
499
+ 'scheduled_time': send_time.isoformat(),
500
+ 'recipient_address': recipient_address,
501
+ 'recipient_name': recipient_name,
502
+ 'draft_id': draft_id,
503
+ 'email_type': email_type,
504
+ 'reminder_task_id': reminder_task_id,
505
+ 'follow_up_event_id': follow_up_event_id,
506
+ 'follow_up_reminder_task_id': follow_up_reminder_id
507
+ }
217
508
 
218
509
  except Exception as e:
219
510
  self.logger.error(f"Failed to schedule email event: {str(e)}")
@@ -222,9 +513,10 @@ class EventScheduler:
222
513
  'error': str(e)
223
514
  }
224
515
 
225
- def _schedule_follow_up_event(self, original_draft_id: str, recipient_address: str,
226
- recipient_name: str, org_id: str, team_id: str = None,
227
- customer_timezone: str = None) -> Dict[str, Any]:
516
+ def _schedule_follow_up_event(self, original_draft_id: str, recipient_address: str,
517
+ recipient_name: str, org_id: str, team_id: str = None,
518
+ customer_timezone: str = None,
519
+ reminder_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
228
520
  """
229
521
  Schedule follow-up email event after initial email.
230
522
 
@@ -234,7 +526,8 @@ class EventScheduler:
234
526
  recipient_name: Name of recipient
235
527
  org_id: Organization ID
236
528
  team_id: Team ID (optional)
237
- customer_timezone: Customer's timezone (optional)
529
+ customer_timezone: Customer's timezone (optional)
530
+ reminder_context: Optional metadata for reminder_task rows
238
531
 
239
532
  Returns:
240
533
  Follow-up event creation result
@@ -275,24 +568,41 @@ class EventScheduler:
275
568
  original_draft_id, recipient_address, recipient_name,
276
569
  customer_timezone, json.dumps(event_data)
277
570
  ))
278
-
279
- conn.commit()
280
- conn.close()
281
-
282
- self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
283
-
284
- return {
285
- 'success': True,
286
- 'event_id': followup_event_id,
287
- 'scheduled_time': follow_up_time.isoformat()
288
- }
571
+
572
+ conn.commit()
573
+ conn.close()
574
+
575
+ reminder_task_id = None
576
+ if reminder_context:
577
+ reminder_payload = self._build_reminder_payload(
578
+ dict(reminder_context),
579
+ event_id=followup_event_id,
580
+ send_time=follow_up_time,
581
+ email_type='follow_up',
582
+ org_id=org_id,
583
+ recipient_address=recipient_address,
584
+ recipient_name=recipient_name,
585
+ draft_id=original_draft_id,
586
+ customer_timezone=event_data['customer_timezone']
587
+ )
588
+ reminder_task_id = self._insert_reminder_task(reminder_payload)
589
+
590
+ self.logger.info(f"Scheduled follow-up event {followup_event_id} for {follow_up_time}")
591
+
592
+ return {
593
+ 'success': True,
594
+ 'event_id': followup_event_id,
595
+ 'scheduled_time': follow_up_time.isoformat(),
596
+ 'reminder_task_id': reminder_task_id
597
+ }
289
598
 
290
599
  except Exception as e:
291
- self.logger.error(f"Failed to schedule follow-up event: {str(e)}")
292
- return {
293
- 'success': False,
294
- 'error': str(e)
295
- }
600
+ self.logger.error(f"Failed to schedule follow-up event: {str(e)}")
601
+ return {
602
+ 'success': False,
603
+ 'error': str(e),
604
+ 'reminder_task_id': None
605
+ }
296
606
 
297
607
  def _get_scheduling_rule(self, org_id: str, team_id: str = None) -> Dict[str, Any]:
298
608
  """
@@ -615,4 +925,4 @@ class EventScheduler:
615
925
 
616
926
  except Exception as e:
617
927
  self.logger.error(f"Failed to create scheduling rule: {str(e)}")
618
- return False
928
+ return False