diff --git a/README.md b/README.md index 6df2ade0..b0081480 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/cases/analytics.py b/backend/cases/analytics.py index 00c6d71b..417ad425 100644 --- a/backend/cases/analytics.py +++ b/backend/cases/analytics.py @@ -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 = 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() diff --git a/backend/cases/tests/test_analytics_api.py b/backend/cases/tests/test_analytics_api.py index 0753389b..5f36a007 100644 --- a/backend/cases/tests/test_analytics_api.py +++ b/backend/cases/tests/test_analytics_api.py @@ -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" diff --git a/backend/cases/tests/test_time_tracking.py b/backend/cases/tests/test_time_tracking.py index fd5bbf3e..cfa02d39 100644 --- a/backend/cases/tests/test_time_tracking.py +++ b/backend/cases/tests/test_time_tracking.py @@ -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: diff --git a/backend/cases/time_views.py b/backend/cases/time_views.py index 8eebf462..effd42c8 100644 --- a/backend/cases/time_views.py +++ b/backend/cases/time_views.py @@ -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: