feat: Implement enterprise constraints, update models, serializers, views, and enhance UI components across the application.
This commit is contained in:
parent
0f52064047
commit
b1fdffc1cc
354 changed files with 36654 additions and 32764 deletions
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_initial"),
|
||||
("common", "0002_enable_rls"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0003_add_tags_related_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0004_remove_phonenumberfield"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
|
|
@ -13,25 +13,23 @@ def cleanup_duplicate_account_names(apps, schema_editor):
|
|||
Find duplicate account names (case-insensitive) within each org
|
||||
and append a numeric suffix to make them unique.
|
||||
"""
|
||||
Account = apps.get_model('accounts', 'Account')
|
||||
Account = apps.get_model("accounts", "Account")
|
||||
|
||||
# Find duplicates: group by lower(name) + org, having count > 1
|
||||
duplicates = (
|
||||
Account.objects
|
||||
.annotate(name_lower=Lower('name'))
|
||||
.values('name_lower', 'org_id')
|
||||
.annotate(count=Count('id'))
|
||||
Account.objects.annotate(name_lower=Lower("name"))
|
||||
.values("name_lower", "org_id")
|
||||
.annotate(count=Count("id"))
|
||||
.filter(count__gt=1)
|
||||
)
|
||||
|
||||
for dup in duplicates:
|
||||
# Get all accounts with this name in this org, ordered by created_at
|
||||
accounts = list(
|
||||
Account.objects
|
||||
.filter(org_id=dup['org_id'])
|
||||
.annotate(name_lower=Lower('name'))
|
||||
.filter(name_lower=dup['name_lower'])
|
||||
.order_by('created_at')
|
||||
Account.objects.filter(org_id=dup["org_id"])
|
||||
.annotate(name_lower=Lower("name"))
|
||||
.filter(name_lower=dup["name_lower"])
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Keep the first one (oldest), rename the rest
|
||||
|
|
@ -40,17 +38,19 @@ def cleanup_duplicate_account_names(apps, schema_editor):
|
|||
new_name = f"{account.name} ({i})"
|
||||
# Ensure the new name is also unique
|
||||
counter = i
|
||||
while Account.objects.filter(org_id=account.org_id, name__iexact=new_name).exists():
|
||||
while Account.objects.filter(
|
||||
org_id=account.org_id, name__iexact=new_name
|
||||
).exists():
|
||||
counter += 1
|
||||
new_name = f"{original_name} ({counter})"
|
||||
account.name = new_name
|
||||
account.save(update_fields=['name'])
|
||||
account.save(update_fields=["name"])
|
||||
print(f" Renamed duplicate account: '{original_name}' -> '{new_name}'")
|
||||
|
||||
|
||||
def cleanup_negative_revenue(apps, schema_editor):
|
||||
"""Set negative annual_revenue values to NULL."""
|
||||
Account = apps.get_model('accounts', 'Account')
|
||||
Account = apps.get_model("accounts", "Account")
|
||||
updated = Account.objects.filter(annual_revenue__lt=0).update(annual_revenue=None)
|
||||
if updated:
|
||||
print(f" Set {updated} negative annual_revenue values to NULL")
|
||||
|
|
@ -62,7 +62,6 @@ def reverse_noop(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0005_add_currency_fields"),
|
||||
("common", "0008_enable_rls_product_invoice_line_item"),
|
||||
|
|
|
|||
|
|
@ -50,7 +50,15 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||
@extend_schema_field(list)
|
||||
def get_opportunities(self, obj):
|
||||
"""Return opportunities linked to this account"""
|
||||
return [{"id": str(o.id), "name": o.name, "stage": o.stage, "amount": str(o.amount) if o.amount else "0"} for o in obj.opportunities.all()]
|
||||
return [
|
||||
{
|
||||
"id": str(o.id),
|
||||
"name": o.name,
|
||||
"stage": o.stage,
|
||||
"amount": str(o.amount) if o.amount else "0",
|
||||
}
|
||||
for o in obj.opportunities.all()
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
|
|
|
|||
|
|
@ -85,5 +85,3 @@ def send_email_to_assigned_user(recipients, account_id, org_id):
|
|||
msg = EmailMessage(subject, html_content, to=recipients_list)
|
||||
msg.content_subtype = "html"
|
||||
msg.send()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -85,9 +85,13 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
if params.get("search"):
|
||||
queryset = queryset.filter(name__icontains=params.get("search"))
|
||||
if params.get("created_at__gte"):
|
||||
queryset = queryset.filter(created_at__gte=params.get("created_at__gte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__gte=params.get("created_at__gte")
|
||||
)
|
||||
if params.get("created_at__lte"):
|
||||
queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__lte=params.get("created_at__lte")
|
||||
)
|
||||
|
||||
context = {}
|
||||
|
||||
|
|
@ -187,19 +191,25 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
|
||||
# Handle M2M relationships using utilities
|
||||
handle_m2m_assignment(
|
||||
account_object, 'contacts', data.get('contacts'),
|
||||
Contact, request.profile.org
|
||||
account_object,
|
||||
"contacts",
|
||||
data.get("contacts"),
|
||||
Contact,
|
||||
request.profile.org,
|
||||
)
|
||||
tags = get_or_create_tags(data.get('tags'), request.profile.org)
|
||||
tags = get_or_create_tags(data.get("tags"), request.profile.org)
|
||||
if tags:
|
||||
account_object.tags.add(*tags)
|
||||
handle_m2m_assignment(
|
||||
account_object, 'teams', data.get('teams'),
|
||||
Teams, request.profile.org
|
||||
account_object, "teams", data.get("teams"), Teams, request.profile.org
|
||||
)
|
||||
handle_m2m_assignment(
|
||||
account_object, 'assigned_to', data.get('assigned_to'),
|
||||
Profile, request.profile.org, extra_filters={'is_active': True}
|
||||
account_object,
|
||||
"assigned_to",
|
||||
data.get("assigned_to"),
|
||||
Profile,
|
||||
request.profile.org,
|
||||
extra_filters={"is_active": True},
|
||||
)
|
||||
|
||||
# Handle attachment
|
||||
|
|
@ -207,7 +217,7 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
create_attachment(
|
||||
request.FILES.get("account_attachment"),
|
||||
account_object,
|
||||
request.profile
|
||||
request.profile,
|
||||
)
|
||||
|
||||
recipients = list(
|
||||
|
|
@ -286,7 +296,9 @@ class AccountDetailView(APIView):
|
|||
account_object.tags.clear()
|
||||
if data.get("tags"):
|
||||
tags = json.loads(data.get("tags"))
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
account_object.tags.add(*tag_objs)
|
||||
|
||||
account_object.teams.clear()
|
||||
|
|
@ -546,7 +558,10 @@ class AccountDetailView(APIView):
|
|||
account_object = self.get_object(pk=pk)
|
||||
if account_object.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company does not match with header...."},
|
||||
{
|
||||
"error": True,
|
||||
"errors": "User company does not match with header....",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -591,7 +606,9 @@ class AccountDetailView(APIView):
|
|||
if tags:
|
||||
if isinstance(tags, str):
|
||||
tags = json.loads(tags)
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
account_object.tags.add(*tag_objs)
|
||||
|
||||
if "teams" in data:
|
||||
|
|
@ -600,7 +617,9 @@ class AccountDetailView(APIView):
|
|||
if teams_list:
|
||||
if isinstance(teams_list, str):
|
||||
teams_list = json.loads(teams_list)
|
||||
teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org)
|
||||
teams = Teams.objects.filter(
|
||||
id__in=teams_list, org=request.profile.org
|
||||
)
|
||||
account_object.teams.add(*teams)
|
||||
|
||||
if "assigned_to" in data:
|
||||
|
|
|
|||
|
|
@ -534,9 +534,7 @@ class CaseStageCreateView(APIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
stage = serializer.save(
|
||||
pipeline=pipeline, org=org, created_by=request.user
|
||||
)
|
||||
stage = serializer.save(pipeline=pipeline, org=org, created_by=request.user)
|
||||
return Response(CaseStageSerializer(stage).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0002_initial"),
|
||||
("contacts", "0004_change_phone_to_phonenumberfield"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0003_fix_contacts_related_name"),
|
||||
("common", "0002_enable_rls"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0004_add_tags_field"),
|
||||
("common", "0002_enable_rls"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0005_add_tags_related_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0006_fix_case_type_default"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0007_alter_case_created_by_alter_case_updated_by_and_more"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,95 +11,267 @@ class Migration(migrations.Migration):
|
|||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0006_add_enterprise_constraints'),
|
||||
('cases', '0008_add_enterprise_constraints'),
|
||||
('common', '0012_add_tag_color_description_isactive'),
|
||||
('contacts', '0010_add_enterprise_constraints'),
|
||||
("accounts", "0006_add_enterprise_constraints"),
|
||||
("cases", "0008_add_enterprise_constraints"),
|
||||
("common", "0012_add_tag_color_description_isactive"),
|
||||
("contacts", "0010_add_enterprise_constraints"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='kanban_order',
|
||||
field=models.DecimalField(decimal_places=6, default=0, help_text='Order within the kanban column for drag-drop positioning', max_digits=15, verbose_name='Kanban Order'),
|
||||
model_name="case",
|
||||
name="kanban_order",
|
||||
field=models.DecimalField(
|
||||
decimal_places=6,
|
||||
default=0,
|
||||
help_text="Order within the kanban column for drag-drop positioning",
|
||||
max_digits=15,
|
||||
verbose_name="Kanban Order",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CasePipeline',
|
||||
name="CasePipeline",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Pipeline Name')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
|
||||
('is_default', models.BooleanField(default=False, help_text='If true, new cases without explicit pipeline go here')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_pipelines', to='common.org')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=255, verbose_name="Pipeline Name"),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, null=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"is_default",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If true, new cases without explicit pipeline go here",
|
||||
),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="case_pipelines",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Case Pipeline',
|
||||
'verbose_name_plural': 'Case Pipelines',
|
||||
'db_table': 'case_pipeline',
|
||||
'ordering': ('-is_default', 'name'),
|
||||
"verbose_name": "Case Pipeline",
|
||||
"verbose_name_plural": "Case Pipelines",
|
||||
"db_table": "case_pipeline",
|
||||
"ordering": ("-is_default", "name"),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CaseStage',
|
||||
name="CaseStage",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=100, verbose_name='Stage Name')),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('color', models.CharField(default='#6B7280', max_length=7)),
|
||||
('stage_type', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed'), ('rejected', 'Rejected')], default='open', max_length=20)),
|
||||
('maps_to_status', models.CharField(blank=True, choices=[('New', 'New'), ('Assigned', 'Assigned'), ('Pending', 'Pending'), ('Closed', 'Closed'), ('Rejected', 'Rejected'), ('Duplicate', 'Duplicate')], help_text='When case enters this stage, also update Case.status', max_length=64, null=True, verbose_name='Maps to Status')),
|
||||
('wip_limit', models.PositiveIntegerField(blank=True, help_text='Maximum cases allowed in this stage (null = unlimited)', null=True, verbose_name='WIP Limit')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_stages', to='common.org')),
|
||||
('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='cases.casepipeline')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Stage Name")),
|
||||
("order", models.PositiveIntegerField(default=0)),
|
||||
("color", models.CharField(default="#6B7280", max_length=7)),
|
||||
(
|
||||
"stage_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("open", "Open"),
|
||||
("closed", "Closed"),
|
||||
("rejected", "Rejected"),
|
||||
],
|
||||
default="open",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"maps_to_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("New", "New"),
|
||||
("Assigned", "Assigned"),
|
||||
("Pending", "Pending"),
|
||||
("Closed", "Closed"),
|
||||
("Rejected", "Rejected"),
|
||||
("Duplicate", "Duplicate"),
|
||||
],
|
||||
help_text="When case enters this stage, also update Case.status",
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name="Maps to Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"wip_limit",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Maximum cases allowed in this stage (null = unlimited)",
|
||||
null=True,
|
||||
verbose_name="WIP Limit",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="case_stages",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pipeline",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stages",
|
||||
to="cases.casepipeline",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Case Stage',
|
||||
'verbose_name_plural': 'Case Stages',
|
||||
'db_table': 'case_stage',
|
||||
'ordering': ('order',),
|
||||
"verbose_name": "Case Stage",
|
||||
"verbose_name_plural": "Case Stages",
|
||||
"db_table": "case_stage",
|
||||
"ordering": ("order",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='stage',
|
||||
field=models.ForeignKey(blank=True, help_text='Current pipeline stage (null = use status-based kanban)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cases', to='cases.casestage'),
|
||||
model_name="case",
|
||||
name="stage",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Current pipeline stage (null = use status-based kanban)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="cases",
|
||||
to="cases.casestage",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['stage', 'kanban_order'], name='case_stage_i_f04bec_idx'),
|
||||
model_name="case",
|
||||
index=models.Index(
|
||||
fields=["stage", "kanban_order"], name="case_stage_i_f04bec_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['status', 'kanban_order'], name='case_status_a33019_idx'),
|
||||
model_name="case",
|
||||
index=models.Index(
|
||||
fields=["status", "kanban_order"], name="case_status_a33019_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='casepipeline',
|
||||
index=models.Index(fields=['org', '-created_at'], name='case_pipeli_org_id_f4590e_idx'),
|
||||
model_name="casepipeline",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="case_pipeli_org_id_f4590e_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='casepipeline',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='unique_default_case_pipeline_per_org'),
|
||||
model_name="casepipeline",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("is_default", True)),
|
||||
fields=("org",),
|
||||
name="unique_default_case_pipeline_per_org",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='casestage',
|
||||
index=models.Index(fields=['org', 'order'], name='case_stage_org_id_b9d581_idx'),
|
||||
model_name="casestage",
|
||||
index=models.Index(
|
||||
fields=["org", "order"], name="case_stage_org_id_b9d581_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='casestage',
|
||||
index=models.Index(fields=['pipeline', 'order'], name='case_stage_pipelin_3fbf31_idx'),
|
||||
model_name="casestage",
|
||||
index=models.Index(
|
||||
fields=["pipeline", "order"], name="case_stage_pipelin_3fbf31_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='casestage',
|
||||
unique_together={('pipeline', 'name')},
|
||||
name="casestage",
|
||||
unique_together={("pipeline", "name")},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -281,9 +281,7 @@ class CaseStage(BaseModel):
|
|||
help_text="Maximum cases allowed in this stage (null = unlimited)",
|
||||
)
|
||||
|
||||
org = models.ForeignKey(
|
||||
Org, on_delete=models.CASCADE, related_name="case_stages"
|
||||
)
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="case_stages")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Case Stage"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import json
|
|||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema, inline_serializer
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiParameter,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
|
@ -73,13 +78,19 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
assigned_to__id__in=params.getlist("assigned_to")
|
||||
).distinct()
|
||||
if params.get("tags"):
|
||||
queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct()
|
||||
queryset = queryset.filter(
|
||||
tags__id__in=params.getlist("tags")
|
||||
).distinct()
|
||||
if params.get("search"):
|
||||
queryset = queryset.filter(name__icontains=params.get("search"))
|
||||
if params.get("created_at__gte"):
|
||||
queryset = queryset.filter(created_at__gte=params.get("created_at__gte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__gte=params.get("created_at__gte")
|
||||
)
|
||||
if params.get("created_at__lte"):
|
||||
queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__lte=params.get("created_at__lte")
|
||||
)
|
||||
|
||||
context = {}
|
||||
|
||||
|
|
@ -110,19 +121,21 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
operation_id="cases_list",
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.cases_list_get_params,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseListResponse",
|
||||
fields={
|
||||
"cases_count": serializers.IntegerField(),
|
||||
"offset": serializers.IntegerField(allow_null=True),
|
||||
"cases": CaseSerializer(many=True),
|
||||
"status": serializers.ListField(),
|
||||
"priority": serializers.ListField(),
|
||||
"type_of_case": serializers.ListField(),
|
||||
"accounts_list": AccountSerializer(many=True),
|
||||
"contacts_list": ContactSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseListResponse",
|
||||
fields={
|
||||
"cases_count": serializers.IntegerField(),
|
||||
"offset": serializers.IntegerField(allow_null=True),
|
||||
"cases": CaseSerializer(many=True),
|
||||
"status": serializers.ListField(),
|
||||
"priority": serializers.ListField(),
|
||||
"type_of_case": serializers.ListField(),
|
||||
"accounts_list": AccountSerializer(many=True),
|
||||
"contacts_list": ContactSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
|
|
@ -133,15 +146,17 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCreateSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
"id": serializers.CharField(),
|
||||
"cases_obj": CaseSerializer(),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
"id": serializers.CharField(),
|
||||
"cases_obj": CaseSerializer(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -180,7 +195,9 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
tags = params.get("tags")
|
||||
if isinstance(tags, str):
|
||||
tags = json.loads(tags)
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
cases_obj.tags.add(*tag_objs)
|
||||
|
||||
if self.request.FILES.get("case_attachment"):
|
||||
|
|
@ -226,10 +243,15 @@ class CaseDetailView(APIView):
|
|||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCreateSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -295,7 +317,9 @@ class CaseDetailView(APIView):
|
|||
tags = params.get("tags")
|
||||
if isinstance(tags, str):
|
||||
tags = json.loads(tags)
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
cases_object.tags.add(*tag_objs)
|
||||
|
||||
if self.request.FILES.get("case_attachment"):
|
||||
|
|
@ -329,10 +353,15 @@ class CaseDetailView(APIView):
|
|||
operation_id="cases_destroy",
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
|
|
@ -360,16 +389,18 @@ class CaseDetailView(APIView):
|
|||
operation_id="cases_retrieve",
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseDetailResponse",
|
||||
fields={
|
||||
"cases_obj": CaseSerializer(),
|
||||
"attachments": AttachmentsSerializer(many=True),
|
||||
"comments": CommentSerializer(many=True),
|
||||
"comment_permission": serializers.BooleanField(),
|
||||
"users_mention": serializers.ListField(),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseDetailResponse",
|
||||
fields={
|
||||
"cases_obj": CaseSerializer(),
|
||||
"attachments": AttachmentsSerializer(many=True),
|
||||
"comments": CommentSerializer(many=True),
|
||||
"comment_permission": serializers.BooleanField(),
|
||||
"users_mention": serializers.ListField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, pk, format=None):
|
||||
self.cases = self.get_object(pk=pk)
|
||||
|
|
@ -454,14 +485,16 @@ class CaseDetailView(APIView):
|
|||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseDetailEditSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseCommentAttachmentResponse",
|
||||
fields={
|
||||
"cases_obj": CaseSerializer(),
|
||||
"attachments": AttachmentsSerializer(many=True),
|
||||
"comments": CommentSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseCommentAttachmentResponse",
|
||||
fields={
|
||||
"cases_obj": CaseSerializer(),
|
||||
"attachments": AttachmentsSerializer(many=True),
|
||||
"comments": CommentSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, pk, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -527,10 +560,15 @@ class CaseDetailView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=CaseCreateSwaggerSerializer,
|
||||
description="Partial Case Update",
|
||||
responses={200: inline_serializer(
|
||||
name="CasePatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CasePatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, format=None):
|
||||
"""Handle partial updates to a case."""
|
||||
|
|
@ -538,7 +576,10 @@ class CaseDetailView(APIView):
|
|||
cases_object = self.get_object(pk=pk)
|
||||
if cases_object.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company does not match with header...."},
|
||||
{
|
||||
"error": True,
|
||||
"errors": "User company does not match with header....",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -563,8 +604,12 @@ class CaseDetailView(APIView):
|
|||
|
||||
if serializer.is_valid():
|
||||
cases_object = serializer.save(
|
||||
closed_on=params.get("closed_on") if "closed_on" in params else cases_object.closed_on,
|
||||
case_type=params.get("case_type") if "case_type" in params else cases_object.case_type,
|
||||
closed_on=params.get("closed_on")
|
||||
if "closed_on" in params
|
||||
else cases_object.closed_on,
|
||||
case_type=params.get("case_type")
|
||||
if "case_type" in params
|
||||
else cases_object.case_type,
|
||||
)
|
||||
|
||||
# Handle M2M fields if present in request
|
||||
|
|
@ -581,7 +626,9 @@ class CaseDetailView(APIView):
|
|||
cases_object.teams.clear()
|
||||
teams_list = params.get("teams")
|
||||
if teams_list:
|
||||
teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org)
|
||||
teams = Teams.objects.filter(
|
||||
id__in=teams_list, org=request.profile.org
|
||||
)
|
||||
cases_object.teams.add(*teams)
|
||||
|
||||
if "assigned_to" in params:
|
||||
|
|
@ -599,7 +646,9 @@ class CaseDetailView(APIView):
|
|||
if tags_list:
|
||||
if isinstance(tags_list, str):
|
||||
tags_list = json.loads(tags_list)
|
||||
tag_objs = Tags.objects.filter(id__in=tags_list, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags_list, org=request.profile.org, is_active=True
|
||||
)
|
||||
cases_object.tags.add(*tag_objs)
|
||||
|
||||
return Response(
|
||||
|
|
@ -623,10 +672,15 @@ class CaseCommentView(APIView):
|
|||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCommentEditSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseCommentUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseCommentUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -661,10 +715,15 @@ class CaseCommentView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=CaseCommentEditSwaggerSerializer,
|
||||
description="Partial Comment Update",
|
||||
responses={200: inline_serializer(
|
||||
name="CaseCommentPatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseCommentPatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, format=None):
|
||||
"""Handle partial updates to a comment."""
|
||||
|
|
@ -697,10 +756,15 @@ class CaseCommentView(APIView):
|
|||
@extend_schema(
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseCommentDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseCommentDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
|
|
@ -730,10 +794,15 @@ class CaseAttachmentView(APIView):
|
|||
@extend_schema(
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="CaseAttachmentDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="CaseAttachmentDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.model.objects.get(pk=pk)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Duplicate detection service for CRM entities.
|
|||
Provides methods to find potential duplicate records based on
|
||||
email, phone, name, and other identifying fields.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
@ -206,9 +207,9 @@ class DuplicateDetector:
|
|||
# Also check for partial matches on first word (for company variations)
|
||||
first_word = name.split()[0] if name else ""
|
||||
if len(first_word) >= 3:
|
||||
partial_matches = base_qs.filter(
|
||||
name__istartswith=first_word
|
||||
).exclude(name__iexact=name)
|
||||
partial_matches = base_qs.filter(name__istartswith=first_word).exclude(
|
||||
name__iexact=name
|
||||
)
|
||||
for account in partial_matches:
|
||||
if account not in duplicates:
|
||||
duplicates.append(account)
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ class Command(BaseCommand):
|
|||
|
||||
with connection.cursor() as cursor:
|
||||
# Find orgs that have leads (need to check each org since RLS is active)
|
||||
cursor.execute("SELECT id FROM organization ORDER BY created_at DESC LIMIT 50")
|
||||
cursor.execute(
|
||||
"SELECT id FROM organization ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
all_orgs = cursor.fetchall()
|
||||
|
||||
orgs_with_leads = []
|
||||
|
|
@ -127,14 +129,20 @@ class Command(BaseCommand):
|
|||
orgs = cursor.fetchall()
|
||||
if len(orgs) < 2:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Need at least 2 orgs to test RLS. Skipping.")
|
||||
self.style.WARNING(
|
||||
"Need at least 2 orgs to test RLS. Skipping."
|
||||
)
|
||||
)
|
||||
return
|
||||
org_a = str(orgs[0][0])
|
||||
org_b = str(orgs[1][0])
|
||||
else:
|
||||
org_a = orgs_with_leads[0]
|
||||
org_b = orgs_with_leads[1] if len(orgs_with_leads) > 1 else str(all_orgs[0][0])
|
||||
org_b = (
|
||||
orgs_with_leads[1]
|
||||
if len(orgs_with_leads) > 1
|
||||
else str(all_orgs[0][0])
|
||||
)
|
||||
|
||||
# Test with org_a context
|
||||
cursor.execute(set_context_sql, [org_a])
|
||||
|
|
|
|||
|
|
@ -314,7 +314,9 @@ class Command(BaseCommand):
|
|||
Teams.objects.all().delete()
|
||||
Tags.objects.all().delete()
|
||||
|
||||
self.stdout.write(" Cleared: Tasks, Cases, Opportunities, Leads, Accounts, Contacts, Teams, Tags")
|
||||
self.stdout.write(
|
||||
" Cleared: Tasks, Cases, Opportunities, Leads, Accounts, Contacts, Teams, Tags"
|
||||
)
|
||||
|
||||
def get_or_create_admin(self, email, password):
|
||||
"""Get existing user or create new admin user."""
|
||||
|
|
@ -366,16 +368,28 @@ class Command(BaseCommand):
|
|||
|
||||
# Invoice entities
|
||||
invoices = self.invoice_seeder.create_invoices(
|
||||
org, profiles, teams, products, templates,
|
||||
accounts, contacts, opportunities, options["invoices"]
|
||||
org,
|
||||
profiles,
|
||||
teams,
|
||||
products,
|
||||
templates,
|
||||
accounts,
|
||||
contacts,
|
||||
opportunities,
|
||||
options["invoices"],
|
||||
)
|
||||
self.invoice_seeder.create_payments(org, invoices)
|
||||
self.invoice_seeder.create_estimates(
|
||||
org, profiles, teams, products, accounts, contacts, options["estimates"]
|
||||
)
|
||||
self.invoice_seeder.create_recurring_invoices(
|
||||
org, profiles, teams, products, accounts, contacts,
|
||||
options["recurring_invoices"]
|
||||
org,
|
||||
profiles,
|
||||
teams,
|
||||
products,
|
||||
accounts,
|
||||
contacts,
|
||||
options["recurring_invoices"],
|
||||
)
|
||||
|
||||
cases = self.create_cases(
|
||||
|
|
@ -454,7 +468,9 @@ class Command(BaseCommand):
|
|||
profiles.append(profile)
|
||||
self.stats["profiles"] += 1
|
||||
|
||||
self.stdout.write(f" Created {len(profiles)} profiles (1 admin, {user_count} users)")
|
||||
self.stdout.write(
|
||||
f" Created {len(profiles)} profiles (1 admin, {user_count} users)"
|
||||
)
|
||||
return profiles
|
||||
|
||||
def create_teams(self, org, profiles, count):
|
||||
|
|
@ -542,9 +558,7 @@ class Command(BaseCommand):
|
|||
number_of_employees=random.choice(
|
||||
[10, 50, 100, 250, 500, 1000, 5000, 10000]
|
||||
),
|
||||
annual_revenue=Decimal(
|
||||
str(random.randint(100000, 10000000))
|
||||
),
|
||||
annual_revenue=Decimal(str(random.randint(100000, 10000000))),
|
||||
currency=org.default_currency,
|
||||
address_line=self.fake.street_address(),
|
||||
city=self.fake.city(),
|
||||
|
|
@ -617,9 +631,9 @@ class Command(BaseCommand):
|
|||
opportunity_amount=Decimal(str(random.randint(5000, 500000))),
|
||||
currency=org.default_currency,
|
||||
probability=random.randint(10, 90),
|
||||
close_date=self.fake.date_between(
|
||||
start_date="today", end_date="+90d"
|
||||
) if random.random() > 0.3 else None,
|
||||
close_date=self.fake.date_between(start_date="today", end_date="+90d")
|
||||
if random.random() > 0.3
|
||||
else None,
|
||||
address_line=self.fake.street_address(),
|
||||
city=self.fake.city(),
|
||||
state=self.fake.state_abbr(),
|
||||
|
|
@ -664,11 +678,9 @@ class Command(BaseCommand):
|
|||
amount=Decimal(str(random.randint(10000, 1000000))),
|
||||
currency=org.default_currency,
|
||||
probability=self._stage_to_probability(stage),
|
||||
closed_on=self.fake.date_between(
|
||||
start_date="today", end_date="+120d"
|
||||
) if stage not in ["CLOSED_WON", "CLOSED_LOST"] else self.fake.date_between(
|
||||
start_date="-30d", end_date="today"
|
||||
),
|
||||
closed_on=self.fake.date_between(start_date="today", end_date="+120d")
|
||||
if stage not in ["CLOSED_WON", "CLOSED_LOST"]
|
||||
else self.fake.date_between(start_date="-30d", end_date="today"),
|
||||
lead_source=random.choice(sources),
|
||||
description=self.fake.paragraph() if random.random() > 0.5 else None,
|
||||
org=org,
|
||||
|
|
@ -710,9 +722,9 @@ class Command(BaseCommand):
|
|||
priority=random.choice(priorities),
|
||||
case_type=random.choice(case_types),
|
||||
account=account,
|
||||
closed_on=self.fake.date_between(
|
||||
start_date="-30d", end_date="today"
|
||||
) if status == "Closed" else None,
|
||||
closed_on=self.fake.date_between(start_date="-30d", end_date="today")
|
||||
if status == "Closed"
|
||||
else None,
|
||||
description=self.fake.paragraph(),
|
||||
org=org,
|
||||
)
|
||||
|
|
@ -763,11 +775,9 @@ class Command(BaseCommand):
|
|||
"title": self.fake.sentence(nb_words=5)[:200],
|
||||
"status": status,
|
||||
"priority": random.choice(priorities),
|
||||
"due_date": self.fake.date_between(
|
||||
start_date="today", end_date="+30d"
|
||||
) if status != "Completed" else self.fake.date_between(
|
||||
start_date="-14d", end_date="today"
|
||||
),
|
||||
"due_date": self.fake.date_between(start_date="today", end_date="+30d")
|
||||
if status != "Completed"
|
||||
else self.fake.date_between(start_date="-14d", end_date="today"),
|
||||
"description": self.fake.paragraph() if random.random() > 0.5 else None,
|
||||
"org": org,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import common.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
@ -677,15 +676,11 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
(
|
||||
"phone",
|
||||
models.CharField(
|
||||
blank=True, max_length=20, null=True
|
||||
),
|
||||
models.CharField(blank=True, max_length=20, null=True),
|
||||
),
|
||||
(
|
||||
"alternate_phone",
|
||||
models.CharField(
|
||||
blank=True, max_length=20, null=True
|
||||
),
|
||||
models.CharField(blank=True, max_length=20, null=True),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0002_enable_rls"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ def disable_rls_on_commentfiles(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0003_add_org_to_commentfiles"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0004_populate_commentfiles_org_and_rls"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0005_increase_apikey_length"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0006_remove_phonenumberfield"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0008_enable_rls_product_invoice_line_item"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0011_enable_rls_opportunity_line_item"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -402,7 +402,6 @@ def document_path(self, filename):
|
|||
|
||||
|
||||
class Document(BaseModel):
|
||||
|
||||
DOCUMENT_STATUS_CHOICE = (("active", "active"), ("inactive", "inactive"))
|
||||
|
||||
title = models.TextField(blank=True, null=True)
|
||||
|
|
|
|||
|
|
@ -75,5 +75,3 @@ class IsSuperAdmin(permissions.BasePermission):
|
|||
|
||||
# Check for super admin email domain
|
||||
return request.user.email.endswith("@micropyramid.com")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,15 @@ class OrgSettingsSerializer(serializers.ModelSerializer):
|
|||
class TagsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tags
|
||||
fields = ("id", "name", "slug", "color", "description", "is_active", "created_at")
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"color",
|
||||
"description",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
read_only_fields = ("id", "slug", "created_at")
|
||||
|
||||
|
||||
|
|
@ -261,7 +269,6 @@ class BillingAddressSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class CreateUserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
|
|
@ -306,7 +313,6 @@ class CreateProfileSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "profile_pic"]
|
||||
|
|
@ -549,7 +555,6 @@ class UserCreateSwaggerSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class UserUpdateStatusSwaggerSerializer(serializers.Serializer):
|
||||
|
||||
STATUS_CHOICES = ["Active", "Inactive"]
|
||||
|
||||
status = serializers.ChoiceField(choices=STATUS_CHOICES, required=True)
|
||||
|
|
@ -714,7 +719,6 @@ class TeamsSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class TeamCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
|
@ -748,7 +752,6 @@ class TeamCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class TeamswaggerCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Teams
|
||||
fields = (
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ def convert_to_custom_timezone(custom_date, custom_timezone, to_utc=False):
|
|||
# CRM Entity Utilities
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_or_create_tags(tag_data, org):
|
||||
"""
|
||||
Handle tag creation/lookup for any CRM model.
|
||||
|
|
@ -450,15 +451,15 @@ def get_or_create_tags(tag_data, org):
|
|||
if not tag_name:
|
||||
continue
|
||||
tag_obj, _ = Tags.objects.get_or_create(
|
||||
slug=tag_name.lower(),
|
||||
org=org,
|
||||
defaults={'name': tag_name}
|
||||
slug=tag_name.lower(), org=org, defaults={"name": tag_name}
|
||||
)
|
||||
tags.append(tag_obj)
|
||||
return tags
|
||||
|
||||
|
||||
def handle_m2m_assignment(instance, field_name, ids, model_class, org, extra_filters=None):
|
||||
def handle_m2m_assignment(
|
||||
instance, field_name, ids, model_class, org, extra_filters=None
|
||||
):
|
||||
"""
|
||||
Handle ManyToMany field assignment with org filtering.
|
||||
|
||||
|
|
@ -487,7 +488,7 @@ def handle_m2m_assignment(instance, field_name, ids, model_class, org, extra_fil
|
|||
except json.JSONDecodeError:
|
||||
ids = [ids]
|
||||
|
||||
filters = {'id__in': ids, 'org': org}
|
||||
filters = {"id__in": ids, "org": org}
|
||||
if extra_filters:
|
||||
filters.update(extra_filters)
|
||||
|
||||
|
|
@ -525,10 +526,22 @@ def create_attachment(file, content_object, profile):
|
|||
# Document File Type Utilities
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def is_document_file_image(ext):
|
||||
image_ext_list = [
|
||||
"bmp", "dds", "gif", "jpg", "jpeg", "png", "psd",
|
||||
"pspimage", "tga", "thm", "tif", "tiff", "yuv",
|
||||
"bmp",
|
||||
"dds",
|
||||
"gif",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"psd",
|
||||
"pspimage",
|
||||
"tga",
|
||||
"thm",
|
||||
"tif",
|
||||
"tiff",
|
||||
"yuv",
|
||||
]
|
||||
return ext.lower() in image_ext_list
|
||||
|
||||
|
|
@ -540,8 +553,20 @@ def is_document_file_audio(ext):
|
|||
|
||||
def is_document_file_video(ext):
|
||||
video_ext_list = [
|
||||
"3g2", "3gp", "asf", "avi", "flv", "m4v", "mov",
|
||||
"mp4", "mpg", "rm", "srt", "swf", "vob", "wmv",
|
||||
"3g2",
|
||||
"3gp",
|
||||
"asf",
|
||||
"avi",
|
||||
"flv",
|
||||
"m4v",
|
||||
"mov",
|
||||
"mp4",
|
||||
"mpg",
|
||||
"rm",
|
||||
"srt",
|
||||
"swf",
|
||||
"vob",
|
||||
"wmv",
|
||||
]
|
||||
return ext.lower() in video_ext_list
|
||||
|
||||
|
|
@ -553,64 +578,520 @@ def is_document_file_pdf(ext):
|
|||
|
||||
def is_document_file_code(ext):
|
||||
code_ext_list = [
|
||||
"aspx", "json", "jsp", "do", "htm", "html", "ser", "php", "jad", "cfm",
|
||||
"xml", "js", "pod", "asp", "atomsvc", "rdf", "pou", "jsf", "abs", "pl",
|
||||
"asm", "srz", "luac", "cod", "lib", "arxml", "bas", "ejs", "fs", "hbs",
|
||||
"s", "ss", "cms", "pyc", "vcxproj", "jse", "smali", "xla", "lxk", "pdb",
|
||||
"src", "cs", "ipb", "ave", "mst", "vls", "rcc", "sax", "scr", "dtd",
|
||||
"axd", "mrl", "xsl", "ino", "spr", "xsd", "cgi", "isa", "ws", "rss",
|
||||
"dvb", "nupkg", "xlm", "v4e", "prg", "form", "bat", "mrc", "asi", "jdp",
|
||||
"fmb", "graphml", "gcode", "aia", "py", "atp", "mzp", "o", "scs", "mm",
|
||||
"cpp", "java", "gypi", "idb", "txml", "c", "vip", "tra", "rc", "action",
|
||||
"vlx", "asta", "pyo", "lua", "gml", "prl", "rfs", "cpb", "sh", "rbf",
|
||||
"gp", "phtml", "bp", "scb", "sln", "vbp", "wbf", "bdt", "mac", "rpy",
|
||||
"eaf", "mc", "mwp", "gnt", "h", "swift", "e", "styl", "cxx", "as",
|
||||
"liquid", "dep", "fas", "vbs", "aps", "vbe", "lss", "cmake", "resx",
|
||||
"csb", "dpk", "pdml", "txx", "dbg", "jsa", "sxs", "sasf", "pm", "csx",
|
||||
"r", "wml", "au3", "stm", "cls", "cc", "ins", "jsc", "dwp", "rpg",
|
||||
"arb", "bml", "inc", "eld", "sct", "sm", "wbt", "csproj", "tcz", "html5",
|
||||
"gbl", "cmd", "dlg", "tpl", "rbt", "xcp", "tpm", "qry", "mfa", "ptx",
|
||||
"lsp", "pag", "ebc", "php3", "cob", "csc", "pyt", "dwt", "rb", "wsdl",
|
||||
"lap", "textile", "sfx", "x", "a5r", "dbp", "pmp", "ipr", "fwx", "pbl",
|
||||
"vbw", "phl", "cbl", "pas", "mom", "dbmdl", "lol", "wdl", "ppam", "plx",
|
||||
"vb", "cgx", "lst", "lmp", "vd", "bcp", "thtml", "scpt", "isu", "mrd",
|
||||
"perl", "dtx", "f", "wpk", "ipf", "ptl", "luca", "hx", "uvproj", "qvs",
|
||||
"vba", "xjb", "appxupload", "ti", "svn-base", "bsc", "mak", "vcproj",
|
||||
"dsd", "ksh", "pyw", "bxml", "mo", "irc", "gcl", "dbml", "mlv", "wsf",
|
||||
"tcl", "dqy", "ssi", "pbxproj", "bal", "trt", "sal", "hkp", "vbi", "dob",
|
||||
"htc", "p", "ats", "seam", "loc", "pli", "rptproj", "pxml", "pkb", "dpr",
|
||||
"scss", "dsb", "bb", "vbproj", "ash", "rml", "nbk", "nvi", "lmv", "mw",
|
||||
"jl", "dso", "cba", "jks", "ary", "run", "vps", "clm", "brml", "msha",
|
||||
"mdp", "tmh", "jsx", "sdl", "ptxml", "fxl", "wmw", "dcr", "bcc", "cbp",
|
||||
"bmo", "bsv", "less", "gss", "ctl", "rpyc", "ascx", "odc", "wiki", "obr",
|
||||
"l", "axs", "bpr", "ppa", "rpo", "sqlproj", "smm", "dsr", "arq", "din",
|
||||
"jml", "jsonp", "ml", "rc2", "myapp", "cla", "xme", "obj", "jsdtscope",
|
||||
"gyp", "datasource", "cp", "rh", "lpx", "a2w", "ctp", "ulp", "nt",
|
||||
"script", "bxl", "gs", "xslt", "mg", "pch", "mhl", "zpd", "psm1", "asz",
|
||||
"m", "jacl", "pym", "rws", "acu", "ssq", "wxs", "coffee", "ncb", "akt",
|
||||
"pyx", "zero", "hs", "mkb", "tru", "xul", "mfl", "sca", "sbr", "master",
|
||||
"opv", "matlab", "sami", "agc", "slim", "tea", "m51", "mec", "asc",
|
||||
"gch", "enml", "kst", "jade", "dfb", "ips", "rgs", "vbx", "cspkg", "ncx",
|
||||
"brs", "wfs", "ifp", "nse", "xtx", "j", "cx", "ps1", "nas", "mk", "ccs",
|
||||
"vrp", "lnp", "cml", "c#", "idl", "exp", "apb", "nsi", "asmx", "tdo",
|
||||
"pjt", "fdt", "s5d", "mvba", "mf", "odl", "bzs", "jardesc", "tgml",
|
||||
"moc", "wxi", "cpz", "fsx", "jav", "ocb", "agi", "tec", "txl", "amw",
|
||||
"mscr", "dfd", "dpd", "pun", "f95", "vdproj", "xsc", "diff", "wxl",
|
||||
"dgml", "airi", "kmt", "ksc", "io", "rbw", "sas", "vcp", "resources",
|
||||
"param", "cg", "hlsl", "vssscc", "bgm", "xn", "targets", "sl", "gsc",
|
||||
"qs", "owl", "devpak", "phps", "hdf", "pri", "nbin", "xaml", "s4e",
|
||||
"scm", "tk", "poc", "uix", "clw", "factorypath", "s43", "awd", "htr",
|
||||
"php2", "classpath", "pickle", "rob", "msil", "ebx", "tsq", "lml", "f90",
|
||||
"lds", "vup", "pbi", "swt", "vap", "ig", "pdo", "frt", "fcg", "c++",
|
||||
"xcl", "dfn", "aar", "for", "re", "twig", "ebm", "dhtml", "hc", "pro",
|
||||
"ahk", "rule", "bsh", "jcs", "zrx", "wsdd", "csp", "drc", "appxsym",
|
||||
"aspx",
|
||||
"json",
|
||||
"jsp",
|
||||
"do",
|
||||
"htm",
|
||||
"html",
|
||||
"ser",
|
||||
"php",
|
||||
"jad",
|
||||
"cfm",
|
||||
"xml",
|
||||
"js",
|
||||
"pod",
|
||||
"asp",
|
||||
"atomsvc",
|
||||
"rdf",
|
||||
"pou",
|
||||
"jsf",
|
||||
"abs",
|
||||
"pl",
|
||||
"asm",
|
||||
"srz",
|
||||
"luac",
|
||||
"cod",
|
||||
"lib",
|
||||
"arxml",
|
||||
"bas",
|
||||
"ejs",
|
||||
"fs",
|
||||
"hbs",
|
||||
"s",
|
||||
"ss",
|
||||
"cms",
|
||||
"pyc",
|
||||
"vcxproj",
|
||||
"jse",
|
||||
"smali",
|
||||
"xla",
|
||||
"lxk",
|
||||
"pdb",
|
||||
"src",
|
||||
"cs",
|
||||
"ipb",
|
||||
"ave",
|
||||
"mst",
|
||||
"vls",
|
||||
"rcc",
|
||||
"sax",
|
||||
"scr",
|
||||
"dtd",
|
||||
"axd",
|
||||
"mrl",
|
||||
"xsl",
|
||||
"ino",
|
||||
"spr",
|
||||
"xsd",
|
||||
"cgi",
|
||||
"isa",
|
||||
"ws",
|
||||
"rss",
|
||||
"dvb",
|
||||
"nupkg",
|
||||
"xlm",
|
||||
"v4e",
|
||||
"prg",
|
||||
"form",
|
||||
"bat",
|
||||
"mrc",
|
||||
"asi",
|
||||
"jdp",
|
||||
"fmb",
|
||||
"graphml",
|
||||
"gcode",
|
||||
"aia",
|
||||
"py",
|
||||
"atp",
|
||||
"mzp",
|
||||
"o",
|
||||
"scs",
|
||||
"mm",
|
||||
"cpp",
|
||||
"java",
|
||||
"gypi",
|
||||
"idb",
|
||||
"txml",
|
||||
"c",
|
||||
"vip",
|
||||
"tra",
|
||||
"rc",
|
||||
"action",
|
||||
"vlx",
|
||||
"asta",
|
||||
"pyo",
|
||||
"lua",
|
||||
"gml",
|
||||
"prl",
|
||||
"rfs",
|
||||
"cpb",
|
||||
"sh",
|
||||
"rbf",
|
||||
"gp",
|
||||
"phtml",
|
||||
"bp",
|
||||
"scb",
|
||||
"sln",
|
||||
"vbp",
|
||||
"wbf",
|
||||
"bdt",
|
||||
"mac",
|
||||
"rpy",
|
||||
"eaf",
|
||||
"mc",
|
||||
"mwp",
|
||||
"gnt",
|
||||
"h",
|
||||
"swift",
|
||||
"e",
|
||||
"styl",
|
||||
"cxx",
|
||||
"as",
|
||||
"liquid",
|
||||
"dep",
|
||||
"fas",
|
||||
"vbs",
|
||||
"aps",
|
||||
"vbe",
|
||||
"lss",
|
||||
"cmake",
|
||||
"resx",
|
||||
"csb",
|
||||
"dpk",
|
||||
"pdml",
|
||||
"txx",
|
||||
"dbg",
|
||||
"jsa",
|
||||
"sxs",
|
||||
"sasf",
|
||||
"pm",
|
||||
"csx",
|
||||
"r",
|
||||
"wml",
|
||||
"au3",
|
||||
"stm",
|
||||
"cls",
|
||||
"cc",
|
||||
"ins",
|
||||
"jsc",
|
||||
"dwp",
|
||||
"rpg",
|
||||
"arb",
|
||||
"bml",
|
||||
"inc",
|
||||
"eld",
|
||||
"sct",
|
||||
"sm",
|
||||
"wbt",
|
||||
"csproj",
|
||||
"tcz",
|
||||
"html5",
|
||||
"gbl",
|
||||
"cmd",
|
||||
"dlg",
|
||||
"tpl",
|
||||
"rbt",
|
||||
"xcp",
|
||||
"tpm",
|
||||
"qry",
|
||||
"mfa",
|
||||
"ptx",
|
||||
"lsp",
|
||||
"pag",
|
||||
"ebc",
|
||||
"php3",
|
||||
"cob",
|
||||
"csc",
|
||||
"pyt",
|
||||
"dwt",
|
||||
"rb",
|
||||
"wsdl",
|
||||
"lap",
|
||||
"textile",
|
||||
"sfx",
|
||||
"x",
|
||||
"a5r",
|
||||
"dbp",
|
||||
"pmp",
|
||||
"ipr",
|
||||
"fwx",
|
||||
"pbl",
|
||||
"vbw",
|
||||
"phl",
|
||||
"cbl",
|
||||
"pas",
|
||||
"mom",
|
||||
"dbmdl",
|
||||
"lol",
|
||||
"wdl",
|
||||
"ppam",
|
||||
"plx",
|
||||
"vb",
|
||||
"cgx",
|
||||
"lst",
|
||||
"lmp",
|
||||
"vd",
|
||||
"bcp",
|
||||
"thtml",
|
||||
"scpt",
|
||||
"isu",
|
||||
"mrd",
|
||||
"perl",
|
||||
"dtx",
|
||||
"f",
|
||||
"wpk",
|
||||
"ipf",
|
||||
"ptl",
|
||||
"luca",
|
||||
"hx",
|
||||
"uvproj",
|
||||
"qvs",
|
||||
"vba",
|
||||
"xjb",
|
||||
"appxupload",
|
||||
"ti",
|
||||
"svn-base",
|
||||
"bsc",
|
||||
"mak",
|
||||
"vcproj",
|
||||
"dsd",
|
||||
"ksh",
|
||||
"pyw",
|
||||
"bxml",
|
||||
"mo",
|
||||
"irc",
|
||||
"gcl",
|
||||
"dbml",
|
||||
"mlv",
|
||||
"wsf",
|
||||
"tcl",
|
||||
"dqy",
|
||||
"ssi",
|
||||
"pbxproj",
|
||||
"bal",
|
||||
"trt",
|
||||
"sal",
|
||||
"hkp",
|
||||
"vbi",
|
||||
"dob",
|
||||
"htc",
|
||||
"p",
|
||||
"ats",
|
||||
"seam",
|
||||
"loc",
|
||||
"pli",
|
||||
"rptproj",
|
||||
"pxml",
|
||||
"pkb",
|
||||
"dpr",
|
||||
"scss",
|
||||
"dsb",
|
||||
"bb",
|
||||
"vbproj",
|
||||
"ash",
|
||||
"rml",
|
||||
"nbk",
|
||||
"nvi",
|
||||
"lmv",
|
||||
"mw",
|
||||
"jl",
|
||||
"dso",
|
||||
"cba",
|
||||
"jks",
|
||||
"ary",
|
||||
"run",
|
||||
"vps",
|
||||
"clm",
|
||||
"brml",
|
||||
"msha",
|
||||
"mdp",
|
||||
"tmh",
|
||||
"jsx",
|
||||
"sdl",
|
||||
"ptxml",
|
||||
"fxl",
|
||||
"wmw",
|
||||
"dcr",
|
||||
"bcc",
|
||||
"cbp",
|
||||
"bmo",
|
||||
"bsv",
|
||||
"less",
|
||||
"gss",
|
||||
"ctl",
|
||||
"rpyc",
|
||||
"ascx",
|
||||
"odc",
|
||||
"wiki",
|
||||
"obr",
|
||||
"l",
|
||||
"axs",
|
||||
"bpr",
|
||||
"ppa",
|
||||
"rpo",
|
||||
"sqlproj",
|
||||
"smm",
|
||||
"dsr",
|
||||
"arq",
|
||||
"din",
|
||||
"jml",
|
||||
"jsonp",
|
||||
"ml",
|
||||
"rc2",
|
||||
"myapp",
|
||||
"cla",
|
||||
"xme",
|
||||
"obj",
|
||||
"jsdtscope",
|
||||
"gyp",
|
||||
"datasource",
|
||||
"cp",
|
||||
"rh",
|
||||
"lpx",
|
||||
"a2w",
|
||||
"ctp",
|
||||
"ulp",
|
||||
"nt",
|
||||
"script",
|
||||
"bxl",
|
||||
"gs",
|
||||
"xslt",
|
||||
"mg",
|
||||
"pch",
|
||||
"mhl",
|
||||
"zpd",
|
||||
"psm1",
|
||||
"asz",
|
||||
"m",
|
||||
"jacl",
|
||||
"pym",
|
||||
"rws",
|
||||
"acu",
|
||||
"ssq",
|
||||
"wxs",
|
||||
"coffee",
|
||||
"ncb",
|
||||
"akt",
|
||||
"pyx",
|
||||
"zero",
|
||||
"hs",
|
||||
"mkb",
|
||||
"tru",
|
||||
"xul",
|
||||
"mfl",
|
||||
"sca",
|
||||
"sbr",
|
||||
"master",
|
||||
"opv",
|
||||
"matlab",
|
||||
"sami",
|
||||
"agc",
|
||||
"slim",
|
||||
"tea",
|
||||
"m51",
|
||||
"mec",
|
||||
"asc",
|
||||
"gch",
|
||||
"enml",
|
||||
"kst",
|
||||
"jade",
|
||||
"dfb",
|
||||
"ips",
|
||||
"rgs",
|
||||
"vbx",
|
||||
"cspkg",
|
||||
"ncx",
|
||||
"brs",
|
||||
"wfs",
|
||||
"ifp",
|
||||
"nse",
|
||||
"xtx",
|
||||
"j",
|
||||
"cx",
|
||||
"ps1",
|
||||
"nas",
|
||||
"mk",
|
||||
"ccs",
|
||||
"vrp",
|
||||
"lnp",
|
||||
"cml",
|
||||
"c#",
|
||||
"idl",
|
||||
"exp",
|
||||
"apb",
|
||||
"nsi",
|
||||
"asmx",
|
||||
"tdo",
|
||||
"pjt",
|
||||
"fdt",
|
||||
"s5d",
|
||||
"mvba",
|
||||
"mf",
|
||||
"odl",
|
||||
"bzs",
|
||||
"jardesc",
|
||||
"tgml",
|
||||
"moc",
|
||||
"wxi",
|
||||
"cpz",
|
||||
"fsx",
|
||||
"jav",
|
||||
"ocb",
|
||||
"agi",
|
||||
"tec",
|
||||
"txl",
|
||||
"amw",
|
||||
"mscr",
|
||||
"dfd",
|
||||
"dpd",
|
||||
"pun",
|
||||
"f95",
|
||||
"vdproj",
|
||||
"xsc",
|
||||
"diff",
|
||||
"wxl",
|
||||
"dgml",
|
||||
"airi",
|
||||
"kmt",
|
||||
"ksc",
|
||||
"io",
|
||||
"rbw",
|
||||
"sas",
|
||||
"vcp",
|
||||
"resources",
|
||||
"param",
|
||||
"cg",
|
||||
"hlsl",
|
||||
"vssscc",
|
||||
"bgm",
|
||||
"xn",
|
||||
"targets",
|
||||
"sl",
|
||||
"gsc",
|
||||
"qs",
|
||||
"owl",
|
||||
"devpak",
|
||||
"phps",
|
||||
"hdf",
|
||||
"pri",
|
||||
"nbin",
|
||||
"xaml",
|
||||
"s4e",
|
||||
"scm",
|
||||
"tk",
|
||||
"poc",
|
||||
"uix",
|
||||
"clw",
|
||||
"factorypath",
|
||||
"s43",
|
||||
"awd",
|
||||
"htr",
|
||||
"php2",
|
||||
"classpath",
|
||||
"pickle",
|
||||
"rob",
|
||||
"msil",
|
||||
"ebx",
|
||||
"tsq",
|
||||
"lml",
|
||||
"f90",
|
||||
"lds",
|
||||
"vup",
|
||||
"pbi",
|
||||
"swt",
|
||||
"vap",
|
||||
"ig",
|
||||
"pdo",
|
||||
"frt",
|
||||
"fcg",
|
||||
"c++",
|
||||
"xcl",
|
||||
"dfn",
|
||||
"aar",
|
||||
"for",
|
||||
"re",
|
||||
"twig",
|
||||
"ebm",
|
||||
"dhtml",
|
||||
"hc",
|
||||
"pro",
|
||||
"ahk",
|
||||
"rule",
|
||||
"bsh",
|
||||
"jcs",
|
||||
"zrx",
|
||||
"wsdd",
|
||||
"csp",
|
||||
"drc",
|
||||
"appxsym",
|
||||
]
|
||||
return ext.lower() in code_ext_list
|
||||
|
||||
|
||||
def is_document_file_text(ext):
|
||||
text_ext_list = [
|
||||
"doc", "docx", "log", "msg", "odt", "pages", "rtf", "tex", "txt", "wpd", "wps",
|
||||
"doc",
|
||||
"docx",
|
||||
"log",
|
||||
"msg",
|
||||
"odt",
|
||||
"pages",
|
||||
"rtf",
|
||||
"tex",
|
||||
"txt",
|
||||
"wpd",
|
||||
"wps",
|
||||
]
|
||||
return ext.lower() in text_ext_list
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""
|
||||
Common validators for CRM models.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
|
|
@ -19,7 +20,9 @@ e164_phone_validator = RegexValidator(
|
|||
# Examples: +1 (202) 555-1234, 202-555-1234, +44 20 7123 4567
|
||||
flexible_phone_validator = RegexValidator(
|
||||
regex=r"^[\d\s\-\(\)\+\.]{7,25}$",
|
||||
message=_("Enter a valid phone number (7-25 characters, digits and separators only)"),
|
||||
message=_(
|
||||
"Enter a valid phone number (7-25 characters, digits and separators only)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,16 +35,18 @@ class GoogleOAuthCallbackView(APIView):
|
|||
"code": serializers.CharField(),
|
||||
"code_verifier": serializers.CharField(),
|
||||
"redirect_uri": serializers.CharField(),
|
||||
}
|
||||
},
|
||||
),
|
||||
responses={200: inline_serializer(
|
||||
name="GoogleOAuthResponse",
|
||||
fields={
|
||||
"access_token": serializers.CharField(),
|
||||
"refresh_token": serializers.CharField(),
|
||||
"user": serializers.DictField(),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="GoogleOAuthResponse",
|
||||
fields={
|
||||
"access_token": serializers.CharField(),
|
||||
"refresh_token": serializers.CharField(),
|
||||
"user": serializers.DictField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
import base64
|
||||
|
|
@ -133,11 +135,13 @@ class GoogleOAuthCallbackView(APIView):
|
|||
# Generate JWT tokens
|
||||
token = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
"access_token": str(token.access_token),
|
||||
"refresh_token": str(token),
|
||||
"user": {"id": str(user.id), "email": user.email},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"access_token": str(token.access_token),
|
||||
"refresh_token": str(token),
|
||||
"user": {"id": str(user.id), "email": user.email},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
|
|
@ -211,7 +215,9 @@ class LoginView(APIView):
|
|||
|
||||
# Generate JWT tokens with org context (include profile for role)
|
||||
if default_org:
|
||||
token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile)
|
||||
token = OrgAwareRefreshToken.for_user_and_org(
|
||||
user, default_org, profile
|
||||
)
|
||||
else:
|
||||
# User has no orgs - generate token without org context
|
||||
token = RefreshToken.for_user(user)
|
||||
|
|
@ -317,9 +323,7 @@ class OrgAwareTokenRefreshView(APIView):
|
|||
description="Refresh access token with org membership validation",
|
||||
request=inline_serializer(
|
||||
name="OrgAwareTokenRefreshRequest",
|
||||
fields={
|
||||
"refresh": serializers.CharField(help_text="Refresh token")
|
||||
},
|
||||
fields={"refresh": serializers.CharField(help_text="Refresh token")},
|
||||
),
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
|
|
@ -393,7 +397,10 @@ class OrgAwareTokenRefreshView(APIView):
|
|||
)
|
||||
|
||||
except TokenError:
|
||||
return Response({"error": "Invalid or expired token"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
return Response(
|
||||
{"error": "Invalid or expired token"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not found"}, status=status.HTTP_401_UNAUTHORIZED
|
||||
|
|
@ -459,7 +466,9 @@ class OrgSwitchView(APIView):
|
|||
)
|
||||
|
||||
# Generate new tokens with the target org (include profile for role)
|
||||
token = OrgAwareRefreshToken.for_user_and_org(request.user, profile.org, profile)
|
||||
token = OrgAwareRefreshToken.for_user_and_org(
|
||||
request.user, profile.org, profile
|
||||
)
|
||||
|
||||
# Audit log the org switch
|
||||
audit_log.org_switch(request.user, from_org, profile.org, request)
|
||||
|
|
|
|||
|
|
@ -26,25 +26,26 @@ from tasks.serializer import TaskSerializer
|
|||
|
||||
|
||||
class ApiHomeView(APIView):
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["home"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="ApiHomeResponse",
|
||||
fields={
|
||||
"accounts_count": serializers.IntegerField(),
|
||||
"contacts_count": serializers.IntegerField(),
|
||||
"leads_count": serializers.IntegerField(),
|
||||
"opportunities_count": serializers.IntegerField(),
|
||||
"accounts": AccountSerializer(many=True),
|
||||
"contacts": ContactSerializer(many=True),
|
||||
"leads": LeadSerializer(many=True),
|
||||
"opportunities": OpportunitySerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ApiHomeResponse",
|
||||
fields={
|
||||
"accounts_count": serializers.IntegerField(),
|
||||
"contacts_count": serializers.IntegerField(),
|
||||
"leads_count": serializers.IntegerField(),
|
||||
"opportunities_count": serializers.IntegerField(),
|
||||
"accounts": AccountSerializer(many=True),
|
||||
"contacts": ContactSerializer(many=True),
|
||||
"leads": LeadSerializer(many=True),
|
||||
"opportunities": OpportunitySerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
org = request.profile.org
|
||||
|
|
@ -63,24 +64,19 @@ class ApiHomeView(APIView):
|
|||
|
||||
if not is_admin:
|
||||
accounts = accounts.filter(
|
||||
Q(assigned_to=profile)
|
||||
| Q(created_by=profile.user)
|
||||
Q(assigned_to=profile) | Q(created_by=profile.user)
|
||||
)
|
||||
contacts = contacts.filter(
|
||||
Q(assigned_to__id__in=[profile.id])
|
||||
| Q(created_by=profile.user)
|
||||
Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user)
|
||||
)
|
||||
leads = leads.filter(
|
||||
Q(assigned_to__id__in=[profile.id])
|
||||
| Q(created_by=profile.user)
|
||||
Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user)
|
||||
).exclude(status="closed")
|
||||
opportunities = opportunities.filter(
|
||||
Q(assigned_to__id__in=[profile.id])
|
||||
| Q(created_by=profile.user)
|
||||
Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user)
|
||||
)
|
||||
tasks = tasks.filter(
|
||||
Q(assigned_to__id__in=[profile.id])
|
||||
| Q(created_by=profile.user)
|
||||
Q(assigned_to__id__in=[profile.id]) | Q(created_by=profile.user)
|
||||
)
|
||||
|
||||
# Build base context (existing)
|
||||
|
|
@ -96,22 +92,17 @@ class ApiHomeView(APIView):
|
|||
|
||||
# NEW: Urgent counts for Focus Bar
|
||||
overdue_tasks = tasks.filter(
|
||||
status__in=["New", "In Progress"],
|
||||
due_date__lt=today
|
||||
status__in=["New", "In Progress"], due_date__lt=today
|
||||
).count()
|
||||
|
||||
tasks_due_today = tasks.filter(
|
||||
status__in=["New", "In Progress"],
|
||||
due_date=today
|
||||
status__in=["New", "In Progress"], due_date=today
|
||||
).count()
|
||||
|
||||
followups_today = leads.filter(
|
||||
next_follow_up=today
|
||||
).count()
|
||||
followups_today = leads.filter(next_follow_up=today).count()
|
||||
|
||||
hot_leads = leads.filter(
|
||||
rating="HOT",
|
||||
status__in=["assigned", "in process"]
|
||||
rating="HOT", status__in=["assigned", "in process"]
|
||||
).count()
|
||||
|
||||
context["urgent_counts"] = {
|
||||
|
|
@ -160,16 +151,17 @@ class ApiHomeView(APIView):
|
|||
total=Coalesce(
|
||||
Sum(F("amount") * F("probability") / 100),
|
||||
0,
|
||||
output_field=DecimalField()
|
||||
output_field=DecimalField(),
|
||||
)
|
||||
)["total"]
|
||||
|
||||
# Won this month (use timezone-aware datetime for updated_at comparison)
|
||||
now = timezone.now()
|
||||
first_day_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
first_day_of_month = now.replace(
|
||||
day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
won_opps = opportunities.filter(
|
||||
stage="CLOSED_WON",
|
||||
updated_at__gte=first_day_of_month
|
||||
stage="CLOSED_WON", updated_at__gte=first_day_of_month
|
||||
)
|
||||
won_opps_with_currency = won_opps.filter(
|
||||
Q(currency=org_currency) | Q(currency__isnull=True) | Q(currency="")
|
||||
|
|
@ -181,7 +173,9 @@ class ApiHomeView(APIView):
|
|||
# Conversion rate: leads converted / total leads
|
||||
total_leads_all = Lead.objects.filter(org=org).count()
|
||||
converted_leads = Lead.objects.filter(org=org, status="converted").count()
|
||||
conversion_rate = (converted_leads / total_leads_all * 100) if total_leads_all > 0 else 0
|
||||
conversion_rate = (
|
||||
(converted_leads / total_leads_all * 100) if total_leads_all > 0 else 0
|
||||
)
|
||||
|
||||
# Count opportunities in other currencies (for info)
|
||||
other_currency_count = opportunities.exclude(
|
||||
|
|
@ -199,8 +193,7 @@ class ApiHomeView(APIView):
|
|||
|
||||
# NEW: Hot leads list for dedicated panel
|
||||
hot_leads_qs = leads.filter(
|
||||
rating="HOT",
|
||||
status__in=["assigned", "in process"]
|
||||
rating="HOT", status__in=["assigned", "in process"]
|
||||
).order_by("-created_at")[:10]
|
||||
|
||||
context["hot_leads"] = [
|
||||
|
|
@ -210,24 +203,31 @@ class ApiHomeView(APIView):
|
|||
"last_name": lead.last_name,
|
||||
"company": lead.company_name,
|
||||
"rating": lead.rating,
|
||||
"next_follow_up": lead.next_follow_up.isoformat() if lead.next_follow_up else None,
|
||||
"last_contacted": lead.last_contacted.isoformat() if lead.last_contacted else None,
|
||||
"next_follow_up": lead.next_follow_up.isoformat()
|
||||
if lead.next_follow_up
|
||||
else None,
|
||||
"last_contacted": lead.last_contacted.isoformat()
|
||||
if lead.last_contacted
|
||||
else None,
|
||||
}
|
||||
for lead in hot_leads_qs
|
||||
]
|
||||
|
||||
# Include tasks in dashboard response (avoid separate API call)
|
||||
upcoming_tasks = tasks.filter(
|
||||
status__in=["New", "In Progress"],
|
||||
due_date__isnull=False
|
||||
status__in=["New", "In Progress"], due_date__isnull=False
|
||||
).order_by("due_date")[:10]
|
||||
context["tasks"] = TaskSerializer(upcoming_tasks, many=True).data
|
||||
|
||||
# Include recent activities (avoid separate API call)
|
||||
activities = Activity.objects.filter(
|
||||
org=org
|
||||
).select_related("user", "user__user").order_by("-created_at")[:10]
|
||||
context["activities"] = serializer.ActivitySerializer(activities, many=True).data
|
||||
activities = (
|
||||
Activity.objects.filter(org=org)
|
||||
.select_related("user", "user__user")
|
||||
.order_by("-created_at")[:10]
|
||||
)
|
||||
context["activities"] = serializer.ActivitySerializer(
|
||||
activities, many=True
|
||||
).data
|
||||
|
||||
return Response(context, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
|
|
@ -121,16 +121,18 @@ class DocumentListView(APIView, LimitOffsetPagination):
|
|||
tags=["documents"],
|
||||
operation_id="documents_list",
|
||||
parameters=swagger_params.document_get_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DocumentListResponse",
|
||||
fields={
|
||||
"search": serializers.BooleanField(),
|
||||
"documents_active": serializers.DictField(),
|
||||
"documents_inactive": serializers.DictField(),
|
||||
"users": ProfileSerializer(many=True),
|
||||
"status_choices": serializers.ListField(),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DocumentListResponse",
|
||||
fields={
|
||||
"search": serializers.BooleanField(),
|
||||
"documents_active": serializers.DictField(),
|
||||
"documents_inactive": serializers.DictField(),
|
||||
"users": ProfileSerializer(many=True),
|
||||
"status_choices": serializers.ListField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
|
|
@ -141,10 +143,15 @@ class DocumentListView(APIView, LimitOffsetPagination):
|
|||
operation_id="documents_create",
|
||||
parameters=swagger_params.organization_params,
|
||||
request=DocumentCreateSwaggerSerializer,
|
||||
responses={201: inline_serializer(
|
||||
name="DocumentCreateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
201: inline_serializer(
|
||||
name="DocumentCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -189,14 +196,16 @@ class DocumentDetailView(APIView):
|
|||
tags=["documents"],
|
||||
operation_id="documents_retrieve",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DocumentDetailResponse",
|
||||
fields={
|
||||
"doc_obj": DocumentSerializer(),
|
||||
"file_type_code": serializers.CharField(),
|
||||
"users": ProfileSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DocumentDetailResponse",
|
||||
fields={
|
||||
"doc_obj": DocumentSerializer(),
|
||||
"file_type_code": serializers.CharField(),
|
||||
"users": ProfileSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, pk, format=None):
|
||||
# get_object_or_404 now handles org filtering - returns 404 if not found/wrong org
|
||||
|
|
@ -232,10 +241,15 @@ class DocumentDetailView(APIView):
|
|||
tags=["documents"],
|
||||
operation_id="documents_destroy",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DocumentDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DocumentDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, format=None):
|
||||
document = self.get_object(pk)
|
||||
|
|
@ -272,10 +286,15 @@ class DocumentDetailView(APIView):
|
|||
operation_id="documents_update",
|
||||
parameters=swagger_params.organization_params,
|
||||
request=DocumentEditSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="DocumentUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DocumentUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
|
|
@ -340,10 +359,15 @@ class DocumentDetailView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=DocumentEditSwaggerSerializer,
|
||||
description="Partial Document Update",
|
||||
responses={200: inline_serializer(
|
||||
name="DocumentPatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DocumentPatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, format=None):
|
||||
"""Handle partial updates to a document."""
|
||||
|
|
@ -356,7 +380,10 @@ class DocumentDetailView(APIView):
|
|||
)
|
||||
if self.object.org != self.request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company does not match with header...."},
|
||||
{
|
||||
"error": True,
|
||||
"errors": "User company does not match with header....",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class OrgProfileCreateView(APIView):
|
|||
|
||||
@extend_schema(
|
||||
operation_id="org_list",
|
||||
description="Just Pass the token, will return ORG list, associated with user"
|
||||
description="Just Pass the token, will return ORG list, associated with user",
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
"""
|
||||
|
|
@ -210,7 +210,8 @@ class OrgUpdateView(APIView):
|
|||
|
||||
@extend_schema(
|
||||
operation_id="org_retrieve",
|
||||
description="Get organization details", responses={200: OrganizationSerializer}
|
||||
description="Get organization details",
|
||||
responses={200: OrganizationSerializer},
|
||||
)
|
||||
def get(self, request, pk, format=None):
|
||||
# Check if user has access to this organization
|
||||
|
|
@ -247,10 +248,11 @@ class ProfileView(APIView):
|
|||
@extend_schema(
|
||||
tags=["profile"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="ProfileViewResponse",
|
||||
fields={"user_obj": ProfileSerializer()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ProfileViewResponse", fields={"user_obj": ProfileSerializer()}
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
# profile=Profile.objects.get(user=request.user)
|
||||
|
|
@ -263,12 +265,17 @@ class ProfileView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=inline_serializer(
|
||||
name="ProfilePatchRequest",
|
||||
fields={"phone": serializers.CharField(required=False)}
|
||||
fields={"phone": serializers.CharField(required=False)},
|
||||
),
|
||||
responses={200: inline_serializer(
|
||||
name="ProfilePatchUpdateResponse",
|
||||
fields={"message": serializers.CharField(), "user_obj": ProfileSerializer()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ProfilePatchUpdateResponse",
|
||||
fields={
|
||||
"message": serializers.CharField(),
|
||||
"user_obj": ProfileSerializer(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, format=None):
|
||||
profile = request.profile
|
||||
|
|
|
|||
|
|
@ -24,14 +24,16 @@ class DomainList(APIView):
|
|||
tags=["Settings"],
|
||||
operation_id="api_settings_list",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DomainListResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"api_settings": APISettingsListSerializer(many=True),
|
||||
"users": ProfileSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DomainListResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"api_settings": APISettingsListSerializer(many=True),
|
||||
"users": ProfileSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
api_settings = APISettings.objects.filter(org=request.profile.org)
|
||||
|
|
@ -52,10 +54,15 @@ class DomainList(APIView):
|
|||
operation_id="api_settings_create",
|
||||
parameters=swagger_params.organization_params,
|
||||
request=APISettingsSwaggerSerializer,
|
||||
responses={201: inline_serializer(
|
||||
name="DomainCreateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
201: inline_serializer(
|
||||
name="DomainCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -97,10 +104,15 @@ class DomainDetailView(APIView):
|
|||
tags=["Settings"],
|
||||
operation_id="api_settings_retrieve",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DomainDetailResponse",
|
||||
fields={"error": serializers.BooleanField(), "domain": APISettingsListSerializer()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DomainDetailResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"domain": APISettingsListSerializer(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, pk, format=None):
|
||||
api_setting = self.get_object(pk)
|
||||
|
|
@ -114,10 +126,15 @@ class DomainDetailView(APIView):
|
|||
operation_id="api_settings_update",
|
||||
parameters=swagger_params.organization_params,
|
||||
request=APISettingsSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="DomainUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DomainUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, **kwargs):
|
||||
api_setting = self.get_object(pk)
|
||||
|
|
@ -153,16 +170,23 @@ class DomainDetailView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=APISettingsSwaggerSerializer,
|
||||
description="Partial API Settings Update",
|
||||
responses={200: inline_serializer(
|
||||
name="DomainPatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DomainPatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, **kwargs):
|
||||
"""Handle partial updates to API settings."""
|
||||
api_setting = self.get_object(pk)
|
||||
params = request.data
|
||||
serializer = APISettingsSerializer(data=params, instance=api_setting, partial=True)
|
||||
serializer = APISettingsSerializer(
|
||||
data=params, instance=api_setting, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
api_setting = serializer.save()
|
||||
return Response(
|
||||
|
|
@ -178,10 +202,15 @@ class DomainDetailView(APIView):
|
|||
tags=["Settings"],
|
||||
operation_id="api_settings_destroy",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="DomainDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="DomainDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, **kwargs):
|
||||
api_setting = self.get_object(pk)
|
||||
|
|
|
|||
|
|
@ -55,16 +55,18 @@ class TeamsListView(APIView, LimitOffsetPagination):
|
|||
tags=["Teams"],
|
||||
operation_id="teams_list",
|
||||
parameters=swagger_params.teams_list_get_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamsListResponse",
|
||||
fields={
|
||||
"per_page": serializers.IntegerField(),
|
||||
"page_number": serializers.ListField(),
|
||||
"teams_count": serializers.IntegerField(),
|
||||
"offset": serializers.IntegerField(allow_null=True),
|
||||
"teams": TeamsSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamsListResponse",
|
||||
fields={
|
||||
"per_page": serializers.IntegerField(),
|
||||
"page_number": serializers.ListField(),
|
||||
"teams_count": serializers.IntegerField(),
|
||||
"offset": serializers.IntegerField(allow_null=True),
|
||||
"teams": TeamsSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, *args, **kwargs):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -83,10 +85,15 @@ class TeamsListView(APIView, LimitOffsetPagination):
|
|||
operation_id="teams_create",
|
||||
request=TeamswaggerCreateSerializer,
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamCreateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -130,10 +137,11 @@ class TeamsDetailView(APIView):
|
|||
tags=["Teams"],
|
||||
operation_id="teams_retrieve",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamDetailResponse",
|
||||
fields={"team": TeamsSerializer()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamDetailResponse", fields={"team": TeamsSerializer()}
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, pk, **kwargs):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -154,10 +162,15 @@ class TeamsDetailView(APIView):
|
|||
operation_id="teams_update",
|
||||
request=TeamswaggerCreateSerializer,
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, *args, **kwargs):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -206,10 +219,15 @@ class TeamsDetailView(APIView):
|
|||
request=TeamswaggerCreateSerializer,
|
||||
parameters=swagger_params.organization_params,
|
||||
description="Partial Team Update",
|
||||
responses={200: inline_serializer(
|
||||
name="TeamPatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamPatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, *args, **kwargs):
|
||||
"""Handle partial updates to a team."""
|
||||
|
|
@ -241,10 +259,15 @@ class TeamsDetailView(APIView):
|
|||
tags=["Teams"],
|
||||
operation_id="teams_destroy",
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamDeleteResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamDeleteResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, **kwargs):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
|
|||
|
|
@ -32,19 +32,20 @@ from opportunity.serializer import OpportunitySerializer
|
|||
|
||||
|
||||
class GetTeamsAndUsersView(APIView):
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["users"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="TeamsAndUsersResponse",
|
||||
fields={
|
||||
"teams": TeamsSerializer(many=True),
|
||||
"profiles": ProfileSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="TeamsAndUsersResponse",
|
||||
fields={
|
||||
"teams": TeamsSerializer(many=True),
|
||||
"profiles": ProfileSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
data = {}
|
||||
|
|
@ -60,17 +61,21 @@ class GetTeamsAndUsersView(APIView):
|
|||
|
||||
|
||||
class UsersListView(APIView, LimitOffsetPagination):
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["users"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=UserCreateSwaggerSerializer,
|
||||
responses={201: inline_serializer(
|
||||
name="UserCreateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
201: inline_serializer(
|
||||
name="UserCreateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser:
|
||||
|
|
@ -120,16 +125,18 @@ class UsersListView(APIView, LimitOffsetPagination):
|
|||
@extend_schema(
|
||||
tags=["users"],
|
||||
parameters=swagger_params.user_list_params,
|
||||
responses={200: inline_serializer(
|
||||
name="UsersListResponse",
|
||||
fields={
|
||||
"active_users": serializers.DictField(),
|
||||
"inactive_users": serializers.DictField(),
|
||||
"admin_email": serializers.CharField(),
|
||||
"roles": serializers.ListField(),
|
||||
"status": serializers.ListField(),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UsersListResponse",
|
||||
fields={
|
||||
"active_users": serializers.DictField(),
|
||||
"inactive_users": serializers.DictField(),
|
||||
"admin_email": serializers.CharField(),
|
||||
"roles": serializers.ListField(),
|
||||
"status": serializers.ListField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
# Check if profile exists and user has permission
|
||||
|
|
@ -210,10 +217,15 @@ class UserDetailView(APIView):
|
|||
@extend_schema(
|
||||
tags=["users"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="UserDetailResponse",
|
||||
fields={"error": serializers.BooleanField(), "data": serializers.DictField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UserDetailResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"data": serializers.DictField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def get(self, request, pk, format=None):
|
||||
profile_obj = self.get_object(pk)
|
||||
|
|
@ -258,10 +270,15 @@ class UserDetailView(APIView):
|
|||
tags=["users"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=UserCreateSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="UserUpdateResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UserUpdateResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -321,10 +338,15 @@ class UserDetailView(APIView):
|
|||
parameters=swagger_params.organization_params,
|
||||
request=UserCreateSwaggerSerializer,
|
||||
description="Partial User Update",
|
||||
responses={200: inline_serializer(
|
||||
name="UserPatchResponse",
|
||||
fields={"error": serializers.BooleanField(), "message": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UserPatchResponse",
|
||||
fields={
|
||||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def patch(self, request, pk, format=None):
|
||||
"""Handle partial updates to a user."""
|
||||
|
|
@ -342,7 +364,10 @@ class UserDetailView(APIView):
|
|||
|
||||
if profile.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company does not match with header...."},
|
||||
{
|
||||
"error": True,
|
||||
"errors": "User company does not match with header....",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
|
|
@ -377,10 +402,11 @@ class UserDetailView(APIView):
|
|||
@extend_schema(
|
||||
tags=["users"],
|
||||
parameters=swagger_params.organization_params,
|
||||
responses={200: inline_serializer(
|
||||
name="UserDeleteResponse",
|
||||
fields={"status": serializers.CharField()}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UserDeleteResponse", fields={"status": serializers.CharField()}
|
||||
)
|
||||
},
|
||||
)
|
||||
def delete(self, request, pk, format=None):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -411,13 +437,15 @@ class UserStatusView(APIView):
|
|||
description="User Status View",
|
||||
parameters=swagger_params.organization_params,
|
||||
request=UserUpdateStatusSwaggerSerializer,
|
||||
responses={200: inline_serializer(
|
||||
name="UserStatusResponse",
|
||||
fields={
|
||||
"active_profiles": ProfileSerializer(many=True),
|
||||
"inactive_profiles": ProfileSerializer(many=True),
|
||||
}
|
||||
)},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="UserStatusResponse",
|
||||
fields={
|
||||
"active_profiles": ProfileSerializer(many=True),
|
||||
"inactive_profiles": ProfileSerializer(many=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
def post(self, request, pk, format=None):
|
||||
if self.request.profile.role != "ADMIN" and not self.request.user.is_superuser:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0001_initial"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
("contacts", "0002_change_phone_to_charfield"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0003_add_account_fk"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0004_change_phone_to_phonenumberfield"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0002_enable_rls"),
|
||||
("contacts", "0005_rename_linked_in_url_to_linkedin_url"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0006_add_tags_related_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0007_remove_organization_index"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0008_change_phone_to_charfield"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
|
|
@ -13,36 +13,34 @@ def cleanup_duplicate_contact_emails(apps, schema_editor):
|
|||
Find duplicate contact emails (case-insensitive) within each org
|
||||
and clear email on duplicates (keep oldest).
|
||||
"""
|
||||
Contact = apps.get_model('contacts', 'Contact')
|
||||
Contact = apps.get_model("contacts", "Contact")
|
||||
|
||||
# Find duplicates: group by lower(email) + org, having count > 1
|
||||
duplicates = (
|
||||
Contact.objects
|
||||
.exclude(email__isnull=True)
|
||||
.exclude(email='')
|
||||
.annotate(email_lower=Lower('email'))
|
||||
.values('email_lower', 'org_id')
|
||||
.annotate(count=Count('id'))
|
||||
Contact.objects.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower", "org_id")
|
||||
.annotate(count=Count("id"))
|
||||
.filter(count__gt=1)
|
||||
)
|
||||
|
||||
for dup in duplicates:
|
||||
# Get all contacts with this email in this org, ordered by created_at
|
||||
contacts = list(
|
||||
Contact.objects
|
||||
.filter(org_id=dup['org_id'])
|
||||
Contact.objects.filter(org_id=dup["org_id"])
|
||||
.exclude(email__isnull=True)
|
||||
.exclude(email='')
|
||||
.annotate(email_lower=Lower('email'))
|
||||
.filter(email_lower=dup['email_lower'])
|
||||
.order_by('created_at')
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.filter(email_lower=dup["email_lower"])
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Keep the first one (oldest), clear email on the rest
|
||||
for contact in contacts[1:]:
|
||||
old_email = contact.email
|
||||
contact.email = None
|
||||
contact.save(update_fields=['email'])
|
||||
contact.save(update_fields=["email"])
|
||||
print(f" Cleared duplicate email on contact {contact.id}: '{old_email}'")
|
||||
|
||||
|
||||
|
|
@ -52,7 +50,6 @@ def reverse_noop(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0006_add_enterprise_constraints"),
|
||||
("common", "0008_enable_rls_product_invoice_line_item"),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import json
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema, inline_serializer
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiParameter,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
|
@ -51,7 +56,9 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
assigned_to__id__in=params.get("assigned_to")
|
||||
).distinct()
|
||||
if params.get("tags"):
|
||||
queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct()
|
||||
queryset = queryset.filter(
|
||||
tags__id__in=params.getlist("tags")
|
||||
).distinct()
|
||||
if params.get("search"):
|
||||
search = params.get("search")
|
||||
queryset = queryset.filter(
|
||||
|
|
@ -61,9 +68,13 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
| Q(phone__icontains=search)
|
||||
)
|
||||
if params.get("created_at__gte"):
|
||||
queryset = queryset.filter(created_at__gte=params.get("created_at__gte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__gte=params.get("created_at__gte")
|
||||
)
|
||||
if params.get("created_at__lte"):
|
||||
queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__lte=params.get("created_at__lte")
|
||||
)
|
||||
|
||||
context = {}
|
||||
results_contact = self.paginate_queryset(
|
||||
|
|
@ -160,7 +171,9 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
tags = params.get("tags")
|
||||
if isinstance(tags, str):
|
||||
tags = json.loads(tags)
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
contact_obj.tags.add(*tag_objs)
|
||||
|
||||
recipients = list(contact_obj.assigned_to.all().values_list("id", flat=True))
|
||||
|
|
@ -256,7 +269,9 @@ class ContactDetailView(APIView):
|
|||
tags = data.get("tags")
|
||||
if isinstance(tags, str):
|
||||
tags = json.loads(tags)
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
contact_obj.tags.add(*tag_objs)
|
||||
|
||||
previous_assigned_to_users = list(
|
||||
|
|
@ -514,7 +529,10 @@ class ContactDetailView(APIView):
|
|||
contact_obj = self.get_object(pk=pk)
|
||||
if contact_obj.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company does not match with header...."},
|
||||
{
|
||||
"error": True,
|
||||
"errors": "User company does not match with header....",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
|
|
@ -568,7 +586,9 @@ class ContactDetailView(APIView):
|
|||
if tags_list:
|
||||
if isinstance(tags_list, str):
|
||||
tags_list = json.loads(tags_list)
|
||||
tag_objs = Tags.objects.filter(id__in=tags_list, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags_list, org=request.profile.org, is_active=True
|
||||
)
|
||||
contact_obj.tags.add(*tag_objs)
|
||||
|
||||
return Response(
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ AWS_S3_SECURE_URLS = True
|
|||
EMAIL_BACKEND = "django_ses.SESBackend"
|
||||
|
||||
SESSION_COOKIE_DOMAIN = ".bottlecrm.io"
|
||||
SESSION_COOKIE_SECURE = True # Only send session cookie over HTTPS
|
||||
CSRF_COOKIE_SECURE = True # Only send CSRF cookie over HTTPS
|
||||
SESSION_COOKIE_SECURE = True # Only send session cookie over HTTPS
|
||||
CSRF_COOKIE_SECURE = True # Only send CSRF cookie over HTTPS
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=os.environ["SENTRY_DSN"],
|
||||
|
|
|
|||
|
|
@ -58,7 +58,14 @@ class InvoiceAdmin(admin.ModelAdmin):
|
|||
"client_email",
|
||||
"account__name",
|
||||
)
|
||||
raw_id_fields = ("created_by", "org", "account", "contact", "opportunity", "template")
|
||||
raw_id_fields = (
|
||||
"created_by",
|
||||
"org",
|
||||
"account",
|
||||
"contact",
|
||||
"opportunity",
|
||||
"template",
|
||||
)
|
||||
filter_horizontal = ("assigned_to", "teams")
|
||||
inlines = [InvoiceLineItemInline, PaymentInline]
|
||||
date_hierarchy = "created_at"
|
||||
|
|
|
|||
|
|
@ -77,14 +77,17 @@ class InvoiceListView(APIView, LimitOffsetPagination):
|
|||
org = self.request.profile.org
|
||||
role = self.request.profile.role
|
||||
|
||||
queryset = self.model.objects.filter(org=org).select_related(
|
||||
"account", "contact", "opportunity", "created_by"
|
||||
).prefetch_related("line_items", "payments", "assigned_to")
|
||||
queryset = (
|
||||
self.model.objects.filter(org=org)
|
||||
.select_related("account", "contact", "opportunity", "created_by")
|
||||
.prefetch_related("line_items", "payments", "assigned_to")
|
||||
)
|
||||
|
||||
# Non-admin users can only see their own or assigned invoices
|
||||
if role != "ADMIN" and not self.request.user.is_superuser:
|
||||
queryset = queryset.filter(
|
||||
Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile)
|
||||
Q(created_by=self.request.profile.user)
|
||||
| Q(assigned_to=self.request.profile)
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
|
@ -140,7 +143,13 @@ class InvoiceListView(APIView, LimitOffsetPagination):
|
|||
|
||||
# Sorting
|
||||
sort = params.get("sort", "-created_at")
|
||||
if sort.lstrip("-") in ["created_at", "due_date", "issue_date", "total_amount", "status"]:
|
||||
if sort.lstrip("-") in [
|
||||
"created_at",
|
||||
"due_date",
|
||||
"issue_date",
|
||||
"total_amount",
|
||||
"status",
|
||||
]:
|
||||
queryset = queryset.order_by(sort)
|
||||
|
||||
return queryset
|
||||
|
|
@ -339,9 +348,7 @@ class InvoiceSendView(APIView):
|
|||
protocol=request.scheme,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": False, "message": "Invoice sent successfully"}
|
||||
)
|
||||
return Response({"error": False, "message": "Invoice sent successfully"})
|
||||
|
||||
|
||||
class InvoiceMarkPaidView(APIView):
|
||||
|
|
@ -701,9 +708,7 @@ class PaymentListView(APIView, LimitOffsetPagination):
|
|||
|
||||
@extend_schema(tags=["Payments"], operation_id="payments_list")
|
||||
def get(self, request, invoice_id):
|
||||
invoice = Invoice.objects.filter(
|
||||
id=invoice_id, org=request.profile.org
|
||||
).first()
|
||||
invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first()
|
||||
if not invoice:
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found"},
|
||||
|
|
@ -715,9 +720,7 @@ class PaymentListView(APIView, LimitOffsetPagination):
|
|||
|
||||
@extend_schema(tags=["Payments"], operation_id="payments_create")
|
||||
def post(self, request, invoice_id):
|
||||
invoice = Invoice.objects.filter(
|
||||
id=invoice_id, org=request.profile.org
|
||||
).first()
|
||||
invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first()
|
||||
if not invoice:
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found"},
|
||||
|
|
@ -750,9 +753,13 @@ class PaymentDetailView(APIView):
|
|||
|
||||
@extend_schema(tags=["Payments"], operation_id="payments_destroy")
|
||||
def delete(self, request, invoice_id, pk):
|
||||
payment = Payment.objects.filter(
|
||||
id=pk, invoice_id=invoice_id, org=request.profile.org
|
||||
).select_related("invoice").first()
|
||||
payment = (
|
||||
Payment.objects.filter(
|
||||
id=pk, invoice_id=invoice_id, org=request.profile.org
|
||||
)
|
||||
.select_related("invoice")
|
||||
.first()
|
||||
)
|
||||
if not payment:
|
||||
return Response(
|
||||
{"error": True, "message": "Payment not found"},
|
||||
|
|
@ -1214,9 +1221,11 @@ class RecurringInvoiceListView(APIView, LimitOffsetPagination):
|
|||
|
||||
@extend_schema(tags=["Recurring Invoices"], operation_id="recurring_list")
|
||||
def get(self, request):
|
||||
queryset = RecurringInvoice.objects.filter(
|
||||
org=request.profile.org
|
||||
).select_related("account", "contact").order_by("-created_at")
|
||||
queryset = (
|
||||
RecurringInvoice.objects.filter(org=request.profile.org)
|
||||
.select_related("account", "contact")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if request.query_params.get("is_active"):
|
||||
|
|
@ -1363,9 +1372,9 @@ class InvoiceTemplateListView(APIView, LimitOffsetPagination):
|
|||
|
||||
@extend_schema(tags=["Invoice Templates"], operation_id="templates_list")
|
||||
def get(self, request):
|
||||
queryset = InvoiceTemplate.objects.filter(
|
||||
org=request.profile.org
|
||||
).order_by("-is_default", "name")
|
||||
queryset = InvoiceTemplate.objects.filter(org=request.profile.org).order_by(
|
||||
"-is_default", "name"
|
||||
)
|
||||
|
||||
results = self.paginate_queryset(queryset, request, view=self)
|
||||
return Response(
|
||||
|
|
@ -1474,9 +1483,7 @@ class InvoiceCommentView(APIView):
|
|||
|
||||
@extend_schema(tags=["Invoice Comments"], operation_id="comments_create")
|
||||
def post(self, request, invoice_id):
|
||||
invoice = Invoice.objects.filter(
|
||||
id=invoice_id, org=request.profile.org
|
||||
).first()
|
||||
invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first()
|
||||
if not invoice:
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found"},
|
||||
|
|
@ -1575,9 +1582,7 @@ class InvoiceAttachmentView(APIView):
|
|||
|
||||
@extend_schema(tags=["Invoice Attachments"], operation_id="attachments_create")
|
||||
def post(self, request, invoice_id):
|
||||
invoice = Invoice.objects.filter(
|
||||
id=invoice_id, org=request.profile.org
|
||||
).first()
|
||||
invoice = Invoice.objects.filter(id=invoice_id, org=request.profile.org).first()
|
||||
if not invoice:
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found"},
|
||||
|
|
@ -1617,9 +1622,7 @@ class InvoiceAttachmentDetailView(APIView):
|
|||
|
||||
@extend_schema(tags=["Invoice Attachments"], operation_id="attachments_destroy")
|
||||
def delete(self, request, pk):
|
||||
attachment = Attachments.objects.filter(
|
||||
id=pk, org=request.profile.org
|
||||
).first()
|
||||
attachment = Attachments.objects.filter(id=pk, org=request.profile.org).first()
|
||||
if not attachment:
|
||||
return Response(
|
||||
{"error": True, "message": "Attachment not found"},
|
||||
|
|
@ -1661,9 +1664,7 @@ class InvoiceDashboardView(APIView):
|
|||
|
||||
# Status counts
|
||||
status_counts = (
|
||||
all_invoices.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("status")
|
||||
all_invoices.values("status").annotate(count=Count("id")).order_by("status")
|
||||
)
|
||||
|
||||
# Financial summary
|
||||
|
|
@ -1679,14 +1680,18 @@ class InvoiceDashboardView(APIView):
|
|||
due_date__lt=today,
|
||||
)
|
||||
overdue_count = overdue_invoices.count()
|
||||
overdue_amount = overdue_invoices.aggregate(total=Sum("amount_due"))["total"] or 0
|
||||
overdue_amount = (
|
||||
overdue_invoices.aggregate(total=Sum("amount_due"))["total"] or 0
|
||||
)
|
||||
|
||||
# Recent activity (last 30 days)
|
||||
recent_invoices = all_invoices.filter(created_at__gte=thirty_days_ago)
|
||||
recent_paid = all_invoices.filter(paid_at__gte=thirty_days_ago)
|
||||
|
||||
recent_revenue = recent_paid.aggregate(total=Sum("amount_paid"))["total"] or 0
|
||||
recent_invoiced = recent_invoices.aggregate(total=Sum("total_amount"))["total"] or 0
|
||||
recent_invoiced = (
|
||||
recent_invoices.aggregate(total=Sum("total_amount"))["total"] or 0
|
||||
)
|
||||
|
||||
# Estimates summary
|
||||
all_estimates = Estimate.objects.filter(org=org)
|
||||
|
|
@ -1694,31 +1699,33 @@ class InvoiceDashboardView(APIView):
|
|||
estimates_accepted = all_estimates.filter(status="Accepted").count()
|
||||
estimates_declined = all_estimates.filter(status="Declined").count()
|
||||
|
||||
return Response({
|
||||
"summary": {
|
||||
"total_invoiced": str(totals["total_invoiced"] or 0),
|
||||
"total_paid": str(totals["total_paid"] or 0),
|
||||
"total_due": str(totals["total_due"] or 0),
|
||||
},
|
||||
"status_counts": {
|
||||
item["status"]: item["count"] for item in status_counts
|
||||
},
|
||||
"overdue": {
|
||||
"count": overdue_count,
|
||||
"amount": str(overdue_amount),
|
||||
},
|
||||
"recent_activity": {
|
||||
"revenue_30d": str(recent_revenue),
|
||||
"invoiced_30d": str(recent_invoiced),
|
||||
"invoices_created_30d": recent_invoices.count(),
|
||||
"invoices_paid_30d": recent_paid.count(),
|
||||
},
|
||||
"estimates": {
|
||||
"pending": estimates_pending,
|
||||
"accepted": estimates_accepted,
|
||||
"declined": estimates_declined,
|
||||
},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"summary": {
|
||||
"total_invoiced": str(totals["total_invoiced"] or 0),
|
||||
"total_paid": str(totals["total_paid"] or 0),
|
||||
"total_due": str(totals["total_due"] or 0),
|
||||
},
|
||||
"status_counts": {
|
||||
item["status"]: item["count"] for item in status_counts
|
||||
},
|
||||
"overdue": {
|
||||
"count": overdue_count,
|
||||
"amount": str(overdue_amount),
|
||||
},
|
||||
"recent_activity": {
|
||||
"revenue_30d": str(recent_revenue),
|
||||
"invoiced_30d": str(recent_invoiced),
|
||||
"invoices_created_30d": recent_invoices.count(),
|
||||
"invoices_paid_30d": recent_paid.count(),
|
||||
},
|
||||
"estimates": {
|
||||
"pending": estimates_pending,
|
||||
"accepted": estimates_accepted,
|
||||
"declined": estimates_declined,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RevenueReportView(APIView):
|
||||
|
|
@ -1755,41 +1762,59 @@ class RevenueReportView(APIView):
|
|||
# Group revenue
|
||||
if group_by == "day":
|
||||
from django.db.models.functions import TruncDay
|
||||
grouped = paid_invoices.annotate(
|
||||
period=TruncDay("paid_at")
|
||||
).values("period").annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
).order_by("period")
|
||||
|
||||
grouped = (
|
||||
paid_invoices.annotate(period=TruncDay("paid_at"))
|
||||
.values("period")
|
||||
.annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
)
|
||||
.order_by("period")
|
||||
)
|
||||
elif group_by == "week":
|
||||
from django.db.models.functions import TruncWeek
|
||||
grouped = paid_invoices.annotate(
|
||||
period=TruncWeek("paid_at")
|
||||
).values("period").annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
).order_by("period")
|
||||
|
||||
grouped = (
|
||||
paid_invoices.annotate(period=TruncWeek("paid_at"))
|
||||
.values("period")
|
||||
.annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
)
|
||||
.order_by("period")
|
||||
)
|
||||
elif group_by == "year":
|
||||
from django.db.models.functions import TruncYear
|
||||
grouped = paid_invoices.annotate(
|
||||
period=TruncYear("paid_at")
|
||||
).values("period").annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
).order_by("period")
|
||||
|
||||
grouped = (
|
||||
paid_invoices.annotate(period=TruncYear("paid_at"))
|
||||
.values("period")
|
||||
.annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
)
|
||||
.order_by("period")
|
||||
)
|
||||
else: # month (default)
|
||||
from django.db.models.functions import TruncMonth
|
||||
grouped = paid_invoices.annotate(
|
||||
period=TruncMonth("paid_at")
|
||||
).values("period").annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
).order_by("period")
|
||||
|
||||
grouped = (
|
||||
paid_invoices.annotate(period=TruncMonth("paid_at"))
|
||||
.values("period")
|
||||
.annotate(
|
||||
revenue=Sum("amount_paid"),
|
||||
count=Count("id"),
|
||||
)
|
||||
.order_by("period")
|
||||
)
|
||||
|
||||
# Format results
|
||||
data = [
|
||||
{
|
||||
"period": item["period"].strftime("%Y-%m-%d") if item["period"] else None,
|
||||
"period": item["period"].strftime("%Y-%m-%d")
|
||||
if item["period"]
|
||||
else None,
|
||||
"revenue": str(item["revenue"] or 0),
|
||||
"count": item["count"],
|
||||
}
|
||||
|
|
@ -1802,16 +1827,18 @@ class RevenueReportView(APIView):
|
|||
count=Count("id"),
|
||||
)
|
||||
|
||||
return Response({
|
||||
"start_date": str(start_date),
|
||||
"end_date": str(end_date),
|
||||
"group_by": group_by,
|
||||
"data": data,
|
||||
"total": {
|
||||
"revenue": str(total["revenue"] or 0),
|
||||
"count": total["count"],
|
||||
},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"start_date": str(start_date),
|
||||
"end_date": str(end_date),
|
||||
"group_by": group_by,
|
||||
"data": data,
|
||||
"total": {
|
||||
"revenue": str(total["revenue"] or 0),
|
||||
"count": total["count"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AgingReportView(APIView):
|
||||
|
|
@ -1868,23 +1895,29 @@ class AgingReportView(APIView):
|
|||
"client_name": inv.client_name,
|
||||
"due_date": str(inv.due_date) if inv.due_date else None,
|
||||
"amount_due": str(inv.amount_due),
|
||||
"days_overdue": (today - inv.due_date).days if inv.due_date else 0,
|
||||
"days_overdue": (today - inv.due_date).days
|
||||
if inv.due_date
|
||||
else 0,
|
||||
}
|
||||
for inv in invoices[:10] # Limit to 10 per category
|
||||
],
|
||||
}
|
||||
|
||||
return Response({
|
||||
"current": summarize(current),
|
||||
"1_30_days": summarize(days_1_30),
|
||||
"31_60_days": summarize(days_31_60),
|
||||
"61_90_days": summarize(days_61_90),
|
||||
"over_90_days": summarize(over_90),
|
||||
"total": {
|
||||
"count": unpaid_invoices.count(),
|
||||
"amount": str(sum((inv.amount_due or Decimal("0")) for inv in unpaid_invoices)),
|
||||
},
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"current": summarize(current),
|
||||
"1_30_days": summarize(days_1_30),
|
||||
"31_60_days": summarize(days_31_60),
|
||||
"61_90_days": summarize(days_61_90),
|
||||
"over_90_days": summarize(over_90),
|
||||
"total": {
|
||||
"count": unpaid_invoices.count(),
|
||||
"amount": str(
|
||||
sum((inv.amount_due or Decimal("0")) for inv in unpaid_invoices)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -1917,9 +1950,11 @@ class InvoiceFromOpportunityView(APIView):
|
|||
org = request.profile.org
|
||||
|
||||
# Get the opportunity
|
||||
opportunity = Opportunity.objects.filter(
|
||||
id=opportunity_id, org=org
|
||||
).prefetch_related("line_items", "contacts").first()
|
||||
opportunity = (
|
||||
Opportunity.objects.filter(id=opportunity_id, org=org)
|
||||
.prefetch_related("line_items", "contacts")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not opportunity:
|
||||
return Response(
|
||||
|
|
@ -1934,14 +1969,20 @@ class InvoiceFromOpportunityView(APIView):
|
|||
or (request.profile in opportunity.assigned_to.all())
|
||||
):
|
||||
return Response(
|
||||
{"error": True, "message": "You do not have permission to create invoice from this opportunity"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "You do not have permission to create invoice from this opportunity",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Verify opportunity is CLOSED_WON
|
||||
if opportunity.stage != "CLOSED_WON":
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice can only be created from CLOSED_WON opportunities"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Invoice can only be created from CLOSED_WON opportunities",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -1949,14 +1990,20 @@ class InvoiceFromOpportunityView(APIView):
|
|||
line_items = opportunity.line_items.all()
|
||||
if not line_items.exists():
|
||||
return Response(
|
||||
{"error": True, "message": "Opportunity has no products/line items to invoice"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Opportunity has no products/line items to invoice",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if opportunity has an account
|
||||
if not opportunity.account:
|
||||
return Response(
|
||||
{"error": True, "message": "Opportunity must have an account to create an invoice"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Opportunity must have an account to create an invoice",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -1966,19 +2013,26 @@ class InvoiceFromOpportunityView(APIView):
|
|||
|
||||
if not primary_contact:
|
||||
return Response(
|
||||
{"error": True, "message": "Opportunity must have at least one contact to create an invoice"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Opportunity must have at least one contact to create an invoice",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# Generate invoice number
|
||||
last_invoice = Invoice.objects.filter(org=org).order_by("-created_at").first()
|
||||
last_invoice = (
|
||||
Invoice.objects.filter(org=org).order_by("-created_at").first()
|
||||
)
|
||||
if last_invoice and last_invoice.invoice_number:
|
||||
try:
|
||||
last_num = int(last_invoice.invoice_number.replace("INV-", ""))
|
||||
new_number = f"INV-{last_num + 1:06d}"
|
||||
except ValueError:
|
||||
new_number = f"INV-{Invoice.objects.filter(org=org).count() + 1:06d}"
|
||||
new_number = (
|
||||
f"INV-{Invoice.objects.filter(org=org).count() + 1:06d}"
|
||||
)
|
||||
else:
|
||||
new_number = "INV-000001"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
@ -238,9 +237,7 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
(
|
||||
"phone",
|
||||
models.CharField(
|
||||
blank=True, max_length=20, null=True
|
||||
),
|
||||
models.CharField(blank=True, max_length=20, null=True),
|
||||
),
|
||||
(
|
||||
"amount_due",
|
||||
|
|
@ -630,9 +627,7 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
(
|
||||
"phone",
|
||||
models.CharField(
|
||||
blank=True, max_length=20, null=True
|
||||
),
|
||||
models.CharField(blank=True, max_length=20, null=True),
|
||||
),
|
||||
(
|
||||
"amount_due",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("invoices", "0001_initial"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("invoices", "0002_remove_phonenumberfield"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("invoices", "0004_estimate_estimatelineitem_invoicetemplate_payment_and_more"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("invoices", "0005_add_billing_period_po_number_and_lineitem_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0006_remove_legacy_invoice_fields'),
|
||||
("invoices", "0006_remove_legacy_invoice_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='cancelled_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Cancelled At'),
|
||||
model_name="invoice",
|
||||
name="cancelled_at",
|
||||
field=models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Cancelled At"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -144,9 +144,7 @@ class Product(BaseModel):
|
|||
name = models.CharField(_("Product Name"), max_length=255)
|
||||
description = models.TextField(_("Description"), blank=True, null=True)
|
||||
sku = models.CharField(_("SKU"), max_length=100, blank=True, null=True)
|
||||
price = models.DecimalField(
|
||||
_("Price"), max_digits=12, decimal_places=2, default=0
|
||||
)
|
||||
price = models.DecimalField(_("Price"), max_digits=12, decimal_places=2, default=0)
|
||||
currency = models.CharField(
|
||||
_("Currency"), max_length=3, choices=CURRENCY_CODES, blank=True, null=True
|
||||
)
|
||||
|
|
@ -340,9 +338,7 @@ class Invoice(AssignableMixin, BaseModel):
|
|||
billing_period = models.CharField(
|
||||
_("Billing Period"), max_length=100, blank=True, null=True
|
||||
)
|
||||
po_number = models.CharField(
|
||||
_("PO Number"), max_length=100, blank=True, null=True
|
||||
)
|
||||
po_number = models.CharField(_("PO Number"), max_length=100, blank=True, null=True)
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ManyToManyField(Profile, related_name="invoice_assigned_to")
|
||||
|
|
@ -409,8 +405,7 @@ class Invoice(AssignableMixin, BaseModel):
|
|||
.select_for_update()
|
||||
.annotate(
|
||||
seq_num=Cast(
|
||||
Substr("invoice_number", prefix_len + 1),
|
||||
IntegerField()
|
||||
Substr("invoice_number", prefix_len + 1), IntegerField()
|
||||
)
|
||||
)
|
||||
.aggregate(max_seq=Max("seq_num"))
|
||||
|
|
@ -447,7 +442,9 @@ class Invoice(AssignableMixin, BaseModel):
|
|||
|
||||
# Calculate discount
|
||||
if self.discount_type == "PERCENTAGE":
|
||||
self.discount_amount = self.subtotal * (self.discount_value / Decimal("100"))
|
||||
self.discount_amount = self.subtotal * (
|
||||
self.discount_value / Decimal("100")
|
||||
)
|
||||
else:
|
||||
self.discount_amount = self.discount_value
|
||||
|
||||
|
|
@ -564,7 +561,9 @@ class InvoiceLineItem(BaseModel):
|
|||
|
||||
# Calculate discount
|
||||
if self.discount_type == "PERCENTAGE":
|
||||
self.discount_amount = self.subtotal * (self.discount_value / Decimal("100"))
|
||||
self.discount_amount = self.subtotal * (
|
||||
self.discount_value / Decimal("100")
|
||||
)
|
||||
else:
|
||||
self.discount_amount = self.discount_value
|
||||
|
||||
|
|
@ -648,7 +647,9 @@ class Payment(BaseModel):
|
|||
def update_invoice_payment(self, invoice=None):
|
||||
"""Update the invoice's amount_paid and status"""
|
||||
invoice = invoice or self.invoice
|
||||
total_paid = invoice.payments.aggregate(total=models.Sum("amount"))["total"] or 0
|
||||
total_paid = (
|
||||
invoice.payments.aggregate(total=models.Sum("amount"))["total"] or 0
|
||||
)
|
||||
invoice.amount_paid = total_paid
|
||||
invoice.amount_due = invoice.total_amount - total_paid
|
||||
|
||||
|
|
@ -674,9 +675,7 @@ class Estimate(AssignableMixin, BaseModel):
|
|||
"""Estimates/Quotes - can be converted to Invoice"""
|
||||
|
||||
# Core Info
|
||||
estimate_number = models.CharField(
|
||||
_("Estimate Number"), max_length=50, unique=True
|
||||
)
|
||||
estimate_number = models.CharField(_("Estimate Number"), max_length=50, unique=True)
|
||||
title = models.CharField(_("Title"), max_length=100)
|
||||
status = models.CharField(
|
||||
_("Status"), max_length=20, choices=ESTIMATE_STATUS, default="Draft"
|
||||
|
|
@ -836,8 +835,7 @@ class Estimate(AssignableMixin, BaseModel):
|
|||
.select_for_update()
|
||||
.annotate(
|
||||
seq_num=Cast(
|
||||
Substr("estimate_number", prefix_len + 1),
|
||||
IntegerField()
|
||||
Substr("estimate_number", prefix_len + 1), IntegerField()
|
||||
)
|
||||
)
|
||||
.aggregate(max_seq=Max("seq_num"))
|
||||
|
|
@ -854,7 +852,9 @@ class Estimate(AssignableMixin, BaseModel):
|
|||
self.subtotal = sum(item.subtotal for item in line_items)
|
||||
|
||||
if self.discount_type == "PERCENTAGE":
|
||||
self.discount_amount = self.subtotal * (self.discount_value / Decimal("100"))
|
||||
self.discount_amount = self.subtotal * (
|
||||
self.discount_value / Decimal("100")
|
||||
)
|
||||
else:
|
||||
self.discount_amount = self.discount_value
|
||||
|
||||
|
|
@ -939,7 +939,9 @@ class EstimateLineItem(BaseModel):
|
|||
self.subtotal = self.quantity * self.unit_price
|
||||
|
||||
if self.discount_type == "PERCENTAGE":
|
||||
self.discount_amount = self.subtotal * (self.discount_value / Decimal("100"))
|
||||
self.discount_amount = self.subtotal * (
|
||||
self.discount_value / Decimal("100")
|
||||
)
|
||||
else:
|
||||
self.discount_amount = self.discount_value
|
||||
|
||||
|
|
@ -1001,7 +1003,9 @@ class RecurringInvoice(AssignableMixin, BaseModel):
|
|||
)
|
||||
start_date = models.DateField(_("Start Date"), default=datetime.date.today)
|
||||
end_date = models.DateField(_("End Date"), null=True, blank=True)
|
||||
next_generation_date = models.DateField(_("Next Generation Date"), default=datetime.date.today)
|
||||
next_generation_date = models.DateField(
|
||||
_("Next Generation Date"), default=datetime.date.today
|
||||
)
|
||||
|
||||
# Settings
|
||||
payment_terms = models.CharField(
|
||||
|
|
@ -1034,9 +1038,7 @@ class RecurringInvoice(AssignableMixin, BaseModel):
|
|||
terms = models.TextField(_("Terms & Conditions"), blank=True)
|
||||
|
||||
# Statistics
|
||||
invoices_generated = models.PositiveIntegerField(
|
||||
_("Invoices Generated"), default=0
|
||||
)
|
||||
invoices_generated = models.PositiveIntegerField(_("Invoices Generated"), default=0)
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ManyToManyField(
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ def generate_invoice_pdf(invoice, include_payments=True):
|
|||
template = invoice.template
|
||||
if not template:
|
||||
from invoices.models import InvoiceTemplate
|
||||
|
||||
template = InvoiceTemplate.objects.filter(
|
||||
org=invoice.org, is_default=True
|
||||
).first()
|
||||
|
|
@ -357,26 +358,32 @@ def generate_invoice_pdf(invoice, include_payments=True):
|
|||
line_items = []
|
||||
for item in invoice.line_items.all().order_by("order"):
|
||||
# Use name field if present, otherwise fall back to description
|
||||
line_items.append({
|
||||
"name": item.name if item.name else item.description,
|
||||
"description": item.description if item.name else "", # Show description as subtitle if name is set
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_currency(item.unit_price, invoice.currency),
|
||||
"tax_rate": item.tax_rate,
|
||||
"total": format_currency(item.total, invoice.currency),
|
||||
})
|
||||
line_items.append(
|
||||
{
|
||||
"name": item.name if item.name else item.description,
|
||||
"description": item.description
|
||||
if item.name
|
||||
else "", # Show description as subtitle if name is set
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_currency(item.unit_price, invoice.currency),
|
||||
"tax_rate": item.tax_rate,
|
||||
"total": format_currency(item.total, invoice.currency),
|
||||
}
|
||||
)
|
||||
|
||||
# Pre-format payments
|
||||
payments = []
|
||||
if include_payments:
|
||||
for payment in invoice.payments.all().order_by("-payment_date"):
|
||||
payments.append({
|
||||
"payment_date": payment.payment_date,
|
||||
"payment_method": payment.payment_method,
|
||||
"get_payment_method_display": payment.get_payment_method_display(),
|
||||
"reference_number": payment.reference_number,
|
||||
"amount": format_currency(payment.amount, invoice.currency),
|
||||
})
|
||||
payments.append(
|
||||
{
|
||||
"payment_date": payment.payment_date,
|
||||
"payment_method": payment.payment_method,
|
||||
"get_payment_method_display": payment.get_payment_method_display(),
|
||||
"reference_number": payment.reference_number,
|
||||
"amount": format_currency(payment.amount, invoice.currency),
|
||||
}
|
||||
)
|
||||
|
||||
# Prepare context with pre-formatted currency values
|
||||
context = {
|
||||
|
|
@ -442,22 +449,25 @@ def generate_estimate_pdf(estimate):
|
|||
|
||||
# Get the org's default template for styling
|
||||
from invoices.models import InvoiceTemplate
|
||||
template = InvoiceTemplate.objects.filter(
|
||||
org=estimate.org, is_default=True
|
||||
).first()
|
||||
|
||||
template = InvoiceTemplate.objects.filter(org=estimate.org, is_default=True).first()
|
||||
|
||||
# Pre-format line items with currency
|
||||
line_items = []
|
||||
for item in estimate.line_items.all().order_by("order"):
|
||||
# Use name field if present, otherwise fall back to description
|
||||
line_items.append({
|
||||
"name": item.name if item.name else item.description,
|
||||
"description": item.description if item.name else "", # Show description as subtitle if name is set
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_currency(item.unit_price, estimate.currency),
|
||||
"tax_rate": item.tax_rate,
|
||||
"total": format_currency(item.total, estimate.currency),
|
||||
})
|
||||
line_items.append(
|
||||
{
|
||||
"name": item.name if item.name else item.description,
|
||||
"description": item.description
|
||||
if item.name
|
||||
else "", # Show description as subtitle if name is set
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_currency(item.unit_price, estimate.currency),
|
||||
"tax_rate": item.tax_rate,
|
||||
"total": format_currency(item.total, estimate.currency),
|
||||
}
|
||||
)
|
||||
|
||||
# Prepare context with pre-formatted currency values
|
||||
context = {
|
||||
|
|
@ -516,7 +526,9 @@ def render_invoice_template(template_html, context):
|
|||
"{{invoice.issue_date}}": str(invoice.issue_date) if invoice.issue_date else "",
|
||||
"{{invoice.due_date}}": str(invoice.due_date) if invoice.due_date else "",
|
||||
"{{invoice.subtotal}}": format_currency(invoice.subtotal, currency),
|
||||
"{{invoice.discount_amount}}": format_currency(invoice.discount_amount, currency),
|
||||
"{{invoice.discount_amount}}": format_currency(
|
||||
invoice.discount_amount, currency
|
||||
),
|
||||
"{{invoice.tax_amount}}": format_currency(invoice.tax_amount, currency),
|
||||
"{{invoice.total_amount}}": format_currency(invoice.total_amount, currency),
|
||||
"{{invoice.amount_paid}}": format_currency(invoice.amount_paid, currency),
|
||||
|
|
@ -528,10 +540,16 @@ def render_invoice_template(template_html, context):
|
|||
|
||||
# Address fields
|
||||
address_fields = [
|
||||
"billing_address_line", "billing_city", "billing_state",
|
||||
"billing_postcode", "billing_country",
|
||||
"client_address_line", "client_city", "client_state",
|
||||
"client_postcode", "client_country",
|
||||
"billing_address_line",
|
||||
"billing_city",
|
||||
"billing_state",
|
||||
"billing_postcode",
|
||||
"billing_country",
|
||||
"client_address_line",
|
||||
"client_city",
|
||||
"client_state",
|
||||
"client_postcode",
|
||||
"client_country",
|
||||
]
|
||||
for field in address_fields:
|
||||
replacements[f"{{{{invoice.{field}}}}}"] = getattr(invoice, field, "") or ""
|
||||
|
|
|
|||
|
|
@ -313,7 +313,10 @@ class PublicEstimateAcceptView(APIView):
|
|||
|
||||
if estimate.status not in ["Sent", "Viewed"]:
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate cannot be accepted in current state"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Estimate cannot be accepted in current state",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -321,9 +324,7 @@ class PublicEstimateAcceptView(APIView):
|
|||
estimate.accepted_at = timezone.now()
|
||||
estimate.save()
|
||||
|
||||
return Response(
|
||||
{"error": False, "message": "Estimate accepted successfully"}
|
||||
)
|
||||
return Response({"error": False, "message": "Estimate accepted successfully"})
|
||||
|
||||
|
||||
class PublicEstimateDeclineView(APIView):
|
||||
|
|
@ -349,7 +350,10 @@ class PublicEstimateDeclineView(APIView):
|
|||
|
||||
if estimate.status not in ["Sent", "Viewed"]:
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate cannot be declined in current state"},
|
||||
{
|
||||
"error": True,
|
||||
"message": "Estimate cannot be declined in current state",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
|
@ -357,6 +361,4 @@ class PublicEstimateDeclineView(APIView):
|
|||
estimate.declined_at = timezone.now()
|
||||
estimate.save()
|
||||
|
||||
return Response(
|
||||
{"error": False, "message": "Estimate declined"}
|
||||
)
|
||||
return Response({"error": False, "message": "Estimate declined"})
|
||||
|
|
|
|||
|
|
@ -82,21 +82,73 @@ class InvoiceSeeder:
|
|||
|
||||
# Product templates with realistic pricing
|
||||
PRODUCT_TEMPLATES = [
|
||||
{"name": "Consulting - Senior", "category": "Consulting Services", "price_range": (150, 350)},
|
||||
{"name": "Consulting - Standard", "category": "Consulting Services", "price_range": (75, 150)},
|
||||
{"name": "Implementation Services", "category": "Implementation", "price_range": (5000, 25000)},
|
||||
{"name": "Training Session (Full Day)", "category": "Training", "price_range": (1500, 3500)},
|
||||
{"name": "Training Session (Half Day)", "category": "Training", "price_range": (800, 1800)},
|
||||
{"name": "Enterprise License", "category": "Software License", "price_range": (10000, 50000)},
|
||||
{"name": "Professional License", "category": "Software License", "price_range": (1000, 5000)},
|
||||
{"name": "Team License (per seat)", "category": "Software License", "price_range": (50, 200)},
|
||||
{"name": "Annual Support Plan", "category": "Support", "price_range": (2000, 10000)},
|
||||
{"name": "Monthly Subscription", "category": "Subscription", "price_range": (99, 499)},
|
||||
{
|
||||
"name": "Consulting - Senior",
|
||||
"category": "Consulting Services",
|
||||
"price_range": (150, 350),
|
||||
},
|
||||
{
|
||||
"name": "Consulting - Standard",
|
||||
"category": "Consulting Services",
|
||||
"price_range": (75, 150),
|
||||
},
|
||||
{
|
||||
"name": "Implementation Services",
|
||||
"category": "Implementation",
|
||||
"price_range": (5000, 25000),
|
||||
},
|
||||
{
|
||||
"name": "Training Session (Full Day)",
|
||||
"category": "Training",
|
||||
"price_range": (1500, 3500),
|
||||
},
|
||||
{
|
||||
"name": "Training Session (Half Day)",
|
||||
"category": "Training",
|
||||
"price_range": (800, 1800),
|
||||
},
|
||||
{
|
||||
"name": "Enterprise License",
|
||||
"category": "Software License",
|
||||
"price_range": (10000, 50000),
|
||||
},
|
||||
{
|
||||
"name": "Professional License",
|
||||
"category": "Software License",
|
||||
"price_range": (1000, 5000),
|
||||
},
|
||||
{
|
||||
"name": "Team License (per seat)",
|
||||
"category": "Software License",
|
||||
"price_range": (50, 200),
|
||||
},
|
||||
{
|
||||
"name": "Annual Support Plan",
|
||||
"category": "Support",
|
||||
"price_range": (2000, 10000),
|
||||
},
|
||||
{
|
||||
"name": "Monthly Subscription",
|
||||
"category": "Subscription",
|
||||
"price_range": (99, 499),
|
||||
},
|
||||
{"name": "Premium Support", "category": "Support", "price_range": (500, 2000)},
|
||||
{"name": "Custom Development (per hour)", "category": "Custom Development", "price_range": (100, 250)},
|
||||
{"name": "API Integration", "category": "Integration", "price_range": (3000, 15000)},
|
||||
{
|
||||
"name": "Custom Development (per hour)",
|
||||
"category": "Custom Development",
|
||||
"price_range": (100, 250),
|
||||
},
|
||||
{
|
||||
"name": "API Integration",
|
||||
"category": "Integration",
|
||||
"price_range": (3000, 15000),
|
||||
},
|
||||
{"name": "Hardware Setup", "category": "Hardware", "price_range": (500, 2500)},
|
||||
{"name": "Maintenance (Monthly)", "category": "Maintenance", "price_range": (200, 1000)},
|
||||
{
|
||||
"name": "Maintenance (Monthly)",
|
||||
"category": "Maintenance",
|
||||
"price_range": (200, 1000),
|
||||
},
|
||||
]
|
||||
|
||||
# Invoice template configurations
|
||||
|
|
@ -158,9 +210,9 @@ class InvoiceSeeder:
|
|||
def _generate_invoice_date(self):
|
||||
"""Generate realistic invoice dates weighted toward recent months."""
|
||||
days_ago_weights = {
|
||||
(0, 30): 35, # Last month: 35%
|
||||
(31, 60): 25, # 1-2 months ago: 25%
|
||||
(61, 90): 20, # 2-3 months ago: 20%
|
||||
(0, 30): 35, # Last month: 35%
|
||||
(31, 60): 25, # 1-2 months ago: 25%
|
||||
(61, 90): 20, # 2-3 months ago: 20%
|
||||
(91, 120): 10, # 3-4 months ago: 10%
|
||||
(121, 150): 6, # 4-5 months ago: 6%
|
||||
(151, 180): 4, # 5-6 months ago: 4%
|
||||
|
|
@ -195,8 +247,7 @@ class InvoiceSeeder:
|
|||
|
||||
# Use product templates first
|
||||
templates_to_use = random.sample(
|
||||
self.PRODUCT_TEMPLATES,
|
||||
min(count, len(self.PRODUCT_TEMPLATES))
|
||||
self.PRODUCT_TEMPLATES, min(count, len(self.PRODUCT_TEMPLATES))
|
||||
)
|
||||
|
||||
for i, template in enumerate(templates_to_use):
|
||||
|
|
@ -247,7 +298,7 @@ class InvoiceSeeder:
|
|||
from invoices.models import InvoiceTemplate
|
||||
|
||||
templates = []
|
||||
configs_to_use = self.TEMPLATE_CONFIGS[:min(count, len(self.TEMPLATE_CONFIGS))]
|
||||
configs_to_use = self.TEMPLATE_CONFIGS[: min(count, len(self.TEMPLATE_CONFIGS))]
|
||||
|
||||
for config in configs_to_use:
|
||||
template = InvoiceTemplate.objects.create(
|
||||
|
|
@ -266,7 +317,18 @@ class InvoiceSeeder:
|
|||
self.stdout.write(f" Created {len(templates)} invoice templates")
|
||||
return templates
|
||||
|
||||
def create_invoices(self, org, profiles, teams, products, templates, accounts, contacts, opportunities, count):
|
||||
def create_invoices(
|
||||
self,
|
||||
org,
|
||||
profiles,
|
||||
teams,
|
||||
products,
|
||||
templates,
|
||||
accounts,
|
||||
contacts,
|
||||
opportunities,
|
||||
count,
|
||||
):
|
||||
"""
|
||||
Create invoices with line items.
|
||||
|
||||
|
|
@ -299,8 +361,14 @@ class InvoiceSeeder:
|
|||
# Select account and contact
|
||||
account = random.choice(accounts)
|
||||
# Prefer contacts linked to this account
|
||||
account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else []
|
||||
contact = random.choice(account_contacts) if account_contacts else random.choice(contacts)
|
||||
account_contacts = (
|
||||
list(account.contacts.all()) if hasattr(account, "contacts") else []
|
||||
)
|
||||
contact = (
|
||||
random.choice(account_contacts)
|
||||
if account_contacts
|
||||
else random.choice(contacts)
|
||||
)
|
||||
|
||||
# Determine status
|
||||
status = self._weighted_choice(self.INVOICE_STATUS_WEIGHTS)
|
||||
|
|
@ -313,7 +381,11 @@ class InvoiceSeeder:
|
|||
if won_opps and random.random() < 0.3:
|
||||
# Try to find an opp for this account
|
||||
account_opps = [o for o in won_opps if o.account_id == account.id]
|
||||
opportunity = random.choice(account_opps) if account_opps else random.choice(won_opps)
|
||||
opportunity = (
|
||||
random.choice(account_opps)
|
||||
if account_opps
|
||||
else random.choice(won_opps)
|
||||
)
|
||||
|
||||
payment_terms = self._weighted_choice(self.PAYMENT_TERMS_WEIGHTS)
|
||||
tax_rate = Decimal(str(random.choice([0, 7.5, 8.25, 10, 12, 20])))
|
||||
|
|
@ -335,15 +407,19 @@ class InvoiceSeeder:
|
|||
client_postcode=contact.postcode or "",
|
||||
client_country=contact.country or "",
|
||||
# Billing from org (could be from org company profile)
|
||||
billing_address_line=getattr(org, 'address_line', '') or "",
|
||||
billing_city=getattr(org, 'city', '') or "",
|
||||
billing_state=getattr(org, 'state', '') or "",
|
||||
billing_postcode=getattr(org, 'postcode', '') or "",
|
||||
billing_country=getattr(org, 'billing_country', '') or "",
|
||||
billing_address_line=getattr(org, "address_line", "") or "",
|
||||
billing_city=getattr(org, "city", "") or "",
|
||||
billing_state=getattr(org, "state", "") or "",
|
||||
billing_postcode=getattr(org, "postcode", "") or "",
|
||||
billing_country=getattr(org, "billing_country", "") or "",
|
||||
# Financial
|
||||
currency=org.default_currency,
|
||||
discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) if random.random() > 0.6 else "",
|
||||
discount_value=Decimal(str(random.choice([0, 5, 10, 15]))) if random.random() > 0.6 else Decimal("0"),
|
||||
discount_type=random.choice(["PERCENTAGE", "FIXED", ""])
|
||||
if random.random() > 0.6
|
||||
else "",
|
||||
discount_value=Decimal(str(random.choice([0, 5, 10, 15])))
|
||||
if random.random() > 0.6
|
||||
else Decimal("0"),
|
||||
tax_rate=tax_rate,
|
||||
# Dates
|
||||
issue_date=issue_date,
|
||||
|
|
@ -361,9 +437,13 @@ class InvoiceSeeder:
|
|||
datetime.datetime.combine(issue_date, datetime.time(10, 0))
|
||||
)
|
||||
if status in ["Viewed", "Paid", "Partially_Paid", "Overdue"]:
|
||||
invoice.viewed_at = invoice.sent_at + datetime.timedelta(days=random.randint(1, 5))
|
||||
invoice.viewed_at = invoice.sent_at + datetime.timedelta(
|
||||
days=random.randint(1, 5)
|
||||
)
|
||||
if status == "Paid":
|
||||
invoice.paid_at = invoice.viewed_at + datetime.timedelta(days=random.randint(1, 15))
|
||||
invoice.paid_at = invoice.viewed_at + datetime.timedelta(
|
||||
days=random.randint(1, 15)
|
||||
)
|
||||
|
||||
# Save invoice (this auto-generates public_token and calculates due_date)
|
||||
invoice.save()
|
||||
|
|
@ -371,7 +451,11 @@ class InvoiceSeeder:
|
|||
# Create 2-5 line items
|
||||
num_items = random.randint(2, 5)
|
||||
for order in range(num_items):
|
||||
product = random.choice(products) if products and random.random() > 0.3 else None
|
||||
product = (
|
||||
random.choice(products)
|
||||
if products and random.random() > 0.3
|
||||
else None
|
||||
)
|
||||
|
||||
if product:
|
||||
name = product.name
|
||||
|
|
@ -389,7 +473,9 @@ class InvoiceSeeder:
|
|||
description=description[:500],
|
||||
quantity=Decimal(str(random.choice([1, 1, 1, 2, 3, 5, 10]))),
|
||||
unit_price=unit_price,
|
||||
discount_type="" if random.random() > 0.2 else random.choice(["PERCENTAGE", "FIXED"]),
|
||||
discount_type=""
|
||||
if random.random() > 0.2
|
||||
else random.choice(["PERCENTAGE", "FIXED"]),
|
||||
discount_value=Decimal("0"),
|
||||
tax_rate=tax_rate if random.random() > 0.3 else Decimal("0"),
|
||||
order=order,
|
||||
|
|
@ -427,15 +513,21 @@ class InvoiceSeeder:
|
|||
|
||||
# Only create payments for certain invoice statuses
|
||||
payable_invoices = [
|
||||
inv for inv in invoices
|
||||
inv
|
||||
for inv in invoices
|
||||
if inv.status in ["Paid", "Partially_Paid", "Sent", "Viewed", "Overdue"]
|
||||
]
|
||||
|
||||
for invoice in payable_invoices:
|
||||
if invoice.status == "Paid":
|
||||
# Full payment
|
||||
payment_date = invoice.paid_at.date() if invoice.paid_at else (
|
||||
invoice.issue_date + datetime.timedelta(days=random.randint(5, 30))
|
||||
payment_date = (
|
||||
invoice.paid_at.date()
|
||||
if invoice.paid_at
|
||||
else (
|
||||
invoice.issue_date
|
||||
+ datetime.timedelta(days=random.randint(5, 30))
|
||||
)
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
invoice=invoice,
|
||||
|
|
@ -453,7 +545,9 @@ class InvoiceSeeder:
|
|||
# Partial payment (30-70% of total)
|
||||
partial_percent = random.uniform(0.3, 0.7)
|
||||
payment_amount = invoice.total_amount * Decimal(str(partial_percent))
|
||||
payment_date = invoice.issue_date + datetime.timedelta(days=random.randint(5, 20))
|
||||
payment_date = invoice.issue_date + datetime.timedelta(
|
||||
days=random.randint(5, 20)
|
||||
)
|
||||
|
||||
payment = Payment.objects.create(
|
||||
invoice=invoice,
|
||||
|
|
@ -469,8 +563,12 @@ class InvoiceSeeder:
|
|||
|
||||
elif random.random() < 0.3: # 30% chance of payment on other statuses
|
||||
# Some random payments
|
||||
payment_amount = invoice.total_amount * Decimal(str(random.uniform(0.2, 0.5)))
|
||||
payment_date = invoice.issue_date + datetime.timedelta(days=random.randint(10, 45))
|
||||
payment_amount = invoice.total_amount * Decimal(
|
||||
str(random.uniform(0.2, 0.5))
|
||||
)
|
||||
payment_date = invoice.issue_date + datetime.timedelta(
|
||||
days=random.randint(10, 45)
|
||||
)
|
||||
|
||||
payment = Payment.objects.create(
|
||||
invoice=invoice,
|
||||
|
|
@ -487,7 +585,9 @@ class InvoiceSeeder:
|
|||
self.stdout.write(f" Created {len(payments)} payments")
|
||||
return payments
|
||||
|
||||
def create_estimates(self, org, profiles, teams, products, accounts, contacts, count):
|
||||
def create_estimates(
|
||||
self, org, profiles, teams, products, accounts, contacts, count
|
||||
):
|
||||
"""
|
||||
Create estimates with line items.
|
||||
|
||||
|
|
@ -512,8 +612,14 @@ class InvoiceSeeder:
|
|||
date_str = issue_date.strftime("%Y%m%d")
|
||||
|
||||
account = random.choice(accounts)
|
||||
account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else []
|
||||
contact = random.choice(account_contacts) if account_contacts else random.choice(contacts)
|
||||
account_contacts = (
|
||||
list(account.contacts.all()) if hasattr(account, "contacts") else []
|
||||
)
|
||||
contact = (
|
||||
random.choice(account_contacts)
|
||||
if account_contacts
|
||||
else random.choice(contacts)
|
||||
)
|
||||
|
||||
status = self._weighted_choice(self.ESTIMATE_STATUS_WEIGHTS)
|
||||
|
||||
|
|
@ -537,7 +643,9 @@ class InvoiceSeeder:
|
|||
client_postcode=contact.postcode or "",
|
||||
client_country=contact.country or "",
|
||||
currency=org.default_currency,
|
||||
discount_type=random.choice(["PERCENTAGE", "FIXED", ""]) if random.random() > 0.7 else "",
|
||||
discount_type=random.choice(["PERCENTAGE", "FIXED", ""])
|
||||
if random.random() > 0.7
|
||||
else "",
|
||||
discount_value=Decimal("0"),
|
||||
tax_rate=tax_rate,
|
||||
issue_date=issue_date,
|
||||
|
|
@ -553,18 +661,28 @@ class InvoiceSeeder:
|
|||
datetime.datetime.combine(issue_date, datetime.time(10, 0))
|
||||
)
|
||||
if status in ["Viewed", "Accepted", "Declined"]:
|
||||
estimate.viewed_at = estimate.sent_at + datetime.timedelta(days=random.randint(1, 5))
|
||||
estimate.viewed_at = estimate.sent_at + datetime.timedelta(
|
||||
days=random.randint(1, 5)
|
||||
)
|
||||
if status == "Accepted":
|
||||
estimate.accepted_at = estimate.viewed_at + datetime.timedelta(days=random.randint(1, 10))
|
||||
estimate.accepted_at = estimate.viewed_at + datetime.timedelta(
|
||||
days=random.randint(1, 10)
|
||||
)
|
||||
if status == "Declined":
|
||||
estimate.declined_at = estimate.viewed_at + datetime.timedelta(days=random.randint(1, 10))
|
||||
estimate.declined_at = estimate.viewed_at + datetime.timedelta(
|
||||
days=random.randint(1, 10)
|
||||
)
|
||||
|
||||
estimate.save()
|
||||
|
||||
# Create 2-4 line items
|
||||
num_items = random.randint(2, 4)
|
||||
for order in range(num_items):
|
||||
product = random.choice(products) if products and random.random() > 0.3 else None
|
||||
product = (
|
||||
random.choice(products)
|
||||
if products and random.random() > 0.3
|
||||
else None
|
||||
)
|
||||
|
||||
if product:
|
||||
name = product.name
|
||||
|
|
@ -599,7 +717,9 @@ class InvoiceSeeder:
|
|||
self.stdout.write(f" Created {len(estimates)} estimates with line items")
|
||||
return estimates
|
||||
|
||||
def create_recurring_invoices(self, org, profiles, teams, products, accounts, contacts, count):
|
||||
def create_recurring_invoices(
|
||||
self, org, profiles, teams, products, accounts, contacts, count
|
||||
):
|
||||
"""
|
||||
Create recurring invoice templates.
|
||||
|
||||
|
|
@ -621,8 +741,14 @@ class InvoiceSeeder:
|
|||
|
||||
for i in range(count):
|
||||
account = random.choice(accounts)
|
||||
account_contacts = list(account.contacts.all()) if hasattr(account, 'contacts') else []
|
||||
contact = random.choice(account_contacts) if account_contacts else random.choice(contacts)
|
||||
account_contacts = (
|
||||
list(account.contacts.all()) if hasattr(account, "contacts") else []
|
||||
)
|
||||
contact = (
|
||||
random.choice(account_contacts)
|
||||
if account_contacts
|
||||
else random.choice(contacts)
|
||||
)
|
||||
|
||||
frequency = self._weighted_choice(self.RECURRING_FREQUENCY_WEIGHTS)
|
||||
start_date = self.fake.date_between(start_date="-60d", end_date="+30d")
|
||||
|
|
@ -630,7 +756,9 @@ class InvoiceSeeder:
|
|||
# Some recurring invoices have end dates
|
||||
end_date = None
|
||||
if random.random() > 0.6:
|
||||
end_date = start_date + datetime.timedelta(days=random.randint(180, 730))
|
||||
end_date = start_date + datetime.timedelta(
|
||||
days=random.randint(180, 730)
|
||||
)
|
||||
|
||||
tax_rate = Decimal(str(random.choice([0, 8, 10, 12])))
|
||||
|
||||
|
|
@ -653,7 +781,9 @@ class InvoiceSeeder:
|
|||
discount_value=Decimal("0"),
|
||||
tax_rate=tax_rate,
|
||||
notes="Auto-generated recurring invoice",
|
||||
invoices_generated=random.randint(0, 12) if start_date < timezone.now().date() else 0,
|
||||
invoices_generated=random.randint(0, 12)
|
||||
if start_date < timezone.now().date()
|
||||
else 0,
|
||||
org=org,
|
||||
)
|
||||
rec.save()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ from rest_framework import serializers
|
|||
|
||||
from accounts.models import Account
|
||||
from accounts.serializer import AccountSerializer
|
||||
from common.serializer import OrganizationSerializer, ProfileSerializer, TeamsSerializer, UserSerializer
|
||||
from common.serializer import (
|
||||
OrganizationSerializer,
|
||||
ProfileSerializer,
|
||||
TeamsSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from contacts.models import Contact
|
||||
from contacts.serializer import ContactSerializer
|
||||
from invoices.models import (
|
||||
|
|
@ -389,8 +394,12 @@ class InvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
# CRM Integration fields - accept IDs for writing
|
||||
account_id = serializers.UUIDField(write_only=True, required=True)
|
||||
contact_id = serializers.UUIDField(write_only=True, required=True)
|
||||
opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
opportunity_id = serializers.UUIDField(
|
||||
write_only=True, required=False, allow_null=True
|
||||
)
|
||||
template_id = serializers.UUIDField(
|
||||
write_only=True, required=False, allow_null=True
|
||||
)
|
||||
|
||||
# Line items for bulk creation
|
||||
line_items = InvoiceLineItemCreateSerializer(many=True, required=False)
|
||||
|
|
@ -498,9 +507,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
contact = Contact.objects.filter(id=contact_id, org=self.org).first()
|
||||
if contact and contact.account_id and contact.account_id != account_id:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"contact_id": f"Contact does not belong to the selected account"
|
||||
}
|
||||
{"contact_id": f"Contact does not belong to the selected account"}
|
||||
)
|
||||
return data
|
||||
|
||||
|
|
@ -519,7 +526,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
invoice=invoice,
|
||||
org=self.org,
|
||||
order=item_data.get("order", idx),
|
||||
**{k: v for k, v in item_data.items() if k != "order"}
|
||||
**{k: v for k, v in item_data.items() if k != "order"},
|
||||
)
|
||||
|
||||
# Recalculate totals
|
||||
|
|
@ -544,7 +551,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
invoice=instance,
|
||||
org=self.org or instance.org,
|
||||
order=item_data.get("order", idx),
|
||||
**{k: v for k, v in item_data.items() if k != "order"}
|
||||
**{k: v for k, v in item_data.items() if k != "order"},
|
||||
)
|
||||
|
||||
instance.recalculate_totals()
|
||||
|
|
@ -671,7 +678,9 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
account_id = serializers.UUIDField(write_only=True, required=True)
|
||||
contact_id = serializers.UUIDField(write_only=True, required=True)
|
||||
opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
opportunity_id = serializers.UUIDField(
|
||||
write_only=True, required=False, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
|
|
@ -819,7 +828,9 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
account_id = serializers.UUIDField(write_only=True, required=True)
|
||||
contact_id = serializers.UUIDField(write_only=True, required=True)
|
||||
opportunity_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||
opportunity_id = serializers.UUIDField(
|
||||
write_only=True, required=False, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RecurringInvoice
|
||||
|
|
|
|||
|
|
@ -67,13 +67,17 @@ def send_email(invoice_id, recipients, org_id, domain="localhost", protocol="htt
|
|||
msg.content_subtype = "html"
|
||||
try:
|
||||
msg.send()
|
||||
logger.info(f"Sent assignment email for invoice {invoice_id} to {profile.user.email}")
|
||||
logger.info(
|
||||
f"Sent assignment email for invoice {invoice_id} to {profile.user.email}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_invoice_to_client(invoice_id, org_id, domain="localhost", protocol="http", include_pdf=True):
|
||||
def send_invoice_to_client(
|
||||
invoice_id, org_id, domain="localhost", protocol="http", include_pdf=True
|
||||
):
|
||||
"""
|
||||
Send invoice to client via email with optional PDF attachment.
|
||||
|
||||
|
|
@ -109,7 +113,9 @@ def send_invoice_to_client(invoice_id, org_id, domain="localhost", protocol="htt
|
|||
"public_url": public_url,
|
||||
"org": invoice.org,
|
||||
}
|
||||
html_content = render_to_string("invoices/emails/invoice_to_client.html", context=context)
|
||||
html_content = render_to_string(
|
||||
"invoices/emails/invoice_to_client.html", context=context
|
||||
)
|
||||
|
||||
msg = EmailMessage(
|
||||
subject=subject,
|
||||
|
|
@ -163,10 +169,15 @@ def create_invoice_history(invoice_id, updated_by_user_id, changed_fields, org_i
|
|||
|
||||
# Format changed fields message
|
||||
if changed_fields:
|
||||
changed_data = [(" ".join(field.split("_")).title()) for field in changed_fields]
|
||||
changed_data = [
|
||||
(" ".join(field.split("_")).title()) for field in changed_fields
|
||||
]
|
||||
if len(changed_data) > 1:
|
||||
changed_data = (
|
||||
", ".join(changed_data[:-1]) + " and " + changed_data[-1] + " have changed."
|
||||
", ".join(changed_data[:-1])
|
||||
+ " and "
|
||||
+ changed_data[-1]
|
||||
+ " have changed."
|
||||
)
|
||||
elif len(changed_data) == 1:
|
||||
changed_data = changed_data[0] + " has changed."
|
||||
|
|
@ -220,7 +231,9 @@ def generate_recurring_invoices():
|
|||
if recurring.end_date and recurring.end_date < today:
|
||||
recurring.is_active = False
|
||||
recurring.save()
|
||||
logger.info(f"Deactivated recurring invoice {recurring.id} - end date reached")
|
||||
logger.info(
|
||||
f"Deactivated recurring invoice {recurring.id} - end date reached"
|
||||
)
|
||||
continue
|
||||
|
||||
# Set RLS context for this org
|
||||
|
|
@ -279,11 +292,15 @@ def generate_recurring_invoices():
|
|||
str(invoice.id),
|
||||
str(recurring.org.id),
|
||||
domain=getattr(settings, "DOMAIN_NAME", "localhost"),
|
||||
protocol="https" if getattr(settings, "USE_HTTPS", False) else "http",
|
||||
protocol="https"
|
||||
if getattr(settings, "USE_HTTPS", False)
|
||||
else "http",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate invoice from recurring {recurring.id}: {e}")
|
||||
logger.error(
|
||||
f"Failed to generate invoice from recurring {recurring.id}: {e}"
|
||||
)
|
||||
|
||||
logger.info("Finished recurring invoice generation")
|
||||
|
||||
|
|
@ -354,9 +371,13 @@ def send_payment_reminder(invoice_id, org_id, domain="localhost", protocol="http
|
|||
"invoice": invoice,
|
||||
"public_url": public_url,
|
||||
"org": invoice.org,
|
||||
"days_overdue": (timezone.now().date() - invoice.due_date).days if invoice.due_date else 0,
|
||||
"days_overdue": (timezone.now().date() - invoice.due_date).days
|
||||
if invoice.due_date
|
||||
else 0,
|
||||
}
|
||||
html_content = render_to_string("invoices/emails/payment_reminder.html", context=context)
|
||||
html_content = render_to_string(
|
||||
"invoices/emails/payment_reminder.html", context=context
|
||||
)
|
||||
|
||||
msg = EmailMessage(
|
||||
subject=subject,
|
||||
|
|
@ -387,10 +408,14 @@ def process_payment_reminders():
|
|||
today = timezone.now().date()
|
||||
|
||||
# Get invoices with reminders enabled
|
||||
invoices = Invoice.objects.filter(
|
||||
reminder_enabled=True,
|
||||
status__in=["Sent", "Viewed", "Partially_Paid", "Overdue"],
|
||||
).exclude(client_email__isnull=True).exclude(client_email="")
|
||||
invoices = (
|
||||
Invoice.objects.filter(
|
||||
reminder_enabled=True,
|
||||
status__in=["Sent", "Viewed", "Partially_Paid", "Overdue"],
|
||||
)
|
||||
.exclude(client_email__isnull=True)
|
||||
.exclude(client_email="")
|
||||
)
|
||||
|
||||
for invoice in invoices:
|
||||
set_rls_context(str(invoice.org.id))
|
||||
|
|
@ -408,20 +433,28 @@ def process_payment_reminders():
|
|||
|
||||
# Before due date reminder
|
||||
if days_until_due is not None and days_until_due > 0:
|
||||
if invoice.reminder_days_before and days_until_due <= invoice.reminder_days_before:
|
||||
if (
|
||||
invoice.reminder_days_before
|
||||
and days_until_due <= invoice.reminder_days_before
|
||||
):
|
||||
# Check if we haven't sent a reminder recently
|
||||
if not invoice.last_reminder_sent or (
|
||||
(today - invoice.last_reminder_sent.date()).days >= reminder_interval_days
|
||||
(today - invoice.last_reminder_sent.date()).days
|
||||
>= reminder_interval_days
|
||||
):
|
||||
should_send = True
|
||||
|
||||
# After due date reminder
|
||||
elif days_until_due is not None and days_until_due < 0:
|
||||
days_overdue = abs(days_until_due)
|
||||
if invoice.reminder_days_after and days_overdue >= invoice.reminder_days_after:
|
||||
if (
|
||||
invoice.reminder_days_after
|
||||
and days_overdue >= invoice.reminder_days_after
|
||||
):
|
||||
# Check reminder frequency
|
||||
if not invoice.last_reminder_sent or (
|
||||
(today - invoice.last_reminder_sent.date()).days >= reminder_interval_days
|
||||
(today - invoice.last_reminder_sent.date()).days
|
||||
>= reminder_interval_days
|
||||
):
|
||||
should_send = True
|
||||
|
||||
|
|
@ -465,7 +498,9 @@ def check_expired_estimates():
|
|||
|
||||
|
||||
@shared_task
|
||||
def send_estimate_to_client(estimate_id, org_id, domain="localhost", protocol="http", include_pdf=True):
|
||||
def send_estimate_to_client(
|
||||
estimate_id, org_id, domain="localhost", protocol="http", include_pdf=True
|
||||
):
|
||||
"""
|
||||
Send estimate to client via email with optional PDF attachment.
|
||||
|
||||
|
|
@ -500,7 +535,9 @@ def send_estimate_to_client(estimate_id, org_id, domain="localhost", protocol="h
|
|||
"public_url": public_url,
|
||||
"org": estimate.org,
|
||||
}
|
||||
html_content = render_to_string("invoices/emails/estimate_to_client.html", context=context)
|
||||
html_content = render_to_string(
|
||||
"invoices/emails/estimate_to_client.html", context=context
|
||||
)
|
||||
|
||||
msg = EmailMessage(
|
||||
subject=subject,
|
||||
|
|
|
|||
|
|
@ -329,7 +329,6 @@ class InvoiceEditTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class InvoiceSendMailTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoices_send_mail(self):
|
||||
|
||||
self.client.login(email="joeInvoice@example.com", password="password")
|
||||
response = self.client.get(
|
||||
reverse("invoices:invoice_send_mail", args=(self.invoice_1.id,))
|
||||
|
|
@ -345,7 +344,6 @@ class InvoiceSendMailTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class InvoiceChangeStatusPaidTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoices_change_status_to_paid(self):
|
||||
|
||||
self.client.login(email="joeInvoice@example.com", password="password")
|
||||
response = self.client.get(
|
||||
reverse("invoices:invoice_change_status_paid", args=(self.invoice_1.id,))
|
||||
|
|
@ -361,7 +359,6 @@ class InvoiceChangeStatusPaidTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class InvoiceChangeStatusCancelledTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoices_change_status_to_cancelled(self):
|
||||
|
||||
self.client.login(email="joeInvoice@example.com", password="password")
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
|
|
@ -381,7 +378,6 @@ class InvoiceChangeStatusCancelledTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class InvoiceDownloadTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoices_download(self):
|
||||
|
||||
self.client.login(email="joeInvoice@example.com", password="password")
|
||||
response = self.client.get(
|
||||
reverse("invoices:invoice_download", args=(self.invoice.id,))
|
||||
|
|
@ -397,7 +393,6 @@ class InvoiceDownloadTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class AddCommentTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoice_add_comment(self):
|
||||
|
||||
self.client.login(email="johnDoeInvoice@example.com", password="password")
|
||||
data = {
|
||||
"comment": "",
|
||||
|
|
@ -420,7 +415,6 @@ class AddCommentTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class UpdateCommentTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoice_update_comment(self):
|
||||
|
||||
self.client.login(email="johnDoeInvoice@example.com", password="password")
|
||||
data = {
|
||||
"commentid": self.comment.id,
|
||||
|
|
@ -445,7 +439,6 @@ class UpdateCommentTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class DeleteCommentTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoice_delete_comment(self):
|
||||
|
||||
data = {
|
||||
"comment_id": self.comment.id,
|
||||
}
|
||||
|
|
@ -460,7 +453,6 @@ class DeleteCommentTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class AddAttachmentTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoice_add_attachment(self):
|
||||
|
||||
data = {
|
||||
"attachment": SimpleUploadedFile(
|
||||
"file_name.txt", bytes("file contents.", "utf-8")
|
||||
|
|
@ -483,7 +475,6 @@ class AddAttachmentTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class DeleteAttachmentTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoice_delete_attachment(self):
|
||||
|
||||
data = {
|
||||
"attachment_id": self.attachment.id,
|
||||
}
|
||||
|
|
@ -498,7 +489,6 @@ class DeleteAttachmentTestCase(InvoiceCreateTest, TestCase):
|
|||
|
||||
class InvoiceDeleteTestCase(InvoiceCreateTest, TestCase):
|
||||
def test_invoices_delete(self):
|
||||
|
||||
self.client.login(email="joeInvoice@example.com", password="password")
|
||||
response = self.client.get(
|
||||
reverse("invoices:invoice_delete", args=(self.invoice_1.id,))
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ class TestSendMailOnInvoiceCreationTask(InvoiceCreateTest, TestCase):
|
|||
)
|
||||
def test_send_mail_on_invoice_creation_task(self):
|
||||
org_id = str(self.invoice.org.id)
|
||||
task = send_email.apply((self.invoice.id, [self.user.id, self.user1.id], org_id))
|
||||
task = send_email.apply(
|
||||
(self.invoice.id, [self.user.id, self.user1.id], org_id)
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
||||
task = send_invoice_email.apply((self.invoice.id, org_id))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
@ -119,9 +118,7 @@ class Migration(migrations.Migration):
|
|||
("email", models.EmailField(blank=True, max_length=254, null=True)),
|
||||
(
|
||||
"phone",
|
||||
models.CharField(
|
||||
blank=True, max_length=50, null=True
|
||||
),
|
||||
models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
(
|
||||
"contact_title",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_initial"),
|
||||
("leads", "0001_initial"),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contacts", "0002_change_phone_to_charfield"),
|
||||
("leads", "0002_add_converted_account_fk"),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ def reverse_migrate_company_name_to_company(apps, schema_editor):
|
|||
Lead = apps.get_model("leads", "Lead")
|
||||
Company = apps.get_model("leads", "Company")
|
||||
|
||||
for lead in Lead.objects.filter(company_name__isnull=False).exclude(company_name=""):
|
||||
for lead in Lead.objects.filter(company_name__isnull=False).exclude(
|
||||
company_name=""
|
||||
):
|
||||
company, _ = Company.objects.get_or_create(
|
||||
name=lead.company_name,
|
||||
org=lead.org,
|
||||
|
|
@ -26,7 +28,6 @@ def reverse_migrate_company_name_to_company(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0003_add_full_conversion_fields"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0004_remove_company_model"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0002_enable_rls"),
|
||||
("leads", "0005_rename_title_to_salutation"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0006_add_tags_related_name"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0007_add_title_field"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0008_remove_unused_fields"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("leads", "0009_change_phone_to_charfield"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
|
|
@ -13,36 +13,34 @@ def cleanup_duplicate_lead_emails(apps, schema_editor):
|
|||
Find duplicate lead emails (case-insensitive) within each org
|
||||
and clear email on duplicates (keep oldest).
|
||||
"""
|
||||
Lead = apps.get_model('leads', 'Lead')
|
||||
Lead = apps.get_model("leads", "Lead")
|
||||
|
||||
# Find duplicates: group by lower(email) + org, having count > 1
|
||||
duplicates = (
|
||||
Lead.objects
|
||||
.exclude(email__isnull=True)
|
||||
.exclude(email='')
|
||||
.annotate(email_lower=Lower('email'))
|
||||
.values('email_lower', 'org_id')
|
||||
.annotate(count=Count('id'))
|
||||
Lead.objects.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower", "org_id")
|
||||
.annotate(count=Count("id"))
|
||||
.filter(count__gt=1)
|
||||
)
|
||||
|
||||
for dup in duplicates:
|
||||
# Get all leads with this email in this org, ordered by created_at
|
||||
leads = list(
|
||||
Lead.objects
|
||||
.filter(org_id=dup['org_id'])
|
||||
Lead.objects.filter(org_id=dup["org_id"])
|
||||
.exclude(email__isnull=True)
|
||||
.exclude(email='')
|
||||
.annotate(email_lower=Lower('email'))
|
||||
.filter(email_lower=dup['email_lower'])
|
||||
.order_by('created_at')
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.filter(email_lower=dup["email_lower"])
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Keep the first one (oldest), clear email on the rest
|
||||
for lead in leads[1:]:
|
||||
old_email = lead.email
|
||||
lead.email = None
|
||||
lead.save(update_fields=['email'])
|
||||
lead.save(update_fields=["email"])
|
||||
print(f" Cleared duplicate email on lead {lead.id}: '{old_email}'")
|
||||
|
||||
|
||||
|
|
@ -51,22 +49,24 @@ def cleanup_long_phone_numbers(apps, schema_editor):
|
|||
Truncate phone numbers longer than 25 characters.
|
||||
Phone field max_length is being reduced from 50 to 25.
|
||||
"""
|
||||
Lead = apps.get_model('leads', 'Lead')
|
||||
Lead = apps.get_model("leads", "Lead")
|
||||
|
||||
long_phones = Lead.objects.annotate(
|
||||
phone_len=Length('phone')
|
||||
).filter(phone_len__gt=25)
|
||||
long_phones = Lead.objects.annotate(phone_len=Length("phone")).filter(
|
||||
phone_len__gt=25
|
||||
)
|
||||
|
||||
for lead in long_phones:
|
||||
old_phone = lead.phone
|
||||
lead.phone = lead.phone[:25] if lead.phone else None
|
||||
lead.save(update_fields=['phone'])
|
||||
print(f" Truncated long phone on lead {lead.id}: '{old_phone}' -> '{lead.phone}'")
|
||||
lead.save(update_fields=["phone"])
|
||||
print(
|
||||
f" Truncated long phone on lead {lead.id}: '{old_phone}' -> '{lead.phone}'"
|
||||
)
|
||||
|
||||
|
||||
def cleanup_invalid_probability(apps, schema_editor):
|
||||
"""Fix probability values outside 0-100 range."""
|
||||
Lead = apps.get_model('leads', 'Lead')
|
||||
Lead = apps.get_model("leads", "Lead")
|
||||
|
||||
# Set negative probabilities to 0
|
||||
updated = Lead.objects.filter(probability__lt=0).update(probability=0)
|
||||
|
|
@ -81,8 +81,10 @@ def cleanup_invalid_probability(apps, schema_editor):
|
|||
|
||||
def cleanup_negative_amounts(apps, schema_editor):
|
||||
"""Set negative opportunity_amount values to NULL."""
|
||||
Lead = apps.get_model('leads', 'Lead')
|
||||
updated = Lead.objects.filter(opportunity_amount__lt=0).update(opportunity_amount=None)
|
||||
Lead = apps.get_model("leads", "Lead")
|
||||
updated = Lead.objects.filter(opportunity_amount__lt=0).update(
|
||||
opportunity_amount=None
|
||||
)
|
||||
if updated:
|
||||
print(f" Set {updated} negative opportunity_amount values to NULL")
|
||||
|
||||
|
|
@ -93,7 +95,6 @@ def reverse_noop(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0008_enable_rls_product_invoice_line_item"),
|
||||
("contacts", "0010_add_enterprise_constraints"),
|
||||
|
|
|
|||
|
|
@ -7,97 +7,270 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0012_add_tag_color_description_isactive'),
|
||||
('contacts', '0010_add_enterprise_constraints'),
|
||||
('leads', '0011_add_enterprise_constraints'),
|
||||
("common", "0012_add_tag_color_description_isactive"),
|
||||
("contacts", "0010_add_enterprise_constraints"),
|
||||
("leads", "0011_add_enterprise_constraints"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lead',
|
||||
name='kanban_order',
|
||||
field=models.DecimalField(decimal_places=6, default=0, help_text='Order within the kanban column for drag-drop', max_digits=15, verbose_name='Kanban Order'),
|
||||
model_name="lead",
|
||||
name="kanban_order",
|
||||
field=models.DecimalField(
|
||||
decimal_places=6,
|
||||
default=0,
|
||||
help_text="Order within the kanban column for drag-drop",
|
||||
max_digits=15,
|
||||
verbose_name="Kanban Order",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeadPipeline',
|
||||
name="LeadPipeline",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Pipeline Name')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
|
||||
('is_default', models.BooleanField(default=False, help_text='If true, new leads without explicit pipeline go here')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lead_pipelines', to='common.org')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=255, verbose_name="Pipeline Name"),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, null=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"is_default",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If true, new leads without explicit pipeline go here",
|
||||
),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="lead_pipelines",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lead Pipeline',
|
||||
'verbose_name_plural': 'Lead Pipelines',
|
||||
'db_table': 'lead_pipeline',
|
||||
'ordering': ('-is_default', 'name'),
|
||||
"verbose_name": "Lead Pipeline",
|
||||
"verbose_name_plural": "Lead Pipelines",
|
||||
"db_table": "lead_pipeline",
|
||||
"ordering": ("-is_default", "name"),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeadStage',
|
||||
name="LeadStage",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('name', models.CharField(max_length=100, verbose_name='Stage Name')),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('color', models.CharField(default='#6B7280', max_length=7)),
|
||||
('stage_type', models.CharField(choices=[('open', 'Open'), ('won', 'Won'), ('lost', 'Lost')], default='open', max_length=10)),
|
||||
('maps_to_status', models.CharField(blank=True, choices=[('assigned', 'Assigned'), ('in process', 'In Process'), ('converted', 'Converted'), ('recycled', 'Recycled'), ('closed', 'Closed')], help_text='When lead enters this stage, also update Lead.status', max_length=255, null=True, verbose_name='Maps to Status')),
|
||||
('win_probability', models.IntegerField(default=0, help_text='Default probability when lead enters this stage', verbose_name='Default Win Probability %')),
|
||||
('wip_limit', models.PositiveIntegerField(blank=True, help_text='Maximum leads allowed in this stage (null = unlimited)', null=True, verbose_name='WIP Limit')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lead_stages', to='common.org')),
|
||||
('pipeline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='leads.leadpipeline')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Stage Name")),
|
||||
("order", models.PositiveIntegerField(default=0)),
|
||||
("color", models.CharField(default="#6B7280", max_length=7)),
|
||||
(
|
||||
"stage_type",
|
||||
models.CharField(
|
||||
choices=[("open", "Open"), ("won", "Won"), ("lost", "Lost")],
|
||||
default="open",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"maps_to_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("assigned", "Assigned"),
|
||||
("in process", "In Process"),
|
||||
("converted", "Converted"),
|
||||
("recycled", "Recycled"),
|
||||
("closed", "Closed"),
|
||||
],
|
||||
help_text="When lead enters this stage, also update Lead.status",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Maps to Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"win_probability",
|
||||
models.IntegerField(
|
||||
default=0,
|
||||
help_text="Default probability when lead enters this stage",
|
||||
verbose_name="Default Win Probability %",
|
||||
),
|
||||
),
|
||||
(
|
||||
"wip_limit",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text="Maximum leads allowed in this stage (null = unlimited)",
|
||||
null=True,
|
||||
verbose_name="WIP Limit",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="lead_stages",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pipeline",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stages",
|
||||
to="leads.leadpipeline",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lead Stage',
|
||||
'verbose_name_plural': 'Lead Stages',
|
||||
'db_table': 'lead_stage',
|
||||
'ordering': ('order',),
|
||||
"verbose_name": "Lead Stage",
|
||||
"verbose_name_plural": "Lead Stages",
|
||||
"db_table": "lead_stage",
|
||||
"ordering": ("order",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lead',
|
||||
name='stage',
|
||||
field=models.ForeignKey(blank=True, help_text='Current pipeline stage (null = use status-based kanban)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to='leads.leadstage'),
|
||||
model_name="lead",
|
||||
name="stage",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Current pipeline stage (null = use status-based kanban)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="leads",
|
||||
to="leads.leadstage",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='lead',
|
||||
index=models.Index(fields=['stage', 'kanban_order'], name='lead_stage_i_66ca36_idx'),
|
||||
model_name="lead",
|
||||
index=models.Index(
|
||||
fields=["stage", "kanban_order"], name="lead_stage_i_66ca36_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='lead',
|
||||
index=models.Index(fields=['status', 'kanban_order'], name='lead_status_5aa7c0_idx'),
|
||||
model_name="lead",
|
||||
index=models.Index(
|
||||
fields=["status", "kanban_order"], name="lead_status_5aa7c0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='leadpipeline',
|
||||
index=models.Index(fields=['org', '-created_at'], name='lead_pipeli_org_id_23ba65_idx'),
|
||||
model_name="leadpipeline",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="lead_pipeli_org_id_23ba65_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='leadpipeline',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='unique_default_pipeline_per_org'),
|
||||
model_name="leadpipeline",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("is_default", True)),
|
||||
fields=("org",),
|
||||
name="unique_default_pipeline_per_org",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='leadstage',
|
||||
index=models.Index(fields=['org', 'order'], name='lead_stage_org_id_c0f691_idx'),
|
||||
model_name="leadstage",
|
||||
index=models.Index(
|
||||
fields=["org", "order"], name="lead_stage_org_id_c0f691_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='leadstage',
|
||||
index=models.Index(fields=['pipeline', 'order'], name='lead_stage_pipelin_63b7e0_idx'),
|
||||
model_name="leadstage",
|
||||
index=models.Index(
|
||||
fields=["pipeline", "order"], name="lead_stage_pipelin_63b7e0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='leadstage',
|
||||
unique_together={('pipeline', 'name')},
|
||||
name="leadstage",
|
||||
unique_together={("pipeline", "name")},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,12 +33,18 @@ class Lead(AssignableMixin, BaseModel):
|
|||
|
||||
# Core Lead Information
|
||||
title = models.CharField(
|
||||
_("Title"), max_length=255, blank=True, null=True,
|
||||
help_text="Lead name/subject (e.g., 'Enterprise Deal', 'Website Inquiry')"
|
||||
_("Title"),
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Lead name/subject (e.g., 'Enterprise Deal', 'Website Inquiry')",
|
||||
)
|
||||
salutation = models.CharField(
|
||||
_("Salutation"), max_length=64, blank=True, null=True,
|
||||
help_text="e.g., Mr, Mrs, Ms, Dr"
|
||||
_("Salutation"),
|
||||
max_length=64,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="e.g., Mr, Mrs, Ms, Dr",
|
||||
)
|
||||
first_name = models.CharField(_("First name"), null=True, max_length=255)
|
||||
last_name = models.CharField(_("Last name"), null=True, max_length=255)
|
||||
|
|
@ -51,8 +57,11 @@ class Lead(AssignableMixin, BaseModel):
|
|||
validators=[flexible_phone_validator],
|
||||
)
|
||||
job_title = models.CharField(
|
||||
_("Job Title"), max_length=255, blank=True, null=True,
|
||||
help_text="Person's job title (e.g., 'VP of Sales', 'CTO')"
|
||||
_("Job Title"),
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Person's job title (e.g., 'VP of Sales', 'CTO')",
|
||||
)
|
||||
website = models.CharField(_("Website"), max_length=255, blank=True, null=True)
|
||||
linkedin_url = models.URLField(
|
||||
|
|
@ -292,9 +301,7 @@ class LeadStage(BaseModel):
|
|||
help_text="Maximum leads allowed in this stage (null = unlimited)",
|
||||
)
|
||||
|
||||
org = models.ForeignKey(
|
||||
Org, on_delete=models.CASCADE, related_name="lead_stages"
|
||||
)
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="lead_stages")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Lead Stage"
|
||||
|
|
@ -314,4 +321,3 @@ class LeadStage(BaseModel):
|
|||
if not self.org_id and self.pipeline_id:
|
||||
self.org_id = self.pipeline.org_id
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,9 @@ class LeadCreateSerializer(serializers.ModelSerializer):
|
|||
|
||||
def create(self, validated_data):
|
||||
# Default currency from org if not provided and has opportunity_amount
|
||||
if not validated_data.get("currency") and validated_data.get("opportunity_amount"):
|
||||
if not validated_data.get("currency") and validated_data.get(
|
||||
"opportunity_amount"
|
||||
):
|
||||
request = self.context.get("request")
|
||||
if request and hasattr(request, "profile") and request.profile.org:
|
||||
validated_data["currency"] = request.profile.org.default_currency
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ def convert_lead_to_account(lead_obj, request, create_opportunity=True):
|
|||
|
||||
# Create Opportunity if requested and lead has opportunity data
|
||||
opportunity = None
|
||||
if create_opportunity and (lead_obj.opportunity_amount or lead_obj.first_name or lead_obj.company_name):
|
||||
if create_opportunity and (
|
||||
lead_obj.opportunity_amount or lead_obj.first_name or lead_obj.company_name
|
||||
):
|
||||
# Construct opportunity name from available lead data
|
||||
opp_name_parts = []
|
||||
if lead_obj.company_name:
|
||||
|
|
@ -120,7 +122,11 @@ def convert_lead_to_account(lead_obj, request, create_opportunity=True):
|
|||
if lead_obj.first_name or lead_obj.last_name:
|
||||
name = " ".join(filter(None, [lead_obj.first_name, lead_obj.last_name]))
|
||||
opp_name_parts.append(name)
|
||||
opp_name = " - ".join(opp_name_parts) if opp_name_parts else f"Opportunity {lead_obj.id}"
|
||||
opp_name = (
|
||||
" - ".join(opp_name_parts)
|
||||
if opp_name_parts
|
||||
else f"Opportunity {lead_obj.id}"
|
||||
)
|
||||
|
||||
opportunity = Opportunity.objects.create(
|
||||
name=opp_name,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,12 @@ urlpatterns = [
|
|||
# Kanban endpoints
|
||||
path("kanban/", LeadKanbanView.as_view(), name="lead_kanban"),
|
||||
# Pipeline management
|
||||
path("pipelines/", LeadPipelineListCreateView.as_view(), name="pipeline_list_create"),
|
||||
path("pipelines/<str:pk>/", LeadPipelineDetailView.as_view(), name="pipeline_detail"),
|
||||
path(
|
||||
"pipelines/", LeadPipelineListCreateView.as_view(), name="pipeline_list_create"
|
||||
),
|
||||
path(
|
||||
"pipelines/<str:pk>/", LeadPipelineDetailView.as_view(), name="pipeline_detail"
|
||||
),
|
||||
path(
|
||||
"pipelines/<str:pipeline_pk>/stages/",
|
||||
LeadStageCreateView.as_view(),
|
||||
|
|
|
|||
|
|
@ -178,9 +178,7 @@ class LeadKanbanView(APIView):
|
|||
|
||||
columns = []
|
||||
for stage in pipeline.stages.all().order_by("order"):
|
||||
leads = queryset.filter(stage=stage).order_by(
|
||||
"kanban_order", "-created_at"
|
||||
)
|
||||
leads = queryset.filter(stage=stage).order_by("kanban_order", "-created_at")
|
||||
|
||||
columns.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -255,7 +255,9 @@ class CreateLeadFromSite(APIView):
|
|||
# user = User.objects.filter(is_admin=True, is_active=True).first()
|
||||
user = api_setting.created_by
|
||||
lead = Lead.objects.create(
|
||||
salutation=params.get("title"), # 'title' param maps to salutation for backwards compatibility
|
||||
salutation=params.get(
|
||||
"title"
|
||||
), # 'title' param maps to salutation for backwards compatibility
|
||||
first_name=params.get("first_name"),
|
||||
last_name=params.get("last_name"),
|
||||
status="assigned",
|
||||
|
|
@ -270,7 +272,9 @@ class CreateLeadFromSite(APIView):
|
|||
lead.assigned_to.add(user)
|
||||
# Send Email to Assigned Users
|
||||
site_address = request.scheme + "://" + request.META["HTTP_HOST"]
|
||||
send_lead_assigned_emails.delay(lead.id, [user.id], site_address, str(api_setting.org.id))
|
||||
send_lead_assigned_emails.delay(
|
||||
lead.id, [user.id], site_address, str(api_setting.org.id)
|
||||
)
|
||||
# Create Contact
|
||||
try:
|
||||
contact = Contact.objects.create(
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ class LeadListView(APIView, LimitOffsetPagination):
|
|||
& Q(last_name__icontains=params.get("name"))
|
||||
)
|
||||
if params.get("salutation"):
|
||||
queryset = queryset.filter(salutation__icontains=params.get("salutation"))
|
||||
queryset = queryset.filter(
|
||||
salutation__icontains=params.get("salutation")
|
||||
)
|
||||
if params.get("source"):
|
||||
queryset = queryset.filter(source=params.get("source"))
|
||||
if params.getlist("assigned_to"):
|
||||
|
|
@ -85,13 +87,21 @@ class LeadListView(APIView, LimitOffsetPagination):
|
|||
| Q(email__icontains=search)
|
||||
)
|
||||
if params.get("created_at__gte"):
|
||||
queryset = queryset.filter(created_at__gte=params.get("created_at__gte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__gte=params.get("created_at__gte")
|
||||
)
|
||||
if params.get("created_at__lte"):
|
||||
queryset = queryset.filter(created_at__lte=params.get("created_at__lte"))
|
||||
queryset = queryset.filter(
|
||||
created_at__lte=params.get("created_at__lte")
|
||||
)
|
||||
if params.get("close_date__gte"):
|
||||
queryset = queryset.filter(close_date__gte=params.get("close_date__gte"))
|
||||
queryset = queryset.filter(
|
||||
close_date__gte=params.get("close_date__gte")
|
||||
)
|
||||
if params.get("close_date__lte"):
|
||||
queryset = queryset.filter(close_date__lte=params.get("close_date__lte"))
|
||||
queryset = queryset.filter(
|
||||
close_date__lte=params.get("close_date__lte")
|
||||
)
|
||||
context = {}
|
||||
queryset_open = queryset.exclude(status="closed")
|
||||
results_leads_open = self.paginate_queryset(
|
||||
|
|
@ -189,8 +199,12 @@ class LeadListView(APIView, LimitOffsetPagination):
|
|||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
"account_id": serializers.CharField(required=False),
|
||||
"contact_id": serializers.CharField(required=False, allow_null=True),
|
||||
"opportunity_id": serializers.CharField(required=False, allow_null=True),
|
||||
"contact_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
"opportunity_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
@ -204,7 +218,9 @@ class LeadListView(APIView, LimitOffsetPagination):
|
|||
)
|
||||
if data.get("tags", None):
|
||||
tags = data.get("tags")
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
lead_obj.tags.add(*tag_objs)
|
||||
|
||||
if data.get("contacts", None):
|
||||
|
|
@ -244,7 +260,9 @@ class LeadListView(APIView, LimitOffsetPagination):
|
|||
if data.get("status") == "converted":
|
||||
from leads.services import convert_lead_to_account
|
||||
|
||||
account, contact, opportunity = convert_lead_to_account(lead_obj, request)
|
||||
account, contact, opportunity = convert_lead_to_account(
|
||||
lead_obj, request
|
||||
)
|
||||
|
||||
if data.get("assigned_to", None):
|
||||
assigned_to_list = data.getlist("assigned_to")
|
||||
|
|
@ -501,8 +519,12 @@ class LeadDetailView(APIView):
|
|||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
"account_id": serializers.CharField(required=False),
|
||||
"contact_id": serializers.CharField(required=False, allow_null=True),
|
||||
"opportunity_id": serializers.CharField(required=False, allow_null=True),
|
||||
"contact_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
"opportunity_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
@ -531,7 +553,9 @@ class LeadDetailView(APIView):
|
|||
lead_obj.tags.clear()
|
||||
if params.get("tags"):
|
||||
tags = params.get("tags")
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
lead_obj.tags.add(*tag_objs)
|
||||
|
||||
assigned_to_list = list(
|
||||
|
|
@ -576,7 +600,9 @@ class LeadDetailView(APIView):
|
|||
if params.get("status") == "converted":
|
||||
from leads.services import convert_lead_to_account
|
||||
|
||||
account, contact, opportunity = convert_lead_to_account(lead_obj, request)
|
||||
account, contact, opportunity = convert_lead_to_account(
|
||||
lead_obj, request
|
||||
)
|
||||
|
||||
if params.get("assigned_to"):
|
||||
assigned_to_list = params.get("assigned_to")
|
||||
|
|
@ -617,8 +643,12 @@ class LeadDetailView(APIView):
|
|||
"error": serializers.BooleanField(),
|
||||
"message": serializers.CharField(),
|
||||
"account_id": serializers.CharField(required=False),
|
||||
"contact_id": serializers.CharField(required=False, allow_null=True),
|
||||
"opportunity_id": serializers.CharField(required=False, allow_null=True),
|
||||
"contact_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
"opportunity_id": serializers.CharField(
|
||||
required=False, allow_null=True
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
|
@ -654,7 +684,9 @@ class LeadDetailView(APIView):
|
|||
if params.get("status") == "converted" or params.get("is_converted"):
|
||||
from leads.services import convert_lead_to_account
|
||||
|
||||
account, contact, opportunity = convert_lead_to_account(self.lead_obj, request)
|
||||
account, contact, opportunity = convert_lead_to_account(
|
||||
self.lead_obj, request
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -682,7 +714,9 @@ class LeadDetailView(APIView):
|
|||
lead_obj.tags.clear()
|
||||
tags = params.get("tags")
|
||||
if tags:
|
||||
tag_objs = Tags.objects.filter(id__in=tags, org=request.profile.org, is_active=True)
|
||||
tag_objs = Tags.objects.filter(
|
||||
id__in=tags, org=request.profile.org, is_active=True
|
||||
)
|
||||
lead_obj.tags.add(*tag_objs)
|
||||
|
||||
if "contacts" in params:
|
||||
|
|
@ -698,7 +732,9 @@ class LeadDetailView(APIView):
|
|||
lead_obj.teams.clear()
|
||||
teams_list = params.get("teams")
|
||||
if teams_list:
|
||||
teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org)
|
||||
teams = Teams.objects.filter(
|
||||
id__in=teams_list, org=request.profile.org
|
||||
)
|
||||
lead_obj.teams.add(*teams)
|
||||
|
||||
if "assigned_to" in params:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0002_enable_rls"),
|
||||
("opportunity", "0001_initial"),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0002_enable_rls"),
|
||||
("opportunity", "0002_add_tags_related_name"),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("opportunity", "0003_alter_opportunity_assigned_to"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||
|
||||
def cleanup_invalid_probability(apps, schema_editor):
|
||||
"""Fix probability values outside 0-100 range."""
|
||||
Opportunity = apps.get_model('opportunity', 'Opportunity')
|
||||
Opportunity = apps.get_model("opportunity", "Opportunity")
|
||||
|
||||
# Set negative probabilities to 0
|
||||
updated = Opportunity.objects.filter(probability__lt=0).update(probability=0)
|
||||
|
|
@ -21,7 +21,7 @@ def cleanup_invalid_probability(apps, schema_editor):
|
|||
|
||||
def cleanup_negative_amounts(apps, schema_editor):
|
||||
"""Set negative amount values to NULL."""
|
||||
Opportunity = apps.get_model('opportunity', 'Opportunity')
|
||||
Opportunity = apps.get_model("opportunity", "Opportunity")
|
||||
updated = Opportunity.objects.filter(amount__lt=0).update(amount=None)
|
||||
if updated:
|
||||
print(f" Set {updated} negative amount values to NULL")
|
||||
|
|
@ -33,7 +33,6 @@ def reverse_noop(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0006_add_enterprise_constraints"),
|
||||
("common", "0008_enable_rls_product_invoice_line_item"),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import uuid
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0001_initial"),
|
||||
("invoices", "0004_estimate_estimatelineitem_invoicetemplate_payment_and_more"),
|
||||
|
|
@ -19,7 +18,10 @@ class Migration(migrations.Migration):
|
|||
model_name="opportunity",
|
||||
name="amount_source",
|
||||
field=models.CharField(
|
||||
choices=[("MANUAL", "Manual"), ("CALCULATED", "Calculated from Products")],
|
||||
choices=[
|
||||
("MANUAL", "Manual"),
|
||||
("CALCULATED", "Calculated from Products"),
|
||||
],
|
||||
default="MANUAL",
|
||||
max_length=20,
|
||||
verbose_name="Amount Source",
|
||||
|
|
@ -96,7 +98,10 @@ class Migration(migrations.Migration):
|
|||
"discount_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[("PERCENTAGE", "Percentage (%)"), ("FIXED", "Fixed Amount")],
|
||||
choices=[
|
||||
("PERCENTAGE", "Percentage (%)"),
|
||||
("FIXED", "Fixed Amount"),
|
||||
],
|
||||
max_length=20,
|
||||
verbose_name="Discount Type",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,35 +5,41 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('opportunity', '0006_add_opportunity_line_item'),
|
||||
("opportunity", "0006_add_opportunity_line_item"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='opportunitylineitem',
|
||||
new_name='opportunity_opportu_456e8f_idx',
|
||||
old_name='opportunity__opportu_5e4c7e_idx',
|
||||
model_name="opportunitylineitem",
|
||||
new_name="opportunity_opportu_456e8f_idx",
|
||||
old_name="opportunity__opportu_5e4c7e_idx",
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='opportunitylineitem',
|
||||
new_name='opportunity_org_id_c76c2c_idx',
|
||||
old_name='opportunity__org_id_a1b2c3_idx',
|
||||
model_name="opportunitylineitem",
|
||||
new_name="opportunity_org_id_c76c2c_idx",
|
||||
old_name="opportunity__org_id_a1b2c3_idx",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='opportunitylineitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Created At'),
|
||||
model_name="opportunitylineitem",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='opportunitylineitem',
|
||||
name='id',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True),
|
||||
model_name="opportunitylineitem",
|
||||
name="id",
|
||||
field=models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='opportunitylineitem',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Last Modified At'),
|
||||
model_name="opportunitylineitem",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ from opportunity.views.opportunity_interactions import (
|
|||
OpportunityAttachmentView,
|
||||
OpportunityCommentView,
|
||||
)
|
||||
from opportunity.views.opportunity_views import OpportunityDetailView, OpportunityListView
|
||||
from opportunity.views.opportunity_views import (
|
||||
OpportunityDetailView,
|
||||
OpportunityListView,
|
||||
)
|
||||
|
||||
app_name = "api_opportunities"
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue