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.
@@ -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