amsdal_crm 0.1.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.
- amsdal_crm/__about__.py +1 -0
- amsdal_crm/__init__.py +13 -0
- amsdal_crm/app.py +25 -0
- amsdal_crm/constants.py +13 -0
- amsdal_crm/errors.py +21 -0
- amsdal_crm/fixtures/__init__.py +1 -0
- amsdal_crm/fixtures/permissions.py +29 -0
- amsdal_crm/fixtures/pipelines.py +74 -0
- amsdal_crm/lifecycle/__init__.py +1 -0
- amsdal_crm/lifecycle/consumer.py +44 -0
- amsdal_crm/migrations/0000_initial.py +633 -0
- amsdal_crm/models/__init__.py +46 -0
- amsdal_crm/models/account.py +127 -0
- amsdal_crm/models/activity.py +140 -0
- amsdal_crm/models/attachment.py +43 -0
- amsdal_crm/models/contact.py +132 -0
- amsdal_crm/models/custom_field_definition.py +44 -0
- amsdal_crm/models/deal.py +172 -0
- amsdal_crm/models/pipeline.py +28 -0
- amsdal_crm/models/stage.py +47 -0
- amsdal_crm/models/workflow_rule.py +44 -0
- amsdal_crm/services/__init__.py +15 -0
- amsdal_crm/services/activity_service.py +56 -0
- amsdal_crm/services/custom_field_service.py +143 -0
- amsdal_crm/services/deal_service.py +131 -0
- amsdal_crm/services/email_service.py +118 -0
- amsdal_crm/services/workflow_service.py +177 -0
- amsdal_crm/settings.py +26 -0
- amsdal_crm-0.1.0.dist-info/METADATA +68 -0
- amsdal_crm-0.1.0.dist-info/RECORD +31 -0
- amsdal_crm-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Deal Model."""
|
|
2
|
+
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from amsdal.contrib.auth.models.user import User
|
|
9
|
+
from amsdal.models.mixins import TimestampMixin
|
|
10
|
+
from amsdal_models.classes.data_models.indexes import IndexInfo
|
|
11
|
+
from amsdal_models.classes.model import Model
|
|
12
|
+
from amsdal_utils.models.data_models.reference import Reference
|
|
13
|
+
from amsdal_utils.models.enums import ModuleType
|
|
14
|
+
from pydantic.fields import Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Deal(TimestampMixin, Model):
|
|
18
|
+
"""Deal (Sales Opportunity) model.
|
|
19
|
+
|
|
20
|
+
Represents a sales opportunity linked to an account and contact,
|
|
21
|
+
progressing through pipeline stages.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
|
|
25
|
+
__indexes__: ClassVar[list[IndexInfo]] = [
|
|
26
|
+
IndexInfo(name='idx_deal_owner_email', field='owner_email'),
|
|
27
|
+
IndexInfo(name='idx_deal_close_date', field='expected_close_date'),
|
|
28
|
+
IndexInfo(name='idx_deal_created_at', field='created_at'),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Core fields
|
|
32
|
+
name: str = Field(title='Deal Name')
|
|
33
|
+
amount: float | None = Field(default=None, title='Amount', ge=0)
|
|
34
|
+
currency: str = Field(default='USD', title='Currency')
|
|
35
|
+
|
|
36
|
+
# Relationships
|
|
37
|
+
account: Optional['Account'] = Field(default=None, title='Account')
|
|
38
|
+
contact: Optional['Contact'] = Field(default=None, title='Primary Contact')
|
|
39
|
+
stage: 'Stage' = Field(title='Stage')
|
|
40
|
+
owner_email: str = Field(title='Owner Email')
|
|
41
|
+
|
|
42
|
+
# Dates
|
|
43
|
+
expected_close_date: _dt.datetime | None = Field(default=None, title='Expected Close Date')
|
|
44
|
+
closed_date: _dt.datetime | None = Field(default=None, title='Closed Date')
|
|
45
|
+
|
|
46
|
+
# Status tracking
|
|
47
|
+
is_closed: bool = Field(default=False, title='Is Closed')
|
|
48
|
+
is_won: bool = Field(default=False, title='Is Won')
|
|
49
|
+
|
|
50
|
+
# Custom fields (JSON)
|
|
51
|
+
custom_fields: dict[str, Any] | None = Field(default=None, title='Custom Fields')
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def display_name(self) -> str:
|
|
55
|
+
"""Return display name for the deal."""
|
|
56
|
+
return self.name
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def stage_name(self) -> str:
|
|
60
|
+
"""Returns stage name for display."""
|
|
61
|
+
if hasattr(self.stage, 'name'):
|
|
62
|
+
return self.stage.name
|
|
63
|
+
return str(self.stage)
|
|
64
|
+
|
|
65
|
+
def has_object_permission(self, user: 'User', action: str) -> bool:
|
|
66
|
+
"""Check if user has permission to perform action on this deal.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
user: The user attempting the action
|
|
70
|
+
action: The action being attempted (read, create, update, delete)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if user has permission, False otherwise
|
|
74
|
+
"""
|
|
75
|
+
# Owner has all permissions
|
|
76
|
+
if self.owner_email == user.email:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
# Check admin permissions
|
|
80
|
+
if user.permissions:
|
|
81
|
+
for permission in user.permissions:
|
|
82
|
+
if permission.model == '*' and permission.action in ('*', action):
|
|
83
|
+
return True
|
|
84
|
+
if permission.model == 'Deal' and permission.action in ('*', action):
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def pre_create(self) -> None:
|
|
90
|
+
"""Hook called before creating deal."""
|
|
91
|
+
if self.custom_fields:
|
|
92
|
+
from amsdal_crm.services.custom_field_service import CustomFieldService
|
|
93
|
+
|
|
94
|
+
self.custom_fields = CustomFieldService.validate_custom_fields('Deal', self.custom_fields)
|
|
95
|
+
super().pre_create()
|
|
96
|
+
|
|
97
|
+
async def apre_create(self) -> None:
|
|
98
|
+
"""Async hook called before creating deal."""
|
|
99
|
+
if self.custom_fields:
|
|
100
|
+
from amsdal_crm.services.custom_field_service import CustomFieldService
|
|
101
|
+
|
|
102
|
+
self.custom_fields = CustomFieldService.validate_custom_fields('Deal', self.custom_fields)
|
|
103
|
+
await super().apre_create()
|
|
104
|
+
|
|
105
|
+
def pre_update(self) -> None:
|
|
106
|
+
"""Hook called before updating deal.
|
|
107
|
+
|
|
108
|
+
Automatically syncs is_closed and is_won status with stage,
|
|
109
|
+
and sets closed_date when deal is closed.
|
|
110
|
+
"""
|
|
111
|
+
# Validate custom fields first
|
|
112
|
+
if self.custom_fields:
|
|
113
|
+
from amsdal_crm.services.custom_field_service import CustomFieldService
|
|
114
|
+
|
|
115
|
+
self.custom_fields = CustomFieldService.validate_custom_fields('Deal', self.custom_fields)
|
|
116
|
+
|
|
117
|
+
# Load stage if it's a reference and sync closed status
|
|
118
|
+
from amsdal_models.classes.helpers.reference_loader import ReferenceLoader
|
|
119
|
+
|
|
120
|
+
stage = ReferenceLoader(self.stage).load_reference() if isinstance(self.stage, Reference) else self.stage
|
|
121
|
+
self.is_closed = stage.is_closed_won or stage.is_closed_lost
|
|
122
|
+
self.is_won = stage.is_closed_won
|
|
123
|
+
|
|
124
|
+
if self.is_closed and not self.closed_date:
|
|
125
|
+
self.closed_date = _dt.datetime.now(_dt.UTC)
|
|
126
|
+
|
|
127
|
+
# Call parent to handle timestamps
|
|
128
|
+
super().pre_update()
|
|
129
|
+
|
|
130
|
+
async def apre_update(self) -> None:
|
|
131
|
+
"""Async hook called before updating deal.
|
|
132
|
+
|
|
133
|
+
Automatically syncs is_closed and is_won status with stage,
|
|
134
|
+
and sets closed_date when deal is closed.
|
|
135
|
+
"""
|
|
136
|
+
# Validate custom fields first
|
|
137
|
+
if self.custom_fields:
|
|
138
|
+
from amsdal_crm.services.custom_field_service import CustomFieldService
|
|
139
|
+
|
|
140
|
+
self.custom_fields = CustomFieldService.validate_custom_fields('Deal', self.custom_fields)
|
|
141
|
+
|
|
142
|
+
# Load stage if it's a reference and sync closed status
|
|
143
|
+
from amsdal_models.classes.helpers.reference_loader import ReferenceLoader
|
|
144
|
+
|
|
145
|
+
stage = await ReferenceLoader(self.stage).aload_reference() if isinstance(self.stage, Reference) else self.stage
|
|
146
|
+
self.is_closed = stage.is_closed_won or stage.is_closed_lost
|
|
147
|
+
self.is_won = stage.is_closed_won
|
|
148
|
+
|
|
149
|
+
if self.is_closed and not self.closed_date:
|
|
150
|
+
self.closed_date = _dt.datetime.now(_dt.UTC)
|
|
151
|
+
|
|
152
|
+
# Call parent to handle timestamps
|
|
153
|
+
await super().apre_update()
|
|
154
|
+
|
|
155
|
+
def post_update(self) -> None:
|
|
156
|
+
"""Hook called after updating deal."""
|
|
157
|
+
from amsdal_crm.services.workflow_service import WorkflowService
|
|
158
|
+
|
|
159
|
+
WorkflowService.execute_rules('Deal', 'update', self)
|
|
160
|
+
|
|
161
|
+
async def apost_update(self) -> None:
|
|
162
|
+
"""Async hook called after updating deal."""
|
|
163
|
+
from amsdal_crm.services.workflow_service import WorkflowService
|
|
164
|
+
|
|
165
|
+
WorkflowService.execute_rules('Deal', 'update', self)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
from amsdal_crm.models.account import Account
|
|
169
|
+
from amsdal_crm.models.contact import Contact
|
|
170
|
+
from amsdal_crm.models.stage import Stage
|
|
171
|
+
|
|
172
|
+
Deal.model_rebuild()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Pipeline Model."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from amsdal_models.classes.data_models.constraints import UniqueConstraint
|
|
6
|
+
from amsdal_models.classes.model import Model
|
|
7
|
+
from amsdal_utils.models.enums import ModuleType
|
|
8
|
+
from pydantic.fields import Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Pipeline(Model):
|
|
12
|
+
"""Sales pipeline model.
|
|
13
|
+
|
|
14
|
+
Represents a sales pipeline with multiple stages.
|
|
15
|
+
Pipelines are system-wide and not owned by individual users.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
|
|
19
|
+
__constraints__: ClassVar[list[UniqueConstraint]] = [UniqueConstraint(name='unq_pipeline_name', fields=['name'])]
|
|
20
|
+
|
|
21
|
+
name: str = Field(title='Pipeline Name')
|
|
22
|
+
description: str | None = Field(default=None, title='Description')
|
|
23
|
+
is_active: bool = Field(default=True, title='Is Active')
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def display_name(self) -> str:
|
|
27
|
+
"""Return display name for the pipeline."""
|
|
28
|
+
return self.name
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Stage Model."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from amsdal_models.classes.data_models.constraints import UniqueConstraint
|
|
6
|
+
from amsdal_models.classes.data_models.indexes import IndexInfo
|
|
7
|
+
from amsdal_models.classes.model import Model
|
|
8
|
+
from amsdal_utils.models.enums import ModuleType
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
from pydantic.fields import Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Stage(Model):
|
|
14
|
+
"""Pipeline stage model.
|
|
15
|
+
|
|
16
|
+
Represents a stage within a sales pipeline with win probability
|
|
17
|
+
and closed status indicators.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True)
|
|
21
|
+
|
|
22
|
+
__module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
|
|
23
|
+
__constraints__: ClassVar[list[UniqueConstraint]] = [
|
|
24
|
+
UniqueConstraint(name='unq_stage_pipeline_name', fields=['pipeline', 'name'])
|
|
25
|
+
]
|
|
26
|
+
__indexes__: ClassVar[list[IndexInfo]] = [
|
|
27
|
+
IndexInfo(name='idx_stage_order', field='order'),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
pipeline: 'Pipeline' = Field(title='Pipeline')
|
|
31
|
+
name: str = Field(title='Stage Name')
|
|
32
|
+
order: int = Field(title='Order')
|
|
33
|
+
probability: float = Field(default=0.0, title='Win Probability (%)', ge=0, le=100)
|
|
34
|
+
is_closed_won: bool = Field(default=False, title='Is Closed Won')
|
|
35
|
+
is_closed_lost: bool = Field(default=False, title='Is Closed Lost')
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def display_name(self) -> str:
|
|
39
|
+
"""Return display name for the stage."""
|
|
40
|
+
if isinstance(self.pipeline, str):
|
|
41
|
+
return f'{self.pipeline} - {self.name}'
|
|
42
|
+
return f'{self.pipeline.display_name} - {self.name}'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
from amsdal_crm.models.pipeline import Pipeline
|
|
46
|
+
|
|
47
|
+
Stage.model_rebuild()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""WorkflowRule Model."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from amsdal_models.classes.model import Model
|
|
8
|
+
from amsdal_utils.models.enums import ModuleType
|
|
9
|
+
from pydantic.fields import Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkflowRule(Model):
|
|
13
|
+
"""Configuration for workflow automation rules.
|
|
14
|
+
|
|
15
|
+
Defines rules that trigger actions when certain conditions are met
|
|
16
|
+
on CRM entities (create, update, delete events).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
|
|
20
|
+
|
|
21
|
+
name: str = Field(title='Rule Name')
|
|
22
|
+
entity_type: Literal['Contact', 'Account', 'Deal', 'Activity'] = Field(title='Entity Type')
|
|
23
|
+
|
|
24
|
+
# Trigger
|
|
25
|
+
trigger_event: Literal['create', 'update', 'delete'] = Field(title='Trigger Event')
|
|
26
|
+
|
|
27
|
+
# Condition (simplified - single field condition)
|
|
28
|
+
condition_field: str | None = Field(None, title='Condition Field')
|
|
29
|
+
condition_operator: Literal['equals', 'not_equals', 'contains', 'greater_than', 'less_than'] | None = Field(
|
|
30
|
+
default=None, title='Condition Operator'
|
|
31
|
+
)
|
|
32
|
+
condition_value: Any | None = Field(None, title='Condition Value')
|
|
33
|
+
|
|
34
|
+
# Action
|
|
35
|
+
action_type: Literal['update_field', 'create_activity', 'send_notification'] = Field(title='Action Type')
|
|
36
|
+
action_config: dict[str, Any] = Field(title='Action Configuration')
|
|
37
|
+
|
|
38
|
+
# Status
|
|
39
|
+
is_active: bool = Field(default=True, title='Is Active')
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def display_name(self) -> str:
|
|
43
|
+
"""Return display name for the workflow rule."""
|
|
44
|
+
return f'{self.entity_type}: {self.name}'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""CRM Services."""
|
|
2
|
+
|
|
3
|
+
from amsdal_crm.services.activity_service import ActivityService
|
|
4
|
+
from amsdal_crm.services.custom_field_service import CustomFieldService
|
|
5
|
+
from amsdal_crm.services.deal_service import DealService
|
|
6
|
+
from amsdal_crm.services.email_service import EmailService
|
|
7
|
+
from amsdal_crm.services.workflow_service import WorkflowService
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ActivityService',
|
|
11
|
+
'CustomFieldService',
|
|
12
|
+
'DealService',
|
|
13
|
+
'EmailService',
|
|
14
|
+
'WorkflowService',
|
|
15
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""ActivityService for managing activities and timelines."""
|
|
2
|
+
|
|
3
|
+
from amsdal_utils.models.enums import Versions
|
|
4
|
+
|
|
5
|
+
from amsdal_crm.models.activity import Activity
|
|
6
|
+
from amsdal_crm.models.activity import ActivityRelatedTo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActivityService:
|
|
10
|
+
"""Service for querying and managing activities."""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def get_timeline(cls, related_to_type: ActivityRelatedTo, related_to_id: str, limit: int = 100) -> list[Activity]:
|
|
14
|
+
"""Get chronological activity timeline for a record.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
related_to_type: Type of record (Contact, Account, Deal)
|
|
18
|
+
related_to_id: ID of the record
|
|
19
|
+
limit: Maximum number of activities to return
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of activities sorted by created_at desc (newest first)
|
|
23
|
+
"""
|
|
24
|
+
activities = (
|
|
25
|
+
Activity.objects.filter(
|
|
26
|
+
related_to_type=related_to_type, related_to_id=related_to_id, _address__object_version=Versions.LATEST
|
|
27
|
+
)
|
|
28
|
+
.order_by('-created_at')
|
|
29
|
+
.execute()
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return activities[:limit]
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
async def aget_timeline(
|
|
36
|
+
cls, related_to_type: ActivityRelatedTo, related_to_id: str, limit: int = 100
|
|
37
|
+
) -> list[Activity]:
|
|
38
|
+
"""Async version of get_timeline.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
related_to_type: Type of record (Contact, Account, Deal)
|
|
42
|
+
related_to_id: ID of the record
|
|
43
|
+
limit: Maximum number of activities to return
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of activities sorted by created_at desc (newest first)
|
|
47
|
+
"""
|
|
48
|
+
activities = await (
|
|
49
|
+
Activity.objects.filter(
|
|
50
|
+
related_to_type=related_to_type, related_to_id=related_to_id, _address__object_version=Versions.LATEST
|
|
51
|
+
)
|
|
52
|
+
.order_by('-created_at')
|
|
53
|
+
.aexecute()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return activities[:limit]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""CustomFieldService for validating custom field values."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from decimal import InvalidOperation
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from amsdal_utils.models.enums import Versions
|
|
9
|
+
|
|
10
|
+
from amsdal_crm.errors import CustomFieldValidationError
|
|
11
|
+
from amsdal_crm.models.custom_field_definition import CustomFieldDefinition
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomFieldService:
|
|
15
|
+
"""Service for validating custom field values against their definitions."""
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def validate_custom_fields(cls, entity_type: str, custom_fields: dict[str, Any] | None) -> dict[str, Any]:
|
|
19
|
+
"""Validate custom field values against their definitions.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
entity_type: The entity type (Contact, Account, Deal)
|
|
23
|
+
custom_fields: Dictionary of custom field values
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Validated custom_fields dict
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
CustomFieldValidationError: If validation fails
|
|
30
|
+
"""
|
|
31
|
+
if not custom_fields:
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
# Load field definitions for this entity type
|
|
35
|
+
definitions = CustomFieldDefinition.objects.filter(
|
|
36
|
+
entity_type=entity_type, _address__object_version=Versions.LATEST
|
|
37
|
+
).execute()
|
|
38
|
+
|
|
39
|
+
definitions_by_name = {d.field_name: d for d in definitions}
|
|
40
|
+
validated = {}
|
|
41
|
+
|
|
42
|
+
for field_name, value in custom_fields.items():
|
|
43
|
+
if field_name not in definitions_by_name:
|
|
44
|
+
msg = f'Unknown custom field: {field_name} for {entity_type}'
|
|
45
|
+
raise CustomFieldValidationError(msg)
|
|
46
|
+
|
|
47
|
+
definition = definitions_by_name[field_name]
|
|
48
|
+
|
|
49
|
+
# Required check
|
|
50
|
+
if definition.is_required and value is None:
|
|
51
|
+
msg = f'Required custom field {field_name} is missing'
|
|
52
|
+
raise CustomFieldValidationError(msg)
|
|
53
|
+
|
|
54
|
+
# Type validation
|
|
55
|
+
if value is not None:
|
|
56
|
+
validated[field_name] = cls._validate_field_value(definition, value)
|
|
57
|
+
|
|
58
|
+
return validated
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
async def avalidate_custom_fields(cls, entity_type: str, custom_fields: dict[str, Any] | None) -> dict[str, Any]:
|
|
62
|
+
"""Validate custom field values against their definitions.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
entity_type: The entity type (Contact, Account, Deal)
|
|
66
|
+
custom_fields: Dictionary of custom field values
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Validated custom_fields dict
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
CustomFieldValidationError: If validation fails
|
|
73
|
+
"""
|
|
74
|
+
if not custom_fields:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
# Load field definitions for this entity type
|
|
78
|
+
definitions = await CustomFieldDefinition.objects.filter(
|
|
79
|
+
entity_type=entity_type, _address__object_version=Versions.LATEST
|
|
80
|
+
).aexecute()
|
|
81
|
+
|
|
82
|
+
definitions_by_name = {d.field_name: d for d in definitions}
|
|
83
|
+
validated = {}
|
|
84
|
+
|
|
85
|
+
for field_name, value in custom_fields.items():
|
|
86
|
+
if field_name not in definitions_by_name:
|
|
87
|
+
msg = f'Unknown custom field: {field_name} for {entity_type}'
|
|
88
|
+
raise CustomFieldValidationError(msg)
|
|
89
|
+
|
|
90
|
+
definition = definitions_by_name[field_name]
|
|
91
|
+
|
|
92
|
+
# Required check
|
|
93
|
+
if definition.is_required and value is None:
|
|
94
|
+
msg = f'Required custom field {field_name} is missing'
|
|
95
|
+
raise CustomFieldValidationError(msg)
|
|
96
|
+
|
|
97
|
+
# Type validation
|
|
98
|
+
if value is not None:
|
|
99
|
+
validated[field_name] = cls._validate_field_value(definition, value)
|
|
100
|
+
|
|
101
|
+
return validated
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _validate_field_value(cls, definition: CustomFieldDefinition, value: Any) -> Any:
|
|
105
|
+
"""Validate a single field value against its definition.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
definition: The field definition
|
|
109
|
+
value: The value to validate
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The validated (and potentially converted) value
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
CustomFieldValidationError: If validation fails
|
|
116
|
+
"""
|
|
117
|
+
if definition.field_type == 'text':
|
|
118
|
+
return str(value)
|
|
119
|
+
|
|
120
|
+
elif definition.field_type == 'number':
|
|
121
|
+
try:
|
|
122
|
+
return Decimal(str(value))
|
|
123
|
+
except (InvalidOperation, ValueError) as exc:
|
|
124
|
+
msg = f'Invalid number for field {definition.field_name}: {value}'
|
|
125
|
+
raise CustomFieldValidationError(msg) from exc
|
|
126
|
+
|
|
127
|
+
elif definition.field_type == 'date':
|
|
128
|
+
if isinstance(value, datetime):
|
|
129
|
+
return value
|
|
130
|
+
# Try parsing ISO format
|
|
131
|
+
try:
|
|
132
|
+
return datetime.fromisoformat(value)
|
|
133
|
+
except (ValueError, AttributeError) as exc:
|
|
134
|
+
msg = f'Invalid date for field {definition.field_name}: {value}'
|
|
135
|
+
raise CustomFieldValidationError(msg) from exc
|
|
136
|
+
|
|
137
|
+
elif definition.field_type == 'choice':
|
|
138
|
+
if definition.choices and value not in definition.choices:
|
|
139
|
+
msg = f'Invalid choice for field {definition.field_name}: {value}. Must be one of {definition.choices}'
|
|
140
|
+
raise CustomFieldValidationError(msg)
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
return value
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""DealService for deal management and pipeline operations."""
|
|
2
|
+
|
|
3
|
+
from amsdal_data.transactions.decorators import async_transaction
|
|
4
|
+
from amsdal_data.transactions.decorators import transaction
|
|
5
|
+
from amsdal_utils.lifecycle.producer import LifecycleProducer
|
|
6
|
+
from amsdal_utils.models.enums import Versions
|
|
7
|
+
|
|
8
|
+
from amsdal_crm.constants import CRMLifecycleEvent
|
|
9
|
+
from amsdal_crm.models.activity import ActivityRelatedTo
|
|
10
|
+
from amsdal_crm.models.activity import ActivityType
|
|
11
|
+
from amsdal_crm.models.activity import Note
|
|
12
|
+
from amsdal_crm.models.deal import Deal
|
|
13
|
+
from amsdal_crm.models.stage import Stage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DealService:
|
|
17
|
+
"""Business logic for deal management."""
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
@transaction
|
|
21
|
+
def move_deal_to_stage(cls, deal: Deal, new_stage_id: str, note: str | None, user_email: str) -> Deal:
|
|
22
|
+
"""Move a deal to a new stage with optional note.
|
|
23
|
+
|
|
24
|
+
Creates an activity log entry for stage change and emits lifecycle events.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
deal: The deal to move
|
|
28
|
+
new_stage_id: ID of the new stage
|
|
29
|
+
note: Optional note about the stage change
|
|
30
|
+
user_email: Email of user performing the action
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The updated deal
|
|
34
|
+
"""
|
|
35
|
+
# Load new stage
|
|
36
|
+
new_stage = (
|
|
37
|
+
Stage.objects.filter(_object_id=new_stage_id, _address__object_version=Versions.LATEST).get().execute()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
old_stage_name = deal.stage_name
|
|
41
|
+
|
|
42
|
+
# Update deal stage (lifecycle hook will handle closed status)
|
|
43
|
+
deal.stage = new_stage
|
|
44
|
+
deal.save()
|
|
45
|
+
|
|
46
|
+
# Create activity log
|
|
47
|
+
activity_note = Note(
|
|
48
|
+
activity_type=ActivityType.NOTE,
|
|
49
|
+
subject=f'Deal moved: {old_stage_name} → {new_stage.name}',
|
|
50
|
+
description=note or f'Deal stage changed from {old_stage_name} to {new_stage.name}',
|
|
51
|
+
related_to_type=ActivityRelatedTo.DEAL,
|
|
52
|
+
related_to_id=deal._object_id,
|
|
53
|
+
owner_email=user_email,
|
|
54
|
+
due_date=None,
|
|
55
|
+
completed_at=None,
|
|
56
|
+
is_completed=False,
|
|
57
|
+
)
|
|
58
|
+
activity_note.save(force_insert=True)
|
|
59
|
+
|
|
60
|
+
# Emit lifecycle events
|
|
61
|
+
LifecycleProducer.publish(
|
|
62
|
+
CRMLifecycleEvent.ON_DEAL_STAGE_CHANGE, # type: ignore[arg-type]
|
|
63
|
+
deal=deal,
|
|
64
|
+
old_stage=old_stage_name,
|
|
65
|
+
new_stage=new_stage.name,
|
|
66
|
+
user_email=user_email,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if new_stage.is_closed_won:
|
|
70
|
+
LifecycleProducer.publish(CRMLifecycleEvent.ON_DEAL_WON, deal=deal, user_email=user_email) # type: ignore[arg-type]
|
|
71
|
+
elif new_stage.is_closed_lost:
|
|
72
|
+
LifecycleProducer.publish(CRMLifecycleEvent.ON_DEAL_LOST, deal=deal, user_email=user_email) # type: ignore[arg-type]
|
|
73
|
+
|
|
74
|
+
return deal
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
@async_transaction
|
|
78
|
+
async def amove_deal_to_stage(cls, deal: Deal, new_stage_id: str, note: str | None, user_email: str) -> Deal:
|
|
79
|
+
"""Async version of move_deal_to_stage.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
deal: The deal to move
|
|
83
|
+
new_stage_id: ID of the new stage
|
|
84
|
+
note: Optional note about the stage change
|
|
85
|
+
user_email: Email of user performing the action
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The updated deal
|
|
89
|
+
"""
|
|
90
|
+
# Load new stage
|
|
91
|
+
new_stage = (
|
|
92
|
+
await Stage.objects.filter(_object_id=new_stage_id, _address__object_version=Versions.LATEST)
|
|
93
|
+
.get()
|
|
94
|
+
.aexecute()
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
old_stage_name = deal.stage_name
|
|
98
|
+
|
|
99
|
+
# Update deal stage (lifecycle hook will handle closed status)
|
|
100
|
+
deal.stage = new_stage
|
|
101
|
+
await deal.asave()
|
|
102
|
+
|
|
103
|
+
# Create activity log
|
|
104
|
+
activity_note = Note(
|
|
105
|
+
activity_type=ActivityType.NOTE,
|
|
106
|
+
subject=f'Deal moved: {old_stage_name} → {new_stage.name}',
|
|
107
|
+
description=note or f'Deal stage changed from {old_stage_name} to {new_stage.name}',
|
|
108
|
+
related_to_type=ActivityRelatedTo.DEAL,
|
|
109
|
+
related_to_id=deal._object_id,
|
|
110
|
+
owner_email=user_email,
|
|
111
|
+
due_date=None,
|
|
112
|
+
completed_at=None,
|
|
113
|
+
is_completed=False,
|
|
114
|
+
)
|
|
115
|
+
await activity_note.asave(force_insert=True)
|
|
116
|
+
|
|
117
|
+
# Emit lifecycle events
|
|
118
|
+
await LifecycleProducer.publish_async(
|
|
119
|
+
CRMLifecycleEvent.ON_DEAL_STAGE_CHANGE, # type: ignore[arg-type]
|
|
120
|
+
deal=deal,
|
|
121
|
+
old_stage=old_stage_name,
|
|
122
|
+
new_stage=new_stage.name,
|
|
123
|
+
user_email=user_email,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if new_stage.is_closed_won:
|
|
127
|
+
await LifecycleProducer.publish_async(CRMLifecycleEvent.ON_DEAL_WON, deal=deal, user_email=user_email) # type: ignore[arg-type]
|
|
128
|
+
elif new_stage.is_closed_lost:
|
|
129
|
+
await LifecycleProducer.publish_async(CRMLifecycleEvent.ON_DEAL_LOST, deal=deal, user_email=user_email) # type: ignore[arg-type]
|
|
130
|
+
|
|
131
|
+
return deal
|