fusesell 1.2.0__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.

@@ -0,0 +1,618 @@
1
+ """
2
+ Event Scheduler - Database-based event scheduling system
3
+ Creates scheduled events in database for external app to handle
4
+ """
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
14
+
15
+
16
+ class EventScheduler:
17
+ """
18
+ Database-based event scheduling system.
19
+ Creates scheduled events that external apps can process.
20
+ """
21
+
22
+ def __init__(self, data_dir: str = "./fusesell_data"):
23
+ """
24
+ Initialize the event scheduler.
25
+
26
+ Args:
27
+ data_dir: Directory for data storage
28
+ """
29
+ self.data_dir = Path(data_dir)
30
+ self.data_dir.mkdir(exist_ok=True)
31
+
32
+ self.logger = logging.getLogger(__name__)
33
+
34
+ # Database path
35
+ self.main_db_path = self.data_dir / "fusesell.db"
36
+
37
+ # Initialize scheduled events database
38
+ self._initialize_scheduled_events_db()
39
+
40
+ # Initialize scheduling rules database
41
+ self._initialize_scheduling_rules_db()
42
+
43
+ def _initialize_scheduled_events_db(self):
44
+ """Initialize database table for scheduled events."""
45
+ try:
46
+ conn = sqlite3.connect(self.main_db_path)
47
+ cursor = conn.cursor()
48
+
49
+ cursor.execute("""
50
+ CREATE TABLE IF NOT EXISTS scheduled_events (
51
+ id TEXT PRIMARY KEY,
52
+ event_id TEXT UNIQUE NOT NULL,
53
+ event_type TEXT NOT NULL,
54
+ scheduled_time TIMESTAMP NOT NULL,
55
+ status TEXT DEFAULT 'pending',
56
+ org_id TEXT NOT NULL,
57
+ team_id TEXT,
58
+ draft_id TEXT,
59
+ recipient_address TEXT NOT NULL,
60
+ recipient_name TEXT,
61
+ customer_timezone TEXT,
62
+ event_data TEXT,
63
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
64
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
+ executed_at TIMESTAMP,
66
+ error_message TEXT
67
+ )
68
+ """)
69
+
70
+ # Create index for efficient querying
71
+ cursor.execute("""
72
+ CREATE INDEX IF NOT EXISTS idx_scheduled_events_time_status
73
+ ON scheduled_events(scheduled_time, status)
74
+ """)
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")
85
+
86
+ except Exception as e:
87
+ self.logger.error(f"Failed to initialize scheduled events DB: {str(e)}")
88
+ raise
89
+
90
+ def _initialize_scheduling_rules_db(self):
91
+ """Initialize database table for scheduling rules."""
92
+ try:
93
+ conn = sqlite3.connect(self.main_db_path)
94
+ cursor = conn.cursor()
95
+
96
+ cursor.execute("""
97
+ CREATE TABLE IF NOT EXISTS scheduling_rules (
98
+ id TEXT PRIMARY KEY,
99
+ org_id TEXT NOT NULL,
100
+ team_id TEXT,
101
+ rule_name TEXT NOT NULL,
102
+ is_active BOOLEAN DEFAULT 1,
103
+ business_hours_start TEXT DEFAULT '08:00',
104
+ business_hours_end TEXT DEFAULT '20:00',
105
+ default_delay_hours INTEGER DEFAULT 2,
106
+ timezone TEXT DEFAULT 'Asia/Bangkok',
107
+ follow_up_delay_hours INTEGER DEFAULT 120,
108
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
109
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
110
+ UNIQUE(org_id, team_id, rule_name)
111
+ )
112
+ """)
113
+
114
+ # Create default rule if none exists
115
+ cursor.execute("""
116
+ INSERT OR IGNORE INTO scheduling_rules
117
+ (id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
118
+ default_delay_hours, timezone, follow_up_delay_hours)
119
+ VALUES (?, 'default', 'default', 'default_rule', '08:00', '20:00', 2, 'Asia/Bangkok', 120)
120
+ """, (f"uuid:{str(uuid.uuid4())}",))
121
+
122
+ conn.commit()
123
+ conn.close()
124
+
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
130
+
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]:
134
+ """
135
+ Schedule an email event in the database for external app to handle.
136
+
137
+ Args:
138
+ draft_id: ID of the email draft to send
139
+ recipient_address: Email address of recipient
140
+ recipient_name: Name of recipient
141
+ org_id: Organization ID
142
+ team_id: Team ID (optional)
143
+ customer_timezone: Customer's timezone (optional)
144
+ email_type: Type of email ('initial' or 'follow_up')
145
+ send_immediately: If True, schedule for immediate sending
146
+
147
+ Returns:
148
+ Event creation result with event ID and scheduled time
149
+ """
150
+ try:
151
+ # Get scheduling rule for the team
152
+ rule = self._get_scheduling_rule(org_id, team_id)
153
+
154
+ # Determine customer timezone
155
+ if not customer_timezone:
156
+ customer_timezone = rule.get('timezone', 'Asia/Bangkok')
157
+
158
+ # Calculate optimal send time
159
+ if send_immediately:
160
+ send_time = datetime.utcnow()
161
+ else:
162
+ send_time = self._calculate_send_time(rule, customer_timezone)
163
+
164
+ # Create event ID
165
+ event_id = f"uuid:{str(uuid.uuid4())}"
166
+ event_type = "email"
167
+ related_draft_id = draft_id
168
+
169
+ # Prepare event data
170
+ event_data = {
171
+ 'draft_id': draft_id,
172
+ 'email_type': email_type,
173
+ 'org_id': org_id,
174
+ 'team_id': team_id,
175
+ 'customer_timezone': customer_timezone,
176
+ 'send_immediately': send_immediately
177
+ }
178
+
179
+ # Insert scheduled event into database
180
+ conn = sqlite3.connect(self.main_db_path)
181
+ cursor = conn.cursor()
182
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
188
+ """, (
189
+ f"uuid:{str(uuid.uuid4())}", event_id, 'email_send', send_time, org_id, team_id, draft_id,
190
+ recipient_address, recipient_name, customer_timezone, json.dumps(event_data)
191
+ ))
192
+
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
+ }
217
+
218
+ except Exception as e:
219
+ self.logger.error(f"Failed to schedule email event: {str(e)}")
220
+ return {
221
+ 'success': False,
222
+ 'error': str(e)
223
+ }
224
+
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]:
228
+ """
229
+ Schedule follow-up email event after initial email.
230
+
231
+ Args:
232
+ original_draft_id: ID of the original draft
233
+ recipient_address: Email address of recipient
234
+ recipient_name: Name of recipient
235
+ org_id: Organization ID
236
+ team_id: Team ID (optional)
237
+ customer_timezone: Customer's timezone (optional)
238
+
239
+ Returns:
240
+ Follow-up event creation result
241
+ """
242
+ try:
243
+ # Get scheduling rule
244
+ rule = self._get_scheduling_rule(org_id, team_id)
245
+
246
+ # Calculate follow-up time (default: 5 days after initial send)
247
+ follow_up_delay = rule.get('follow_up_delay_hours', 120) # 120 hours = 5 days
248
+ follow_up_time = datetime.utcnow() + timedelta(hours=follow_up_delay)
249
+
250
+ # Create follow-up event ID
251
+ followup_event_id = f"uuid:{str(uuid.uuid4())}"
252
+ followup_event_type = "followup"
253
+ related_original_draft_id = original_draft_id
254
+
255
+ # Prepare event data
256
+ event_data = {
257
+ 'original_draft_id': original_draft_id,
258
+ 'email_type': 'follow_up',
259
+ 'org_id': org_id,
260
+ 'team_id': team_id,
261
+ 'customer_timezone': customer_timezone or rule.get('timezone', 'Asia/Bangkok')
262
+ }
263
+
264
+ # Insert follow-up event into database
265
+ conn = sqlite3.connect(self.main_db_path)
266
+ cursor = conn.cursor()
267
+
268
+ cursor.execute("""
269
+ INSERT INTO scheduled_events
270
+ (id, event_id, event_type, scheduled_time, org_id, team_id, draft_id,
271
+ recipient_address, recipient_name, customer_timezone, event_data)
272
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
273
+ """, (
274
+ f"uuid:{str(uuid.uuid4())}", followup_event_id, 'email_follow_up', follow_up_time, org_id, team_id,
275
+ original_draft_id, recipient_address, recipient_name,
276
+ customer_timezone, json.dumps(event_data)
277
+ ))
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
+ }
289
+
290
+ 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
+ }
296
+
297
+ def _get_scheduling_rule(self, org_id: str, team_id: str = None) -> Dict[str, Any]:
298
+ """
299
+ Get scheduling rule for organization/team.
300
+
301
+ Args:
302
+ org_id: Organization ID
303
+ team_id: Team ID (optional)
304
+
305
+ Returns:
306
+ Scheduling rule dictionary
307
+ """
308
+ try:
309
+ conn = sqlite3.connect(self.main_db_path)
310
+ cursor = conn.cursor()
311
+
312
+ # Try to get team-specific settings from team_settings table first
313
+ if team_id:
314
+ cursor.execute("""
315
+ SELECT gs_team_schedule_time
316
+ FROM team_settings
317
+ WHERE team_id = ?
318
+ """, (team_id,))
319
+
320
+ row = cursor.fetchone()
321
+ if row and row[0]:
322
+ try:
323
+ schedule_settings = json.loads(row[0])
324
+ if schedule_settings:
325
+ self.logger.debug(f"Using team settings for scheduling: {team_id}")
326
+ conn.close()
327
+ # Convert team settings to scheduling rule format
328
+ return {
329
+ 'business_hours_start': schedule_settings.get('business_hours_start', '08:00'),
330
+ 'business_hours_end': schedule_settings.get('business_hours_end', '20:00'),
331
+ 'default_delay_hours': schedule_settings.get('default_delay_hours', 2),
332
+ 'timezone': schedule_settings.get('timezone', 'Asia/Bangkok'),
333
+ 'follow_up_delay_hours': schedule_settings.get('follow_up_delay_hours', 120),
334
+ 'avoid_weekends': schedule_settings.get('avoid_weekends', True)
335
+ }
336
+ except (json.JSONDecodeError, TypeError) as e:
337
+ self.logger.warning(f"Failed to parse team schedule settings: {e}")
338
+
339
+ # Fall back to scheduling_rules table for team-specific rule
340
+ cursor.execute("""
341
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
342
+ timezone, follow_up_delay_hours
343
+ FROM scheduling_rules
344
+ WHERE org_id = ? AND team_id = ? AND is_active = 1
345
+ ORDER BY updated_at DESC LIMIT 1
346
+ """, (org_id, team_id))
347
+
348
+ row = cursor.fetchone()
349
+ if row:
350
+ conn.close()
351
+ return {
352
+ 'business_hours_start': row[0],
353
+ 'business_hours_end': row[1],
354
+ 'default_delay_hours': row[2],
355
+ 'timezone': row[3],
356
+ 'follow_up_delay_hours': row[4]
357
+ }
358
+
359
+ # Fall back to org-specific rule
360
+ cursor.execute("""
361
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
362
+ timezone, follow_up_delay_hours
363
+ FROM scheduling_rules
364
+ WHERE org_id = ? AND is_active = 1
365
+ ORDER BY updated_at DESC LIMIT 1
366
+ """, (org_id,))
367
+
368
+ row = cursor.fetchone()
369
+ if row:
370
+ conn.close()
371
+ return {
372
+ 'business_hours_start': row[0],
373
+ 'business_hours_end': row[1],
374
+ 'default_delay_hours': row[2],
375
+ 'timezone': row[3],
376
+ 'follow_up_delay_hours': row[4]
377
+ }
378
+
379
+ # Fall back to default rule
380
+ cursor.execute("""
381
+ SELECT business_hours_start, business_hours_end, default_delay_hours,
382
+ timezone, follow_up_delay_hours
383
+ FROM scheduling_rules
384
+ WHERE org_id = 'default' AND is_active = 1
385
+ LIMIT 1
386
+ """)
387
+
388
+ row = cursor.fetchone()
389
+ conn.close()
390
+
391
+ if row:
392
+ return {
393
+ 'business_hours_start': row[0],
394
+ 'business_hours_end': row[1],
395
+ 'default_delay_hours': row[2],
396
+ 'timezone': row[3],
397
+ 'follow_up_delay_hours': row[4]
398
+ }
399
+
400
+ # Ultimate fallback
401
+ return {
402
+ 'business_hours_start': '08:00',
403
+ 'business_hours_end': '20:00',
404
+ 'default_delay_hours': 2,
405
+ 'timezone': 'Asia/Bangkok',
406
+ 'follow_up_delay_hours': 120
407
+ }
408
+
409
+ except Exception as e:
410
+ self.logger.error(f"Failed to get scheduling rule: {str(e)}")
411
+ # Return default rule
412
+ return {
413
+ 'business_hours_start': '08:00',
414
+ 'business_hours_end': '20:00',
415
+ 'default_delay_hours': 2,
416
+ 'timezone': 'Asia/Bangkok',
417
+ 'follow_up_delay_hours': 120
418
+ }
419
+
420
+ def _calculate_send_time(self, rule: Dict[str, Any], customer_timezone: str) -> datetime:
421
+ """
422
+ Calculate optimal send time based on scheduling rule and customer timezone.
423
+
424
+ Args:
425
+ rule: Scheduling rule dictionary
426
+ customer_timezone: Customer's timezone
427
+
428
+ Returns:
429
+ Optimal send time in UTC
430
+ """
431
+ try:
432
+ # Validate timezone
433
+ try:
434
+ customer_tz = pytz.timezone(customer_timezone)
435
+ except pytz.exceptions.UnknownTimeZoneError:
436
+ self.logger.warning(f"Unknown timezone '{customer_timezone}', using default")
437
+ customer_tz = pytz.timezone(rule.get('timezone', 'Asia/Bangkok'))
438
+ customer_timezone = rule.get('timezone', 'Asia/Bangkok')
439
+
440
+ # Get current time in customer timezone
441
+ now_customer = datetime.now(customer_tz)
442
+
443
+ # Parse business hours
444
+ start_hour, start_minute = map(int, rule['business_hours_start'].split(':'))
445
+ end_hour, end_minute = map(int, rule['business_hours_end'].split(':'))
446
+
447
+ # Calculate proposed send time (now + delay)
448
+ delay_hours = rule['default_delay_hours']
449
+ proposed_time = now_customer + timedelta(hours=delay_hours)
450
+
451
+ # Check if proposed time is within business hours
452
+ business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
453
+ business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
454
+
455
+ # Skip weekends (Saturday=5, Sunday=6)
456
+ while proposed_time.weekday() >= 5:
457
+ proposed_time += timedelta(days=1)
458
+ business_start = proposed_time.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
459
+ business_end = proposed_time.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
460
+
461
+ if business_start <= proposed_time <= business_end:
462
+ # Within business hours, use proposed time
463
+ send_time_customer = proposed_time
464
+ else:
465
+ # Outside business hours, schedule for next business day at start time
466
+ if proposed_time < business_start:
467
+ # Too early, schedule for today's business start
468
+ send_time_customer = business_start
469
+ else:
470
+ # Too late, schedule for tomorrow's business start
471
+ next_day = proposed_time + timedelta(days=1)
472
+ # Skip weekends
473
+ while next_day.weekday() >= 5:
474
+ next_day += timedelta(days=1)
475
+ send_time_customer = next_day.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
476
+
477
+ # Convert to UTC for storage
478
+ send_time_utc = send_time_customer.astimezone(pytz.UTC)
479
+
480
+ self.logger.info(f"Calculated send time: {send_time_customer} ({customer_timezone}) -> {send_time_utc} (UTC)")
481
+
482
+ return send_time_utc.replace(tzinfo=None) # Store as naive datetime in UTC
483
+
484
+ except Exception as e:
485
+ self.logger.error(f"Failed to calculate send time: {str(e)}")
486
+ # Fallback: 2 hours from now
487
+ return datetime.utcnow() + timedelta(hours=2)
488
+
489
+ def get_scheduled_events(self, org_id: str = None, status: str = None) -> List[Dict[str, Any]]:
490
+ """
491
+ Get list of scheduled events.
492
+
493
+ Args:
494
+ org_id: Filter by organization ID (optional)
495
+ status: Filter by status (optional)
496
+
497
+ Returns:
498
+ List of scheduled events
499
+ """
500
+ try:
501
+ conn = sqlite3.connect(self.main_db_path)
502
+ cursor = conn.cursor()
503
+
504
+ query = "SELECT * FROM scheduled_events WHERE 1=1"
505
+ params = []
506
+
507
+ if org_id:
508
+ query += " AND org_id = ?"
509
+ params.append(org_id)
510
+
511
+ if status:
512
+ query += " AND status = ?"
513
+ params.append(status)
514
+
515
+ query += " ORDER BY scheduled_time ASC"
516
+
517
+ cursor.execute(query, params)
518
+ rows = cursor.fetchall()
519
+
520
+ # Get column names
521
+ columns = [description[0] for description in cursor.description]
522
+
523
+ conn.close()
524
+
525
+ # Convert to list of dictionaries
526
+ events = []
527
+ for row in rows:
528
+ event = dict(zip(columns, row))
529
+ # Parse event_data JSON
530
+ if event['event_data']:
531
+ try:
532
+ event['event_data'] = json.loads(event['event_data'])
533
+ except json.JSONDecodeError:
534
+ pass
535
+ events.append(event)
536
+
537
+ return events
538
+
539
+ except Exception as e:
540
+ self.logger.error(f"Failed to get scheduled events: {str(e)}")
541
+ return []
542
+
543
+ def cancel_scheduled_event(self, event_id: str) -> bool:
544
+ """
545
+ Cancel a scheduled event by marking it as cancelled.
546
+
547
+ Args:
548
+ event_id: ID of the event to cancel
549
+
550
+ Returns:
551
+ True if cancelled successfully
552
+ """
553
+ try:
554
+ conn = sqlite3.connect(self.main_db_path)
555
+ cursor = conn.cursor()
556
+
557
+ cursor.execute("""
558
+ UPDATE scheduled_events
559
+ SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
560
+ WHERE event_id = ?
561
+ """, (event_id,))
562
+
563
+ conn.commit()
564
+ rows_affected = cursor.rowcount
565
+ conn.close()
566
+
567
+ if rows_affected > 0:
568
+ self.logger.info(f"Cancelled scheduled event: {event_id}")
569
+ return True
570
+ else:
571
+ self.logger.warning(f"Event not found for cancellation: {event_id}")
572
+ return False
573
+
574
+ except Exception as e:
575
+ self.logger.error(f"Failed to cancel event {event_id}: {str(e)}")
576
+ return False
577
+
578
+ def create_scheduling_rule(self, org_id: str, team_id: str = None, rule_name: str = 'default',
579
+ business_hours_start: str = '08:00', business_hours_end: str = '20:00',
580
+ default_delay_hours: int = 2, timezone: str = 'Asia/Bangkok',
581
+ follow_up_delay_hours: int = 120) -> bool:
582
+ """
583
+ Create or update a scheduling rule.
584
+
585
+ Args:
586
+ org_id: Organization ID
587
+ team_id: Team ID (optional)
588
+ rule_name: Name of the rule
589
+ business_hours_start: Business hours start time (HH:MM)
590
+ business_hours_end: Business hours end time (HH:MM)
591
+ default_delay_hours: Default delay in hours
592
+ timezone: Timezone for the rule
593
+ follow_up_delay_hours: Follow-up delay in hours
594
+
595
+ Returns:
596
+ True if created/updated successfully
597
+ """
598
+ try:
599
+ conn = sqlite3.connect(self.main_db_path)
600
+ cursor = conn.cursor()
601
+
602
+ cursor.execute("""
603
+ INSERT OR REPLACE INTO scheduling_rules
604
+ (id, org_id, team_id, rule_name, business_hours_start, business_hours_end,
605
+ default_delay_hours, timezone, follow_up_delay_hours, updated_at)
606
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
607
+ """, (f"uuid:{str(uuid.uuid4())}", org_id, team_id, rule_name, business_hours_start, business_hours_end,
608
+ default_delay_hours, timezone, follow_up_delay_hours))
609
+
610
+ conn.commit()
611
+ conn.close()
612
+
613
+ self.logger.info(f"Created/updated scheduling rule for {org_id}/{team_id}")
614
+ return True
615
+
616
+ except Exception as e:
617
+ self.logger.error(f"Failed to create scheduling rule: {str(e)}")
618
+ return False