Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,17 @@ class Staff(AbstractBaseUser, PermissionsMixin):
"date_joined",
"created_at",
"updated_at",
"last_login",
"groups",
"user_permissions",
]

# Internal fields not exposed via API (write-only or internal use).
# `icon` is deliberately absent: it is written only by the dedicated icon
# upload endpoint and read via the icon_url property.
# `last_login` is deliberately absent: authentication is JWT-only
# (`SIMPLE_JWT` leaves `UPDATE_LAST_LOGIN` at its `False` default and
# `authenticate()` never emits `user_logged_in`), so the inherited
# `AbstractBaseUser` column is never written and has no meaning to expose.
STAFF_INTERNAL_FIELDS = [
"password",
]
Expand Down
1 change: 0 additions & 1 deletion apps/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ class Meta:
read_only_fields = [
"id",
"wage_rate",
"last_login",
"date_joined",
"created_at",
"updated_at",
Expand Down
27 changes: 27 additions & 0 deletions apps/accounts/tests/test_staff_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,33 @@ def test_create_leaves_a_new_staff_member_active(self) -> None:
self.assertIsNone(created.date_left)
self.assertTrue(created.is_currently_active)

def test_create_ignores_a_client_supplied_last_login(self) -> None:
"""`last_login` is not part of the write contract.

Authentication is JWT-only and nothing ever stamps the inherited
`AbstractBaseUser` column, so an API client must not be able to forge a
login time. The create serializer once listed only `wage_rate` as
read-only, which let this value through.
"""
response = self.client_api.post(
"/api/accounts/staff/",
{
"email": "forger@example.test",
"first_name": "For",
"last_name": "Ger",
"password": "TestPassword123!",
"base_wage_rate": 32.5,
"date_left": None,
"last_login": "2026-07-01T09:00:00Z",
},
format="json",
)

self.assertEqual(response.status_code, 201, response.content)
self.assertNotIn("last_login", response.json())
created = Staff.objects.get(email="forger@example.test")
self.assertIsNone(created.last_login)

def test_setting_date_left_offboards_a_staff_member(self) -> None:
target = Staff.objects.create_user(
email="leaving@example.test",
Expand Down
16 changes: 8 additions & 8 deletions apps/workflow/api/xero/transforms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import logging
import time
from collections.abc import Iterable
from datetime import date, datetime
from datetime import timezone as dt_timezone
from decimal import Decimal
from uuid import UUID

from django.utils import timezone
from xero_python.accounting import AccountingApi
from xero_python.accounting.models import Account

from apps.accounting.models import Bill, CreditNote, Invoice, Quote
from apps.company.models import Company
Expand Down Expand Up @@ -999,20 +1001,18 @@ def sync_companies(xero_contacts):
return companies


def sync_accounts(xero_accounts):
def sync_accounts(xero_accounts: Iterable[Account]) -> None:
"""Sync Xero accounts"""
for account in xero_accounts:
XeroAccount.objects.update_or_create(
xero_id=account.account_id,
defaults={
"account_code": account.code,
"account_code": account.code or None,
"account_name": account.name,
"description": getattr(account, "description", None),
"account_type": account.type,
"tax_type": account.tax_type,
"enable_payments": getattr(
account, "enable_payments_to_account", False
),
"description": account.description or None,
"account_type": account.type or None,
"tax_type": account.tax_type or None,
"enable_payments": account.enable_payments_to_account,
"xero_last_modified": account._updated_date_utc,
"xero_last_synced": timezone.now(),
"raw_json": process_xero_data(account),
Expand Down
14 changes: 6 additions & 8 deletions apps/workflow/management/commands/seed_xero_from_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def handle(self, *args, **options):
CompanyDefaults.set_xero_sync_enabled(enabled=True)
self.stdout.write("Xero seeding complete! enable_xero_sync is now True.")

def process_accounts(self, dry_run):
def process_accounts(self, dry_run: bool) -> int:
"""Phase 0: Update XeroAccount xero_ids from prod to dev Xero tenant.

The backup includes XeroAccount records with prod xero_id values.
Expand Down Expand Up @@ -210,13 +210,11 @@ def process_accounts(self, dry_run):
account_name=account.name,
defaults={
"xero_id": account.account_id,
"account_code": account.code,
"description": getattr(account, "description", None),
"account_type": account.type,
"tax_type": account.tax_type,
"enable_payments": getattr(
account, "enable_payments_to_account", False
),
"account_code": account.code or None,
"description": account.description or None,
"account_type": account.type or None,
"tax_type": account.tax_type or None,
"enable_payments": account.enable_payments_to_account,
"xero_last_modified": account._updated_date_utc,
"xero_last_synced": None,
"raw_json": process_xero_data(account),
Expand Down
84 changes: 83 additions & 1 deletion apps/workflow/tests/test_seed_xero_from_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
"""

from io import StringIO
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uuid import uuid4

from django.conf import settings
from django.test import TestCase
from django.utils import timezone

from apps.company.models import Company
from apps.workflow.api.xero.transforms import sync_accounts
from apps.workflow.management.commands.seed_xero_from_database import Command
from apps.workflow.models import XeroAccount

SENTINEL_XERO_CONTACT_ID = "11111111-1111-1111-1111-111111111111"

Expand All @@ -44,3 +48,81 @@ def test_refuses_when_db_name_ends_with_prod(self) -> None:

company.refresh_from_db()
self.assertEqual(company.xero_contact_id, SENTINEL_XERO_CONTACT_ID)


class XeroAccountSeedTests(TestCase):
@patch(
"apps.workflow.api.xero.transforms.process_xero_data",
return_value={},
)
def test_sync_normalises_empty_optional_text(
self, _process_xero_data_mock: Mock
) -> None:
sync_accounts(
[
SimpleNamespace(
account_id=uuid4(),
code="",
name="Uncoded account",
description="",
type="",
tax_type="",
enable_payments_to_account=False,
_updated_date_utc=timezone.now(),
)
]
)

account = XeroAccount.objects.get(account_name="Uncoded account")
self.assertIsNone(account.account_code)
self.assertIsNone(account.description)
self.assertIsNone(account.account_type)
self.assertIsNone(account.tax_type)

@patch(
"apps.workflow.management.commands.seed_xero_from_database.process_xero_data",
return_value={},
)
@patch(
"apps.workflow.management.commands.seed_xero_from_database.get_tenant_id",
return_value="demo-tenant",
)
@patch("apps.workflow.management.commands.seed_xero_from_database.AccountingApi")
def test_empty_xero_description_is_stored_as_null(
self,
accounting_api_mock: Mock,
_tenant_id_mock: Mock,
_process_xero_data_mock: Mock,
) -> None:
prod_xero_id = uuid4()
demo_xero_id = uuid4()
modified_at = timezone.now()
XeroAccount.objects.create(
xero_id=prod_xero_id,
account_code="805",
account_name="Accrued Liabilities",
description="Production description",
account_type="CURRLIAB",
tax_type="NONE",
xero_last_modified=modified_at,
raw_json={},
)
accounting_api_mock.return_value.get_accounts.return_value.accounts = [
SimpleNamespace(
account_id=demo_xero_id,
code="805",
name="Accrued Liabilities",
description="",
type="CURRLIAB",
tax_type="NONE",
enable_payments_to_account=False,
_updated_date_utc=modified_at,
)
]

command = Command()
command.process_accounts(dry_run=False)

account = XeroAccount.objects.get(account_name="Accrued Liabilities")
self.assertEqual(account.xero_id, demo_xero_id)
self.assertIsNone(account.description)
60 changes: 56 additions & 4 deletions apps/workflow/tests/test_xero_instance_templates.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
import subprocess
import tempfile
from decimal import Decimal
Expand All @@ -10,11 +11,12 @@
from django.test import SimpleTestCase, TestCase

from apps.accounts.models import Staff
from apps.crm.models import PhoneEndpoint
from apps.crm.models import PhoneEndpoint, PhoneProviderSettings
from apps.workflow.models import CompanyDefaults, XeroApp

REPO_ROOT = Path(__file__).resolve().parents[3]
COMMON_SCRIPT = REPO_ROOT / "scripts" / "server" / "common.sh"
INSTANCE_SCRIPT = REPO_ROOT / "scripts" / "server" / "instance.sh"
XERO_APPS_TEMPLATE = (
REPO_ROOT / "scripts" / "server" / "templates" / "xero-apps.json.template"
)
Expand Down Expand Up @@ -135,9 +137,9 @@ def test_phone_provider_settings_template_renders_to_valid_json(self) -> None:
"__PHONE_PROVIDER_BASE_URL_JSON__",
'"http://phone-provider.lan"',
)
.replace("__PHONE_PROVIDER_USERNAME__", "phone-user")
.replace("__PHONE_PROVIDER_PASSWORD__", "phone-secret")
.replace("__PHONE_PROVIDER_ACCOUNT_CODE__", "15539090")
.replace("__PHONE_PROVIDER_USERNAME_JSON__", '"phone-user"')
.replace("__PHONE_PROVIDER_PASSWORD_JSON__", '"phone-secret"')
.replace("__PHONE_PROVIDER_ACCOUNT_CODE_JSON__", '"15539090"')
)

payload = json.loads(rendered)
Expand Down Expand Up @@ -237,3 +239,53 @@ def run(creds_file: str) -> "subprocess.CompletedProcess[str]":
link = base / "linkdir"
link.symlink_to(base)
self.assertNotEqual(run(str(link / "inst.credentials.env")).returncode, 0)


class PhoneProviderReconfigureFixtureTests(TestCase):
def test_unconfigured_fixture_loads_with_null_credentials(self) -> None:
"""Reconfigure must seed a scrubbed instance without violating constraints."""
PhoneProviderSettings.objects.all().delete()
PhoneProviderSettings.get_solo()

with tempfile.TemporaryDirectory() as instance_dir:
environment = os.environ.copy()
for variable in (
"PHONE_PROVIDER_BASE_URL",
"PHONE_PROVIDER_USERNAME",
"PHONE_PROVIDER_PASSWORD",
"PHONE_PROVIDER_ACCOUNT_CODE",
"PHONE_PROVIDER_DOWNLOADS_ENABLED",
"PHONE_PROVIDER_RECORDING_DELETION_ENABLED",
):
environment.pop(variable, None)

subprocess.run(
[
"bash",
"-c",
(
'source "$1"; '
"log() { :; }; "
"chown() { :; }; "
'render_phone_provider_settings_fixture "$2" ignored'
),
str(INSTANCE_SCRIPT.parent / "test-harness"),
str(INSTANCE_SCRIPT),
instance_dir,
],
check=True,
capture_output=True,
text=True,
env=environment,
)

fixture = Path(instance_dir) / ".fixtures" / "phone_provider_settings.json"
call_command("loaddata", str(fixture), verbosity=0)

settings = PhoneProviderSettings.objects.get(pk=1)
self.assertFalse(settings.downloads_enabled)
self.assertFalse(settings.recording_deletion_enabled)
self.assertIsNone(settings.base_url)
self.assertIsNone(settings.username)
self.assertIsNone(settings.password)
self.assertIsNone(settings.account_code)
10 changes: 0 additions & 10 deletions frontend/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21719,11 +21719,6 @@ components:
type: string
format: date-time
readOnly: true
last_login:
type: string
format: date-time
readOnly: true
nullable: true
groups:
type: array
items:
Expand All @@ -21746,7 +21741,6 @@ components:
- first_name
- icon_url
- id
- last_login
- last_name
- updated_at
- wage_rate
Expand Down Expand Up @@ -21864,10 +21858,6 @@ components:
created_at:
type: string
format: date-time
last_login:
type: string
format: date-time
nullable: true
groups:
type: array
items:
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/api/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,6 @@ const Staff = z.object({
date_joined: z.string().datetime({ offset: true }),
created_at: z.string().datetime({ offset: true }),
updated_at: z.string().datetime({ offset: true }),
last_login: z.string().datetime({ offset: true }).nullable(),
groups: z.array(z.number().int()).optional(),
user_permissions: z.array(z.number().int()).optional(),
icon_url: z.string().nullable(),
Expand All @@ -492,7 +491,6 @@ const StaffCreateRequest = z.object({
hours_sun: z.number().gt(-100).lt(100).optional(),
date_joined: z.string().datetime({ offset: true }).optional(),
created_at: z.string().datetime({ offset: true }).optional(),
last_login: z.string().datetime({ offset: true }).nullish(),
groups: z.array(z.number().int()).optional(),
user_permissions: z.array(z.number().int()).optional(),
password: z.string().min(1).max(128),
Expand Down
Loading
Loading