constec 0.7.1__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.
Files changed (40) hide show
  1. constec/db/__init__.py +11 -0
  2. constec/db/apps.py +8 -0
  3. constec/db/migrations/0001_initial.py +551 -0
  4. constec/db/migrations/0002_module_level.py +38 -0
  5. constec/db/migrations/0003_remove_module_level.py +15 -0
  6. constec/db/migrations/0004_rename_entities_company_cuit_idx_entities_company_e2c50f_idx_and_more.py +345 -0
  7. constec/db/migrations/0005_event.py +46 -0
  8. constec/db/migrations/0006_automation_trigger_action_executionlog_notificationtemplate.py +275 -0
  9. constec/db/migrations/0007_add_organization_to_automations.py +91 -0
  10. constec/db/migrations/0008_refactor_creator_fields.py +173 -0
  11. constec/db/migrations/0009_rename_user_to_companyuser.py +40 -0
  12. constec/db/migrations/__init__.py +0 -0
  13. constec/db/models/__init__.py +110 -0
  14. constec/db/models/automation.py +488 -0
  15. constec/db/models/base.py +23 -0
  16. constec/db/models/company.py +36 -0
  17. constec/db/models/contact.py +71 -0
  18. constec/db/models/erp.py +101 -0
  19. constec/db/models/erp_entity.py +122 -0
  20. constec/db/models/flow.py +138 -0
  21. constec/db/models/group.py +36 -0
  22. constec/db/models/module.py +67 -0
  23. constec/db/models/organization.py +62 -0
  24. constec/db/models/person.py +28 -0
  25. constec/db/models/session.py +89 -0
  26. constec/db/models/tag.py +70 -0
  27. constec/db/models/user.py +74 -0
  28. constec/py.typed +0 -0
  29. constec/services/__init__.py +14 -0
  30. constec/services/encryption.py +92 -0
  31. constec/shared/__init__.py +20 -0
  32. constec/shared/exceptions.py +48 -0
  33. constec/utils/__init__.py +20 -0
  34. constec/utils/cuit.py +107 -0
  35. constec/utils/password.py +62 -0
  36. constec-0.7.1.dist-info/METADATA +94 -0
  37. constec-0.7.1.dist-info/RECORD +40 -0
  38. constec-0.7.1.dist-info/WHEEL +5 -0
  39. constec-0.7.1.dist-info/licenses/LICENSE +21 -0
  40. constec-0.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,488 @@
1
+ """Automation models for event scheduling and task automation."""
2
+
3
+ import uuid
4
+ from django.db import models
5
+ from django.db.models import Q
6
+ from django.core.exceptions import ValidationError
7
+ from .base import UUIDModel
8
+ from .company import Company
9
+ from .organization import Organization, OrganizationUser
10
+ from .user import CompanyUser
11
+
12
+
13
+ class Automation(UUIDModel):
14
+ """
15
+ Automatización principal basada en el patrón Trigger-Action.
16
+
17
+ Una automatización define un conjunto de acciones que se ejecutan cuando
18
+ se activan uno o más triggers (disparadores).
19
+ """
20
+
21
+ STATUS_CHOICES = [
22
+ ('active', 'Active'),
23
+ ('paused', 'Paused'),
24
+ ('archived', 'Archived'),
25
+ ('draft', 'Draft'),
26
+ ]
27
+
28
+ EXECUTION_MODE_CHOICES = [
29
+ ('sequential', 'Sequential'),
30
+ ('parallel', 'Parallel'), # Futuro
31
+ ]
32
+
33
+ ON_ERROR_CHOICES = [
34
+ ('stop', 'Stop'),
35
+ ('continue', 'Continue'),
36
+ ('retry', 'Retry'),
37
+ ]
38
+
39
+ # FKs - Al menos uno debe estar presente
40
+ company = models.ForeignKey(
41
+ Company,
42
+ on_delete=models.CASCADE,
43
+ null=True,
44
+ blank=True,
45
+ db_index=True,
46
+ help_text="Company específica (opcional si se define organization)"
47
+ )
48
+ organization = models.ForeignKey(
49
+ Organization,
50
+ on_delete=models.CASCADE,
51
+ null=True,
52
+ blank=True,
53
+ db_index=True,
54
+ help_text="Organization para automations compartidas (opcional si se define company)"
55
+ )
56
+
57
+ # Creator - only ONE of these should be filled (XOR)
58
+ created_by_user = models.ForeignKey(
59
+ CompanyUser,
60
+ on_delete=models.CASCADE,
61
+ null=True,
62
+ blank=True,
63
+ related_name='created_automations',
64
+ help_text="User creator (company-level)"
65
+ )
66
+ created_by_org_user = models.ForeignKey(
67
+ OrganizationUser,
68
+ on_delete=models.CASCADE,
69
+ null=True,
70
+ blank=True,
71
+ related_name='created_automations',
72
+ help_text="OrganizationUser creator (org-level)"
73
+ )
74
+
75
+ # Info básica
76
+ name = models.CharField(max_length=255)
77
+ description = models.TextField(blank=True)
78
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
79
+
80
+ # Configuración de ejecución
81
+ execution_mode = models.CharField(
82
+ max_length=20,
83
+ choices=EXECUTION_MODE_CHOICES,
84
+ default='sequential'
85
+ )
86
+ max_executions = models.IntegerField(
87
+ null=True,
88
+ blank=True,
89
+ help_text="Máximo número de ejecuciones (null = ilimitado)"
90
+ )
91
+ execution_count = models.IntegerField(default=0)
92
+
93
+ # Manejo de errores
94
+ on_error = models.CharField(
95
+ max_length=20,
96
+ choices=ON_ERROR_CHOICES,
97
+ default='stop'
98
+ )
99
+ retry_count = models.IntegerField(default=3)
100
+ retry_delay_seconds = models.IntegerField(default=60)
101
+
102
+ # Tracking de ejecución
103
+ last_executed_at = models.DateTimeField(null=True, blank=True)
104
+ next_execution_at = models.DateTimeField(null=True, blank=True, db_index=True)
105
+
106
+ # Metadata
107
+ tags = models.JSONField(default=list, blank=True)
108
+ metadata = models.JSONField(default=dict, blank=True)
109
+
110
+ class Meta:
111
+ db_table = '"automations"."automations"'
112
+ app_label = 'constec_db'
113
+ indexes = [
114
+ models.Index(fields=['company', 'status']),
115
+ models.Index(fields=['organization', 'status']),
116
+ models.Index(fields=['status', 'next_execution_at']),
117
+ ]
118
+ ordering = ['-created_at']
119
+ constraints = [
120
+ models.CheckConstraint(
121
+ check=Q(company__isnull=False) | Q(organization__isnull=False),
122
+ name='automation_requires_company_or_organization'
123
+ ),
124
+ models.CheckConstraint(
125
+ check=Q(created_by_user__isnull=False) | Q(created_by_org_user__isnull=False),
126
+ name='automation_requires_creator'
127
+ ),
128
+ models.CheckConstraint(
129
+ check=Q(created_by_user__isnull=False, created_by_org_user__isnull=True) |
130
+ Q(created_by_user__isnull=True, created_by_org_user__isnull=False),
131
+ name='automation_single_creator'
132
+ ),
133
+ ]
134
+
135
+ def __str__(self):
136
+ return f"{self.name} ({self.status})"
137
+
138
+ @property
139
+ def creator(self):
140
+ """Helper to access creator regardless of type."""
141
+ return self.created_by_user or self.created_by_org_user
142
+
143
+
144
+ class Trigger(UUIDModel):
145
+ """
146
+ Trigger (disparador) que inicia la ejecución de una automatización.
147
+
148
+ Tipos soportados:
149
+ - schedule: Programado por fecha/hora
150
+ - webhook: Disparado por HTTP POST
151
+ - manual: Ejecución manual del usuario
152
+ - event: Disparado por evento del sistema (futuro)
153
+ """
154
+
155
+ TRIGGER_TYPE_CHOICES = [
156
+ ('schedule', 'Schedule'),
157
+ ('webhook', 'Webhook'),
158
+ ('manual', 'Manual'),
159
+ ('event', 'Event'),
160
+ ]
161
+
162
+ # FKs
163
+ automation = models.ForeignKey(
164
+ Automation,
165
+ on_delete=models.CASCADE,
166
+ related_name='triggers'
167
+ )
168
+
169
+ # Info básica
170
+ trigger_type = models.CharField(max_length=20, choices=TRIGGER_TYPE_CHOICES)
171
+ name = models.CharField(max_length=255)
172
+ description = models.TextField(blank=True)
173
+ is_enabled = models.BooleanField(default=True)
174
+
175
+ # Configuración tipo-específica
176
+ config = models.JSONField(
177
+ default=dict,
178
+ help_text="Configuración específica del tipo de trigger (schedule, webhook, etc.)"
179
+ )
180
+
181
+ # Tracking
182
+ last_triggered_at = models.DateTimeField(null=True, blank=True)
183
+ trigger_count = models.IntegerField(default=0)
184
+ priority = models.IntegerField(default=50, help_text="0-100, mayor = más prioridad")
185
+
186
+ class Meta:
187
+ db_table = '"automations"."triggers"'
188
+ app_label = 'constec_db'
189
+ indexes = [
190
+ models.Index(fields=['automation', 'is_enabled']),
191
+ models.Index(fields=['trigger_type', 'is_enabled']),
192
+ ]
193
+ ordering = ['-priority', 'created_at']
194
+
195
+ def __str__(self):
196
+ return f"{self.trigger_type}: {self.name}"
197
+
198
+
199
+ class Action(UUIDModel):
200
+ """
201
+ Acción que se ejecuta como parte de una automatización.
202
+
203
+ Tipos soportados:
204
+ - notification: Enviar notificación (email, SMS, WhatsApp, push)
205
+ - webhook: Ejecutar HTTP request
206
+ - batch_task: Ejecutar tarea batch (futuro)
207
+ - flow_execution: Ejecutar workflow LangGraph (futuro)
208
+ """
209
+
210
+ ACTION_TYPE_CHOICES = [
211
+ ('notification', 'Notification'),
212
+ ('webhook', 'Webhook'),
213
+ ('batch_task', 'Batch Task'),
214
+ ('flow_execution', 'Flow Execution'),
215
+ ]
216
+
217
+ ON_ERROR_CHOICES = [
218
+ ('stop', 'Stop'),
219
+ ('continue', 'Continue'),
220
+ ('retry', 'Retry'),
221
+ ]
222
+
223
+ # FKs
224
+ automation = models.ForeignKey(
225
+ Automation,
226
+ on_delete=models.CASCADE,
227
+ related_name='actions'
228
+ )
229
+
230
+ # Info básica
231
+ action_type = models.CharField(max_length=20, choices=ACTION_TYPE_CHOICES)
232
+ name = models.CharField(max_length=255)
233
+ description = models.TextField(blank=True)
234
+ is_enabled = models.BooleanField(default=True)
235
+ order = models.IntegerField(default=0, help_text="Orden de ejecución")
236
+
237
+ # Configuración tipo-específica
238
+ config = models.JSONField(
239
+ default=dict,
240
+ help_text="Configuración específica del tipo de acción"
241
+ )
242
+
243
+ # Manejo de errores (override de automation)
244
+ on_error = models.CharField(
245
+ max_length=20,
246
+ choices=ON_ERROR_CHOICES,
247
+ null=True,
248
+ blank=True,
249
+ help_text="Override del on_error de la automation"
250
+ )
251
+ retry_count = models.IntegerField(
252
+ null=True,
253
+ blank=True,
254
+ help_text="Override del retry_count de la automation"
255
+ )
256
+
257
+ # Ejecución condicional (futuro)
258
+ condition = models.JSONField(
259
+ null=True,
260
+ blank=True,
261
+ help_text="Condición JSONLogic para ejecutar esta acción"
262
+ )
263
+
264
+ # Tracking
265
+ last_executed_at = models.DateTimeField(null=True, blank=True)
266
+ execution_count = models.IntegerField(default=0)
267
+ success_count = models.IntegerField(default=0)
268
+ failure_count = models.IntegerField(default=0)
269
+
270
+ class Meta:
271
+ db_table = '"automations"."actions"'
272
+ app_label = 'constec_db'
273
+ indexes = [
274
+ models.Index(fields=['automation', 'order']),
275
+ models.Index(fields=['action_type', 'is_enabled']),
276
+ ]
277
+ ordering = ['order', 'created_at']
278
+
279
+ def __str__(self):
280
+ return f"{self.action_type}: {self.name}"
281
+
282
+
283
+ class ExecutionLog(UUIDModel):
284
+ """
285
+ Log de ejecución de una automatización.
286
+
287
+ Registra cada ejecución con su estado, duración, errores y detalles
288
+ de cada acción ejecutada.
289
+ """
290
+
291
+ STATUS_CHOICES = [
292
+ ('pending', 'Pending'),
293
+ ('running', 'Running'),
294
+ ('completed', 'Completed'),
295
+ ('failed', 'Failed'),
296
+ ('partial', 'Partial'), # Algunas acciones fallaron
297
+ ]
298
+
299
+ TRIGGERED_BY_CHOICES = [
300
+ ('schedule', 'Schedule'),
301
+ ('webhook', 'Webhook'),
302
+ ('manual', 'Manual'),
303
+ ('system', 'System'),
304
+ ]
305
+
306
+ # FKs
307
+ automation = models.ForeignKey(
308
+ Automation,
309
+ on_delete=models.CASCADE,
310
+ related_name='execution_logs'
311
+ )
312
+ trigger = models.ForeignKey(
313
+ Trigger,
314
+ on_delete=models.SET_NULL,
315
+ null=True,
316
+ blank=True,
317
+ related_name='execution_logs'
318
+ )
319
+ triggered_by_user = models.ForeignKey(
320
+ CompanyUser,
321
+ on_delete=models.SET_NULL,
322
+ null=True,
323
+ blank=True,
324
+ related_name='triggered_executions',
325
+ help_text="User who triggered execution (company-level)"
326
+ )
327
+ triggered_by_org_user = models.ForeignKey(
328
+ OrganizationUser,
329
+ on_delete=models.SET_NULL,
330
+ null=True,
331
+ blank=True,
332
+ related_name='triggered_executions',
333
+ help_text="OrganizationUser who triggered execution (org-level)"
334
+ )
335
+
336
+ # Estado
337
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
338
+ triggered_by = models.CharField(max_length=20, choices=TRIGGERED_BY_CHOICES)
339
+
340
+ # Timing
341
+ started_at = models.DateTimeField(auto_now_add=True)
342
+ completed_at = models.DateTimeField(null=True, blank=True)
343
+ duration_seconds = models.IntegerField(null=True, blank=True)
344
+
345
+ # Contexto de ejecución
346
+ execution_context = models.JSONField(
347
+ default=dict,
348
+ help_text="Variables y contexto disponible durante la ejecución"
349
+ )
350
+
351
+ # Estadísticas de acciones
352
+ actions_total = models.IntegerField(default=0)
353
+ actions_succeeded = models.IntegerField(default=0)
354
+ actions_failed = models.IntegerField(default=0)
355
+ actions_skipped = models.IntegerField(default=0)
356
+
357
+ # Log detallado de cada acción
358
+ action_logs = models.JSONField(
359
+ default=list,
360
+ help_text="Array de logs detallados de cada acción ejecutada"
361
+ )
362
+
363
+ # Errores
364
+ error_message = models.TextField(blank=True)
365
+ error_traceback = models.TextField(blank=True)
366
+
367
+ class Meta:
368
+ db_table = '"automations"."execution_logs"'
369
+ app_label = 'constec_db'
370
+ indexes = [
371
+ models.Index(fields=['automation', 'started_at']),
372
+ models.Index(fields=['status', 'started_at']),
373
+ models.Index(fields=['triggered_by', 'started_at']),
374
+ ]
375
+ ordering = ['-started_at']
376
+
377
+ def __str__(self):
378
+ return f"{self.automation.name} - {self.status} ({self.started_at})"
379
+
380
+ @property
381
+ def trigger_user(self):
382
+ """Helper to access trigger user regardless of type."""
383
+ return self.triggered_by_user or self.triggered_by_org_user
384
+
385
+
386
+ class NotificationTemplate(UUIDModel):
387
+ """
388
+ Template para notificaciones reutilizables.
389
+
390
+ Soporta variables en formato {{variable_name}} que se reemplazan
391
+ en tiempo de ejecución.
392
+ """
393
+
394
+ CHANNEL_CHOICES = [
395
+ ('email', 'Email'),
396
+ ('whatsapp', 'WhatsApp'),
397
+ ('sms', 'SMS'),
398
+ ('push', 'Push Notification'),
399
+ ]
400
+
401
+ # FKs - Al menos uno debe estar presente
402
+ company = models.ForeignKey(
403
+ Company,
404
+ on_delete=models.CASCADE,
405
+ null=True,
406
+ blank=True,
407
+ db_index=True,
408
+ help_text="Company específica (opcional si se define organization)"
409
+ )
410
+ organization = models.ForeignKey(
411
+ Organization,
412
+ on_delete=models.CASCADE,
413
+ null=True,
414
+ blank=True,
415
+ db_index=True,
416
+ help_text="Organization para templates compartidos (opcional si se define company)"
417
+ )
418
+
419
+ # Creator - only ONE of these should be filled (XOR)
420
+ created_by_user = models.ForeignKey(
421
+ CompanyUser,
422
+ on_delete=models.CASCADE,
423
+ null=True,
424
+ blank=True,
425
+ related_name='created_templates',
426
+ help_text="User creator (company-level)"
427
+ )
428
+ created_by_org_user = models.ForeignKey(
429
+ OrganizationUser,
430
+ on_delete=models.CASCADE,
431
+ null=True,
432
+ blank=True,
433
+ related_name='created_templates',
434
+ help_text="OrganizationUser creator (org-level)"
435
+ )
436
+
437
+ # Info básica
438
+ name = models.CharField(max_length=255)
439
+ description = models.TextField(blank=True)
440
+ channel = models.CharField(max_length=20, choices=CHANNEL_CHOICES)
441
+
442
+ # Contenido
443
+ subject = models.CharField(
444
+ max_length=255,
445
+ blank=True,
446
+ help_text="Asunto (solo para email)"
447
+ )
448
+ body = models.TextField(help_text="Cuerpo del mensaje con variables {{variable}}")
449
+
450
+ # Variables esperadas
451
+ variables = models.JSONField(
452
+ default=list,
453
+ help_text="Array de nombres de variables esperadas ['customer_name', 'amount']"
454
+ )
455
+
456
+ # is_active inherited from UUIDModel
457
+
458
+ class Meta:
459
+ db_table = '"automations"."notification_templates"'
460
+ app_label = 'constec_db'
461
+ indexes = [
462
+ models.Index(fields=['company', 'channel', 'is_active']),
463
+ models.Index(fields=['organization', 'channel', 'is_active']),
464
+ ]
465
+ ordering = ['name']
466
+ constraints = [
467
+ models.CheckConstraint(
468
+ check=Q(company__isnull=False) | Q(organization__isnull=False),
469
+ name='template_requires_company_or_organization'
470
+ ),
471
+ models.CheckConstraint(
472
+ check=Q(created_by_user__isnull=False) | Q(created_by_org_user__isnull=False),
473
+ name='template_requires_creator'
474
+ ),
475
+ models.CheckConstraint(
476
+ check=Q(created_by_user__isnull=False, created_by_org_user__isnull=True) |
477
+ Q(created_by_user__isnull=True, created_by_org_user__isnull=False),
478
+ name='template_single_creator'
479
+ ),
480
+ ]
481
+
482
+ def __str__(self):
483
+ return f"{self.channel}: {self.name}"
484
+
485
+ @property
486
+ def creator(self):
487
+ """Helper to access creator regardless of type."""
488
+ return self.created_by_user or self.created_by_org_user
@@ -0,0 +1,23 @@
1
+ import uuid
2
+ from django.db import models
3
+
4
+
5
+ class UUIDModel(models.Model):
6
+ """Base model with UUID primary key and common audit fields.
7
+
8
+ All Constec models inherit from this to ensure consistent
9
+ UUID-based primary keys and audit fields across the platform.
10
+
11
+ Fields provided:
12
+ - id: UUID primary key
13
+ - is_active: soft delete flag
14
+ - created_at: auto-set on creation
15
+ - updated_at: auto-set on every save
16
+ """
17
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
18
+ is_active = models.BooleanField(default=True)
19
+ created_at = models.DateTimeField(auto_now_add=True)
20
+ updated_at = models.DateTimeField(auto_now=True)
21
+
22
+ class Meta:
23
+ abstract = True
@@ -0,0 +1,36 @@
1
+ from django.db import models
2
+ from django.core.exceptions import ValidationError
3
+ from .base import UUIDModel
4
+ from .organization import Organization
5
+
6
+
7
+ class Company(UUIDModel):
8
+ """Client company, belongs to an Organization."""
9
+ organization = models.ForeignKey(
10
+ Organization,
11
+ on_delete=models.CASCADE,
12
+ related_name="companies",
13
+ )
14
+ name = models.CharField(max_length=255)
15
+ legal_name = models.CharField(max_length=255)
16
+ slug = models.SlugField(max_length=100, unique=True)
17
+ parent_company = models.ForeignKey(
18
+ 'self',
19
+ null=True,
20
+ blank=True,
21
+ on_delete=models.CASCADE,
22
+ related_name='children'
23
+ )
24
+ website = models.URLField(blank=True, null=True)
25
+
26
+ class Meta:
27
+ app_label = 'constec_db'
28
+ db_table = 'core"."companies'
29
+ verbose_name_plural = 'Companies'
30
+
31
+ def __str__(self):
32
+ return self.name
33
+
34
+ def clean(self):
35
+ if self.parent_company == self:
36
+ raise ValidationError('A company cannot be its own parent')
@@ -0,0 +1,71 @@
1
+ from django.db import models
2
+ from .base import UUIDModel
3
+ from .person import Person
4
+
5
+
6
+ class ContactType(UUIDModel):
7
+ """Types of contact (email, phone, etc)."""
8
+ name = models.CharField(max_length=50)
9
+ description = models.TextField(blank=True, null=True)
10
+
11
+ class Meta:
12
+ app_label = 'constec_db'
13
+ db_table = 'core"."contact_types'
14
+
15
+ def __str__(self):
16
+ return f"{self.name}"
17
+
18
+
19
+ class Contact(UUIDModel):
20
+ """Contact value."""
21
+ type = models.ForeignKey(
22
+ ContactType,
23
+ on_delete=models.CASCADE,
24
+ related_name="contacts",
25
+ )
26
+ label = models.CharField(max_length=100, blank=True, help_text="Optional label (e.g. 'Personal', 'Work')")
27
+ value = models.CharField(max_length=255)
28
+ metadata = models.JSONField(default=dict, blank=True)
29
+
30
+ class Meta:
31
+ app_label = 'constec_db'
32
+ db_table = 'core"."contacts'
33
+
34
+ def __str__(self):
35
+ if self.label:
36
+ return f"{self.label}: {self.value} ({self.type.name})"
37
+ return f"{self.value} ({self.type.name})"
38
+
39
+
40
+ class PersonContact(UUIDModel):
41
+ """Person-contact relationship."""
42
+ person = models.ForeignKey(
43
+ Person,
44
+ on_delete=models.CASCADE,
45
+ related_name="person_contacts",
46
+ )
47
+ contact = models.ForeignKey(
48
+ Contact,
49
+ on_delete=models.CASCADE,
50
+ related_name="person_contacts",
51
+ )
52
+ is_primary = models.BooleanField(default=False)
53
+
54
+ class Meta:
55
+ app_label = 'constec_db'
56
+ db_table = 'core"."person_contacts'
57
+ unique_together = [['person', 'contact']]
58
+
59
+ def __str__(self):
60
+ return f"{self.person.full_name} - {self.contact.value} ({self.contact.type.name})"
61
+
62
+ def save(self, *args, **kwargs):
63
+ """Remove is_primary from other contacts of the same type for this person."""
64
+ if self.is_primary:
65
+ PersonContact.objects.filter(
66
+ person=self.person,
67
+ contact__type=self.contact.type,
68
+ is_primary=True
69
+ ).exclude(pk=self.pk).update(is_primary=False)
70
+
71
+ super().save(*args, **kwargs)