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
|
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.
|
login, loading demo data, and what each service does.
|
||||||
|
|
||||||
|
## One-click deploy
|
||||||
|
|
||||||
|
[](https://repocloud.io/details/BottleCRM/)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full documentation (setup, self-hosting, architecture, the REST API, and contributing) is
|
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":
|
if bucket == "breach":
|
||||||
data = compute_frt(qs, from_dt, to_dt)
|
data = compute_frt(qs, from_dt, to_dt)
|
||||||
return [UUID(s) for s in data["breach_case_ids"]]
|
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)
|
return in_window.values_list("id", flat=True)
|
||||||
|
|
||||||
if metric == "mttr":
|
if metric == "mttr":
|
||||||
|
|
@ -726,6 +728,12 @@ def case_ids_for_metric(
|
||||||
UUID(s)
|
UUID(s)
|
||||||
for s in compute_sla(qs, from_dt, to_dt)["resolution_breach_case_ids"]
|
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(
|
return qs.filter(created_at__gte=from_dt, created_at__lt=to_dt).values_list(
|
||||||
"id", flat=True
|
"id", flat=True
|
||||||
)
|
)
|
||||||
|
|
@ -745,11 +753,12 @@ def case_ids_for_metric(
|
||||||
# Bucket = profile_id (uuid string).
|
# Bucket = profile_id (uuid string).
|
||||||
if not bucket:
|
if not bucket:
|
||||||
raise ValueError("agents drilldown requires a bucket=<profile_id>")
|
raise ValueError("agents drilldown requires a bucket=<profile_id>")
|
||||||
|
profile_id = UUID(bucket)
|
||||||
return (
|
return (
|
||||||
qs.filter(
|
qs.filter(
|
||||||
created_at__gte=from_dt,
|
created_at__gte=from_dt,
|
||||||
created_at__lt=to_dt,
|
created_at__lt=to_dt,
|
||||||
assigned_to=bucket,
|
assigned_to=profile_id,
|
||||||
)
|
)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,38 @@ class TestDrilldown:
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.data["count"] == 1
|
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):
|
def test_drilldown_backlog_requires_bucket(self, admin_client, cases_in_window):
|
||||||
resp = admin_client.get(
|
resp = admin_client.get(
|
||||||
"/api/cases/analytics/drilldown/?metric=backlog&from=2026-05-01&to=2026-05-02"
|
"/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}/")
|
resp = admin_client.delete(f"/api/time-entries/{entry.id}/")
|
||||||
assert resp.status_code == 400
|
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
|
@pytest.mark.django_db
|
||||||
class TestTimesheetEndpoint:
|
class TestTimesheetEndpoint:
|
||||||
|
|
|
||||||
|
|
@ -253,13 +253,16 @@ class TimeEntryDetailView(APIView):
|
||||||
|
|
||||||
def _get_entry(self, request, pk):
|
def _get_entry(self, request, pk):
|
||||||
try:
|
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:
|
except TimeEntry.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
if entry.profile_id != request.profile.id and not is_org_admin(request.profile):
|
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 False # Sentinel: row exists but caller is not authorized.
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def put(self, request, pk):
|
def put(self, request, pk):
|
||||||
entry = self._get_entry(request, pk)
|
entry = self._get_entry(request, pk)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
|
@ -269,6 +272,11 @@ class TimeEntryDetailView(APIView):
|
||||||
{"detail": "Not authorized to edit this entry."},
|
{"detail": "Not authorized to edit this entry."},
|
||||||
status=status.HTTP_403_FORBIDDEN,
|
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 = TimeEntryUpdateSerializer(entry, data=request.data, partial=True)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
# Apply allowed fields explicitly so we never overwrite invoice/profile/case.
|
# Apply allowed fields explicitly so we never overwrite invoice/profile/case.
|
||||||
|
|
@ -277,6 +285,7 @@ class TimeEntryDetailView(APIView):
|
||||||
entry.save()
|
entry.save()
|
||||||
return Response(TimeEntrySerializer(entry).data)
|
return Response(TimeEntrySerializer(entry).data)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def delete(self, request, pk):
|
def delete(self, request, pk):
|
||||||
entry = self._get_entry(request, pk)
|
entry = self._get_entry(request, pk)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue