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,46 @@
1
+ """CRM Models."""
2
+
3
+ # Import File first for Attachment's forward reference
4
+ from amsdal.models.core.file import File # noqa: F401
5
+
6
+ from amsdal_crm.models.account import Account
7
+ from amsdal_crm.models.activity import Activity
8
+ from amsdal_crm.models.activity import ActivityRelatedTo
9
+ from amsdal_crm.models.activity import ActivityType
10
+ from amsdal_crm.models.activity import Call
11
+ from amsdal_crm.models.activity import EmailActivity
12
+ from amsdal_crm.models.activity import Event
13
+ from amsdal_crm.models.activity import Note
14
+ from amsdal_crm.models.activity import Task
15
+ from amsdal_crm.models.attachment import Attachment
16
+ from amsdal_crm.models.contact import Contact
17
+ from amsdal_crm.models.custom_field_definition import CustomFieldDefinition
18
+ from amsdal_crm.models.deal import Deal
19
+ from amsdal_crm.models.pipeline import Pipeline
20
+ from amsdal_crm.models.stage import Stage
21
+ from amsdal_crm.models.workflow_rule import WorkflowRule
22
+
23
+ __all__ = [
24
+ 'Account',
25
+ 'Activity',
26
+ 'ActivityRelatedTo',
27
+ 'ActivityType',
28
+ 'Attachment',
29
+ 'Call',
30
+ 'Contact',
31
+ 'CustomFieldDefinition',
32
+ 'Deal',
33
+ 'EmailActivity',
34
+ 'Event',
35
+ 'Note',
36
+ 'Pipeline',
37
+ 'Stage',
38
+ 'Task',
39
+ 'WorkflowRule',
40
+ ]
41
+
42
+ # Rebuild models to resolve forward references
43
+ Contact.model_rebuild()
44
+ Deal.model_rebuild()
45
+ Stage.model_rebuild()
46
+ Attachment.model_rebuild()
@@ -0,0 +1,127 @@
1
+ """Account Model."""
2
+
3
+ from typing import Any
4
+ from typing import ClassVar
5
+
6
+ from amsdal.contrib.auth.models.user import User
7
+ from amsdal.models.mixins import TimestampMixin
8
+ from amsdal_models.classes.data_models.constraints import UniqueConstraint
9
+ from amsdal_models.classes.data_models.indexes import IndexInfo
10
+ from amsdal_models.classes.model import Model
11
+ from amsdal_utils.models.enums import ModuleType
12
+ from pydantic.fields import Field
13
+
14
+
15
+ class Account(TimestampMixin, Model):
16
+ """Account (Company/Organization) model.
17
+
18
+ Represents a company or organization in the CRM system.
19
+ Owned by individual users with permission controls.
20
+ """
21
+
22
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
23
+ __constraints__: ClassVar[list[UniqueConstraint]] = [
24
+ UniqueConstraint(name='unq_account_name_owner', fields=['name', 'owner_email'])
25
+ ]
26
+ __indexes__: ClassVar[list[IndexInfo]] = [
27
+ IndexInfo(name='idx_account_owner_email', field='owner_email'),
28
+ IndexInfo(name='idx_account_created_at', field='created_at'),
29
+ ]
30
+
31
+ # Core fields
32
+ name: str = Field(title='Account Name')
33
+ website: str | None = Field(default=None, title='Website')
34
+ phone: str | None = Field(default=None, title='Phone')
35
+ industry: str | None = Field(default=None, title='Industry')
36
+
37
+ # Address fields
38
+ billing_street: str | None = Field(default=None, title='Billing Street')
39
+ billing_city: str | None = Field(default=None, title='Billing City')
40
+ billing_state: str | None = Field(default=None, title='Billing State')
41
+ billing_postal_code: str | None = Field(default=None, title='Billing Postal Code')
42
+ billing_country: str | None = Field(default=None, title='Billing Country')
43
+
44
+ # Ownership
45
+ owner_email: str = Field(title='Owner Email')
46
+
47
+ # Custom fields (JSON)
48
+ custom_fields: dict[str, Any] | None = Field(default=None, title='Custom Fields')
49
+
50
+ @property
51
+ def display_name(self) -> str:
52
+ """Return display name for the account."""
53
+ return self.name
54
+
55
+ def has_object_permission(self, user: 'User', action: str) -> bool:
56
+ """Check if user has permission to perform action on this account.
57
+
58
+ Args:
59
+ user: The user attempting the action
60
+ action: The action being attempted (read, create, update, delete)
61
+
62
+ Returns:
63
+ True if user has permission, False otherwise
64
+ """
65
+ # Owner has all permissions
66
+ if self.owner_email == user.email:
67
+ return True
68
+
69
+ # Check admin permissions
70
+ if user.permissions:
71
+ for permission in user.permissions:
72
+ if permission.model == '*' and permission.action in ('*', action):
73
+ return True
74
+ if permission.model == 'Account' and permission.action in ('*', action):
75
+ return True
76
+
77
+ return False
78
+
79
+ def pre_create(self) -> None:
80
+ """Hook called before creating account."""
81
+ if self.custom_fields:
82
+ from amsdal_crm.services.custom_field_service import CustomFieldService
83
+
84
+ self.custom_fields = CustomFieldService.validate_custom_fields('Account', self.custom_fields)
85
+ super().pre_create()
86
+
87
+ async def apre_create(self) -> None:
88
+ """Async hook called before creating account."""
89
+ if self.custom_fields:
90
+ from amsdal_crm.services.custom_field_service import CustomFieldService
91
+
92
+ self.custom_fields = CustomFieldService.validate_custom_fields('Account', self.custom_fields)
93
+ await super().apre_create()
94
+
95
+ def pre_update(self) -> None:
96
+ """Hook called before updating account."""
97
+ # Validate custom fields first
98
+ if self.custom_fields:
99
+ from amsdal_crm.services.custom_field_service import CustomFieldService
100
+
101
+ self.custom_fields = CustomFieldService.validate_custom_fields('Account', self.custom_fields)
102
+
103
+ # Call parent to handle timestamps
104
+ super().pre_update()
105
+
106
+ async def apre_update(self) -> None:
107
+ """Async hook called before updating account."""
108
+ # Validate custom fields first
109
+ if self.custom_fields:
110
+ from amsdal_crm.services.custom_field_service import CustomFieldService
111
+
112
+ self.custom_fields = CustomFieldService.validate_custom_fields('Account', self.custom_fields)
113
+
114
+ # Call parent to handle timestamps
115
+ await super().apre_update()
116
+
117
+ def post_update(self) -> None:
118
+ """Hook called after updating account."""
119
+ from amsdal_crm.services.workflow_service import WorkflowService
120
+
121
+ WorkflowService.execute_rules('Account', 'update', self)
122
+
123
+ async def apost_update(self) -> None:
124
+ """Async hook called after updating account."""
125
+ from amsdal_crm.services.workflow_service import WorkflowService
126
+
127
+ WorkflowService.execute_rules('Account', 'update', self)
@@ -0,0 +1,140 @@
1
+ """Activity Models."""
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import ClassVar
6
+ from typing import Literal
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.enums import ModuleType
13
+ from pydantic.fields import Field
14
+
15
+
16
+ class ActivityType(str, Enum):
17
+ """Activity type enumeration."""
18
+
19
+ TASK = 'task'
20
+ EVENT = 'event'
21
+ EMAIL = 'email'
22
+ NOTE = 'note'
23
+ CALL = 'call'
24
+
25
+
26
+ class ActivityRelatedTo(str, Enum):
27
+ """What type of record this activity is related to."""
28
+
29
+ CONTACT = 'Contact'
30
+ ACCOUNT = 'Account'
31
+ DEAL = 'Deal'
32
+
33
+
34
+ class Activity(TimestampMixin, Model):
35
+ """Base activity model with polymorphic related_to field.
36
+
37
+ Activities can be linked to Contacts, Accounts, or Deals using
38
+ a generic foreign key pattern (related_to_type + related_to_id).
39
+ """
40
+
41
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
42
+ __indexes__: ClassVar[list[IndexInfo]] = [
43
+ IndexInfo(name='idx_activity_related_to', field='related_to_id'),
44
+ IndexInfo(name='idx_activity_owner', field='owner_email'),
45
+ IndexInfo(name='idx_activity_created_at', field='created_at'),
46
+ IndexInfo(name='idx_activity_due_date', field='due_date'),
47
+ ]
48
+
49
+ # Discriminator
50
+ activity_type: ActivityType = Field(title='Activity Type')
51
+
52
+ # Core fields
53
+ subject: str = Field(title='Subject')
54
+ description: str | None = Field(default=None, title='Description')
55
+
56
+ # Polymorphic relationship (generic FK pattern)
57
+ related_to_type: ActivityRelatedTo = Field(title='Related To Type')
58
+ related_to_id: str = Field(title='Related To ID')
59
+
60
+ # Owner
61
+ owner_email: str = Field(title='Owner Email')
62
+
63
+ # Timing
64
+ due_date: datetime | None = Field(default=None, title='Due Date')
65
+ completed_at: datetime | None = Field(default=None, title='Completed At')
66
+
67
+ # Status
68
+ is_completed: bool = Field(default=False, title='Is Completed')
69
+
70
+ @property
71
+ def display_name(self) -> str:
72
+ """Return display name for the activity."""
73
+ return f'{self.activity_type.value}: {self.subject}'
74
+
75
+ def has_object_permission(self, user: 'User', action: str) -> bool:
76
+ """Check if user has permission to perform action on this activity.
77
+
78
+ Args:
79
+ user: The user attempting the action
80
+ action: The action being attempted (read, create, update, delete)
81
+
82
+ Returns:
83
+ True if user has permission, False otherwise
84
+ """
85
+ # Owner has all permissions
86
+ if self.owner_email == user.email:
87
+ return True
88
+
89
+ # Check admin permissions
90
+ if user.permissions:
91
+ for permission in user.permissions:
92
+ if permission.model == '*' and permission.action in ('*', action):
93
+ return True
94
+ if permission.model == 'Activity' and permission.action in ('*', action):
95
+ return True
96
+
97
+ return False
98
+
99
+
100
+ class Task(Activity):
101
+ """Task activity with priority and status."""
102
+
103
+ activity_type: Literal[ActivityType.TASK] = Field(ActivityType.TASK, title='Activity Type')
104
+ priority: Literal['low', 'medium', 'high'] = Field('medium', title='Priority')
105
+ status: Literal['not_started', 'in_progress', 'waiting', 'completed'] = Field('not_started', title='Status')
106
+
107
+
108
+ class Event(Activity):
109
+ """Event/meeting activity with start/end times."""
110
+
111
+ activity_type: Literal[ActivityType.EVENT] = Field(ActivityType.EVENT, title='Activity Type')
112
+ start_time: datetime = Field(title='Start Time')
113
+ end_time: datetime = Field(title='End Time')
114
+ location: str | None = Field(None, title='Location')
115
+
116
+
117
+ class EmailActivity(Activity):
118
+ """Email activity with sender/recipients."""
119
+
120
+ activity_type: Literal[ActivityType.EMAIL] = Field(ActivityType.EMAIL, title='Activity Type')
121
+ from_address: str = Field(title='From Address')
122
+ to_addresses: list[str] = Field(title='To Addresses')
123
+ cc_addresses: list[str] | None = Field(None, title='CC Addresses')
124
+ body: str = Field(title='Email Body')
125
+ is_outbound: bool = Field(True, title='Is Outbound')
126
+
127
+
128
+ class Note(Activity):
129
+ """Simple note activity."""
130
+
131
+ activity_type: Literal[ActivityType.NOTE] = Field(ActivityType.NOTE, title='Activity Type')
132
+
133
+
134
+ class Call(Activity):
135
+ """Phone call activity."""
136
+
137
+ activity_type: Literal[ActivityType.CALL] = Field(ActivityType.CALL, title='Activity Type')
138
+ phone_number: str = Field(title='Phone Number')
139
+ duration_seconds: int | None = Field(None, title='Duration (seconds)')
140
+ call_outcome: str | None = Field(None, title='Call Outcome')
@@ -0,0 +1,43 @@
1
+ """Attachment Model."""
2
+
3
+ import datetime as _dt
4
+ from typing import ClassVar
5
+ from typing import Literal
6
+
7
+ from amsdal.models.core.file import File
8
+ from amsdal_models.classes.data_models.indexes import IndexInfo
9
+ from amsdal_models.classes.model import Model
10
+ from amsdal_utils.models.enums import ModuleType
11
+ from pydantic.fields import Field
12
+
13
+
14
+ class Attachment(Model):
15
+ """Explicit attachment model for tracking file relationships.
16
+
17
+ Uses polymorphic relationship to link files to Contacts, Accounts,
18
+ Deals, or Activities.
19
+ """
20
+
21
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
22
+ __indexes__: ClassVar[list[IndexInfo]] = [
23
+ IndexInfo(name='idx_attachment_related_to', field='related_to_id'),
24
+ IndexInfo(name='idx_attachment_uploaded_at', field='uploaded_at'),
25
+ ]
26
+
27
+ file: File = Field(title='File')
28
+
29
+ # Polymorphic relationship
30
+ related_to_type: Literal['Contact', 'Account', 'Deal', 'Activity'] = Field(title='Related To Type')
31
+ related_to_id: str = Field(title='Related To ID')
32
+
33
+ # Metadata
34
+ uploaded_by: str = Field(title='Uploaded By (User Email)')
35
+ uploaded_at: _dt.datetime = Field(default_factory=lambda: _dt.datetime.now(_dt.UTC), title='Uploaded At')
36
+ description: str | None = Field(default=None, title='Description')
37
+
38
+ @property
39
+ def display_name(self) -> str:
40
+ """Return display name for the attachment."""
41
+ if hasattr(self.file, 'filename'):
42
+ return self.file.filename
43
+ return f'Attachment {self._object_id}'
@@ -0,0 +1,132 @@
1
+ """Contact Model."""
2
+
3
+ from typing import Any
4
+ from typing import ClassVar
5
+ from typing import Optional
6
+
7
+ from amsdal.contrib.auth.models.user import User
8
+ from amsdal.models.mixins import TimestampMixin
9
+ from amsdal_models.classes.data_models.constraints import UniqueConstraint
10
+ from amsdal_models.classes.data_models.indexes import IndexInfo
11
+ from amsdal_models.classes.model import Model
12
+ from amsdal_utils.models.enums import ModuleType
13
+ from pydantic.fields import Field
14
+
15
+
16
+ class Contact(TimestampMixin, Model):
17
+ """Contact (Person) model.
18
+
19
+ Represents a person in the CRM system, optionally linked to an Account.
20
+ Owned by individual users with permission controls.
21
+ """
22
+
23
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
24
+ __constraints__: ClassVar[list[UniqueConstraint]] = [UniqueConstraint(name='unq_contact_email', fields=['email'])]
25
+ __indexes__: ClassVar[list[IndexInfo]] = [
26
+ IndexInfo(name='idx_contact_owner_email', field='owner_email'),
27
+ IndexInfo(name='idx_contact_created_at', field='created_at'),
28
+ ]
29
+
30
+ # Core fields
31
+ first_name: str = Field(title='First Name')
32
+ last_name: str = Field(title='Last Name')
33
+ email: str = Field(title='Email')
34
+ phone: str | None = Field(default=None, title='Phone Number')
35
+ mobile: str | None = Field(default=None, title='Mobile Number')
36
+ title: str | None = Field(default=None, title='Job Title')
37
+
38
+ # Relationships
39
+ account: Optional['Account'] = Field(default=None, title='Account')
40
+ owner_email: str = Field(title='Owner Email')
41
+
42
+ # Custom fields (JSON)
43
+ custom_fields: dict[str, Any] | None = Field(default=None, title='Custom Fields')
44
+
45
+ @property
46
+ def display_name(self) -> str:
47
+ """Return display name for the contact."""
48
+ return f'{self.first_name} {self.last_name}'
49
+
50
+ @property
51
+ def full_name(self) -> str:
52
+ """Return full name of the contact."""
53
+ return f'{self.first_name} {self.last_name}'
54
+
55
+ def has_object_permission(self, user: 'User', action: str) -> bool:
56
+ """Check if user has permission to perform action on this contact.
57
+
58
+ Args:
59
+ user: The user attempting the action
60
+ action: The action being attempted (read, create, update, delete)
61
+
62
+ Returns:
63
+ True if user has permission, False otherwise
64
+ """
65
+ # Owner has all permissions
66
+ if self.owner_email == user.email:
67
+ return True
68
+
69
+ # Check admin permissions
70
+ if user.permissions:
71
+ for permission in user.permissions:
72
+ if permission.model == '*' and permission.action in ('*', action):
73
+ return True
74
+ if permission.model == 'Contact' and permission.action in ('*', action):
75
+ return True
76
+
77
+ return False
78
+
79
+ def pre_create(self) -> None:
80
+ """Hook called before creating contact."""
81
+ if self.custom_fields:
82
+ from amsdal_crm.services.custom_field_service import CustomFieldService
83
+
84
+ self.custom_fields = CustomFieldService.validate_custom_fields('Contact', self.custom_fields)
85
+ super().pre_create()
86
+
87
+ async def apre_create(self) -> None:
88
+ """Async hook called before creating contact."""
89
+ if self.custom_fields:
90
+ from amsdal_crm.services.custom_field_service import CustomFieldService
91
+
92
+ self.custom_fields = CustomFieldService.validate_custom_fields('Contact', self.custom_fields)
93
+ await super().apre_create()
94
+
95
+ def pre_update(self) -> None:
96
+ """Hook called before updating contact."""
97
+ # Validate custom fields first
98
+ if self.custom_fields:
99
+ from amsdal_crm.services.custom_field_service import CustomFieldService
100
+
101
+ self.custom_fields = CustomFieldService.validate_custom_fields('Contact', self.custom_fields)
102
+
103
+ # Call parent to handle timestamps
104
+ super().pre_update()
105
+
106
+ async def apre_update(self) -> None:
107
+ """Async hook called before updating contact."""
108
+ # Validate custom fields first
109
+ if self.custom_fields:
110
+ from amsdal_crm.services.custom_field_service import CustomFieldService
111
+
112
+ self.custom_fields = CustomFieldService.validate_custom_fields('Contact', self.custom_fields)
113
+
114
+ # Call parent to handle timestamps
115
+ await super().apre_update()
116
+
117
+ def post_update(self) -> None:
118
+ """Hook called after updating contact."""
119
+ from amsdal_crm.services.workflow_service import WorkflowService
120
+
121
+ WorkflowService.execute_rules('Contact', 'update', self)
122
+
123
+ async def apost_update(self) -> None:
124
+ """Async hook called after updating contact."""
125
+ from amsdal_crm.services.workflow_service import WorkflowService
126
+
127
+ WorkflowService.execute_rules('Contact', 'update', self)
128
+
129
+
130
+ from amsdal_crm.models.account import Account
131
+
132
+ Contact.model_rebuild()
@@ -0,0 +1,44 @@
1
+ """CustomFieldDefinition Model."""
2
+
3
+ from typing import Any
4
+ from typing import ClassVar
5
+ from typing import Literal
6
+
7
+ from amsdal_models.classes.data_models.constraints import UniqueConstraint
8
+ from amsdal_models.classes.model import Model
9
+ from amsdal_utils.models.enums import ModuleType
10
+ from pydantic.fields import Field
11
+
12
+
13
+ class CustomFieldDefinition(Model):
14
+ """Metadata about custom fields available for CRM entities.
15
+
16
+ Defines custom fields that users can add to Contacts, Accounts, or Deals.
17
+ Field values are stored in the entity's custom_fields JSON dict.
18
+ """
19
+
20
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
21
+ __constraints__: ClassVar[list[UniqueConstraint]] = [
22
+ UniqueConstraint(name='unq_custom_field_entity_name', fields=['entity_type', 'field_name'])
23
+ ]
24
+
25
+ entity_type: Literal['Contact', 'Account', 'Deal'] = Field(title='Entity Type')
26
+ field_name: str = Field(title='Field Name')
27
+ field_label: str = Field(title='Field Label')
28
+ field_type: Literal['text', 'number', 'date', 'choice'] = Field(title='Field Type')
29
+
30
+ # For choice fields
31
+ choices: list[str] | None = Field(default=None, title='Choices (for choice type)')
32
+
33
+ # Validation
34
+ is_required: bool = Field(default=False, title='Is Required')
35
+ default_value: Any | None = Field(default=None, title='Default Value')
36
+
37
+ # Display
38
+ help_text: str | None = Field(default=None, title='Help Text')
39
+ display_order: int = Field(default=0, title='Display Order')
40
+
41
+ @property
42
+ def display_name(self) -> str:
43
+ """Return display name for the custom field definition."""
44
+ return f'{self.entity_type}.{self.field_name}'