diff --git a/backend/common/migrations/0013_enable_rls_orders_salesforce.py b/backend/common/migrations/0013_enable_rls_orders_salesforce.py new file mode 100644 index 00000000..1a94b6d8 --- /dev/null +++ b/backend/common/migrations/0013_enable_rls_orders_salesforce.py @@ -0,0 +1,88 @@ +# Enable RLS on orders tables +# Note: salesforce_imports tables were moved to enterprise edition + +from django.db import migrations + +from common.rls import ( + get_check_table_exists_sql, + get_disable_policy_sql, + get_enable_policy_sql, +) + +TABLES = [ + "orders", + "order_line_item", +] + + +def check_column_exists(cursor, table, column): + """Check if a column exists in a table.""" + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = %s + AND column_name = %s + ) + """, + [table, column], + ) + return cursor.fetchone()[0] + + +def enable_rls(apps, schema_editor): + """Enable RLS on orders tables.""" + if schema_editor.connection.vendor != "postgresql": + print("RLS is only supported on PostgreSQL. Skipping.") + return + + with schema_editor.connection.cursor() as cursor: + for table in TABLES: + # Check if table exists + cursor.execute(get_check_table_exists_sql(), [table]) + if not cursor.fetchone()[0]: + print(f" Skipping {table} (table does not exist)") + continue + + # Check if org_id column exists + if not check_column_exists(cursor, table, "org_id"): + print(f" Skipping {table} (no org_id column)") + continue + + # Enable RLS + cursor.execute(get_enable_policy_sql(table)) + print(f" Enabled RLS on {table}") + + +def disable_rls(apps, schema_editor): + """Disable RLS on orders tables (rollback).""" + if schema_editor.connection.vendor != "postgresql": + return + + with schema_editor.connection.cursor() as cursor: + for table in TABLES: + cursor.execute(get_check_table_exists_sql(), [table]) + if not cursor.fetchone()[0]: + continue + + if not check_column_exists(cursor, table, "org_id"): + continue + + cursor.execute(get_disable_policy_sql(table)) + print(f" Disabled RLS on {table}") + + +class Migration(migrations.Migration): + """ + Enable Row-Level Security (RLS) on orders tables. + """ + + dependencies = [ + ("common", "0012_add_tag_color_description_isactive"), + ("orders", "0001_initial"), + ] + + operations = [ + migrations.RunPython(enable_rls, disable_rls), + ] diff --git a/backend/common/rls/__init__.py b/backend/common/rls/__init__.py index dd513410..d56f5531 100644 --- a/backend/common/rls/__init__.py +++ b/backend/common/rls/__init__.py @@ -70,6 +70,9 @@ ORG_SCOPED_TABLES = [ "recurring_invoice_line_item", # Products "product", + # Orders + "orders", + "order_line_item", # Security & Audit "security_audit_log", ] diff --git a/backend/crm/settings.py b/backend/crm/settings.py index 7d4d0241..a07dcea4 100644 --- a/backend/crm/settings.py +++ b/backend/crm/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ "opportunity", "tasks", "invoices", + "orders", # "teams", # Merged into common app ] diff --git a/backend/orders/__init__.py b/backend/orders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/orders/admin.py b/backend/orders/admin.py new file mode 100644 index 00000000..15f80d48 --- /dev/null +++ b/backend/orders/admin.py @@ -0,0 +1,149 @@ +from django.contrib import admin + +from orders.models import Order, OrderLineItem + + +class OrderLineItemInline(admin.TabularInline): + model = OrderLineItem + extra = 1 + fields = ( + "product", + "name", + "description", + "quantity", + "unit_price", + "discount_amount", + "total", + "sort_order", + ) + readonly_fields = ("total",) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "name", + "order_number", + "account", + "status", + "total_amount", + "order_date", + "created_at", + ) + list_filter = ("status", "created_at", "order_date", "org") + search_fields = ( + "name", + "order_number", + "account__name", + ) + raw_id_fields = ( + "created_by", + "org", + "account", + "contact", + "opportunity", + ) + inlines = [OrderLineItemInline] + date_hierarchy = "created_at" + ordering = ("-created_at",) + + fieldsets = ( + ( + "Order Info", + { + "fields": ( + "name", + "order_number", + "status", + ) + }, + ), + ( + "CRM Integration", + { + "fields": ( + "account", + "contact", + "opportunity", + ) + }, + ), + ( + "Financial", + { + "fields": ( + "currency", + "subtotal", + "discount_amount", + "tax_amount", + "total_amount", + ) + }, + ), + ( + "Dates", + { + "fields": ( + "order_date", + "activated_date", + "shipped_date", + ) + }, + ), + ( + "Billing Address", + { + "fields": ( + "billing_address_line", + "billing_city", + "billing_state", + "billing_postcode", + "billing_country", + ), + "classes": ("collapse",), + }, + ), + ( + "Shipping Address", + { + "fields": ( + "shipping_address_line", + "shipping_city", + "shipping_state", + "shipping_postcode", + "shipping_country", + ), + "classes": ("collapse",), + }, + ), + ( + "Notes", + { + "fields": ("description",) + }, + ), + ( + "Organization", + { + "fields": ("org",) + }, + ), + ) + + +@admin.register(OrderLineItem) +class OrderLineItemAdmin(admin.ModelAdmin): + list_display = ( + "order", + "name", + "product", + "quantity", + "unit_price", + "discount_amount", + "total", + "sort_order", + ) + list_filter = ("order__status", "created_at") + search_fields = ("name", "order__name", "product__name") + raw_id_fields = ("order", "product", "org") + ordering = ("order", "sort_order") diff --git a/backend/orders/apps.py b/backend/orders/apps.py new file mode 100644 index 00000000..cbc6963a --- /dev/null +++ b/backend/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = "django.db.models.AutoField" + name = "orders" diff --git a/backend/orders/migrations/0001_initial.py b/backend/orders/migrations/0001_initial.py new file mode 100644 index 00000000..38bc3641 --- /dev/null +++ b/backend/orders/migrations/0001_initial.py @@ -0,0 +1,924 @@ +# Generated by Django 5.2.9 on 2026-02-19 02:29 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("accounts", "0006_add_enterprise_constraints"), + ("common", "0012_add_tag_color_description_isactive"), + ("contacts", "0010_add_enterprise_constraints"), + ("invoices", "0007_add_cancelled_at_field"), + ("opportunity", "0009_salesgoal"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Order", + 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="Order Name")), + ( + "order_number", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Order Number", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("DRAFT", "Draft"), + ("ACTIVATED", "Activated"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), + ], + default="DRAFT", + max_length=20, + verbose_name="Status", + ), + ), + ( + "currency", + models.CharField( + blank=True, max_length=3, null=True, verbose_name="Currency" + ), + ), + ( + "subtotal", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Subtotal", + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Discount Amount", + ), + ), + ( + "tax_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Tax Amount", + ), + ), + ( + "total_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Total Amount", + ), + ), + ( + "order_date", + models.DateField(blank=True, null=True, verbose_name="Order Date"), + ), + ( + "activated_date", + models.DateField( + blank=True, null=True, verbose_name="Activated Date" + ), + ), + ( + "shipped_date", + models.DateField( + blank=True, null=True, verbose_name="Shipped Date" + ), + ), + ( + "billing_address_line", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Billing Address", + ), + ), + ( + "billing_city", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Billing City", + ), + ), + ( + "billing_state", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Billing State", + ), + ), + ( + "billing_postcode", + models.CharField( + blank=True, + max_length=20, + null=True, + verbose_name="Billing Postal Code", + ), + ), + ( + "billing_country", + models.CharField( + blank=True, + choices=[ + ("GB", "United Kingdom"), + ("AF", "Afghanistan"), + ("AX", "Aland Islands"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("CV", "Cape Verde"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("CI", "Cote d'Ivoire"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CY", "Cyprus"), + ("CZ", "Czech Republic"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libyan Arab Jamahiriya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MK", "Macedonia, The Former Yugoslav Republic of"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("AN", "Netherlands Antilles"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestinian Territory, Occupied"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RE", "Reunion"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("BL", "Saint Barthelemy"), + ("SH", "Saint Helena"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SZ", "Swaziland"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ], + max_length=3, + null=True, + verbose_name="Billing Country", + ), + ), + ( + "shipping_address_line", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Shipping Address", + ), + ), + ( + "shipping_city", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Shipping City", + ), + ), + ( + "shipping_state", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Shipping State", + ), + ), + ( + "shipping_postcode", + models.CharField( + blank=True, + max_length=20, + null=True, + verbose_name="Shipping Postal Code", + ), + ), + ( + "shipping_country", + models.CharField( + blank=True, + choices=[ + ("GB", "United Kingdom"), + ("AF", "Afghanistan"), + ("AX", "Aland Islands"), + ("AL", "Albania"), + ("DZ", "Algeria"), + ("AS", "American Samoa"), + ("AD", "Andorra"), + ("AO", "Angola"), + ("AI", "Anguilla"), + ("AQ", "Antarctica"), + ("AG", "Antigua and Barbuda"), + ("AR", "Argentina"), + ("AM", "Armenia"), + ("AW", "Aruba"), + ("AU", "Australia"), + ("AT", "Austria"), + ("AZ", "Azerbaijan"), + ("BS", "Bahamas"), + ("BH", "Bahrain"), + ("BD", "Bangladesh"), + ("BB", "Barbados"), + ("BY", "Belarus"), + ("BE", "Belgium"), + ("BZ", "Belize"), + ("BJ", "Benin"), + ("BM", "Bermuda"), + ("BT", "Bhutan"), + ("BO", "Bolivia"), + ("BA", "Bosnia and Herzegovina"), + ("BW", "Botswana"), + ("BV", "Bouvet Island"), + ("BR", "Brazil"), + ("IO", "British Indian Ocean Territory"), + ("BN", "Brunei Darussalam"), + ("BG", "Bulgaria"), + ("BF", "Burkina Faso"), + ("BI", "Burundi"), + ("KH", "Cambodia"), + ("CM", "Cameroon"), + ("CA", "Canada"), + ("CV", "Cape Verde"), + ("KY", "Cayman Islands"), + ("CF", "Central African Republic"), + ("TD", "Chad"), + ("CL", "Chile"), + ("CN", "China"), + ("CX", "Christmas Island"), + ("CC", "Cocos (Keeling) Islands"), + ("CO", "Colombia"), + ("KM", "Comoros"), + ("CG", "Congo"), + ("CD", "Congo, The Democratic Republic of the"), + ("CK", "Cook Islands"), + ("CR", "Costa Rica"), + ("CI", "Cote d'Ivoire"), + ("HR", "Croatia"), + ("CU", "Cuba"), + ("CY", "Cyprus"), + ("CZ", "Czech Republic"), + ("DK", "Denmark"), + ("DJ", "Djibouti"), + ("DM", "Dominica"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("SV", "El Salvador"), + ("GQ", "Equatorial Guinea"), + ("ER", "Eritrea"), + ("EE", "Estonia"), + ("ET", "Ethiopia"), + ("FK", "Falkland Islands (Malvinas)"), + ("FO", "Faroe Islands"), + ("FJ", "Fiji"), + ("FI", "Finland"), + ("FR", "France"), + ("GF", "French Guiana"), + ("PF", "French Polynesia"), + ("TF", "French Southern Territories"), + ("GA", "Gabon"), + ("GM", "Gambia"), + ("GE", "Georgia"), + ("DE", "Germany"), + ("GH", "Ghana"), + ("GI", "Gibraltar"), + ("GR", "Greece"), + ("GL", "Greenland"), + ("GD", "Grenada"), + ("GP", "Guadeloupe"), + ("GU", "Guam"), + ("GT", "Guatemala"), + ("GG", "Guernsey"), + ("GN", "Guinea"), + ("GW", "Guinea-Bissau"), + ("GY", "Guyana"), + ("HT", "Haiti"), + ("HM", "Heard Island and McDonald Islands"), + ("VA", "Holy See (Vatican City State)"), + ("HN", "Honduras"), + ("HK", "Hong Kong"), + ("HU", "Hungary"), + ("IS", "Iceland"), + ("IN", "India"), + ("ID", "Indonesia"), + ("IR", "Iran, Islamic Republic of"), + ("IQ", "Iraq"), + ("IE", "Ireland"), + ("IM", "Isle of Man"), + ("IL", "Israel"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JP", "Japan"), + ("JE", "Jersey"), + ("JO", "Jordan"), + ("KZ", "Kazakhstan"), + ("KE", "Kenya"), + ("KI", "Kiribati"), + ("KP", "Korea, Democratic People's Republic of"), + ("KR", "Korea, Republic of"), + ("KW", "Kuwait"), + ("KG", "Kyrgyzstan"), + ("LA", "Lao People's Democratic Republic"), + ("LV", "Latvia"), + ("LB", "Lebanon"), + ("LS", "Lesotho"), + ("LR", "Liberia"), + ("LY", "Libyan Arab Jamahiriya"), + ("LI", "Liechtenstein"), + ("LT", "Lithuania"), + ("LU", "Luxembourg"), + ("MO", "Macao"), + ("MK", "Macedonia, The Former Yugoslav Republic of"), + ("MG", "Madagascar"), + ("MW", "Malawi"), + ("MY", "Malaysia"), + ("MV", "Maldives"), + ("ML", "Mali"), + ("MT", "Malta"), + ("MH", "Marshall Islands"), + ("MQ", "Martinique"), + ("MR", "Mauritania"), + ("MU", "Mauritius"), + ("YT", "Mayotte"), + ("MX", "Mexico"), + ("FM", "Micronesia, Federated States of"), + ("MD", "Moldova"), + ("MC", "Monaco"), + ("MN", "Mongolia"), + ("ME", "Montenegro"), + ("MS", "Montserrat"), + ("MA", "Morocco"), + ("MZ", "Mozambique"), + ("MM", "Myanmar"), + ("NA", "Namibia"), + ("NR", "Nauru"), + ("NP", "Nepal"), + ("NL", "Netherlands"), + ("AN", "Netherlands Antilles"), + ("NC", "New Caledonia"), + ("NZ", "New Zealand"), + ("NI", "Nicaragua"), + ("NE", "Niger"), + ("NG", "Nigeria"), + ("NU", "Niue"), + ("NF", "Norfolk Island"), + ("MP", "Northern Mariana Islands"), + ("NO", "Norway"), + ("OM", "Oman"), + ("PK", "Pakistan"), + ("PW", "Palau"), + ("PS", "Palestinian Territory, Occupied"), + ("PA", "Panama"), + ("PG", "Papua New Guinea"), + ("PY", "Paraguay"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PN", "Pitcairn"), + ("PL", "Poland"), + ("PT", "Portugal"), + ("PR", "Puerto Rico"), + ("QA", "Qatar"), + ("RE", "Reunion"), + ("RO", "Romania"), + ("RU", "Russian Federation"), + ("RW", "Rwanda"), + ("BL", "Saint Barthelemy"), + ("SH", "Saint Helena"), + ("KN", "Saint Kitts and Nevis"), + ("LC", "Saint Lucia"), + ("MF", "Saint Martin"), + ("PM", "Saint Pierre and Miquelon"), + ("VC", "Saint Vincent and the Grenadines"), + ("WS", "Samoa"), + ("SM", "San Marino"), + ("ST", "Sao Tome and Principe"), + ("SA", "Saudi Arabia"), + ("SN", "Senegal"), + ("RS", "Serbia"), + ("SC", "Seychelles"), + ("SL", "Sierra Leone"), + ("SG", "Singapore"), + ("SK", "Slovakia"), + ("SI", "Slovenia"), + ("SB", "Solomon Islands"), + ("SO", "Somalia"), + ("ZA", "South Africa"), + ("GS", "South Georgia and the South Sandwich Islands"), + ("ES", "Spain"), + ("LK", "Sri Lanka"), + ("SD", "Sudan"), + ("SR", "Suriname"), + ("SJ", "Svalbard and Jan Mayen"), + ("SZ", "Swaziland"), + ("SE", "Sweden"), + ("CH", "Switzerland"), + ("SY", "Syrian Arab Republic"), + ("TW", "Taiwan, Province of China"), + ("TJ", "Tajikistan"), + ("TZ", "Tanzania, United Republic of"), + ("TH", "Thailand"), + ("TL", "Timor-Leste"), + ("TG", "Togo"), + ("TK", "Tokelau"), + ("TO", "Tonga"), + ("TT", "Trinidad and Tobago"), + ("TN", "Tunisia"), + ("TR", "Turkey"), + ("TM", "Turkmenistan"), + ("TC", "Turks and Caicos Islands"), + ("TV", "Tuvalu"), + ("UG", "Uganda"), + ("UA", "Ukraine"), + ("AE", "United Arab Emirates"), + ("US", "United States"), + ("UM", "United States Minor Outlying Islands"), + ("UY", "Uruguay"), + ("UZ", "Uzbekistan"), + ("VU", "Vanuatu"), + ("VE", "Venezuela"), + ("VN", "Viet Nam"), + ("VG", "Virgin Islands, British"), + ("VI", "Virgin Islands, U.S."), + ("WF", "Wallis and Futuna"), + ("EH", "Western Sahara"), + ("YE", "Yemen"), + ("ZM", "Zambia"), + ("ZW", "Zimbabwe"), + ], + max_length=3, + null=True, + verbose_name="Shipping Country", + ), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "account", + models.ForeignKey( + help_text="The account this order belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="orders", + to="accounts.account", + ), + ), + ( + "contact", + models.ForeignKey( + blank=True, + help_text="The contact associated with this order", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="orders", + to="contacts.contact", + ), + ), + ( + "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", + ), + ), + ( + "opportunity", + models.ForeignKey( + blank=True, + help_text="The opportunity this order originated from", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="orders", + to="opportunity.opportunity", + ), + ), + ( + "org", + models.ForeignKey( + help_text="Organization this record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + 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": "Order", + "verbose_name_plural": "Orders", + "db_table": "orders", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="OrderLineItem", + 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="Item Name")), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ( + "quantity", + models.DecimalField( + decimal_places=2, + default=1, + max_digits=10, + verbose_name="Quantity", + ), + ), + ( + "unit_price", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Unit Price", + ), + ), + ( + "discount_amount", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=15, + verbose_name="Discount Amount", + ), + ), + ( + "total", + models.DecimalField( + decimal_places=2, default=0, max_digits=15, verbose_name="Total" + ), + ), + ( + "sort_order", + models.IntegerField(default=0, verbose_name="Sort Order"), + ), + ( + "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", + ), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="line_items", + to="orders.order", + ), + ), + ( + "org", + models.ForeignKey( + help_text="Organization this record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="common.org", + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="order_line_items", + to="invoices.product", + ), + ), + ( + "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": "Order Line Item", + "verbose_name_plural": "Order Line Items", + "db_table": "order_line_item", + "ordering": ("sort_order",), + }, + ), + migrations.AddIndex( + model_name="order", + index=models.Index( + fields=["org", "-created_at"], name="orders_org_id_9026a7_idx" + ), + ), + migrations.AddIndex( + model_name="orderlineitem", + index=models.Index( + fields=["org", "-created_at"], name="order_line__org_id_444fcc_idx" + ), + ), + ] diff --git a/backend/orders/migrations/__init__.py b/backend/orders/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/orders/models.py b/backend/orders/models.py new file mode 100644 index 00000000..26fbf6f8 --- /dev/null +++ b/backend/orders/models.py @@ -0,0 +1,182 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from common.base import BaseOrgModel +from common.utils import COUNTRIES + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +ORDER_STATUS = ( + ("DRAFT", "Draft"), + ("ACTIVATED", "Activated"), + ("COMPLETED", "Completed"), + ("CANCELLED", "Cancelled"), +) + + +# ============================================================================= +# ORDER +# ============================================================================= + + +class Order(BaseOrgModel): + """ + Order model for CRM - tracks sales orders linked to accounts, + contacts, and opportunities. + """ + + # Core Order Information + name = models.CharField(_("Order Name"), max_length=255) + order_number = models.CharField( + _("Order Number"), max_length=100, blank=True, null=True + ) + status = models.CharField( + _("Status"), max_length=20, choices=ORDER_STATUS, default="DRAFT" + ) + + # CRM Integration + account = models.ForeignKey( + "accounts.Account", + on_delete=models.CASCADE, + related_name="orders", + help_text="The account this order belongs to", + ) + contact = models.ForeignKey( + "contacts.Contact", + on_delete=models.SET_NULL, + related_name="orders", + null=True, + blank=True, + help_text="The contact associated with this order", + ) + opportunity = models.ForeignKey( + "opportunity.Opportunity", + on_delete=models.SET_NULL, + related_name="orders", + null=True, + blank=True, + help_text="The opportunity this order originated from", + ) + + # Financial + currency = models.CharField(_("Currency"), max_length=3, blank=True, null=True) + subtotal = models.DecimalField( + _("Subtotal"), max_digits=15, decimal_places=2, default=0 + ) + discount_amount = models.DecimalField( + _("Discount Amount"), max_digits=15, decimal_places=2, default=0 + ) + tax_amount = models.DecimalField( + _("Tax Amount"), max_digits=15, decimal_places=2, default=0 + ) + total_amount = models.DecimalField( + _("Total Amount"), max_digits=15, decimal_places=2, default=0 + ) + + # Dates + order_date = models.DateField(_("Order Date"), null=True, blank=True) + activated_date = models.DateField(_("Activated Date"), null=True, blank=True) + shipped_date = models.DateField(_("Shipped Date"), null=True, blank=True) + + # Billing Address + billing_address_line = models.CharField( + _("Billing Address"), max_length=255, blank=True, null=True + ) + billing_city = models.CharField( + _("Billing City"), max_length=100, blank=True, null=True + ) + billing_state = models.CharField( + _("Billing State"), max_length=100, blank=True, null=True + ) + billing_postcode = models.CharField( + _("Billing Postal Code"), max_length=20, blank=True, null=True + ) + billing_country = models.CharField( + _("Billing Country"), max_length=3, choices=COUNTRIES, blank=True, null=True + ) + + # Shipping Address + shipping_address_line = models.CharField( + _("Shipping Address"), max_length=255, blank=True, null=True + ) + shipping_city = models.CharField( + _("Shipping City"), max_length=100, blank=True, null=True + ) + shipping_state = models.CharField( + _("Shipping State"), max_length=100, blank=True, null=True + ) + shipping_postcode = models.CharField( + _("Shipping Postal Code"), max_length=20, blank=True, null=True + ) + shipping_country = models.CharField( + _("Shipping Country"), max_length=3, choices=COUNTRIES, blank=True, null=True + ) + + # Notes + description = models.TextField(_("Description"), blank=True, null=True) + + class Meta: + verbose_name = "Order" + verbose_name_plural = "Orders" + db_table = "orders" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["org", "-created_at"]), + ] + + def __str__(self): + return self.name + + +# ============================================================================= +# ORDER LINE ITEM +# ============================================================================= + + +class OrderLineItem(BaseOrgModel): + """Line item for Orders - individual products/services within an order.""" + + order = models.ForeignKey( + Order, on_delete=models.CASCADE, related_name="line_items" + ) + product = models.ForeignKey( + "invoices.Product", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="order_line_items", + ) + name = models.CharField(_("Item Name"), max_length=255) + description = models.TextField(_("Description"), blank=True, null=True) + quantity = models.DecimalField( + _("Quantity"), max_digits=10, decimal_places=2, default=1 + ) + unit_price = models.DecimalField( + _("Unit Price"), max_digits=15, decimal_places=2, default=0 + ) + discount_amount = models.DecimalField( + _("Discount Amount"), max_digits=15, decimal_places=2, default=0 + ) + total = models.DecimalField(_("Total"), max_digits=15, decimal_places=2, default=0) + sort_order = models.IntegerField(_("Sort Order"), default=0) + + class Meta: + verbose_name = "Order Line Item" + verbose_name_plural = "Order Line Items" + db_table = "order_line_item" + ordering = ("sort_order",) + indexes = [ + models.Index(fields=["org", "-created_at"]), + ] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + # Inherit org from order if not set + if not self.org_id and self.order_id: + self.org_id = self.order.org_id + super().save(*args, **kwargs) diff --git a/backend/orders/tests/__init__.py b/backend/orders/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/orders/tests/test_models.py b/backend/orders/tests/test_models.py new file mode 100644 index 00000000..cfe159dd --- /dev/null +++ b/backend/orders/tests/test_models.py @@ -0,0 +1,335 @@ +""" +Tests for orders/models.py - Order and OrderLineItem models. + +Covers: +- Order creation with required fields +- Order __str__ representation +- Order status choices and default +- Order foreign key relationships (account, contact, opportunity) +- Order Meta options (db_table, ordering) +- OrderLineItem creation +- OrderLineItem __str__ representation +- OrderLineItem auto-populate org from parent order +- OrderLineItem Meta options (db_table, ordering) + +Run with: pytest orders/tests/test_models.py -v +""" + +from decimal import Decimal + +import pytest + +from accounts.models import Account +from contacts.models import Contact +from opportunity.models import Opportunity +from orders.models import ORDER_STATUS, Order, OrderLineItem + + +# --------------------------------------------------------------------------- +# Order model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestOrderModel: + """Test Order model creation, fields, and behavior.""" + + def test_create_order_minimal(self, org_a): + """Order can be created with just required fields (name, account, org).""" + account = Account.objects.create(name="Test Account", org=org_a) + order = Order.objects.create( + name="Test Order", + account=account, + org=org_a, + ) + assert order.pk is not None + assert order.name == "Test Order" + assert order.account == account + assert order.org == org_a + + def test_str_returns_name(self, org_a): + """Order __str__ should return the order name.""" + account = Account.objects.create(name="Test Account", org=org_a) + order = Order.objects.create( + name="My Sales Order", + account=account, + org=org_a, + ) + assert str(order) == "My Sales Order" + + def test_default_status_is_draft(self, org_a): + """Order status should default to DRAFT.""" + account = Account.objects.create(name="Test Account", org=org_a) + order = Order.objects.create( + name="Test Order", + account=account, + org=org_a, + ) + assert order.status == "DRAFT" + + def test_status_choices(self): + """ORDER_STATUS should contain expected choices.""" + status_values = [s[0] for s in ORDER_STATUS] + assert "DRAFT" in status_values + assert "ACTIVATED" in status_values + assert "COMPLETED" in status_values + assert "CANCELLED" in status_values + + def test_create_order_all_fields(self, org_a): + """Order can be created with all fields populated.""" + account = Account.objects.create(name="Full Account", org=org_a) + contact = Contact.objects.create( + first_name="Jane", last_name="Doe", org=org_a + ) + opportunity = Opportunity.objects.create( + name="Big Deal", + account=account, + org=org_a, + ) + order = Order.objects.create( + name="Full Order", + order_number="ORD-001", + status="ACTIVATED", + account=account, + contact=contact, + opportunity=opportunity, + currency="USD", + subtotal=Decimal("1000.00"), + discount_amount=Decimal("100.00"), + tax_amount=Decimal("90.00"), + total_amount=Decimal("990.00"), + order_date="2026-01-15", + activated_date="2026-01-16", + shipped_date="2026-01-20", + billing_address_line="123 Main St", + billing_city="Austin", + billing_state="TX", + billing_postcode="78701", + billing_country="US", + shipping_address_line="456 Oak Ave", + shipping_city="Dallas", + shipping_state="TX", + shipping_postcode="75201", + shipping_country="US", + description="Full order with all fields", + org=org_a, + ) + assert order.pk is not None + assert order.order_number == "ORD-001" + assert order.status == "ACTIVATED" + assert order.contact == contact + assert order.opportunity == opportunity + assert order.total_amount == Decimal("990.00") + assert order.billing_city == "Austin" + assert order.shipping_city == "Dallas" + + def test_order_account_cascade_delete(self, org_a): + """Deleting an account should cascade-delete its orders.""" + account = Account.objects.create(name="To Delete", org=org_a) + Order.objects.create(name="Order to Delete", account=account, org=org_a) + assert Order.objects.count() == 1 + account.delete() + assert Order.objects.count() == 0 + + def test_order_contact_set_null_on_delete(self, org_a): + """Deleting a contact should set order.contact to NULL.""" + account = Account.objects.create(name="Test Account", org=org_a) + contact = Contact.objects.create( + first_name="John", last_name="Doe", org=org_a + ) + order = Order.objects.create( + name="Contact Order", + account=account, + contact=contact, + org=org_a, + ) + contact.delete() + order.refresh_from_db() + assert order.contact is None + + def test_order_opportunity_set_null_on_delete(self, org_a): + """Deleting an opportunity should set order.opportunity to NULL.""" + account = Account.objects.create(name="Test Account", org=org_a) + opportunity = Opportunity.objects.create( + name="Opp", account=account, org=org_a + ) + order = Order.objects.create( + name="Opp Order", + account=account, + opportunity=opportunity, + org=org_a, + ) + opportunity.delete() + order.refresh_from_db() + assert order.opportunity is None + + def test_order_meta_db_table(self): + """Order Meta db_table should be 'orders'.""" + assert Order._meta.db_table == "orders" + + def test_order_meta_ordering(self): + """Order Meta ordering should be ('-created_at',).""" + assert Order._meta.ordering == ("-created_at",) + + def test_order_decimal_defaults(self, org_a): + """Financial fields should default to 0.""" + account = Account.objects.create(name="Default Account", org=org_a) + order = Order.objects.create(name="Default Order", account=account, org=org_a) + assert order.subtotal == Decimal("0") + assert order.discount_amount == Decimal("0") + assert order.tax_amount == Decimal("0") + assert order.total_amount == Decimal("0") + + def test_order_has_uuid_pk(self, org_a): + """Order primary key should be a UUID (inherited from BaseOrgModel).""" + import uuid + + account = Account.objects.create(name="UUID Account", org=org_a) + order = Order.objects.create(name="UUID Order", account=account, org=org_a) + assert isinstance(order.pk, uuid.UUID) + + def test_order_has_timestamps(self, org_a): + """Order should have created_at and updated_at timestamps.""" + account = Account.objects.create(name="Timestamp Account", org=org_a) + order = Order.objects.create( + name="Timestamp Order", account=account, org=org_a + ) + assert order.created_at is not None + assert order.updated_at is not None + + +# --------------------------------------------------------------------------- +# OrderLineItem model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestOrderLineItemModel: + """Test OrderLineItem model creation, fields, and behavior.""" + + def _make_order(self, org): + account = Account.objects.create(name="Line Item Account", org=org) + return Order.objects.create(name="Line Item Order", account=account, org=org) + + def test_create_line_item_minimal(self, org_a): + """OrderLineItem can be created with required fields.""" + order = self._make_order(org_a) + item = OrderLineItem.objects.create( + order=order, + name="Widget", + org=org_a, + ) + assert item.pk is not None + assert item.name == "Widget" + assert item.order == order + + def test_str_returns_name(self, org_a): + """OrderLineItem __str__ should return the item name.""" + order = self._make_order(org_a) + item = OrderLineItem.objects.create( + order=order, + name="Premium Widget", + org=org_a, + ) + assert str(item) == "Premium Widget" + + def test_auto_populate_org_from_order(self, org_a): + """OrderLineItem should inherit org from parent order if not set.""" + order = self._make_order(org_a) + item = OrderLineItem(order=order, name="Auto Org Item") + item.save() + assert item.org_id == org_a.pk + + def test_explicit_org_not_overridden(self, org_a, org_b): + """OrderLineItem with explicitly set org should keep it.""" + order = self._make_order(org_a) + item = OrderLineItem(order=order, name="Explicit Org Item", org=org_b) + item.save() + assert item.org_id == org_b.pk + + def test_line_item_all_fields(self, org_a): + """OrderLineItem can be created with all fields populated.""" + order = self._make_order(org_a) + item = OrderLineItem.objects.create( + order=order, + name="Full Item", + description="A fully specified line item", + quantity=Decimal("5.00"), + unit_price=Decimal("25.50"), + discount_amount=Decimal("10.00"), + total=Decimal("117.50"), + sort_order=1, + org=org_a, + ) + assert item.quantity == Decimal("5.00") + assert item.unit_price == Decimal("25.50") + assert item.discount_amount == Decimal("10.00") + assert item.total == Decimal("117.50") + assert item.sort_order == 1 + + def test_line_item_cascade_delete_with_order(self, org_a): + """Deleting an order should cascade-delete its line items.""" + order = self._make_order(org_a) + OrderLineItem.objects.create(order=order, name="Item 1", org=org_a) + OrderLineItem.objects.create(order=order, name="Item 2", org=org_a) + assert OrderLineItem.objects.count() == 2 + order.delete() + assert OrderLineItem.objects.count() == 0 + + def test_line_item_product_set_null_on_delete(self, org_a): + """Deleting a product should set line_item.product to NULL.""" + from invoices.models import Product + + order = self._make_order(org_a) + product = Product.objects.create(name="Test Product", price=10, org=org_a) + item = OrderLineItem.objects.create( + order=order, + name="Product Item", + product=product, + org=org_a, + ) + product.delete() + item.refresh_from_db() + assert item.product is None + + def test_line_item_meta_db_table(self): + """OrderLineItem Meta db_table should be 'order_line_item'.""" + assert OrderLineItem._meta.db_table == "order_line_item" + + def test_line_item_meta_ordering(self): + """OrderLineItem Meta ordering should be ('sort_order',).""" + assert OrderLineItem._meta.ordering == ("sort_order",) + + def test_line_item_decimal_defaults(self, org_a): + """Financial fields should default to expected values.""" + order = self._make_order(org_a) + item = OrderLineItem.objects.create( + order=order, name="Default Item", org=org_a + ) + assert item.quantity == Decimal("1") + assert item.unit_price == Decimal("0") + assert item.discount_amount == Decimal("0") + assert item.total == Decimal("0") + assert item.sort_order == 0 + + def test_line_item_ordering_by_sort_order(self, org_a): + """Line items should be ordered by sort_order.""" + order = self._make_order(org_a) + item_c = OrderLineItem.objects.create( + order=order, name="C", sort_order=3, org=org_a + ) + item_a = OrderLineItem.objects.create( + order=order, name="A", sort_order=1, org=org_a + ) + item_b = OrderLineItem.objects.create( + order=order, name="B", sort_order=2, org=org_a + ) + items = list(OrderLineItem.objects.all()) + assert items == [item_a, item_b, item_c] + + def test_order_line_items_related_name(self, org_a): + """Order.line_items should return associated OrderLineItems.""" + order = self._make_order(org_a) + OrderLineItem.objects.create(order=order, name="Item 1", org=org_a) + OrderLineItem.objects.create(order=order, name="Item 2", org=org_a) + assert order.line_items.count() == 2 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 509206b4..89bbbffe 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -458,6 +458,39 @@ export const tasks = createCrudApi('tasks'); export const events = createCrudApi('events'); export const invoices = createCrudApi('invoices'); +/** + * Salesforce Integration API + */ +export const salesforce = { + async status() { + return apiRequest('/salesforce/status/'); + }, + async connect() { + return apiRequest('/salesforce/connect/', { method: 'POST' }); + }, + async callback(code) { + return apiRequest('/salesforce/callback/', { + method: 'POST', + body: { code } + }); + }, + async disconnect() { + return apiRequest('/salesforce/disconnect/', { method: 'DELETE' }); + }, + async startImport(objectTypes) { + return apiRequest('/salesforce/import/', { + method: 'POST', + body: { object_types: objectTypes } + }); + }, + async getImportJob(jobId) { + return apiRequest(`/salesforce/import/${jobId}/`); + }, + async importHistory() { + return apiRequest('/salesforce/import/history/'); + } +}; + /** * Export all as default */ @@ -471,6 +504,7 @@ export default { tasks, events, invoices, + salesforce, apiRequest, getAccessToken, getRefreshToken, diff --git a/frontend/src/lib/components/layout/AppSidebar.svelte b/frontend/src/lib/components/layout/AppSidebar.svelte index 85d03076..7ed79dc9 100644 --- a/frontend/src/lib/components/layout/AppSidebar.svelte +++ b/frontend/src/lib/components/layout/AppSidebar.svelte @@ -36,7 +36,8 @@ Tag, Sparkles, Home, - Trophy + Trophy, + Cloud } from '@lucide/svelte'; /** @@ -599,6 +600,14 @@ Settings + navigateTo('/settings/salesforce')} + class="gap-2.5" + > + + Salesforce + navigateTo('/settings/tags')} diff --git a/frontend/src/routes/(app)/settings/salesforce/+page.server.js b/frontend/src/routes/(app)/settings/salesforce/+page.server.js new file mode 100644 index 00000000..776a73e8 --- /dev/null +++ b/frontend/src/routes/(app)/settings/salesforce/+page.server.js @@ -0,0 +1,55 @@ +import { apiRequest } from '$lib/api-helpers.js'; +import { fail } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ cookies }) { + try { + const status = await apiRequest('/salesforce/status/', {}, { cookies }); + return { sfStatus: status }; + } catch { + return { sfStatus: { connected: false, has_credentials: false } }; + } +} + +/** @type {import('./$types').Actions} */ +export const actions = { + saveCredentials: async ({ request, cookies }) => { + const formData = await request.formData(); + const client_id = formData.get('client_id')?.toString().trim(); + const client_secret = formData.get('client_secret')?.toString().trim(); + const login_url = formData.get('login_url')?.toString().trim() || 'https://login.salesforce.com'; + + if (!client_id || !client_secret) { + return fail(400, { credentialError: 'Both Client ID and Client Secret are required.' }); + } + + // Step 1: Save credentials + try { + await apiRequest( + '/salesforce/credentials/', + { method: 'POST', body: { client_id, client_secret, login_url } }, + { cookies } + ); + } catch (err) { + return fail(400, { credentialError: err?.message || 'Failed to save credentials.' }); + } + + // Step 2: Immediately connect using Client Credentials flow + try { + await apiRequest('/salesforce/connect/', { method: 'POST' }, { cookies }); + return { connected: true }; + } catch (err) { + return fail(400, { + credentialError: err?.message || 'Credentials saved but failed to connect to Salesforce. Check your Connected App settings.' + }); + } + }, + disconnect: async ({ cookies }) => { + try { + await apiRequest('/salesforce/disconnect/', { method: 'DELETE' }, { cookies }); + return { disconnected: true }; + } catch (err) { + return fail(400, { credentialError: err?.message || 'Failed to disconnect.' }); + } + } +}; diff --git a/frontend/src/routes/(app)/settings/salesforce/+page.svelte b/frontend/src/routes/(app)/settings/salesforce/+page.svelte new file mode 100644 index 00000000..37d83d9a --- /dev/null +++ b/frontend/src/routes/(app)/settings/salesforce/+page.svelte @@ -0,0 +1,318 @@ + + + + Salesforce Integration - BottleCRM + + + + + + {#if sfStatus.connected} + + + + + + + + + + + + Salesforce Connection + Your Salesforce org is connected + + + + Connected + + + + + + + + {#if sfStatus.connection?.instance_url} + + Instance URL + + + {sfStatus.connection.instance_url} + + + + + {/if} + {#if sfStatus.connection?.connected_by_email} + + Connected By + {sfStatus.connection.connected_by_email} + + {/if} + {#if sfStatus.connection?.created_at} + + Connected On + {formatDate(sfStatus.connection.created_at)} + + {/if} + + + + + + + + + Import Data + + (disconnectDialogOpen = true)} + type="button" + > + + Disconnect + + + + + + {:else} + + + + + + + + + Connect to Salesforce + + Create a Connected App in your Salesforce org and enter the credentials below to connect. + + + + + + + How to create a Connected App: + + + 1. + Log in to Salesforce Setup + + + 2. + Go to App Manager → New Connected App + + + 3. + Enable OAuth Settings and set Callback URL to https://login.salesforce.com/services/oauth2/success + + + 4. + Add OAuth scopes: api, refresh_token + + + 5. + Check Enable Client Credentials Flow + + + 6. + Assign a Run As user to the Connected App (required for Client Credentials) + + + 7. + Copy the Consumer Key (Client ID) and Consumer Secret (Client Secret) below + + + + + + + + { + isConnecting = true; + return async ({ update }) => { + isConnecting = false; + await update(); + }; + }}> + + + Salesforce My Domain URL + + + Find this in Salesforce Setup → My Domain. Use https://yourcompany.my.salesforce.com + + + + Client ID (Consumer Key) + + + + Client Secret (Consumer Secret) + + + + + {isConnecting ? 'Connecting to Salesforce...' : 'Connect to Salesforce'} + + + + + + + + + + What gets imported? + + + + + + Accounts - Companies and organizations + + + + Contacts - People associated with accounts + + + + Opportunities - Sales deals and pipeline + + + + Products - Product catalog + + + + Orders - Sales orders + + + + Quotes - Price quotes and estimates + + + + + {/if} + + + + + + + Disconnect Salesforce + + Are you sure you want to disconnect from Salesforce? Previously imported data will not be deleted. + + + { + isDisconnecting = true; + return async ({ update }) => { + isDisconnecting = false; + disconnectDialogOpen = false; + await update(); + }; + }}> + + (disconnectDialogOpen = false)}>Cancel + + {isDisconnecting ? 'Disconnecting...' : 'Disconnect'} + + + + + diff --git a/frontend/src/routes/(app)/settings/salesforce/import/+page.server.js b/frontend/src/routes/(app)/settings/salesforce/import/+page.server.js new file mode 100644 index 00000000..c5088125 --- /dev/null +++ b/frontend/src/routes/(app)/settings/salesforce/import/+page.server.js @@ -0,0 +1,108 @@ +import { apiRequest } from '$lib/api-helpers.js'; +import { redirect, fail } from '@sveltejs/kit'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ cookies, url }) { + let status; + try { + status = await apiRequest('/salesforce/status/', {}, { cookies }); + } catch { + redirect(302, '/settings/salesforce'); + } + + if (!status?.connected) { + redirect(302, '/settings/salesforce'); + } + + let history = { jobs: [] }; + try { + history = await apiRequest('/salesforce/import/history/', {}, { cookies }); + } catch { + /* ignore history fetch failure */ + } + + const jobs = history.jobs || []; + + // Determine active job: explicit ?job= param, or auto-detect from history + const jobIdParam = url.searchParams.get('job'); + let activeJob = null; + + if (jobIdParam) { + try { + const result = await apiRequest(`/salesforce/import/${jobIdParam}/`, {}, { cookies }); + activeJob = result.job || result; + } catch { + /* job may have been deleted or is inaccessible */ + } + } + + // Auto-detect: if no explicit job param, find any running job from history + // Skip jobs that have been running for over 60 minutes (likely crashed workers) + if (!activeJob) { + const STALE_MS = 60 * 60 * 1000; + const now = Date.now(); + const runningJob = jobs.find((/** @type {any} */ j) => { + if (j.status !== 'PENDING' && j.status !== 'IN_PROGRESS') return false; + const startedAt = j.started_at || j.created_at; + if (startedAt && now - new Date(startedAt).getTime() > STALE_MS) return false; + return true; + }); + if (runningJob) { + activeJob = runningJob; + } + } + + return { + sfStatus: status, + importHistory: jobs, + activeJob + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + startImport: async ({ request, cookies }) => { + const formData = await request.formData(); + const objectTypesRaw = formData.get('object_types')?.toString(); + + if (!objectTypesRaw) { + return fail(400, { importError: 'Please select at least one object type.' }); + } + + const objectTypes = objectTypesRaw.split(',').filter(Boolean); + if (objectTypes.length === 0) { + return fail(400, { importError: 'Please select at least one object type.' }); + } + + try { + const result = await apiRequest( + '/salesforce/import/', + { method: 'POST', body: { object_types: objectTypes } }, + { cookies } + ); + const jobId = result.job?.id || result.id; + return { importStarted: true, jobId }; + } catch (err) { + return fail(400, { importError: err?.message || 'Failed to start import.' }); + } + }, + cancelImport: async ({ request, cookies }) => { + const formData = await request.formData(); + const jobId = formData.get('job_id')?.toString(); + + if (!jobId) { + return fail(400, { importError: 'No job ID provided.' }); + } + + try { + await apiRequest( + `/salesforce/import/${jobId}/cancel/`, + { method: 'POST' }, + { cookies } + ); + return { importCancelled: true }; + } catch (err) { + return fail(400, { importError: err?.message || 'Failed to cancel import.' }); + } + } +}; diff --git a/frontend/src/routes/(app)/settings/salesforce/import/+page.svelte b/frontend/src/routes/(app)/settings/salesforce/import/+page.svelte new file mode 100644 index 00000000..76e2c289 --- /dev/null +++ b/frontend/src/routes/(app)/settings/salesforce/import/+page.svelte @@ -0,0 +1,471 @@ + + + + Salesforce Import - BottleCRM + + + + {#snippet actions()} + + + Back to Connection + + {/snippet} + + + + + {#if activeJob && isImportActive} + + + + + + Import In Progress + Your Salesforce data is being imported... + + + + + + + + Overall Progress + {overallProgress}% + + + + + {#if activeJob.progress_detail && Object.keys(activeJob.progress_detail).length > 0} + + {#each Object.entries(activeJob.progress_detail) as [objType, detail]} + {@const imported = detail.imported || 0} + {@const skipped = detail.skipped || 0} + {@const errors = detail.errors || 0} + {@const total = imported + skipped + errors} + {@const pct = total > 0 ? 100 : 0} + {@const rawStatus = detail.status || 'importing'} + {@const objStatus = rawStatus === 'done' ? 'COMPLETED' : rawStatus === 'importing' ? 'IN_PROGRESS' : rawStatus === 'error' ? 'FAILED' : 'PENDING'} + {@const StatusIcon = getStatusIcon(objStatus)} + + + + + {getObjectLabel(objType)} + + {objStatus} + + + + {#if total > 0} + + + + + {imported} imported + {skipped} skipped + {errors} errors + + {/if} + + {/each} + + {/if} + + + + { + isCancelling = true; + return async ({ update }) => { + isCancelling = false; + await update(); + }; + }}> + + + + {isCancelling ? 'Cancelling...' : 'Cancel Import'} + + Already-imported data will be kept. + + + + + {/if} + + + {#if activeJob && !isImportActive} + + + + {#if activeJob.status === 'COMPLETED'} + + {:else} + + {/if} + + Import {activeJob.status === 'COMPLETED' ? 'Completed' : 'Failed'} + + {#if activeJob.status === 'COMPLETED'} + Your Salesforce data has been imported successfully. + {:else} + The import encountered errors. Check the details below. + {/if} + + + + + + + Total + + {activeJob.imported_count || 0} imported + {activeJob.skipped_count || 0} skipped + {activeJob.error_count || 0} errors + + + + + {/if} + + + {#if !isImportActive} + + + Select Data to Import + Choose which Salesforce objects you want to import. Dependencies will be resolved automatically. + + + + {#each OBJECT_TYPES as objType} + toggleType(objType.value)} + class="border-border hover:bg-muted/50 flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors + {isSelected(objType.value) ? 'border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/30' : ''}" + > + + {#if isSelected(objType.value)} + + + + {/if} + + + {objType.label} + {objType.description} + {#if objType.depends.length > 0} + Depends on: {objType.depends.join(', ')} + {/if} + + + {/each} + + + + + {selectedTypes.size} object{selectedTypes.size !== 1 ? 's' : ''} selected + + { + isStarting = true; + return async ({ result, update }) => { + isStarting = false; + if (result.type === 'success' && result.data?.jobId) { + polledJob = null; + polledHistory = null; + toast.success('Import started'); + await goto(`?job=${result.data.jobId}`, { invalidateAll: true }); + } else { + await update(); + } + }; + }}> + + + + {isStarting ? 'Starting...' : 'Start Import'} + + + + + + {/if} + + + {#if importHistory.length > 0} + + + Import History + Click a row to see details + + + + + + + Date + Status + Objects + Imported + Skipped + Errors + + + + {#each importHistory as job} + {@const isExpanded = expandedJobs.has(job.id)} + toggleJobExpand(job.id)}> + + {#if isExpanded} + + {:else} + + {/if} + + + {job.created_at ? formatRelativeDate(job.created_at) : 'Unknown'} + + {job.status} + + {(job.object_types || []).join(', ') || 'None'} + + {job.imported_count || 0} + {job.skipped_count || 0} + {job.error_count || 0} + + + {#if isExpanded} + {@const errors = (job.error_log || []).filter((/** @type {any} */ e) => e.level !== 'skip')} + {@const skips = (job.error_log || []).filter((/** @type {any} */ e) => e.level === 'skip')} + + + + {#if errors.length > 0} + + + + {errors.length} {errors.length === 1 ? 'Error' : 'Errors'} + + + {#each errors as err} + + + + {getObjectLabel(err.object_type)} + {#if err.salesforce_id} + ({err.salesforce_id}) + {/if} + {err.error} + + + {/each} + + + {/if} + + {#if skips.length > 0} + + + {skips.length} {skips.length === 1 ? 'Record' : 'Records'} Skipped + + + {#each skips as skip} + + + + {getObjectLabel(skip.object_type)} + {#if skip.salesforce_id} + ({skip.salesforce_id}) + {/if} + {skip.error} + + + {/each} + + + {/if} + + {#if errors.length === 0 && skips.length === 0} + + + All records imported successfully. + + {/if} + + + + {/if} + {/each} + + + + + {/if} + diff --git a/frontend/src/routes/api/salesforce-import-poll/+server.js b/frontend/src/routes/api/salesforce-import-poll/+server.js new file mode 100644 index 00000000..b24b910c --- /dev/null +++ b/frontend/src/routes/api/salesforce-import-poll/+server.js @@ -0,0 +1,23 @@ +import { apiRequest } from '$lib/api-helpers.js'; +import { json } from '@sveltejs/kit'; + +/** @type {import('./$types').RequestHandler} */ +export async function GET({ url, cookies }) { + const jobId = url.searchParams.get('job'); + + try { + const [jobResult, historyResult] = await Promise.all([ + jobId + ? apiRequest(`/salesforce/import/${jobId}/`, {}, { cookies }) + : Promise.resolve(null), + apiRequest('/salesforce/import/history/', {}, { cookies }) + ]); + + return json({ + job: jobResult?.job || jobResult || null, + jobs: historyResult?.jobs || [] + }); + } catch { + return json({ job: null, jobs: [] }, { status: 500 }); + } +}
https://login.salesforce.com/services/oauth2/success
api
refresh_token
+ Find this in Salesforce Setup → My Domain. Use https://yourcompany.my.salesforce.com +
https://yourcompany.my.salesforce.com
+ {selectedTypes.size} object{selectedTypes.size !== 1 ? 's' : ''} selected +
{err.error}