feat: Implement enterprise constraints, update models, serializers, views, and enhance UI components across the application.

This commit is contained in:
Ashwin 2025-12-09 16:26:17 +05:30
parent 0f52064047
commit b1fdffc1cc
354 changed files with 36654 additions and 32764 deletions

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_initial"),
("common", "0002_enable_rls"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_add_tags_related_name"),
]

View file

@ -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),

View file

@ -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"),

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0002_initial"),
("contacts", "0004_change_phone_to_phonenumberfield"),

View file

@ -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"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0004_add_tags_field"),
("common", "0002_enable_rls"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0005_add_tags_related_name"),
]

View file

@ -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),

View file

@ -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"),
]

View file

@ -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")},
),
]

View file

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

View file

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

View file

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

View file

@ -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])

View file

@ -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,
}

View file

@ -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",

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0002_enable_rls"),
]

View file

@ -49,7 +49,6 @@ def disable_rls_on_commentfiles(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("common", "0003_add_org_to_commentfiles"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0004_populate_commentfiles_org_and_rls"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0005_increase_apikey_length"),
]

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0006_remove_phonenumberfield"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0008_enable_rls_product_invoice_line_item"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0011_enable_rls_opportunity_line_item"),
]

View file

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

View file

@ -75,5 +75,3 @@ class IsSuperAdmin(permissions.BasePermission):
# Check for super admin email domain
return request.user.email.endswith("@micropyramid.com")

View file

@ -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 = (

View file

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

View file

@ -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)"
),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0001_initial"),
]

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("contacts", "0002_change_phone_to_charfield"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0003_add_account_fk"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("contacts", "0004_change_phone_to_phonenumberfield"),
]

View file

@ -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"),

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("contacts", "0006_add_tags_related_name"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contacts", "0007_remove_organization_index"),
]

View file

@ -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),

View file

@ -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"),

View file

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

View file

@ -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"],

View file

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

View file

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

View file

@ -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",

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("invoices", "0001_initial"),
]

View file

@ -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),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("invoices", "0004_estimate_estimatelineitem_invoicetemplate_payment_and_more"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("invoices", "0005_add_billing_period_po_number_and_lineitem_name"),
]

View file

@ -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"
),
),
]

View file

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

View file

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

View file

@ -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"})

View file

@ -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()

View file

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

View file

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

View file

@ -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,))

View file

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

View file

@ -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",

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_initial"),
("leads", "0001_initial"),

View file

@ -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"),

View file

@ -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"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("leads", "0004_remove_company_model"),
]

View file

@ -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"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("leads", "0006_add_tags_related_name"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("leads", "0007_add_title_field"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("leads", "0008_remove_unused_fields"),
]

View file

@ -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),

View file

@ -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"),

View file

@ -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")},
),
]

View file

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

View file

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

View file

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

View file

@ -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(),

View file

@ -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(
{

View file

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

View file

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

View file

@ -8,7 +8,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0002_enable_rls"),
("opportunity", "0001_initial"),

View file

@ -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"),

View file

@ -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),

View file

@ -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"),

View file

@ -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",
),

View file

@ -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"),
),
]

View file

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