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.
- constec/db/__init__.py +11 -0
- constec/db/apps.py +8 -0
- constec/db/migrations/0001_initial.py +551 -0
- constec/db/migrations/0002_module_level.py +38 -0
- constec/db/migrations/0003_remove_module_level.py +15 -0
- constec/db/migrations/0004_rename_entities_company_cuit_idx_entities_company_e2c50f_idx_and_more.py +345 -0
- constec/db/migrations/0005_event.py +46 -0
- constec/db/migrations/0006_automation_trigger_action_executionlog_notificationtemplate.py +275 -0
- constec/db/migrations/0007_add_organization_to_automations.py +91 -0
- constec/db/migrations/0008_refactor_creator_fields.py +173 -0
- constec/db/migrations/0009_rename_user_to_companyuser.py +40 -0
- constec/db/migrations/__init__.py +0 -0
- constec/db/models/__init__.py +110 -0
- constec/db/models/automation.py +488 -0
- constec/db/models/base.py +23 -0
- constec/db/models/company.py +36 -0
- constec/db/models/contact.py +71 -0
- constec/db/models/erp.py +101 -0
- constec/db/models/erp_entity.py +122 -0
- constec/db/models/flow.py +138 -0
- constec/db/models/group.py +36 -0
- constec/db/models/module.py +67 -0
- constec/db/models/organization.py +62 -0
- constec/db/models/person.py +28 -0
- constec/db/models/session.py +89 -0
- constec/db/models/tag.py +70 -0
- constec/db/models/user.py +74 -0
- constec/py.typed +0 -0
- constec/services/__init__.py +14 -0
- constec/services/encryption.py +92 -0
- constec/shared/__init__.py +20 -0
- constec/shared/exceptions.py +48 -0
- constec/utils/__init__.py +20 -0
- constec/utils/cuit.py +107 -0
- constec/utils/password.py +62 -0
- constec-0.7.1.dist-info/METADATA +94 -0
- constec-0.7.1.dist-info/RECORD +40 -0
- constec-0.7.1.dist-info/WHEEL +5 -0
- constec-0.7.1.dist-info/licenses/LICENSE +21 -0
- 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)
|