This commit is contained in:
Ashwin 2026-10-21 07:14:59 +05:30
commit 39aff5012a
5 changed files with 95 additions and 2 deletions

View file

@ -65,6 +65,10 @@ Frontend at http://localhost:5181, API and Swagger UI at http://localhost:8000/s
the [Docker quick start guide](docs/getting-started/docker-quick-start.md) for the default admin
login, loading demo data, and what each service does.
## One-click deploy
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/BottleCRM/)
## Documentation
The full documentation (setup, self-hosting, architecture, the REST API, and contributing) is

View file

@ -707,6 +707,8 @@ def case_ids_for_metric(
if bucket == "breach":
data = compute_frt(qs, from_dt, to_dt)
return [UUID(s) for s in data["breach_case_ids"]]
if bucket:
raise ValueError(f"unknown frt bucket: {bucket}")
return in_window.values_list("id", flat=True)
if metric == "mttr":
@ -726,6 +728,12 @@ def case_ids_for_metric(
UUID(s)
for s in compute_sla(qs, from_dt, to_dt)["resolution_breach_case_ids"]
]
if bucket == "nrt_breach":
return [
UUID(s) for s in compute_sla(qs, from_dt, to_dt)["nrt_breach_case_ids"]
]
if bucket:
raise ValueError(f"unknown sla bucket: {bucket}")
return qs.filter(created_at__gte=from_dt, created_at__lt=to_dt).values_list(
"id", flat=True
)
@ -745,11 +753,12 @@ def case_ids_for_metric(
# Bucket = profile_id (uuid string).
if not bucket:
raise ValueError("agents drilldown requires a bucket=<profile_id>")
profile_id = UUID(bucket)
return (
qs.filter(
created_at__gte=from_dt,
created_at__lt=to_dt,
assigned_to=bucket,
assigned_to=profile_id,
)
.values_list("id", flat=True)
.distinct()

View file

@ -182,6 +182,38 @@ class TestDrilldown:
assert resp.status_code == 200
assert resp.data["count"] == 1
def test_drilldown_sla_nrt_breach_bucket(
self, admin_client, cases_in_window, monkeypatch
):
c1, c2 = cases_in_window
monkeypatch.setattr(
"cases.analytics.compute_sla",
lambda *a, **k: {
"frt_breach_case_ids": [str(c1.id)],
"resolution_breach_case_ids": [str(c1.id)],
"nrt_breach_case_ids": [str(c2.id)],
},
)
resp = admin_client.get(
"/api/cases/analytics/drilldown/?metric=sla&bucket=nrt_breach&from=2026-05-01&to=2026-05-02"
)
assert resp.status_code == 200
assert [r["id"] for r in resp.data["results"]] == [str(c2.id)]
@pytest.mark.parametrize(
"query",
[
"metric=sla&bucket=typo",
"metric=frt&bucket=typo",
"metric=agents&bucket=abc",
],
)
def test_drilldown_bad_bucket_is_400(self, admin_client, cases_in_window, query):
base = "/api/cases/analytics/drilldown/?from=2026-05-01&to=2026-05-02&"
assert admin_client.get(base + query).status_code == 400
export = base.replace("drilldown", "export") + query
assert admin_client.get(export).status_code == 400
def test_drilldown_backlog_requires_bucket(self, admin_client, cases_in_window):
resp = admin_client.get(
"/api/cases/analytics/drilldown/?metric=backlog&from=2026-05-01&to=2026-05-02"

View file

@ -361,6 +361,45 @@ class TestDetail:
resp = admin_client.delete(f"/api/time-entries/{entry.id}/")
assert resp.status_code == 400
@pytest.mark.parametrize(
"payload",
[
{"hourly_rate": "1.00", "billable": False},
{"description": "edited"},
{"ended_at": "2030-01-01T00:00:00Z"},
],
)
@pytest.mark.parametrize("invoice_status", ["Draft", "Sent"])
def test_update_invoiced_entry_rejected(
self, admin_client, admin_user, admin_profile, org_a, payload, invoice_status
):
case = _make_case(org_a, admin_user)
account = Account.objects.create(name="ACME", org=org_a)
invoice = Invoice.objects.create(
invoice_title="Test",
account=account,
currency="USD",
status=invoice_status,
org=org_a,
)
entry = _entry(
case,
admin_profile,
started_at=timezone.now() - timedelta(hours=1),
ended_at=timezone.now(),
billable=True,
hourly_rate=Decimal("100.00"),
)
TimeEntry.objects.filter(id=entry.id).update(invoice=invoice)
resp = admin_client.put(
f"/api/time-entries/{entry.id}/", payload, format="json"
)
assert resp.status_code == 400
assert resp.data["detail"] == "Cannot edit an entry that has been invoiced."
entry.refresh_from_db()
assert entry.hourly_rate == Decimal("100.00")
assert entry.billable is True
@pytest.mark.django_db
class TestTimesheetEndpoint:

View file

@ -253,13 +253,16 @@ class TimeEntryDetailView(APIView):
def _get_entry(self, request, pk):
try:
entry = TimeEntry.objects.get(id=pk, org=request.profile.org)
entry = TimeEntry.objects.select_for_update().get(
id=pk, org=request.profile.org
)
except TimeEntry.DoesNotExist:
return None
if entry.profile_id != request.profile.id and not is_org_admin(request.profile):
return False # Sentinel: row exists but caller is not authorized.
return entry
@transaction.atomic
def put(self, request, pk):
entry = self._get_entry(request, pk)
if entry is None:
@ -269,6 +272,11 @@ class TimeEntryDetailView(APIView):
{"detail": "Not authorized to edit this entry."},
status=status.HTTP_403_FORBIDDEN,
)
if entry.invoice_id is not None:
return Response(
{"detail": "Cannot edit an entry that has been invoiced."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = TimeEntryUpdateSerializer(entry, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Apply allowed fields explicitly so we never overwrite invoice/profile/case.
@ -277,6 +285,7 @@ class TimeEntryDetailView(APIView):
entry.save()
return Response(TimeEntrySerializer(entry).data)
@transaction.atomic
def delete(self, request, pk):
entry = self._get_entry(request, pk)
if entry is None: