Merge branch 'master' of https://github.com/django-crm/Django-CRM into dev
This commit is contained in:
commit
39aff5012a
5 changed files with 95 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
[](https://repocloud.io/details/BottleCRM/)
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation (setup, self-hosting, architecture, the REST API, and contributing) is
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue