Let an org switch whole modules off (1.23.0)
This commit is contained in:
parent
cffe004746
commit
0e8735bf4e
100 changed files with 7948 additions and 370 deletions
|
|
@ -3,6 +3,7 @@ from rest_framework import serializers
|
|||
|
||||
from accounts.models import Account, AccountEmail, AccountEmailLog
|
||||
from cases.access import visible_cases_qs
|
||||
from common.modules import only_available
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
OrganizationSerializer,
|
||||
|
|
@ -90,13 +91,19 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||
@extend_schema_field(list)
|
||||
def get_cases(self, obj):
|
||||
"""Cases on this account that the viewer may open."""
|
||||
cases = visible_cases_qs(self.context["profile"]).filter(account=obj)
|
||||
profile = self.context["profile"]
|
||||
cases = only_available(
|
||||
profile.org, "tickets", visible_cases_qs(profile).filter(account=obj)
|
||||
)
|
||||
return [{"id": str(c.id), "name": c.name} for c in cases]
|
||||
|
||||
@extend_schema_field(list)
|
||||
def get_tasks(self, obj):
|
||||
"""Tasks on this account that the viewer may open."""
|
||||
tasks = visible_tasks_qs(self.context["profile"]).filter(account=obj)
|
||||
profile = self.context["profile"]
|
||||
tasks = only_available(
|
||||
profile.org, "tasks", visible_tasks_qs(profile).filter(account=obj)
|
||||
)
|
||||
return [{"id": str(t.id), "title": t.title} for t in tasks]
|
||||
|
||||
@extend_schema_field(list)
|
||||
|
|
@ -108,7 +115,11 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||
One read of the org's stages serves every row.
|
||||
"""
|
||||
profile = self.context["profile"]
|
||||
deals = list(visible_deals_qs(profile).filter(account=obj))
|
||||
deals = list(
|
||||
only_available(
|
||||
profile.org, "deals", visible_deals_qs(profile).filter(account=obj)
|
||||
)
|
||||
)
|
||||
stages = stage_index(obj.org_id) if deals else {}
|
||||
rows = []
|
||||
for o in deals:
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ from common.models import (
|
|||
Tags,
|
||||
Teams,
|
||||
)
|
||||
from common.module_deletes import delete_refusal
|
||||
from common.modules import module_is_available, only_available
|
||||
from common.money import currency_block, deal_currency
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import (
|
||||
|
|
@ -163,10 +165,11 @@ def annotate_rollups(queryset, profile):
|
|||
the loaded rows afterwards by `attach_money_rollups`.
|
||||
|
||||
Counted over the deals and tickets `profile` may open, the same rows the
|
||||
account page lists under the figures. An admin may open all of them.
|
||||
account page lists under the figures. An admin may open all of them. An
|
||||
off module's rows count as none.
|
||||
"""
|
||||
deals = visible_deals_qs(profile)
|
||||
cases = visible_cases_qs(profile)
|
||||
deals = only_available(profile.org, "deals", visible_deals_qs(profile))
|
||||
cases = only_available(profile.org, "tickets", visible_cases_qs(profile))
|
||||
return queryset.annotate(
|
||||
won_count=Coalesce(
|
||||
_per_account(
|
||||
|
|
@ -218,11 +221,15 @@ def attach_money_rollups(accounts, profile):
|
|||
"""
|
||||
org = profile.org
|
||||
groups = {account.pk: {} for account in accounts}
|
||||
deals = Opportunity.objects.filter(
|
||||
org=org,
|
||||
id__in=visible_deals_qs(profile).values("id"),
|
||||
account__in=list(groups),
|
||||
amount__isnull=False,
|
||||
deals = only_available(
|
||||
org,
|
||||
"deals",
|
||||
Opportunity.objects.filter(
|
||||
org=org,
|
||||
id__in=visible_deals_qs(profile).values("id"),
|
||||
account__in=list(groups),
|
||||
amount__isnull=False,
|
||||
),
|
||||
)
|
||||
sources = (
|
||||
("won_amount", deals.filter(stage_kind_q(WON)), "amount", deal_currency(org)),
|
||||
|
|
@ -234,13 +241,17 @@ def attach_money_rollups(accounts, profile):
|
|||
),
|
||||
(
|
||||
"overdue_amount",
|
||||
Invoice.objects.filter(
|
||||
org=org,
|
||||
id__in=visible_invoices_qs(profile).values("id"),
|
||||
account__in=list(groups),
|
||||
status__in=UNPAID_STATUSES,
|
||||
due_date__lt=timezone.localdate(),
|
||||
amount_due__gt=0,
|
||||
only_available(
|
||||
org,
|
||||
"invoices",
|
||||
Invoice.objects.filter(
|
||||
org=org,
|
||||
id__in=visible_invoices_qs(profile).values("id"),
|
||||
account__in=list(groups),
|
||||
status__in=UNPAID_STATUSES,
|
||||
due_date__lt=timezone.localdate(),
|
||||
amount_due__gt=0,
|
||||
),
|
||||
),
|
||||
"amount_due",
|
||||
F("currency"),
|
||||
|
|
@ -389,8 +400,12 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
context["users"] = users
|
||||
# The lead read rule itself, so the picker offers exactly the leads
|
||||
# `/api/leads/<id>/` would open for this caller, and only a label each.
|
||||
leads = visible_leads_qs(self.request.profile).exclude(
|
||||
Q(status="converted") | Q(status="closed")
|
||||
leads = only_available(
|
||||
self.request.profile.org,
|
||||
"leads",
|
||||
visible_leads_qs(self.request.profile).exclude(
|
||||
Q(status="converted") | Q(status="closed")
|
||||
),
|
||||
)
|
||||
context["leads"] = LeadPickerSerializer(leads, many=True).data
|
||||
context["status"] = ["active", "inactive"] # Maps to is_active field
|
||||
|
|
@ -640,6 +655,9 @@ class AccountDetailView(APIView):
|
|||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
refusal = delete_refusal(self.object, "This account")
|
||||
if refusal:
|
||||
return Response(refusal, status=status.HTTP_409_CONFLICT)
|
||||
try:
|
||||
self.object.delete()
|
||||
except ProtectedError:
|
||||
|
|
@ -713,7 +731,10 @@ class AccountDetailView(APIView):
|
|||
object_id=self.account.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
account_cases = visible_cases_qs(profile).filter(account=self.account)
|
||||
org = profile.org
|
||||
account_cases = only_available(
|
||||
org, "tickets", visible_cases_qs(profile).filter(account=self.account)
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
||||
|
|
@ -725,7 +746,11 @@ class AccountDetailView(APIView):
|
|||
many=True,
|
||||
).data,
|
||||
"opportunity_list": OpportunitySerializer(
|
||||
visible_deals_qs(profile).filter(account=self.account),
|
||||
only_available(
|
||||
org,
|
||||
"deals",
|
||||
visible_deals_qs(profile).filter(account=self.account),
|
||||
),
|
||||
many=True,
|
||||
).data,
|
||||
"users": ProfileSerializer(
|
||||
|
|
@ -742,8 +767,13 @@ class AccountDetailView(APIView):
|
|||
"teams": TeamsSerializer(
|
||||
Teams.objects.filter(org=self.request.profile.org), many=True
|
||||
).data,
|
||||
# The default pipeline's stages, as `[code, label]`.
|
||||
"stages": stage_choices(DealPipeline.default_for(profile.org)),
|
||||
# The default pipeline's stages, as `[code, label]`. Not read,
|
||||
# nor created by `default_for`, while Pipeline is off.
|
||||
"stages": (
|
||||
stage_choices(DealPipeline.default_for(org))
|
||||
if module_is_available(org, "deals")
|
||||
else []
|
||||
),
|
||||
"sources": SOURCES,
|
||||
"countries": COUNTRIES,
|
||||
"currencies": CURRENCY_CODES,
|
||||
|
|
@ -752,10 +782,19 @@ class AccountDetailView(APIView):
|
|||
"case_status": STATUS_CHOICE,
|
||||
"comment_permission": comment_permission,
|
||||
"tasks": TaskSerializer(
|
||||
visible_tasks_qs(profile).filter(account=self.account), many=True
|
||||
only_available(
|
||||
org,
|
||||
"tasks",
|
||||
visible_tasks_qs(profile).filter(account=self.account),
|
||||
),
|
||||
many=True,
|
||||
).data,
|
||||
"invoices": InvoiceListSerializer(
|
||||
visible_invoices_qs(profile).filter(account=self.account),
|
||||
only_available(
|
||||
org,
|
||||
"invoices",
|
||||
visible_invoices_qs(profile).filter(account=self.account),
|
||||
),
|
||||
many=True,
|
||||
).data,
|
||||
"users_mention": users_mention,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from automation.validation import LINKED, UNKNOWN_FIELD, active_people
|
|||
from common import notifications
|
||||
from common.links import frontend_url
|
||||
from common.models import Org, Profile
|
||||
from common.modules import module_is_available, module_off_message
|
||||
from common.org_time import org_timezone
|
||||
from common.permissions import is_org_admin
|
||||
from tasks.models import Task
|
||||
|
|
@ -344,6 +345,9 @@ def _task_people(ctx, record, action):
|
|||
|
||||
|
||||
def _plan_create_task(ctx, index, action):
|
||||
if not module_is_available(ctx.org, "tasks"):
|
||||
raise StepSkipped(module_off_message(ctx.org, "tasks"))
|
||||
|
||||
def resolve(record):
|
||||
name = ctx.adapter.name(record)
|
||||
title = _clipped(render(action.get("title") or "", name), TITLE_LENGTH)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ from django.db import transaction
|
|||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from common.models import CustomFieldDefinition, Profile
|
||||
from common.object_registry import all_objects, object_for_key
|
||||
from common.modules import module_is_available
|
||||
from common.object_registry import all_objects, object_for_key, object_module
|
||||
|
||||
FIELD_TYPES = ("text", "number", "date", "choice", "person", "people", "boolean")
|
||||
|
||||
|
|
@ -259,6 +260,14 @@ def adapter_for(org, key) -> Adapter | None:
|
|||
return _adapter_of(entry) if entry is not None else None
|
||||
|
||||
|
||||
def switched_off_module(org, key) -> str | None:
|
||||
"""The module of the built-in object `key` when it is off for `org`."""
|
||||
module = object_module(key)
|
||||
if module is None or module_is_available(org, module):
|
||||
return None
|
||||
return module
|
||||
|
||||
|
||||
def automatable(org) -> list[tuple[Any, Adapter]]:
|
||||
pairs = ((entry, _adapter_of(entry)) for entry in all_objects(org))
|
||||
return [(entry, adapter) for entry, adapter in pairs if adapter is not None]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from automation.models import AutomationRule, AutomationRun
|
||||
from automation.validation import validate_rule
|
||||
from automation.objects import switched_off_module
|
||||
from automation.validation import new_task_step_errors, validate_rule
|
||||
|
||||
# What `validate_rule` reads; a partial update is checked with the rest of the rule.
|
||||
RULE_KEYS = ("object_key", "trigger", "match", "conditions", "actions")
|
||||
|
|
@ -57,12 +58,32 @@ class RuleSerializer(serializers.ModelSerializer):
|
|||
):
|
||||
return attrs
|
||||
current = self.instance or AutomationRule()
|
||||
if self._parked_on_off_module(current, attrs):
|
||||
return attrs
|
||||
org = self.context["org"]
|
||||
rule = {key: attrs.get(key, getattr(current, key)) for key in RULE_KEYS}
|
||||
errors = validate_rule(self.context["org"], rule)
|
||||
errors = validate_rule(org, rule)
|
||||
if "actions" in attrs:
|
||||
refused = new_task_step_errors(org, attrs["actions"], current.actions)
|
||||
for path, messages in refused.items():
|
||||
errors.setdefault(path, []).extend(messages)
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
return attrs
|
||||
|
||||
def _parked_on_off_module(self, current, attrs):
|
||||
"""A stored rule on an off module, saved with none of its rule fields.
|
||||
|
||||
Its fields cannot be checked while the module is off and it lists as
|
||||
Paused, so a switch-on or a rename goes through. Any rule field in the
|
||||
PATCH is validated and refused with the module message.
|
||||
"""
|
||||
return (
|
||||
self.instance is not None
|
||||
and not set(RULE_KEYS) & set(attrs)
|
||||
and switched_off_module(self.context["org"], current.object_key) is not None
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
return super().create({**validated_data, "org": self.context["org"]})
|
||||
|
||||
|
|
|
|||
142
backend/automation/tests/test_module_off_actions.py
Normal file
142
backend/automation/tests/test_module_off_actions.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""A rule's Create a task step while Tasks is off: a stored one is skipped, with
|
||||
the module message, and the rule's other steps still run; a new one is refused,
|
||||
and the catalogue does not offer it."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from automation.models import AutomationRule, AutomationRun
|
||||
from common.models import Notification
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
from tasks.models import Task
|
||||
|
||||
RULES = "/api/automation/rules/"
|
||||
CATALOGUE = "/api/automation/catalogue/"
|
||||
EMAIL = "automation.actions.send_member_email.delay"
|
||||
OFF = "Tasks is switched off for this organization."
|
||||
TASK_STEP = {
|
||||
"type": "create_task",
|
||||
"title": "Call {name}",
|
||||
"due_in_days": None,
|
||||
"priority": "High",
|
||||
"assign_to": "owner",
|
||||
"person": None,
|
||||
}
|
||||
NOTIFY_STEP = {"type": "notify", "to": ["admins"], "people": [], "message": "New lead"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commit(django_capture_on_commit_callbacks):
|
||||
return lambda: django_capture_on_commit_callbacks(execute=True)
|
||||
|
||||
|
||||
def _body(actions):
|
||||
return {
|
||||
"name": "New lead follow-up",
|
||||
"object_key": "lead",
|
||||
"trigger": {"event": "created", "field": None, "to": None},
|
||||
"match": "all",
|
||||
"conditions": [],
|
||||
"actions": actions,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
def _rule(client):
|
||||
response = client.post(RULES, _body([TASK_STEP, NOTIFY_STEP]), format="json")
|
||||
assert response.status_code == 201, response.content
|
||||
return response.json()
|
||||
|
||||
|
||||
def _new_lead(client):
|
||||
response = client.post(
|
||||
"/api/leads/",
|
||||
{"title": "Inbound", "first_name": "Pat", "email": "pat@example.com"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
|
||||
|
||||
def _steps(org):
|
||||
[run] = AutomationRun.objects.filter(org=org)
|
||||
return run, {step["type"]: step for step in run.steps}
|
||||
|
||||
|
||||
def test_create_task_is_skipped_while_tasks_is_off(admin_client, org_a, commit):
|
||||
_rule(admin_client)
|
||||
set_modules_off(org_a, "tasks")
|
||||
|
||||
with patch(EMAIL), commit():
|
||||
_new_lead(admin_client)
|
||||
|
||||
run, steps = _steps(org_a)
|
||||
assert run.status == "done"
|
||||
assert steps["create_task"]["status"] == "skipped"
|
||||
assert steps["create_task"]["text"] == OFF
|
||||
assert steps["notify"]["status"] == "done"
|
||||
assert not Task.objects.filter(org=org_a).exists()
|
||||
assert Notification.objects.filter(org=org_a, verb="automation.message").exists()
|
||||
assert Lead.objects.filter(org=org_a).count() == 1
|
||||
|
||||
|
||||
def test_create_task_runs_when_tasks_is_on(admin_client, org_a, commit):
|
||||
_rule(admin_client)
|
||||
|
||||
with patch(EMAIL), commit():
|
||||
_new_lead(admin_client)
|
||||
|
||||
run, steps = _steps(org_a)
|
||||
assert run.status == "done"
|
||||
assert steps["create_task"]["status"] == "done"
|
||||
assert Task.objects.filter(org=org_a, title__startswith="Call ").count() == 1
|
||||
|
||||
|
||||
def test_the_catalogue_offers_create_task_only_while_tasks_is_on(admin_client, org_a):
|
||||
def actions():
|
||||
response = admin_client.get(CATALOGUE)
|
||||
assert response.status_code == 200
|
||||
return response.json()["actions"]
|
||||
|
||||
assert "create_task" in actions()
|
||||
set_modules_off(org_a, "tasks")
|
||||
assert sorted(actions()) == ["assign", "notify", "set_field", "webhook"]
|
||||
|
||||
|
||||
def test_a_new_create_task_step_is_refused_while_tasks_is_off(admin_client, org_a):
|
||||
set_modules_off(org_a, "tasks")
|
||||
body = _body([NOTIFY_STEP, TASK_STEP])
|
||||
|
||||
response = admin_client.post(RULES, body, format="json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"errors": {"actions[1].type": [OFF]}}
|
||||
assert not AutomationRule.objects.filter(org=org_a).exists()
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.post(RULES, body, format="json").status_code == 201
|
||||
|
||||
|
||||
def test_a_stored_create_task_step_is_kept_while_tasks_is_off(admin_client, org_a):
|
||||
rule = _rule(admin_client)
|
||||
stored_task, stored_notify = rule["actions"]
|
||||
url = f"{RULES}{rule['id']}/"
|
||||
set_modules_off(org_a, "tasks")
|
||||
|
||||
kept = admin_client.patch(
|
||||
url, {"actions": [stored_notify, stored_task]}, format="json"
|
||||
)
|
||||
assert kept.status_code == 200, kept.content
|
||||
|
||||
another = {**stored_task, "title": "Email {name}"}
|
||||
added = admin_client.patch(
|
||||
url, {"actions": [stored_notify, stored_task, another]}, format="json"
|
||||
)
|
||||
assert added.status_code == 400
|
||||
assert added.json() == {"errors": {"actions[2].type": [OFF]}}
|
||||
assert AutomationRule.objects.get(pk=rule["id"]).actions == [
|
||||
stored_notify,
|
||||
stored_task,
|
||||
]
|
||||
[listed] = admin_client.get(RULES).json()["results"]
|
||||
assert listed["status"] == "on"
|
||||
212
backend/automation/tests/test_module_off_status.py
Normal file
212
backend/automation/tests/test_module_off_status.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""A rule on an off module shows Paused, does not run, and changes nothing else."""
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from automation import plan
|
||||
from automation.models import AutomationRule, AutomationRun
|
||||
from automation.plan import Runnable
|
||||
from cases.models import Case
|
||||
from common.testing import set_modules_off
|
||||
|
||||
RULES = "/api/automation/rules/"
|
||||
PAUSED = "Paused: Tickets is switched off"
|
||||
OFF = "Tickets is switched off for this organization."
|
||||
CREATED = {"event": "created", "field": None, "to": None}
|
||||
SET_PRIORITY = {
|
||||
"type": "set_field",
|
||||
"target": "record",
|
||||
"field": "priority",
|
||||
"value": "Urgent",
|
||||
}
|
||||
LEAD_NOTE = {"type": "notify", "to": ["admins"], "people": [], "message": "New lead"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_plan_hooks():
|
||||
saved = list(plan._HOOKS)
|
||||
yield
|
||||
plan._HOOKS[:] = saved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commit(django_capture_on_commit_callbacks):
|
||||
return lambda: django_capture_on_commit_callbacks(execute=True)
|
||||
|
||||
|
||||
def _rule(org, owner, object_key, name, actions=None):
|
||||
return AutomationRule.objects.create(
|
||||
org=org,
|
||||
name=name,
|
||||
object_key=object_key,
|
||||
trigger=dict(CREATED),
|
||||
conditions=[],
|
||||
actions=actions or [dict(SET_PRIORITY)],
|
||||
owner=owner,
|
||||
is_active=True,
|
||||
switched_on_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def _by_name(client):
|
||||
return {r["name"]: r for r in client.get(RULES).json()["results"]}
|
||||
|
||||
|
||||
def _ticket(org):
|
||||
return Case.objects.create(org=org, name="Login bug", status="New", priority="Low")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rules(org_a, admin_profile):
|
||||
return (
|
||||
_rule(org_a, admin_profile, "ticket", "Ticket rule"),
|
||||
_rule(org_a, admin_profile, "lead", "Lead rule", [LEAD_NOTE]),
|
||||
)
|
||||
|
||||
|
||||
def test_a_rule_on_an_off_module_shows_paused(admin_client, org_a, rules):
|
||||
assert _by_name(admin_client)["Ticket rule"]["status"] == "on"
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
listed = _by_name(admin_client)
|
||||
assert listed["Ticket rule"]["status"] == "paused"
|
||||
assert listed["Ticket rule"]["status_text"] == PAUSED
|
||||
assert listed["Lead rule"]["status"] == "on"
|
||||
|
||||
|
||||
def test_a_paused_rule_does_not_run(org_a, rules, commit):
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
with commit():
|
||||
ticket = _ticket(org_a)
|
||||
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.priority == "Low"
|
||||
assert not AutomationRun.objects.filter(org=org_a).exists()
|
||||
|
||||
|
||||
def test_pausing_by_module_changes_nothing_on_the_rule(admin_client, org_a, rules):
|
||||
ticket_rule, _ = rules
|
||||
before = (
|
||||
ticket_rule.is_active,
|
||||
ticket_rule.switched_on_at,
|
||||
ticket_rule.paused_reason,
|
||||
)
|
||||
asked = []
|
||||
|
||||
class Spy:
|
||||
def runnable(self, org, given):
|
||||
asked.append([r.id for r in given])
|
||||
return Runnable(frozenset(r.id for r in given), {}, None)
|
||||
|
||||
def may_switch_on(self, org, rule):
|
||||
return None
|
||||
|
||||
plan._HOOKS[:] = [Spy()]
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
assert _by_name(admin_client)["Ticket rule"]["status"] == "paused"
|
||||
|
||||
assert ticket_rule.id in asked[0]
|
||||
ticket_rule.refresh_from_db()
|
||||
assert (
|
||||
ticket_rule.is_active,
|
||||
ticket_rule.switched_on_at,
|
||||
ticket_rule.paused_reason,
|
||||
) == before
|
||||
|
||||
|
||||
def test_switching_the_module_on_resumes_the_rule(admin_client, org_a, rules, commit):
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert _by_name(admin_client)["Ticket rule"]["status"] == "paused"
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert _by_name(admin_client)["Ticket rule"]["status"] == "on"
|
||||
|
||||
with commit():
|
||||
ticket = _ticket(org_a)
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.priority == "Urgent"
|
||||
assert AutomationRun.objects.filter(org=org_a, object_key="ticket").exists()
|
||||
|
||||
|
||||
def test_saving_a_rule_on_an_off_module_names_the_module(admin_client, org_a, rules):
|
||||
ticket_rule, _ = rules
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
response = admin_client.patch(
|
||||
f"{RULES}{ticket_rule.id}/",
|
||||
{"trigger": {"event": "updated", "field": "name", "to": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"errors": {"object_key": [OFF]}}
|
||||
|
||||
|
||||
def test_the_catalogue_leaves_out_off_module_objects(admin_client, org_a):
|
||||
def keys():
|
||||
body = admin_client.get("/api/automation/catalogue/").json()
|
||||
return {o["key"] for o in body["objects"]}
|
||||
|
||||
assert "ticket" in keys()
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert "ticket" not in keys()
|
||||
assert "lead" in keys()
|
||||
|
||||
|
||||
def test_switching_on_a_rule_on_an_off_module_is_allowed(admin_client, org_a, rules):
|
||||
ticket_rule, _ = rules
|
||||
ticket_rule.is_active = False
|
||||
ticket_rule.save(update_fields=["is_active"])
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
response = admin_client.patch(
|
||||
f"{RULES}{ticket_rule.id}/", {"is_active": True}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
ticket_rule.refresh_from_db()
|
||||
assert ticket_rule.is_active is True
|
||||
listed = _by_name(admin_client)["Ticket rule"]
|
||||
assert (listed["status"], listed["status_text"]) == ("paused", PAUSED)
|
||||
|
||||
|
||||
def test_creating_a_rule_on_an_off_module_names_the_module(admin_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
body = {
|
||||
"name": "New",
|
||||
"object_key": "ticket",
|
||||
"trigger": dict(CREATED),
|
||||
"match": "all",
|
||||
"conditions": [],
|
||||
"actions": [dict(SET_PRIORITY)],
|
||||
}
|
||||
|
||||
response = admin_client.post(RULES, body, format="json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"errors": {"object_key": [OFF]}}
|
||||
assert not AutomationRule.objects.filter(org=org_a, name="New").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
{"conditions": [{"field": "name", "op": "eq", "value": "x"}]},
|
||||
{"is_active": True, "actions": [{"type": "bogus"}]},
|
||||
],
|
||||
)
|
||||
def test_editing_the_rule_fields_of_a_parked_rule_is_refused(
|
||||
admin_client, org_a, rules, change
|
||||
):
|
||||
ticket_rule, _ = rules
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
response = admin_client.patch(f"{RULES}{ticket_rule.id}/", change, format="json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"errors": {"object_key": [OFF]}}
|
||||
ticket_rule.refresh_from_db()
|
||||
assert ticket_rule.actions == [SET_PRIORITY]
|
||||
|
|
@ -15,8 +15,9 @@ from automation.conditions import OPERATORS, UPDATE_ONLY
|
|||
from automation.limits import MAX_ACTIONS, MAX_CONDITIONS
|
||||
from automation.links import link_specs
|
||||
from automation.models import ALL, ANY
|
||||
from automation.objects import adapter_for
|
||||
from automation.objects import adapter_for, switched_off_module
|
||||
from common.models import Profile
|
||||
from common.modules import module_is_available, module_off_message
|
||||
from tasks.models import Task
|
||||
from webhooks.models import JSON, WebhookEndpoint
|
||||
|
||||
|
|
@ -53,6 +54,28 @@ def active_people(org):
|
|||
return Profile.objects.filter(org=org, is_active=True, user__is_active=True)
|
||||
|
||||
|
||||
def offered_actions(org):
|
||||
"""The action types a new step may take: Create a task only while Tasks is on."""
|
||||
if module_is_available(org, "tasks"):
|
||||
return list(ACTION_KEYS)
|
||||
return [kind for kind in ACTION_KEYS if kind != "create_task"]
|
||||
|
||||
|
||||
def new_task_step_errors(org, actions, stored) -> dict[str, list[str]]:
|
||||
"""While Tasks is off, a Create a task step the stored rule lacks is refused.
|
||||
One it has is kept, and skipped when the rule runs."""
|
||||
if module_is_available(org, "tasks") or not isinstance(actions, list):
|
||||
return {}
|
||||
stored = stored if isinstance(stored, list) else []
|
||||
return {
|
||||
f"actions[{index}].type": [module_off_message(org, "tasks")]
|
||||
for index, action in enumerate(actions)
|
||||
if isinstance(action, dict)
|
||||
and action.get("type") == "create_task"
|
||||
and action not in stored
|
||||
}
|
||||
|
||||
|
||||
def validate_rule(org, data: dict) -> dict[str, list[str]]:
|
||||
"""Errors keyed by path ("trigger.to", "actions[0].people"); `{}` when valid."""
|
||||
return _Check(org).rule(data)
|
||||
|
|
@ -115,7 +138,11 @@ class _Check:
|
|||
key = data.get("object_key")
|
||||
adapter = adapter_for(self.org, key) if isinstance(key, str) else None
|
||||
if adapter is None:
|
||||
self.add("object_key", "Rules cannot be built on this object.")
|
||||
module = switched_off_module(self.org, key)
|
||||
if module is not None:
|
||||
self.add("object_key", module_off_message(self.org, module))
|
||||
else:
|
||||
self.add("object_key", "Rules cannot be built on this object.")
|
||||
return self.errors
|
||||
self.key, self.adapter = key, adapter
|
||||
self._catalogues[key] = {spec.key: spec for spec in adapter.fields(self.org)}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,13 @@ from automation.engine import _may_act, try_rule
|
|||
from automation.limits import MAX_ACTIONS, MAX_CONDITIONS
|
||||
from automation.links import link_specs
|
||||
from automation.models import DONE, FAILED, AutomationRule, AutomationRun
|
||||
from automation.objects import adapter_for, automatable
|
||||
from automation.objects import adapter_for, automatable, switched_off_module
|
||||
from automation.ownership import SENTENCES, owner_problem
|
||||
from automation.plan import may_switch_on, runnable
|
||||
from automation.serializers import RULE_KEYS, RuleSerializer, RunSerializer
|
||||
from automation.validation import ACTION_KEYS, active_people, validate_rule
|
||||
from automation.validation import active_people, offered_actions, validate_rule
|
||||
from common.lookups import get_scoped_or_404
|
||||
from common.modules import module_label
|
||||
from common.permissions import HasOrgContext, IsOrgAdmin
|
||||
from webhooks.models import JSON, WebhookEndpoint
|
||||
|
||||
|
|
@ -123,6 +124,11 @@ def _status(org, rule, plan):
|
|||
# An owner changed by a path that skips the pausing signals.
|
||||
if not _may_act(rule):
|
||||
return "paused", SENTENCES.get(owner_problem(rule), PAUSED)
|
||||
# The rule stays in the active-rule list passed to `runnable`; only its
|
||||
# status changes, and `is_active` is left alone so it resumes on its own.
|
||||
module = switched_off_module(org, rule.object_key)
|
||||
if module is not None:
|
||||
return "paused", f"Paused: {module_label(org, module)} is switched off"
|
||||
if rule.id not in plan.rule_ids:
|
||||
return "over_limit", plan.statuses.get(rule.id, OVER_LIMIT)
|
||||
errors = validate_rule(org, {key: getattr(rule, key) for key in RULE_KEYS})
|
||||
|
|
@ -253,7 +259,7 @@ class CatalogueView(AutomationView):
|
|||
],
|
||||
"operators": OPERATORS,
|
||||
"update_only_operators": UPDATE_ONLY,
|
||||
"actions": list(ACTION_KEYS),
|
||||
"actions": offered_actions(org),
|
||||
"people": [
|
||||
{"id": str(p.id), "name": p.user.name or p.user.email}
|
||||
for p in people
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from cases.tasks import (
|
|||
csat_signer,
|
||||
hash_csat_token,
|
||||
)
|
||||
from common.modules import module_is_available
|
||||
from common.permissions import HasOrgContext
|
||||
from common.portal_tokens import resolve_portal_org_by_hash
|
||||
from common.tasks import set_rls_context
|
||||
|
|
@ -69,8 +70,14 @@ def _load_survey(token: str) -> tuple[CsatSurvey | None, int | None, str | None]
|
|||
if org_id is None:
|
||||
return None, 410, "Survey link is no longer valid."
|
||||
set_rls_context(org_id)
|
||||
survey = CsatSurvey.objects.filter(token_hash=token_hash, org_id=org_id).first()
|
||||
if survey is None:
|
||||
survey = (
|
||||
CsatSurvey.objects.select_related("org")
|
||||
.filter(token_hash=token_hash, org_id=org_id)
|
||||
.first()
|
||||
)
|
||||
# Checked before expiry, so a link in an org with Tickets off answers
|
||||
# exactly as an unknown one does.
|
||||
if survey is None or not module_is_available(survey.org, "tickets"):
|
||||
return None, 410, "Survey link is no longer valid."
|
||||
if timezone.now() >= survey.expires_at:
|
||||
return None, 410, "Survey link has expired."
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from cases.portal_serializers import (
|
|||
)
|
||||
from cases.portal_views import published_articles, related_articles
|
||||
from common.models import Org
|
||||
from common.modules import module_is_available
|
||||
from common.request_meta import client_ip
|
||||
from common.tasks import set_rls_context
|
||||
from common.throttles import FirstRefusalThrottleMixin
|
||||
|
|
@ -41,8 +42,9 @@ from common.throttles import FirstRefusalThrottleMixin
|
|||
# A search term longer than this is not a question anybody typed.
|
||||
SEARCH_MAX = 200
|
||||
|
||||
# One body for every miss: no such slug, a help center switched off, an
|
||||
# inactive org, and an article that is a draft, unpublished, or another org's.
|
||||
# One body for every miss: no such slug, a help center or knowledge base
|
||||
# switched off, an inactive org, and an article that is a draft, unpublished,
|
||||
# or another org's.
|
||||
# Telling any two of those apart would let a stranger probe which orgs use the
|
||||
# product and which article ids exist behind the published set.
|
||||
NOT_FOUND = {"error": "Not found"}
|
||||
|
|
@ -105,8 +107,9 @@ class PublicHelpCenterView(FirstRefusalThrottleMixin, APIView):
|
|||
org = Org.objects.filter(
|
||||
help_center_slug=slug, help_center_enabled=True, is_active=True
|
||||
).first()
|
||||
if org is not None:
|
||||
set_rls_context(org.id)
|
||||
if org is None or not module_is_available(org, "knowledge_base"):
|
||||
return None
|
||||
set_rls_context(org.id)
|
||||
return org
|
||||
|
||||
def _not_found(self):
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from cases.inbound.sns import (
|
|||
from cases.models import EmailMessage, InboundMailbox
|
||||
from cases.serializer import InboundMailboxSerializer
|
||||
from common.models import PortalAccessToken
|
||||
from common.modules import module_is_available
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.portal_tokens import resolve_portal_org
|
||||
|
|
@ -214,7 +215,7 @@ class InboundMailboxWebhookView(APIView):
|
|||
)
|
||||
def post(self, request, mailbox_id, *args, **kwargs):
|
||||
# Don't leak which UUIDs exist: an unknown id, one whose mailbox is
|
||||
# inactive and one whose mailbox is gone all get this same 404.
|
||||
# inactive or gone, and one in an org with Tickets off all get this 404.
|
||||
not_found = Response(
|
||||
{"error": True, "errors": "Mailbox not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -233,7 +234,7 @@ class InboundMailboxWebhookView(APIView):
|
|||
.select_related("org")
|
||||
.first()
|
||||
)
|
||||
if mailbox is None:
|
||||
if mailbox is None or not module_is_available(mailbox.org, "tickets"):
|
||||
return not_found
|
||||
|
||||
# Likewise the org's day: the reopen window a reply is judged against
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from cases.portal_serializers import (
|
|||
)
|
||||
from cases.signals import route_after_relations
|
||||
from common.models import Comment
|
||||
from common.modules import module_is_available
|
||||
from common.portal_auth import IsPortalContact, PortalContactAuthentication
|
||||
from common.utils import STATUS_CHOICE
|
||||
|
||||
|
|
@ -88,6 +89,15 @@ class PortalBaseView(APIView):
|
|||
# because that setting applies to every view in the project.
|
||||
authentication_classes = (PortalContactAuthentication,)
|
||||
permission_classes = (IsPortalContact,)
|
||||
module: str
|
||||
|
||||
def closed(self, request):
|
||||
"""None while the org has this view's module on, else the answer the
|
||||
view gives a record that does not exist. Every handler asks first: the
|
||||
middleware gate never sees the portal, which carries no profile."""
|
||||
if module_is_available(request.org, self.module):
|
||||
return None
|
||||
return self._not_found()
|
||||
|
||||
def _my_cases(self, request):
|
||||
"""The only definition of "my cases" in the portal.
|
||||
|
|
@ -116,7 +126,11 @@ class PortalBaseView(APIView):
|
|||
|
||||
|
||||
class PortalCaseListView(PortalBaseView, LimitOffsetPagination):
|
||||
module = "tickets"
|
||||
|
||||
def get(self, request):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
queryset = self._my_cases(request)
|
||||
|
||||
requested_status = request.query_params.get("status")
|
||||
|
|
@ -137,6 +151,8 @@ class PortalCaseListView(PortalBaseView, LimitOffsetPagination):
|
|||
)
|
||||
|
||||
def post(self, request):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
payload = PortalCaseCreateSerializer(data=request.data)
|
||||
if not payload.is_valid():
|
||||
return Response(payload.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
|
@ -159,7 +175,11 @@ class PortalCaseListView(PortalBaseView, LimitOffsetPagination):
|
|||
|
||||
|
||||
class PortalCaseDetailView(PortalBaseView):
|
||||
module = "tickets"
|
||||
|
||||
def get(self, request, pk):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
case = self._case_or_none(request, pk)
|
||||
if case is None:
|
||||
return self._not_found()
|
||||
|
|
@ -186,8 +206,22 @@ class PortalCaseDetailView(PortalBaseView):
|
|||
)
|
||||
|
||||
|
||||
class PortalArticleListView(PortalBaseView, LimitOffsetPagination):
|
||||
class PortalArticleView(PortalBaseView):
|
||||
module = "knowledge_base"
|
||||
|
||||
def _not_found(self):
|
||||
# Same response for "no such article" and "not published yet", for
|
||||
# the reason `PortalBaseView._not_found` gives about cases: telling
|
||||
# them apart confirms that a draft with this id exists.
|
||||
return Response(
|
||||
{"error": "Article not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class PortalArticleListView(PortalArticleView, LimitOffsetPagination):
|
||||
def get(self, request):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
queryset = self._published_articles(request)
|
||||
|
||||
# Narrowed from the visible set, never applied to Solution.objects. A
|
||||
|
|
@ -209,16 +243,13 @@ class PortalArticleListView(PortalBaseView, LimitOffsetPagination):
|
|||
)
|
||||
|
||||
|
||||
class PortalArticleDetailView(PortalBaseView):
|
||||
class PortalArticleDetailView(PortalArticleView):
|
||||
def get(self, request, pk):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
article = self._published_articles(request).filter(pk=pk).first()
|
||||
if article is None:
|
||||
# Same response for "no such article" and "not published yet", for
|
||||
# the reason `_not_found` gives about cases: telling them apart
|
||||
# confirms that a draft with this id exists.
|
||||
return Response(
|
||||
{"error": "Article not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return self._not_found()
|
||||
return Response(
|
||||
{
|
||||
"article": PortalSolutionDetailSerializer(article).data,
|
||||
|
|
@ -230,7 +261,7 @@ class PortalArticleDetailView(PortalBaseView):
|
|||
return related_articles(request.org, article)
|
||||
|
||||
|
||||
class PortalArticleSuggestView(PortalBaseView):
|
||||
class PortalArticleSuggestView(PortalArticleView):
|
||||
"""Deflection, at the moment the customer is about to file a request.
|
||||
|
||||
Query-driven rather than case-driven: the agent suggester hangs off an
|
||||
|
|
@ -238,7 +269,15 @@ class PortalArticleSuggestView(PortalBaseView):
|
|||
is a separate endpoint rather than a reuse of `SolutionSuggestionsView`.
|
||||
"""
|
||||
|
||||
def closed(self, request):
|
||||
# Off answers as an empty query does: a panel with nothing in it.
|
||||
if super().closed(request) is None:
|
||||
return None
|
||||
return Response({"articles": []})
|
||||
|
||||
def get(self, request):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
q = (request.query_params.get("q") or "").strip()
|
||||
if not q:
|
||||
# No seeding from recent articles, unlike the agent side. An agent
|
||||
|
|
@ -266,7 +305,11 @@ class PortalArticleSuggestView(PortalBaseView):
|
|||
|
||||
|
||||
class PortalCaseCommentView(PortalBaseView):
|
||||
module = "tickets"
|
||||
|
||||
def post(self, request, pk):
|
||||
if closed := self.closed(request):
|
||||
return closed
|
||||
case = self._case_or_none(request, pk)
|
||||
if case is None:
|
||||
return self._not_found()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from cases.workflow import (
|
|||
merged_status_refusal,
|
||||
)
|
||||
from common.models import Profile, Teams
|
||||
from common.modules import module_is_available, org_of
|
||||
from common.permissions import is_org_admin
|
||||
from common.serializer import (
|
||||
OrganizationSerializer,
|
||||
|
|
@ -163,10 +164,18 @@ class CaseRowsListSerializer(SharedCalendarListSerializer):
|
|||
.order_by()
|
||||
.values_list("parent_id", "n")
|
||||
)
|
||||
summaries = time_summaries(cases)
|
||||
timed = {}
|
||||
for case in cases:
|
||||
if case.org_id not in timed:
|
||||
timed[case.org_id] = module_is_available(org_of(case), "timesheet")
|
||||
summaries = time_summaries([c for c in cases if timed[c.org_id]])
|
||||
for case in cases:
|
||||
case._child_count = counts.get(case.pk, 0)
|
||||
case._time_summary = summaries.get(case.pk) or _no_time()
|
||||
case._time_summary = (
|
||||
(summaries.get(case.pk) or _no_time())
|
||||
if timed[case.org_id]
|
||||
else None
|
||||
)
|
||||
return super().to_representation(cases)
|
||||
|
||||
|
||||
|
|
@ -233,9 +242,11 @@ class CaseSerializer(serializers.ModelSerializer):
|
|||
|
||||
def get_time_summary(self, obj):
|
||||
# Set for a whole page by `CaseRowsListSerializer`; one ticket asks
|
||||
# the same function for itself.
|
||||
# the same function for itself. Null while the timesheet is off.
|
||||
if hasattr(obj, "_time_summary"):
|
||||
return obj._time_summary
|
||||
if not module_is_available(org_of(obj), "timesheet"):
|
||||
return None
|
||||
return time_summaries([obj]).get(obj.pk) or _no_time()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -1079,9 +1090,25 @@ class RoutingRuleSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||
"""Read shape for time entries. See docs/cases/tier3/time-tracking.md."""
|
||||
"""Read shape for time entries. See docs/cases/tier3/time-tracking.md.
|
||||
|
||||
``invoice`` is null while Invoices is off; ``invoiced`` still says the
|
||||
entry is billed, and so locked against edits.
|
||||
"""
|
||||
|
||||
profile = ProfileSerializer(read_only=True)
|
||||
invoiced = serializers.SerializerMethodField()
|
||||
|
||||
def get_invoiced(self, obj) -> bool:
|
||||
return obj.invoice_id is not None
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.invoice_id and not module_is_available(
|
||||
org_of(instance), "invoices"
|
||||
):
|
||||
data["invoice"] = None
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
|
|
@ -1097,6 +1124,7 @@ class TimeEntrySerializer(serializers.ModelSerializer):
|
|||
"hourly_rate",
|
||||
"currency",
|
||||
"invoice",
|
||||
"invoiced",
|
||||
"auto_stopped",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from rest_framework import serializers
|
|||
|
||||
from cases.kb_access import has_solution_delete_access, has_solution_write_access
|
||||
from cases.models import Solution
|
||||
from common.modules import module_is_available, org_of
|
||||
from common.serializer import OrganizationSerializer, TagsSerializer
|
||||
|
||||
|
||||
|
|
@ -66,6 +67,8 @@ class SolutionSerializer(serializers.ModelSerializer):
|
|||
# `SolutionListView` annotates this so a list of N articles costs one
|
||||
# query rather than N. The fallback keeps the serializer usable on a
|
||||
# single un-annotated instance: the create and publish responses.
|
||||
if not module_is_available(org_of(obj), "tickets"):
|
||||
return 0
|
||||
annotated = getattr(obj, "linked_case_count", None)
|
||||
if annotated is not None:
|
||||
return annotated
|
||||
|
|
@ -216,6 +219,8 @@ class SolutionDetailSerializer(serializers.ModelSerializer):
|
|||
# An empty list is the safe answer; the alternative is the leak
|
||||
# this method existed as.
|
||||
return []
|
||||
if not module_is_available(profile.org, "tickets"):
|
||||
return []
|
||||
|
||||
cases = obj.cases.filter(pk__in=visible_cases_qs(profile).values("pk"))
|
||||
|
||||
|
|
@ -232,6 +237,8 @@ class SolutionDetailSerializer(serializers.ModelSerializer):
|
|||
|
||||
@extend_schema_field(int)
|
||||
def get_case_count(self, obj):
|
||||
if not module_is_available(org_of(obj), "tickets"):
|
||||
return 0
|
||||
return obj.cases.count()
|
||||
|
||||
@extend_schema_field(str)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from cases.notifications import case_link
|
|||
from cases.workflow import TERMINAL_STATUSES
|
||||
from common.links import frontend_url
|
||||
from common.models import Activity, Comment, Org, Profile
|
||||
from common.modules import module_is_available, module_on_since
|
||||
from common.tasks import clear_rls_context, set_rls_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -118,6 +119,10 @@ def _dispatch_breach(case, action, target_profile, team, org_id):
|
|||
|
||||
def _scan_org(org):
|
||||
"""Run the breach scan for one org. Returns count of cases escalated."""
|
||||
if not module_is_available(org, "tickets"):
|
||||
return 0
|
||||
# A deadline that passed while Tickets was off never escalates.
|
||||
since = module_on_since(org, "tickets")
|
||||
now = timezone.now()
|
||||
cooldown_cutoff = now - timezone.timedelta(minutes=ESCALATION_COOLDOWN_MINUTES)
|
||||
|
||||
|
|
@ -139,8 +144,12 @@ def _scan_org(org):
|
|||
return 0
|
||||
|
||||
for case in candidate_qs.iterator():
|
||||
first_breach = case.is_sla_first_response_breached
|
||||
resolution_breach = case.is_sla_resolution_breached
|
||||
first_breach = case.is_sla_first_response_breached and (
|
||||
since is None or case.first_response_sla_deadline >= since
|
||||
)
|
||||
resolution_breach = case.is_sla_resolution_breached and (
|
||||
since is None or case.resolution_sla_deadline >= since
|
||||
)
|
||||
if not (first_breach or resolution_breach):
|
||||
continue
|
||||
|
||||
|
|
@ -220,11 +229,12 @@ def scan_for_breached_cases():
|
|||
|
||||
Walks every org through the unscoped `organization` table and sets the RLS
|
||||
context before any org-scoped query; `_scan_org` returns early for an org
|
||||
with no active policy. The previous version found its orgs by querying
|
||||
`escalation_policy` first, with no context set. That table is org-scoped
|
||||
and a worker runs no middleware, so under an RLS-bound role the lookup
|
||||
matched nothing and no case was ever escalated. The context is cleared in
|
||||
a ``finally`` so the last org's id is not left on a pooled connection.
|
||||
with Tickets off or no active policy. The previous version found its orgs
|
||||
by querying `escalation_policy` first, with no context set. That table is
|
||||
org-scoped and a worker runs no middleware, so under an RLS-bound role the
|
||||
lookup matched nothing and no case was ever escalated. The context is
|
||||
cleared in a ``finally`` so the last org's id is not left on a pooled
|
||||
connection.
|
||||
"""
|
||||
total = 0
|
||||
try:
|
||||
|
|
@ -273,6 +283,7 @@ def send_csat_survey(case_id, org_id):
|
|||
"""Send a CSAT survey for a freshly-closed case.
|
||||
|
||||
Skips when:
|
||||
- Tickets is switched off for the org (the survey page answers 410).
|
||||
- The org has flipped `csat_enabled` off.
|
||||
- The case has no contact with an email (logged, not raised).
|
||||
- The case has been reopened in the meantime (status no longer
|
||||
|
|
@ -291,6 +302,9 @@ def send_csat_survey(case_id, org_id):
|
|||
case.status,
|
||||
)
|
||||
return None
|
||||
if not module_is_available(case.org, "tickets"):
|
||||
logger.info("send_csat_survey: org=%s has Tickets off, skipping", org_id)
|
||||
return None
|
||||
if not case.org.csat_enabled:
|
||||
logger.info("send_csat_survey: org=%s has csat disabled, skipping", org_id)
|
||||
return None
|
||||
|
|
@ -553,6 +567,11 @@ def notify_portal_contacts(
|
|||
if case is None:
|
||||
logger.info("notify_portal_contacts: case=%s not found, skipping", case_id)
|
||||
return None
|
||||
# Queued while Tickets was on; once it is off, the portal page the email
|
||||
# links to answers not found.
|
||||
if not module_is_available(case.org, "tickets"):
|
||||
logger.info("notify_portal_contacts: org=%s has Tickets off, skipping", org_id)
|
||||
return 0
|
||||
|
||||
comment = None
|
||||
if kind == "reply":
|
||||
|
|
@ -693,7 +712,8 @@ TIME_ENTRY_AUTO_STOP_HOURS = 12
|
|||
def auto_stop_stale_timers(threshold_hours=TIME_ENTRY_AUTO_STOP_HOURS):
|
||||
"""Stop running TimeEntry rows whose started_at is older than
|
||||
``threshold_hours``. Sets ``auto_stopped=True`` and recomputes
|
||||
``duration_minutes`` via the model save() path.
|
||||
``duration_minutes`` via the model save() path. Skips an org with Timesheet
|
||||
off, and leaves running a timer that went stale while it was off.
|
||||
|
||||
Returns the number of timers stopped. Walks every org through the
|
||||
unscoped `organization` table and sets the RLS context before touching
|
||||
|
|
@ -706,21 +726,26 @@ def auto_stop_stale_timers(threshold_hours=TIME_ENTRY_AUTO_STOP_HOURS):
|
|||
|
||||
stopped = 0
|
||||
try:
|
||||
for org_id in Org.objects.values_list("id", flat=True):
|
||||
set_rls_context(org_id)
|
||||
for org in Org.objects.all():
|
||||
if not module_is_available(org, "timesheet"):
|
||||
continue
|
||||
set_rls_context(org.id)
|
||||
try:
|
||||
stale = list(
|
||||
TimeEntry.objects.filter(
|
||||
org_id=org_id, ended_at__isnull=True, started_at__lt=cutoff
|
||||
)
|
||||
stale = TimeEntry.objects.filter(
|
||||
org_id=org.id, ended_at__isnull=True, started_at__lt=cutoff
|
||||
)
|
||||
for entry in stale:
|
||||
since = module_on_since(org, "timesheet")
|
||||
if since is not None:
|
||||
stale = stale.filter(
|
||||
started_at__gte=since - timedelta(hours=threshold_hours)
|
||||
)
|
||||
for entry in list(stale):
|
||||
entry.ended_at = now
|
||||
entry.auto_stopped = True
|
||||
entry.save()
|
||||
stopped += 1
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("auto_stop_stale_timers failed for org=%s", org_id)
|
||||
logger.exception("auto_stop_stale_timers failed for org=%s", org.id)
|
||||
finally:
|
||||
clear_rls_context()
|
||||
return stopped
|
||||
|
|
|
|||
246
backend/cases/tests/test_module_off_jobs.py
Normal file
246
backend/cases/tests/test_module_off_jobs.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""SLA escalation and stale timers while their module is off, and once it is
|
||||
back on.
|
||||
|
||||
A breach whose deadline passed while Tickets was off never escalates or
|
||||
reassigns, a timer that went stale while Timesheet was off is left running for
|
||||
its owner to stop, and a customer email queued before Tickets went off is not
|
||||
sent while it is off.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django.utils import timezone
|
||||
|
||||
from cases.models import Case, CsatSurvey, EscalationPolicy, TimeEntry
|
||||
from cases.tasks import (
|
||||
auto_stop_stale_timers,
|
||||
notify_portal_contacts,
|
||||
scan_for_breached_cases,
|
||||
send_csat_survey,
|
||||
)
|
||||
from common.models import Activity, Profile, User
|
||||
from common.module_settings import change_modules
|
||||
from common.testing import restore_rls_context, rls_org
|
||||
from contacts.models import Contact
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
HOUR = datetime.timedelta(hours=1)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = datetime.datetime(2026, 10, 14, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
def advance(self, **delta):
|
||||
self.now += datetime.timedelta(**delta)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
c = Clock()
|
||||
with patch("django.utils.timezone.now", side_effect=lambda: c.now):
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_email():
|
||||
with patch("cases.tasks.send_email_to_assigned_user") as send:
|
||||
yield send
|
||||
|
||||
|
||||
def _switch(org, actor, *off):
|
||||
"""Leave exactly ``off`` switched off, through the one writer."""
|
||||
assert change_modules(org, actor, {"off": list(off), "order": []}) == {}
|
||||
org.refresh_from_db()
|
||||
|
||||
|
||||
def _run(task, *args, **kwargs):
|
||||
# Each job clears the RLS context on its way out; the test reads its own
|
||||
# rows afterwards.
|
||||
try:
|
||||
return task(*args, **kwargs)
|
||||
finally:
|
||||
restore_rls_context()
|
||||
|
||||
|
||||
def _agent(org, email):
|
||||
user = User.objects.create_user(email=email, password="x")
|
||||
return Profile.objects.create(user=user, org=org, role="USER", is_active=True)
|
||||
|
||||
|
||||
def _policy(org, target):
|
||||
return EscalationPolicy.objects.create(
|
||||
org=org,
|
||||
priority="Urgent",
|
||||
first_response_action="notify_and_reassign",
|
||||
resolution_action="notify_and_reassign",
|
||||
first_response_target=target,
|
||||
resolution_target=target,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _case(org, created_at, *, first_response_hours=1, assignee=None):
|
||||
case = Case.objects.create(
|
||||
name="Printer on fire",
|
||||
status="New",
|
||||
priority="Urgent",
|
||||
sla_first_response_hours=first_response_hours,
|
||||
sla_resolution_hours=48,
|
||||
org=org,
|
||||
)
|
||||
Case.objects.filter(pk=case.pk).update(created_at=created_at)
|
||||
if assignee is not None:
|
||||
case.assigned_to.add(assignee)
|
||||
return case
|
||||
|
||||
|
||||
def _timer(org, profile, started_at):
|
||||
case = Case.objects.create(name="Timed", status="New", org=org)
|
||||
return TimeEntry.objects.create(
|
||||
org=org, case=case, profile=profile, started_at=started_at
|
||||
)
|
||||
|
||||
|
||||
def test_sla_scan_skips_an_org_with_tickets_off(org_a, org_b, admin_profile):
|
||||
_switch(org_a, admin_profile, "tickets")
|
||||
cases = {}
|
||||
for org in (org_a, org_b):
|
||||
with rls_org(org):
|
||||
_policy(org, _agent(org, f"agent-{org.id}@test.com"))
|
||||
cases[org.id] = _case(org, timezone.now() - 5 * HOUR)
|
||||
|
||||
assert _run(scan_for_breached_cases) == 1
|
||||
|
||||
cases[org_a.id].refresh_from_db()
|
||||
assert cases[org_a.id].escalation_count == 0
|
||||
with rls_org(org_b):
|
||||
cases[org_b.id].refresh_from_db()
|
||||
assert cases[org_b.id].escalation_count == 1
|
||||
|
||||
|
||||
def test_breaches_from_the_off_period_never_escalate(
|
||||
org_a, admin_profile, clock, no_email
|
||||
):
|
||||
_switch(org_a, admin_profile, "tickets")
|
||||
clock.advance(days=2)
|
||||
switched_on = clock.now
|
||||
target = _agent(org_a, "agent@test.com")
|
||||
_policy(org_a, target)
|
||||
missed_while_off = _case(org_a, switched_on - 3 * HOUR, assignee=admin_profile)
|
||||
due_after = _case(org_a, switched_on, assignee=admin_profile)
|
||||
_switch(org_a, admin_profile)
|
||||
|
||||
assert _run(scan_for_breached_cases) == 0
|
||||
|
||||
clock.advance(hours=1, minutes=5)
|
||||
assert _run(scan_for_breached_cases) == 1
|
||||
|
||||
missed_while_off.refresh_from_db()
|
||||
due_after.refresh_from_db()
|
||||
assert missed_while_off.escalation_count == 0
|
||||
assert list(missed_while_off.assigned_to.all()) == [admin_profile]
|
||||
assert not Activity.objects.filter(
|
||||
action="ESCALATED", entity_id=missed_while_off.pk
|
||||
).exists()
|
||||
assert due_after.escalation_count == 1
|
||||
assert list(due_after.assigned_to.all()) == [target]
|
||||
assert no_email.delay.call_count == 1
|
||||
|
||||
|
||||
def test_stale_timers_skip_an_org_with_timesheet_off(
|
||||
org_a, org_b, admin_profile, profile_b
|
||||
):
|
||||
_switch(org_a, admin_profile, "timesheet")
|
||||
long_ago = timezone.now() - 15 * HOUR
|
||||
ours = _timer(org_a, admin_profile, long_ago)
|
||||
with rls_org(org_b):
|
||||
theirs = _timer(org_b, profile_b, long_ago)
|
||||
|
||||
assert _run(auto_stop_stale_timers, threshold_hours=12) == 1
|
||||
|
||||
ours.refresh_from_db()
|
||||
assert ours.ended_at is None
|
||||
with rls_org(org_b):
|
||||
theirs.refresh_from_db()
|
||||
assert theirs.auto_stopped is True
|
||||
|
||||
|
||||
def test_a_timer_that_went_stale_while_off_is_left_running(org_a, admin_profile, clock):
|
||||
_switch(org_a, admin_profile, "timesheet")
|
||||
clock.advance(days=1)
|
||||
entry = _timer(org_a, admin_profile, clock.now - 20 * HOUR)
|
||||
_switch(org_a, admin_profile)
|
||||
|
||||
assert _run(auto_stop_stale_timers, threshold_hours=12) == 0
|
||||
clock.advance(hours=13)
|
||||
assert _run(auto_stop_stale_timers, threshold_hours=12) == 0
|
||||
|
||||
entry.refresh_from_db()
|
||||
assert (entry.ended_at, entry.auto_stopped) == (None, False)
|
||||
|
||||
|
||||
def test_a_timer_started_after_switch_on_is_stopped_as_before(
|
||||
org_a, admin_profile, clock
|
||||
):
|
||||
"""Pin: both were stopped before this change too. It holds the bound: the
|
||||
timer started two hours before the switch-on went stale after it."""
|
||||
_switch(org_a, admin_profile, "timesheet")
|
||||
clock.advance(days=1)
|
||||
started_before = _timer(org_a, admin_profile, clock.now - 2 * HOUR)
|
||||
_switch(org_a, admin_profile)
|
||||
clock.advance(minutes=1)
|
||||
started_after = _timer(org_a, _agent(org_a, "second@test.com"), clock.now)
|
||||
|
||||
clock.advance(hours=13)
|
||||
assert _run(auto_stop_stale_timers, threshold_hours=12) == 2
|
||||
|
||||
for entry in (started_before, started_after):
|
||||
entry.refresh_from_db()
|
||||
assert entry.auto_stopped is True
|
||||
|
||||
|
||||
def _customer_case(org, status):
|
||||
case = Case.objects.create(
|
||||
org=org,
|
||||
name="Login bug",
|
||||
status=status,
|
||||
priority="Normal",
|
||||
resolved_at=timezone.now() if status == "Closed" else None,
|
||||
)
|
||||
case.contacts.add(
|
||||
Contact.objects.create(
|
||||
org=org, first_name="Pat", last_name="Smith", email="pat@example.com"
|
||||
)
|
||||
)
|
||||
return case
|
||||
|
||||
|
||||
def test_a_queued_csat_survey_is_not_sent_while_tickets_is_off(org_a, admin_profile):
|
||||
case = _customer_case(org_a, "Closed")
|
||||
_switch(org_a, admin_profile, "tickets")
|
||||
mail.outbox.clear()
|
||||
|
||||
assert send_csat_survey(str(case.id), str(org_a.id)) is None
|
||||
assert (mail.outbox, CsatSurvey.objects.count()) == ([], 0)
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
assert send_csat_survey(str(case.id), str(org_a.id)) is not None
|
||||
assert (len(mail.outbox), CsatSurvey.objects.count()) == (1, 1)
|
||||
|
||||
|
||||
def test_a_queued_portal_notice_is_not_sent_while_tickets_is_off(org_a, admin_profile):
|
||||
case = _customer_case(org_a, "New")
|
||||
_switch(org_a, admin_profile, "tickets")
|
||||
mail.outbox.clear()
|
||||
|
||||
assert notify_portal_contacts(str(case.id), str(org_a.id), "status") == 0
|
||||
assert mail.outbox == []
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
assert notify_portal_contacts(str(case.id), str(org_a.id), "status") == 1
|
||||
assert [m.to for m in mail.outbox] == [["pat@example.com"]]
|
||||
240
backend/cases/tests/test_module_off_public.py
Normal file
240
backend/cases/tests/test_module_off_public.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""Public, portal and inbound case entry points while a module is off.
|
||||
|
||||
People outside the org reach these, so an off module answers exactly what an
|
||||
unknown link already gets, and the same request answers as before once the
|
||||
module is back on.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from cases.models import Case, CsatSurvey, EmailMessage, InboundMailbox, Solution
|
||||
from cases.tasks import csat_signer, hash_csat_token, send_csat_survey
|
||||
from common.models import Comment, PortalLoginToken
|
||||
from common.portal_auth import mint_portal_token
|
||||
from common.portal_tokens import register_portal_token_hash
|
||||
from common.testing import rls_org, set_modules_off
|
||||
from contacts.models import Contact
|
||||
|
||||
SLUG = "acme-help"
|
||||
SNS_TOPIC = "arn:aws:sns:us-east-1:123456789012:acme-inbound"
|
||||
RAW_EMAIL = (
|
||||
"From: Customer <user@example.com>\r\n"
|
||||
"To: support@acme.com\r\n"
|
||||
"Subject: Help with login\r\n"
|
||||
"Date: Sat, 9 May 2026 12:00:00 +0000\r\n"
|
||||
"Message-ID: <m1@example.com>\r\n"
|
||||
"MIME-Version: 1.0\r\n"
|
||||
'Content-Type: text/plain; charset="utf-8"\r\n'
|
||||
"\r\n"
|
||||
"I cannot log in."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_throttle():
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anon():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pat(org_a):
|
||||
return Contact.objects.create(
|
||||
org=org_a, first_name="Pat", last_name="Smith", email="pat@example.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal(pat):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {mint_portal_token(pat)}")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def article(org_a):
|
||||
with rls_org(org_a):
|
||||
return Solution.objects.create(
|
||||
org=org_a,
|
||||
title="Resetting your password",
|
||||
description="Open settings, choose security, then reset.",
|
||||
status="approved",
|
||||
is_published=True,
|
||||
)
|
||||
|
||||
|
||||
def _answer(response):
|
||||
return response.status_code, response.json()
|
||||
|
||||
|
||||
def test_help_center_closes_with_the_knowledge_base(anon, org_a, article):
|
||||
org_a.help_center_slug = SLUG
|
||||
org_a.help_center_enabled = True
|
||||
org_a.save()
|
||||
urls = (
|
||||
f"/api/public/help/{SLUG}/",
|
||||
f"/api/public/help/{SLUG}/articles/{article.id}/",
|
||||
)
|
||||
unknown = _answer(anon.get("/api/public/help/no-such-help/"))
|
||||
assert unknown == (404, {"error": "Not found"})
|
||||
|
||||
set_modules_off(org_a, "knowledge_base")
|
||||
for url in urls:
|
||||
assert _answer(anon.get(url)) == unknown
|
||||
|
||||
set_modules_off(org_a)
|
||||
for url in urls:
|
||||
assert anon.get(url).status_code == 200
|
||||
|
||||
|
||||
def test_csat_link_answers_its_expired_answer(client, org_a, pat):
|
||||
case = Case.objects.create(
|
||||
org=org_a,
|
||||
name="Login bug",
|
||||
status="Closed",
|
||||
priority="Normal",
|
||||
closed_on=timezone.localdate(),
|
||||
resolved_at=timezone.now(),
|
||||
)
|
||||
case.contacts.add(pat)
|
||||
send_csat_survey(str(case.id), str(org_a.id))
|
||||
survey = CsatSurvey.objects.get(case=case)
|
||||
token = csat_signer().sign(str(case.id))
|
||||
survey.token_hash = hash_csat_token(token)
|
||||
survey.save(update_fields=["token_hash"])
|
||||
register_portal_token_hash(survey.token_hash, org_a.id, "csat", survey.id)
|
||||
url = f"/api/public/csat/{token}/"
|
||||
unknown = _answer(client.get(f"/api/public/csat/{csat_signer().sign('ghost')}/"))
|
||||
assert unknown == (410, {"error": "Survey link is no longer valid."})
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert _answer(client.get(url)) == unknown
|
||||
posted = client.post(url, {"rating": 5}, content_type="application/json")
|
||||
assert _answer(posted) == unknown
|
||||
survey.refresh_from_db()
|
||||
assert (survey.rating, survey.responded_at) == (None, None)
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert client.get(url).status_code == 200
|
||||
posted = client.post(url, {"rating": 5}, content_type="application/json")
|
||||
assert posted.status_code == 200
|
||||
survey.refresh_from_db()
|
||||
assert survey.rating == 5
|
||||
|
||||
|
||||
def test_portal_cases_close_with_tickets(portal, org_a, pat):
|
||||
case = Case.objects.create(org=org_a, name="Login broken", status="New")
|
||||
case.contacts.add(pat)
|
||||
detail = f"/api/portal/cases/{case.id}/"
|
||||
comment = f"{detail}comment/"
|
||||
unknown = _answer(portal.get(f"/api/portal/cases/{uuid.uuid4()}/"))
|
||||
assert unknown == (404, {"error": "Case not found"})
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert _answer(portal.get("/api/portal/cases/")) == unknown
|
||||
created = portal.post("/api/portal/cases/", {"name": "New one"}, format="json")
|
||||
assert _answer(created) == unknown
|
||||
assert _answer(portal.get(detail)) == unknown
|
||||
assert _answer(portal.post(comment, {"comment": "Hi"}, format="json")) == unknown
|
||||
assert list(Case.objects.filter(org=org_a)) == [case]
|
||||
assert not Comment.objects.filter(object_id=case.id).exists()
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert portal.get("/api/portal/cases/").status_code == 200
|
||||
assert portal.get(detail).status_code == 200
|
||||
assert portal.post(comment, {"comment": "Hi"}, format="json").status_code == 201
|
||||
|
||||
|
||||
def test_portal_articles_close_with_the_knowledge_base(portal, org_a, article):
|
||||
detail = f"/api/portal/articles/{article.id}/"
|
||||
suggest = "/api/portal/articles/suggest/?q=password"
|
||||
unknown = _answer(portal.get(f"/api/portal/articles/{uuid.uuid4()}/"))
|
||||
assert unknown == (404, {"error": "Article not found"})
|
||||
|
||||
set_modules_off(org_a, "knowledge_base")
|
||||
assert _answer(portal.get("/api/portal/articles/")) == unknown
|
||||
assert _answer(portal.get(detail)) == unknown
|
||||
nothing = _answer(portal.get("/api/portal/articles/suggest/?q="))
|
||||
assert _answer(portal.get(suggest)) == nothing == (200, {"articles": []})
|
||||
|
||||
set_modules_off(org_a)
|
||||
listed = portal.get("/api/portal/articles/").json()["articles"]
|
||||
assert [a["id"] for a in listed] == [str(article.id)]
|
||||
assert portal.get(detail).status_code == 200
|
||||
assert [a["id"] for a in portal.get(suggest).json()["articles"]] == [
|
||||
str(article.id)
|
||||
]
|
||||
|
||||
|
||||
def test_portal_sign_in_still_works_with_both_off(org_a, pat):
|
||||
"""Pin: sign-in is core, so it passed before the module checks existed."""
|
||||
set_modules_off(org_a, "tickets", "knowledge_base")
|
||||
requested = APIClient().post(
|
||||
f"/api/portal/login/{org_a.id}/request/",
|
||||
{"email": "pat@example.com"},
|
||||
format="json",
|
||||
)
|
||||
assert requested.status_code == 200
|
||||
assert PortalLoginToken.objects.filter(contact=pat).count() == 1
|
||||
|
||||
PortalLoginToken.objects.filter(contact=pat).update(
|
||||
code_hash=make_password("123456"),
|
||||
expires_at=timezone.now() + timedelta(minutes=10),
|
||||
)
|
||||
verified = APIClient().post(
|
||||
f"/api/portal/login/{org_a.id}/verify/",
|
||||
{"email": "pat@example.com", "code": "123456"},
|
||||
format="json",
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["access_token"]
|
||||
|
||||
|
||||
def test_inbound_email_answers_not_found_while_tickets_is_off(anon, org_a):
|
||||
with rls_org(org_a):
|
||||
mailbox = InboundMailbox.objects.create(
|
||||
org=org_a,
|
||||
address="support@acme.com",
|
||||
provider="ses",
|
||||
topic_arn=SNS_TOPIC,
|
||||
is_active=True,
|
||||
)
|
||||
notification = {
|
||||
"Type": "Notification",
|
||||
"Message": RAW_EMAIL,
|
||||
"Signature": "x",
|
||||
"SigningCertURL": "x",
|
||||
"SignatureVersion": "1",
|
||||
"TopicArn": SNS_TOPIC,
|
||||
}
|
||||
url = f"/api/cases/inbound/{mailbox.id}/"
|
||||
with patch("cases.inbound_views.verify_sns_message"):
|
||||
unknown = _answer(
|
||||
anon.post(
|
||||
f"/api/cases/inbound/{uuid.uuid4()}/", notification, format="json"
|
||||
)
|
||||
)
|
||||
assert unknown == (404, {"error": True, "errors": "Mailbox not found"})
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert _answer(anon.post(url, notification, format="json")) == unknown
|
||||
assert not Case.objects.filter(org=org_a).exists()
|
||||
assert not EmailMessage.objects.filter(org=org_a).exists()
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = anon.post(url, notification, format="json")
|
||||
assert response.status_code == 200, response.content
|
||||
assert Case.objects.filter(org=org_a).count() == 1
|
||||
|
|
@ -167,11 +167,12 @@ def _row_name(group_by, row, fields):
|
|||
return row[fields[1]] or "No account"
|
||||
|
||||
|
||||
def csv_rows(entries):
|
||||
def csv_rows(entries, show_invoices=True):
|
||||
"""Yield the header, then one list per entry, for ``csv.writer``.
|
||||
|
||||
A generator over ``.iterator()`` so a year of entries streams instead of
|
||||
being built in memory: the caller wraps this in a ``StreamingHttpResponse``.
|
||||
The Invoice column stays, blank, when ``show_invoices`` is false.
|
||||
"""
|
||||
yield list(CSV_COLUMNS)
|
||||
|
||||
|
|
@ -199,5 +200,5 @@ def csv_rows(entries):
|
|||
str(entry.hourly_rate) if entry.hourly_rate is not None else "",
|
||||
entry.currency,
|
||||
value,
|
||||
entry.invoice.invoice_number if entry.invoice_id else "",
|
||||
entry.invoice.invoice_number if entry.invoice_id and show_invoices else "",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from cases.serializer import (
|
|||
from cases.time_reports import TimeReportParamError
|
||||
from common.csv_export import csv_response
|
||||
from common.models import Profile
|
||||
from common.modules import module_is_available
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.renderers import CSV_RENDERERS
|
||||
from common.validators import uuid_param
|
||||
|
|
@ -415,6 +416,7 @@ class TimesheetView(APIView):
|
|||
|
||||
now = timezone.now()
|
||||
running_count = 0
|
||||
show_invoices = module_is_available(request.profile.org, "invoices")
|
||||
for entry in qs:
|
||||
day_key = timezone.localdate(entry.started_at).isoformat()
|
||||
bucket = days.get(day_key)
|
||||
|
|
@ -433,7 +435,7 @@ class TimesheetView(APIView):
|
|||
"id": str(entry.invoice_id),
|
||||
"invoice_number": entry.invoice.invoice_number,
|
||||
}
|
||||
if entry.invoice_id
|
||||
if entry.invoice_id and show_invoices
|
||||
else None
|
||||
)
|
||||
if entry.ended_at is None:
|
||||
|
|
@ -545,7 +547,9 @@ class TimeReportExportView(APIView):
|
|||
return Response({"detail": exc.detail}, status=exc.status_code)
|
||||
|
||||
return csv_response(
|
||||
time_reports.csv_rows(entries),
|
||||
time_reports.csv_rows(
|
||||
entries, module_is_available(request.profile.org, "invoices")
|
||||
),
|
||||
f"time-{start.isoformat()}-to-{end.isoformat()}.csv",
|
||||
request.profile.org,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ from common.models import (
|
|||
Tags,
|
||||
Teams,
|
||||
)
|
||||
from common.module_deletes import delete_refusal
|
||||
from common.modules import only_available
|
||||
from common.permissions import HasOrgContext
|
||||
from common.serializer import (
|
||||
ActivitySerializer,
|
||||
|
|
@ -550,6 +552,9 @@ class CaseDetailView(APIView):
|
|||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
assert_case_delete_access(request.profile, self.object)
|
||||
refusal = delete_refusal(self.object, "This ticket")
|
||||
if refusal:
|
||||
return Response(refusal, status=status.HTTP_409_CONFLICT)
|
||||
self.object.delete()
|
||||
return Response(
|
||||
{"error": False, "message": "Case Deleted Successfully."},
|
||||
|
|
@ -646,7 +651,11 @@ class CaseDetailView(APIView):
|
|||
public_comments = comments_qs.filter(is_internal=False)
|
||||
internal_notes = comments_qs.filter(is_internal=True)
|
||||
|
||||
linked_solutions = self.cases.solutions.filter(org=self.request.profile.org)
|
||||
linked_solutions = only_available(
|
||||
request.profile.org,
|
||||
"knowledge_base",
|
||||
self.cases.solutions.filter(org=request.profile.org),
|
||||
)
|
||||
|
||||
recent_activities = Activity.objects.filter(
|
||||
entity_type="Case",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ class SecurityAuditLog(BaseModel):
|
|||
("API_TOKEN_CREATED", "API Token Created"),
|
||||
("API_TOKEN_REVOKED", "API Token Revoked"),
|
||||
("FEATURE_GRANT_CHANGED", "Feature Grant Changed"),
|
||||
("MODULES_CHANGED", "Modules Changed"),
|
||||
)
|
||||
|
||||
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
||||
|
|
@ -464,6 +465,37 @@ class AuditLogger:
|
|||
},
|
||||
)
|
||||
|
||||
def modules_changed(
|
||||
self, user, org, switched_off, switched_on, reordered, request=None
|
||||
):
|
||||
"""Log an admin switching modules off or on, or reordering them."""
|
||||
from common.modules import KEYS, module_label
|
||||
|
||||
def named(keys):
|
||||
return ", ".join(module_label(org, k) for k in keys)
|
||||
|
||||
off = [k for k in KEYS if k in switched_off]
|
||||
on = [k for k in KEYS if k in switched_on]
|
||||
parts = []
|
||||
if off:
|
||||
parts.append(f"Switched off {named(off)}")
|
||||
if on:
|
||||
parts.append(f"Switched on {named(on)}")
|
||||
if reordered:
|
||||
parts.append("Reordered modules")
|
||||
self._log(
|
||||
"MODULES_CHANGED",
|
||||
user=user,
|
||||
org=org,
|
||||
description="; ".join(parts),
|
||||
metadata={
|
||||
"switched_off": off,
|
||||
"switched_on": on,
|
||||
"reordered": bool(reordered),
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def calendar_feed(self, event_type, user, org, request=None):
|
||||
"""Log a member enabling, regenerating or disabling their own task
|
||||
calendar feed (G14). ``event_type`` is one of the three
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import logging
|
|||
from django.db import connection
|
||||
from django.http import JsonResponse
|
||||
|
||||
from common.modules import route_refusal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -192,6 +194,10 @@ class RequireOrgContext:
|
|||
status=403,
|
||||
)
|
||||
|
||||
refusal = route_refusal(request, match)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
|
||||
# Set org context
|
||||
self._set_org_context(request)
|
||||
|
||||
|
|
|
|||
61
backend/common/migrations/0055_org_modules.py
Normal file
61
backend/common/migrations/0055_org_modules.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Generated by Django 6.1.2 on 2026-10-23 01:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("common", "0054_alter_securityauditlog_event_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="org",
|
||||
name="module_order",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="org",
|
||||
name="modules_off",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="org",
|
||||
name="modules_on_at",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="securityauditlog",
|
||||
name="event_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("LOGIN_SUCCESS", "Login Success"),
|
||||
("LOGIN_FAILURE", "Login Failure"),
|
||||
("LOGOUT", "Logout"),
|
||||
("ORG_SWITCH", "Organization Switch"),
|
||||
("TOKEN_REFRESH", "Token Refresh"),
|
||||
("TOKEN_REVOKED", "Token Revoked"),
|
||||
("PERMISSION_DENIED", "Permission Denied"),
|
||||
("CROSS_ORG_ATTEMPT", "Cross-Org Access Attempt"),
|
||||
("API_KEY_USED", "API Key Used"),
|
||||
("API_KEY_INVALID", "Invalid API Key"),
|
||||
("MEMBERSHIP_REVOKED", "Membership Revoked"),
|
||||
("SUSPICIOUS_ACTIVITY", "Suspicious Activity"),
|
||||
("SAMPLE_DATA_CLEARED", "Vertical Pack Sample Data Cleared"),
|
||||
("WEBHOOK_PAUSED", "Webhook Paused"),
|
||||
("WEBHOOK_REENABLED", "Webhook Re-enabled"),
|
||||
("WEBHOOK_CHANGED", "Webhook Destination Changed"),
|
||||
("RECORD_MERGED", "Record Merged"),
|
||||
("CALENDAR_FEED_ENABLED", "Calendar Feed Enabled"),
|
||||
("CALENDAR_FEED_REGENERATED", "Calendar Feed Regenerated"),
|
||||
("CALENDAR_FEED_DISABLED", "Calendar Feed Disabled"),
|
||||
("API_TOKEN_CREATED", "API Token Created"),
|
||||
("API_TOKEN_REVOKED", "API Token Revoked"),
|
||||
("FEATURE_GRANT_CHANGED", "Feature Grant Changed"),
|
||||
("MODULES_CHANGED", "Modules Changed"),
|
||||
],
|
||||
db_index=True,
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -178,6 +178,12 @@ class Org(BaseModel):
|
|||
help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}.",
|
||||
)
|
||||
|
||||
# Modules: written only by `common.module_settings.change_modules`, and no
|
||||
# serializer exposes them.
|
||||
modules_off = models.JSONField(default=list, blank=True)
|
||||
module_order = models.JSONField(default=list, blank=True)
|
||||
modules_on_at = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Public help center at `/help-center/<slug>` on the app frontend. Off by default:
|
||||
# it publishes the org's approved, published articles to anyone, search
|
||||
# engines included, so an admin opts in. The slug is NULL until chosen
|
||||
|
|
|
|||
124
backend/common/module_deletes.py
Normal file
124
backend/common/module_deletes.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Deletes that would reach an off module's records answer 409 instead.
|
||||
|
||||
While a module is off its records are out of sight, so deleting an account,
|
||||
ticket or person that would cascade into them, or is blocked by them, names
|
||||
them rather than destroying them. ``common/tests/test_module_deletes.py`` walks
|
||||
every model, so a new link into a module cannot be left out of ``DELETE_GUARDS``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
|
||||
from common.modules import (
|
||||
Requires,
|
||||
available_modules,
|
||||
model_module,
|
||||
module,
|
||||
module_label,
|
||||
org_of,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Guard:
|
||||
child: str
|
||||
field: str
|
||||
noun: str
|
||||
plural: str
|
||||
|
||||
|
||||
DELETE_GUARDS: dict[str, tuple[Guard, ...]] = {
|
||||
"accounts.Account": (
|
||||
Guard("cases.Case", "account", "ticket", "tickets"),
|
||||
Guard("opportunity.Opportunity", "account", "deal", "deals"),
|
||||
Guard("invoices.Invoice", "account", "invoice", "invoices"),
|
||||
Guard("invoices.Estimate", "account", "estimate", "estimates"),
|
||||
Guard(
|
||||
"invoices.RecurringInvoice",
|
||||
"account",
|
||||
"recurring invoice",
|
||||
"recurring invoices",
|
||||
),
|
||||
),
|
||||
"cases.Case": (Guard("cases.TimeEntry", "case", "time entry", "time entries"),),
|
||||
"common.Profile": (
|
||||
Guard("cases.TimeEntry", "profile", "time entry", "time entries"),
|
||||
Guard("cases.Approval", "requested_by", "approval", "approvals"),
|
||||
Guard("cases.Approval", "approver", "approval", "approvals"),
|
||||
Guard("cases.CaseWatcher", "profile", "watched ticket", "watched tickets"),
|
||||
Guard("macros.Macro", "owner", "saved reply", "saved replies"),
|
||||
Guard("tasks.Board", "owner", "board", "boards"),
|
||||
Guard("tasks.BoardMember", "profile", "board membership", "board memberships"),
|
||||
),
|
||||
}
|
||||
|
||||
# The models whose delete views call `delete_refusal`. Any other key above is
|
||||
# consulted only when deleting one of these cascades into it.
|
||||
ENTRY_POINTS = frozenset({"accounts.Account", "cases.Case", "common.Profile"})
|
||||
|
||||
|
||||
def _blocking(guard, available):
|
||||
"""The module keeping ``guard.child`` out of sight: its need first, so a
|
||||
time entry hidden by Tickets names Tickets."""
|
||||
key = model_module(guard.child)
|
||||
needs = module(key).needs
|
||||
return Requires((needs, key) if needs else (key,)).blocking(available)
|
||||
|
||||
|
||||
def _rows(guard, parent):
|
||||
"""``parent`` is the record being deleted, or a queryset of rows its
|
||||
delete cascades into."""
|
||||
lookup = guard.field if isinstance(parent, models.Model) else f"{guard.field}__in"
|
||||
return apps.get_model(guard.child)._base_manager.filter(**{lookup: parent})
|
||||
|
||||
|
||||
def cascades(guard) -> bool:
|
||||
"""Whether deleting the parent deletes ``guard``'s rows, in Django or in
|
||||
the database."""
|
||||
field = apps.get_model(guard.child)._meta.get_field(guard.field)
|
||||
return field.remote_field.on_delete in (models.CASCADE, models.DB_CASCADE)
|
||||
|
||||
|
||||
def _refusal(org, available, label, parent, subject):
|
||||
guards = DELETE_GUARDS.get(label, ())
|
||||
for guard in guards:
|
||||
key = _blocking(guard, available)
|
||||
if key is None:
|
||||
continue
|
||||
count = _rows(guard, parent).count()
|
||||
if count:
|
||||
return _body(org, key, subject, guard, count)
|
||||
# Rows in an available module that go with the delete can carry rows of an
|
||||
# off one: an account's tickets take their time entries with them.
|
||||
for guard in guards:
|
||||
if _blocking(guard, available) is None and cascades(guard):
|
||||
found = _refusal(org, available, guard.child, _rows(guard, parent), subject)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _body(org, key, subject, guard, count):
|
||||
label = module_label(org, key)
|
||||
if count == 1:
|
||||
message = (
|
||||
f"{subject} has 1 {guard.noun}. {label} is switched off, so switch it "
|
||||
"on to review it before deleting."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"{subject} has {count} {guard.plural}. {label} is switched off, so "
|
||||
"switch it on to review them before deleting."
|
||||
)
|
||||
return {"error": True, "errors": message, "code": "module_off", "module": key}
|
||||
|
||||
|
||||
def delete_refusal(instance, subject: str) -> dict | None:
|
||||
"""The 409 body when deleting ``instance`` would reach records of a module
|
||||
that is off for its org, else None."""
|
||||
org = org_of(instance)
|
||||
return _refusal(
|
||||
org, available_modules(org), instance._meta.label, instance, subject
|
||||
)
|
||||
180
backend/common/module_settings.py
Normal file
180
backend/common/module_settings.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Reading and changing an org's modules (``GET`` and ``PUT /api/org/modules/``).
|
||||
|
||||
``change_modules`` is the only writer of ``Org.modules_off``, ``module_order``
|
||||
and ``modules_on_at``.
|
||||
"""
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from common.audit_log import audit_log
|
||||
from common.modules import (
|
||||
KEYS,
|
||||
available_modules,
|
||||
enabled_modules,
|
||||
module,
|
||||
module_label,
|
||||
ordered_modules,
|
||||
)
|
||||
from common.permissions import is_org_admin
|
||||
|
||||
# Module key to the dotted path of a ``fn(org) -> None`` run when that module
|
||||
# becomes available again, so it starts fresh.
|
||||
START_FRESH: dict[str, str] = {
|
||||
"invoices": "invoices.schedules.start_fresh",
|
||||
"goals": "opportunity.tasks.settle_goal_milestones",
|
||||
}
|
||||
|
||||
_NAMES_SHOWN = 5
|
||||
|
||||
|
||||
def modules_payload(org, profile) -> dict:
|
||||
available = available_modules(org)
|
||||
enabled = enabled_modules(org)
|
||||
rows = []
|
||||
for m in ordered_modules(org):
|
||||
label = module_label(org, m.key)
|
||||
rows.append(
|
||||
{
|
||||
"key": m.key,
|
||||
"label": label,
|
||||
"group": m.group,
|
||||
"covers": m.covers,
|
||||
"stops": m.stops.format(label=label),
|
||||
"enabled": m.key in enabled,
|
||||
"available": m.key in available,
|
||||
"needs": m.needs,
|
||||
}
|
||||
)
|
||||
return {"modules": rows, "can_edit": is_org_admin(profile)}
|
||||
|
||||
|
||||
def _key_list(data, field, errors):
|
||||
if field not in data:
|
||||
errors[field] = ["This field is required."]
|
||||
return None
|
||||
value = data[field]
|
||||
if not isinstance(value, list) or not all(isinstance(k, str) for k in value):
|
||||
errors[field] = ["Send a list of module keys."]
|
||||
return None
|
||||
unknown = [f'Unknown module: "{k}".' for k in value if k not in KEYS]
|
||||
if unknown:
|
||||
errors[field] = unknown
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _named(names):
|
||||
shown = ", ".join(f'"{n}"' for n in names[:_NAMES_SHOWN])
|
||||
extra = len(names) - _NAMES_SHOWN
|
||||
return f"{shown} and {extra} more" if extra > 0 else shown
|
||||
|
||||
|
||||
def _blockers(row, key):
|
||||
"""Why ``key`` cannot become unavailable now: what still receives input."""
|
||||
from cases.models import InboundMailbox
|
||||
from webforms.models import WebForm
|
||||
|
||||
label = module_label(row, key)
|
||||
messages = []
|
||||
target = {v: k for k, v in WebForm.TARGET_MODULES.items()}.get(key)
|
||||
if target:
|
||||
forms = list(
|
||||
WebForm.objects.filter(org=row, is_published=True, target=target)
|
||||
.order_by("name")
|
||||
.values_list("name", flat=True)
|
||||
)
|
||||
if len(forms) == 1:
|
||||
messages.append(
|
||||
f"{label} still receives submissions from the published web form "
|
||||
f'"{forms[0]}". Unpublish it first, so no submission is lost.'
|
||||
)
|
||||
elif forms:
|
||||
messages.append(
|
||||
f"{label} still receives submissions from {len(forms)} published "
|
||||
f"web forms: {_named(forms)}. Unpublish them first, so no "
|
||||
"submission is lost."
|
||||
)
|
||||
if key == "tickets":
|
||||
addresses = list(
|
||||
InboundMailbox.objects.filter(org=row, is_active=True)
|
||||
.order_by("address")
|
||||
.values_list("address", flat=True)
|
||||
)
|
||||
if len(addresses) == 1:
|
||||
messages.append(
|
||||
f"{label} still receives email at {addresses[0]}. Switch that "
|
||||
"mailbox off first, so no email is lost."
|
||||
)
|
||||
elif addresses:
|
||||
shown = ", ".join(addresses[:_NAMES_SHOWN])
|
||||
extra = len(addresses) - _NAMES_SHOWN
|
||||
if extra > 0:
|
||||
shown += f" and {extra} more"
|
||||
messages.append(
|
||||
f"{label} still receives email at {len(addresses)} mailboxes: "
|
||||
f"{shown}. Switch them off first, so no email is lost."
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
def change_modules(org, actor, data, request=None) -> dict[str, list[str]]:
|
||||
"""Validate and apply ``data``; ``{}`` once saved, else the errors, with
|
||||
nothing written."""
|
||||
from common.models import Org
|
||||
|
||||
errors: dict[str, list[str]] = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
off = _key_list(data, "off", errors)
|
||||
order = _key_list(data, "order", errors)
|
||||
if order is not None and len(set(order)) != len(order):
|
||||
errors["order"] = ["Name each module once."]
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
with transaction.atomic():
|
||||
row = Org.objects.select_for_update().get(pk=org.pk)
|
||||
before = available_modules(row)
|
||||
was_off = set(KEYS) - enabled_modules(row)
|
||||
order_before = [m.key for m in ordered_modules(row)]
|
||||
row.modules_off = [k for k in KEYS if k in set(off)]
|
||||
row.module_order = list(order)
|
||||
after = available_modules(row)
|
||||
now_off = set(row.modules_off)
|
||||
|
||||
problems = []
|
||||
for key in KEYS:
|
||||
needs = module(key).needs
|
||||
if key in was_off - now_off and needs in now_off:
|
||||
problems.append(
|
||||
f"{module_label(row, key)} needs {module_label(row, needs)}. "
|
||||
f"Switch {module_label(row, needs)} on first."
|
||||
)
|
||||
for key in KEYS:
|
||||
if key in before - after:
|
||||
problems += _blockers(row, key)
|
||||
if problems:
|
||||
errors["off"] = problems
|
||||
return errors
|
||||
|
||||
now = timezone.now().isoformat()
|
||||
stamps = dict(row.modules_on_at) if isinstance(row.modules_on_at, dict) else {}
|
||||
for key in after - before:
|
||||
stamps[key] = now
|
||||
row.modules_on_at = stamps
|
||||
row.save(update_fields=["modules_off", "module_order", "modules_on_at"])
|
||||
|
||||
for key in KEYS:
|
||||
if key in after - before and key in START_FRESH:
|
||||
import_string(START_FRESH[key])(row)
|
||||
|
||||
switched_off = now_off - was_off
|
||||
switched_on = was_off - now_off
|
||||
reordered = [m.key for m in ordered_modules(row)] != order_before
|
||||
if switched_off or switched_on or reordered:
|
||||
audit_log.modules_changed(
|
||||
actor.user, row, switched_off, switched_on, reordered, request=request
|
||||
)
|
||||
return {}
|
||||
394
backend/common/modules.py
Normal file
394
backend/common/modules.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
"""The module catalogue: nine parts of the CRM an org admin can switch off.
|
||||
|
||||
State lives in three ``Org`` fields (``modules_off``, ``module_order``,
|
||||
``modules_on_at``). Everything here reads them defensively: unknown or
|
||||
malformed stored values are ignored, never raised on.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import crum
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Module:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
covers: str
|
||||
stops: str
|
||||
term: str | None = None
|
||||
needs: str | None = None
|
||||
|
||||
|
||||
GROUPS = ("sell", "serve", "bill")
|
||||
|
||||
MODULES = (
|
||||
Module(
|
||||
key="deals",
|
||||
label="Pipeline",
|
||||
term="opportunity.plural",
|
||||
group="sell",
|
||||
covers="Deals, deal pipelines, line items, the deal board and stage aging.",
|
||||
stops=(
|
||||
"Everything in {label} leaves every menu, search and Today, and stale-deal emails stop. "
|
||||
"Converting a lead no longer creates a deal, and Goals switches off too."
|
||||
),
|
||||
),
|
||||
Module(
|
||||
key="leads",
|
||||
label="Leads",
|
||||
term="lead.plural",
|
||||
group="sell",
|
||||
covers="Leads, lead pipelines, and lead import and export.",
|
||||
stops="{label} leave every menu, search and Today.",
|
||||
),
|
||||
Module(
|
||||
key="goals",
|
||||
label="Goals",
|
||||
group="sell",
|
||||
needs="deals",
|
||||
covers="Sales goals, the leaderboard and goal history.",
|
||||
stops="{label} leave every menu, and goal milestone emails stop.",
|
||||
),
|
||||
Module(
|
||||
key="tasks",
|
||||
label="Tasks",
|
||||
group="serve",
|
||||
covers="Tasks, boards, the task calendar and its calendar feed.",
|
||||
stops=(
|
||||
"{label} leave every menu and Today, calendar feeds stop updating, "
|
||||
"and automation rules skip their Create a task steps."
|
||||
),
|
||||
),
|
||||
Module(
|
||||
key="tickets",
|
||||
label="Tickets",
|
||||
group="serve",
|
||||
covers=(
|
||||
"Tickets, approvals, ticket analytics, routing, escalation, the reopen "
|
||||
"policy, business hours, saved replies, inbound email, CSAT and portal "
|
||||
"tickets."
|
||||
),
|
||||
stops=(
|
||||
"{label} leave every menu and Today, SLA escalation stops, CSAT survey "
|
||||
"links stop opening, and customers stop seeing their tickets in the "
|
||||
"portal. Timesheet switches off too."
|
||||
),
|
||||
),
|
||||
Module(
|
||||
key="knowledge_base",
|
||||
label="Knowledge base",
|
||||
group="serve",
|
||||
covers="Articles, the public help center and portal articles.",
|
||||
stops=(
|
||||
"Articles leave every menu, the public help center goes offline, and "
|
||||
"the portal stops showing articles."
|
||||
),
|
||||
),
|
||||
Module(
|
||||
key="documents",
|
||||
label="Documents",
|
||||
group="serve",
|
||||
covers="Documents and their downloads.",
|
||||
stops="{label} leave every menu, and their downloads stop opening.",
|
||||
),
|
||||
Module(
|
||||
key="invoices",
|
||||
label="Invoices",
|
||||
term="invoice.plural",
|
||||
group="bill",
|
||||
covers=(
|
||||
"Invoices, estimates, recurring invoices, invoice templates, invoice "
|
||||
"reports, account statements, and public invoice and estimate links."
|
||||
),
|
||||
stops=(
|
||||
"Recurring invoices stop being sent, and links you have already sent to "
|
||||
"customers stop opening. Recurring invoices that fall due while {label} "
|
||||
"is off are not sent later. Payment reminders that fall due while "
|
||||
"{label} is off are not sent later."
|
||||
),
|
||||
),
|
||||
Module(
|
||||
key="timesheet",
|
||||
label="Timesheet",
|
||||
group="bill",
|
||||
needs="tickets",
|
||||
covers="Time entries, the timesheet and the time report.",
|
||||
stops=(
|
||||
"Time entries leave every menu and ticket, and running timers are no "
|
||||
"longer stopped automatically."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
KEYS = tuple(m.key for m in MODULES)
|
||||
_BY_KEY = {m.key: m for m in MODULES}
|
||||
|
||||
_LEADS = ("Lead", "LeadPipeline", "LeadStage")
|
||||
_DEALS = ("DealPipeline", "DealStage", "Opportunity", "OpportunityLineItem")
|
||||
_TASKS = (
|
||||
"Board",
|
||||
"BoardMember",
|
||||
"BoardColumn",
|
||||
"BoardTask",
|
||||
"TaskPipeline",
|
||||
"TaskStage",
|
||||
"Task",
|
||||
)
|
||||
_CASES = (
|
||||
"Case", "CaseWatcher", "CsatSurvey", "CasePipeline", "CaseStage", "ReopenPolicy",
|
||||
"EscalationPolicy", "InboundMailbox", "EmailMessage", "RoutingRule",
|
||||
"RoutingRuleState", "ApprovalRule", "Approval",
|
||||
) # fmt: skip
|
||||
_INVOICES = (
|
||||
"InvoiceTemplate", "Invoice", "InvoiceLineItem", "Payment", "Estimate",
|
||||
"EstimateLineItem", "RecurringInvoice", "RecurringInvoiceLineItem",
|
||||
"InvoiceHistory",
|
||||
) # fmt: skip
|
||||
|
||||
# invoices.Product is absent on purpose: it follows deals or invoices.
|
||||
MODEL_MODULES: dict[str, str] = {
|
||||
**{f"leads.{n}": "leads" for n in _LEADS},
|
||||
**{f"opportunity.{n}": "deals" for n in _DEALS},
|
||||
"opportunity.SalesGoal": "goals",
|
||||
**{f"tasks.{n}": "tasks" for n in _TASKS},
|
||||
**{f"cases.{n}": "tickets" for n in _CASES},
|
||||
"cases.Solution": "knowledge_base",
|
||||
"cases.TimeEntry": "timesheet",
|
||||
"business_hours.BusinessCalendar": "tickets",
|
||||
"business_hours.BusinessHoliday": "tickets",
|
||||
"macros.Macro": "tickets",
|
||||
**{f"invoices.{n}": "invoices" for n in _INVOICES},
|
||||
"common.Document": "documents",
|
||||
}
|
||||
|
||||
|
||||
def module(key):
|
||||
"""The catalogue entry for ``key``; ``KeyError`` when unknown."""
|
||||
return _BY_KEY[key]
|
||||
|
||||
|
||||
def model_module(label: str) -> str | None:
|
||||
return MODEL_MODULES.get(label)
|
||||
|
||||
|
||||
def enabled_modules(org) -> frozenset[str]:
|
||||
off = org.modules_off
|
||||
stored = {k for k in off if isinstance(k, str)} if isinstance(off, list) else set()
|
||||
return frozenset(KEYS) - stored
|
||||
|
||||
|
||||
def available_modules(org) -> frozenset[str]:
|
||||
enabled = enabled_modules(org)
|
||||
return frozenset(
|
||||
k for k in enabled if _BY_KEY[k].needs is None or _BY_KEY[k].needs in enabled
|
||||
)
|
||||
|
||||
|
||||
def module_is_available(org, key: str) -> bool:
|
||||
return key in available_modules(org)
|
||||
|
||||
|
||||
def module_label(org, key) -> str:
|
||||
m = _BY_KEY[key]
|
||||
terms = org.terminology
|
||||
if m.term and isinstance(terms, dict):
|
||||
value = terms.get(m.term)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return m.label
|
||||
|
||||
|
||||
def module_off_message(org, key) -> str:
|
||||
return f"{module_label(org, key)} is switched off for this organization."
|
||||
|
||||
|
||||
def module_off_body(org, key) -> dict:
|
||||
return {
|
||||
"detail": module_off_message(org, key),
|
||||
"code": "module_off",
|
||||
"module": key,
|
||||
}
|
||||
|
||||
|
||||
def module_off_response(org, key) -> Response:
|
||||
return Response(module_off_body(org, key), status=404)
|
||||
|
||||
|
||||
def ordered_modules(org) -> list[Module]:
|
||||
stored = org.module_order if isinstance(org.module_order, list) else []
|
||||
result = []
|
||||
for group in GROUPS:
|
||||
members = [m for m in MODULES if m.group == group]
|
||||
first = [
|
||||
_BY_KEY[k]
|
||||
for k in dict.fromkeys(k for k in stored if isinstance(k, str))
|
||||
if k in _BY_KEY and _BY_KEY[k].group == group
|
||||
]
|
||||
result += first + [m for m in members if m not in first]
|
||||
return result
|
||||
|
||||
|
||||
def module_on_since(org, key) -> datetime | None:
|
||||
stamps = org.modules_on_at
|
||||
raw = stamps.get(key) if isinstance(stamps, dict) else None
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
when = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def only_available(org, key, queryset):
|
||||
return queryset if module_is_available(org, key) else queryset.none()
|
||||
|
||||
|
||||
def org_of(record):
|
||||
"""The request's org when it is the record's, so a serializer does not
|
||||
query once per row."""
|
||||
request = crum.get_current_request()
|
||||
org = getattr(getattr(request, "profile", None), "org", None)
|
||||
if org is not None and org.id == record.org_id:
|
||||
return org
|
||||
return record.org
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Requires:
|
||||
"""The modules a route needs. ``all_of`` lists the needed module first, so
|
||||
the refusal names the module that is actually off."""
|
||||
|
||||
all_of: tuple[str, ...] = ()
|
||||
any_of: tuple[str, ...] = ()
|
||||
|
||||
def blocking(self, available: frozenset[str]) -> str | None:
|
||||
for key in self.all_of:
|
||||
if key not in available:
|
||||
return key
|
||||
if self.any_of and not available.intersection(self.any_of):
|
||||
return self.any_of[0]
|
||||
return None
|
||||
|
||||
|
||||
CORE = Requires()
|
||||
|
||||
NAMESPACE_MODULES: dict[str, Requires] = {
|
||||
"api_leads": Requires(("leads",)),
|
||||
"api_opportunities": Requires(("deals",)),
|
||||
"api_tasks": Requires(("tasks",)),
|
||||
"api_boards": Requires(("tasks",)),
|
||||
"api_cases": Requires(("tickets",)),
|
||||
"api_business_hours": Requires(("tickets",)),
|
||||
"api_macros": Requires(("tickets",)),
|
||||
"api_time_entries": Requires(("tickets", "timesheet")),
|
||||
"api_invoices": Requires(("invoices",)),
|
||||
"public_help": Requires(("knowledge_base",)),
|
||||
"public_invoices": Requires(("invoices",)),
|
||||
"api_accounts": CORE,
|
||||
"api_contacts": CORE,
|
||||
"api_webforms": CORE,
|
||||
"api_webhooks": CORE,
|
||||
"api_automation": CORE,
|
||||
"public_webforms": CORE,
|
||||
}
|
||||
|
||||
# Keyed by ``ResolverMatch.route`` prefix; the longest match wins over the
|
||||
# namespace. ``common_urls``, ``api_common`` and ``portal`` mix core and module
|
||||
# routes, so they have no namespace entry: each of their roots is listed here,
|
||||
# and a new root there stays unclassified.
|
||||
ROUTE_MODULES: dict[str, Requires] = {
|
||||
"api/opportunities/goals/": Requires(("deals", "goals")),
|
||||
"api/cases/solutions/": Requires(("knowledge_base",)),
|
||||
"api/cases/<uid:pk>/solutions/": Requires(("tickets", "knowledge_base")),
|
||||
"api/cases/<uid:pk>/solution-suggestions/": Requires(("tickets", "knowledge_base")),
|
||||
"api/cases/<uid:pk>/time-entries/": Requires(("tickets", "timesheet")),
|
||||
"api/cases/<uid:pk>/time-summary/": Requires(("tickets", "timesheet")),
|
||||
"api/invoices/products/": Requires(any_of=("invoices", "deals")),
|
||||
"api/invoices/from-opportunity/": Requires(("invoices", "deals")),
|
||||
"api/invoices/from-time-entries/": Requires(("invoices", "tickets", "timesheet")),
|
||||
"api/documents/": Requires(("documents",)),
|
||||
"api/profile/calendar-feed/": Requires(("tasks",)),
|
||||
"api/public/calendar/": Requires(("tasks",)),
|
||||
"api/public/csat/": Requires(("tickets",)),
|
||||
"api/org/help-center/": Requires(("knowledge_base",)),
|
||||
"api/portal/cases/": Requires(("tickets",)),
|
||||
"api/portal/articles/": Requires(("knowledge_base",)),
|
||||
"api/portal/login/": CORE,
|
||||
"api/schema/": CORE,
|
||||
"api/activities/": CORE,
|
||||
"api/api-settings/": CORE,
|
||||
"api/attachments/": CORE,
|
||||
"api/auth/": CORE,
|
||||
"api/custom-fields/": CORE,
|
||||
"api/dashboard/": CORE,
|
||||
"api/notifications/": CORE,
|
||||
"api/org/": CORE,
|
||||
"api/packs/": CORE,
|
||||
"api/profile/": CORE,
|
||||
"api/saved-views/": CORE,
|
||||
"api/search/": CORE,
|
||||
"api/tags/": CORE,
|
||||
"api/teams/": CORE,
|
||||
"api/user/": CORE,
|
||||
"api/users/": CORE,
|
||||
}
|
||||
|
||||
|
||||
def requirement_for(route: str, namespaces: Sequence[str]) -> Requires | None:
|
||||
"""What ``route`` needs: the longest ``ROUTE_MODULES`` prefix it starts
|
||||
with, else the entry for its innermost namespace, else None (unclassified).
|
||||
|
||||
Outer namespaces are never consulted, so an app mounted under a new
|
||||
namespace inside ``common_urls`` stays unclassified instead of turning core.
|
||||
"""
|
||||
prefixes = [p for p in ROUTE_MODULES if route.startswith(p)]
|
||||
if prefixes:
|
||||
return ROUTE_MODULES[max(prefixes, key=len)]
|
||||
return NAMESPACE_MODULES.get(namespaces[-1]) if namespaces else None
|
||||
|
||||
|
||||
# Module routes ``route_refusal`` never sees (exempt paths, the portal, exempt
|
||||
# views); each checks its module in its view.
|
||||
VIEW_CHECKED_ROUTES: frozenset[str] = frozenset(
|
||||
{
|
||||
"api/public/csat/<str:token>/",
|
||||
"api/public/help/<str:slug>/",
|
||||
"api/public/help/<str:slug>/articles/<uid:pk>/",
|
||||
"api/public/invoice/<str:token>/",
|
||||
"api/public/invoice/<str:token>/pdf/",
|
||||
"api/public/estimate/<str:token>/",
|
||||
"api/public/estimate/<str:token>/pdf/",
|
||||
"api/public/estimate/<str:token>/accept/",
|
||||
"api/public/estimate/<str:token>/decline/",
|
||||
"api/public/calendar/<str:token>.ics",
|
||||
"api/org/help-center/",
|
||||
"api/portal/articles/",
|
||||
"api/portal/articles/suggest/",
|
||||
"api/portal/articles/<uid:pk>/",
|
||||
"api/portal/cases/",
|
||||
"api/portal/cases/<uid:pk>/",
|
||||
"api/portal/cases/<uid:pk>/comment/",
|
||||
"api/cases/inbound/<uid:mailbox_id>/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def route_refusal(request, match) -> JsonResponse | None:
|
||||
"""The 404 for a signed-in request (session, token or org key) to a route
|
||||
whose module is off. The portal carries an org but no profile, so it and
|
||||
anonymous callers pass here and are checked in their views."""
|
||||
org = getattr(request, "org", None)
|
||||
if org is None or getattr(request, "profile", None) is None:
|
||||
return None
|
||||
requires = requirement_for(match.route, match.namespaces)
|
||||
key = requires.blocking(available_modules(org)) if requires else None
|
||||
if key is None:
|
||||
return None
|
||||
return JsonResponse(module_off_body(org, key), status=404)
|
||||
|
|
@ -20,6 +20,8 @@ from dataclasses import dataclass
|
|||
from django.apps import apps
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from common.modules import model_module, module_is_available
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectEntry:
|
||||
|
|
@ -39,6 +41,10 @@ class ObjectEntry:
|
|||
def model(self):
|
||||
return apps.get_model(self.model_label)
|
||||
|
||||
@property
|
||||
def module(self):
|
||||
return model_module(self.model_label)
|
||||
|
||||
def records(self, org):
|
||||
return self.model.objects.filter(org=org)
|
||||
|
||||
|
|
@ -182,8 +188,19 @@ BUILT_IN = (
|
|||
)
|
||||
|
||||
|
||||
def built_in_objects(org):
|
||||
"""BUILT_IN less the entries whose module is not available to `org`."""
|
||||
return tuple(
|
||||
e for e in BUILT_IN if e.module is None or module_is_available(org, e.module)
|
||||
)
|
||||
|
||||
|
||||
def all_objects(org):
|
||||
"""BUILT_IN, then each hook's entries for `org`; no hook runs when org is None."""
|
||||
"""BUILT_IN, then each hook's entries for `org`, less off-module entries.
|
||||
|
||||
No hook runs when org is None, and nothing is dropped. Conflicts are checked
|
||||
over every entry. A hook entry with no `module` (a custom object) stays.
|
||||
"""
|
||||
entries = BUILT_IN
|
||||
if org is not None:
|
||||
extra = [e for hook in _HOOKS for e in hook(org)]
|
||||
|
|
@ -197,7 +214,20 @@ def all_objects(org):
|
|||
if value in seen:
|
||||
raise RegistryConflict(f"{value!r} is registered twice")
|
||||
seen.add(value)
|
||||
return entries
|
||||
if org is None:
|
||||
return entries
|
||||
return tuple(
|
||||
e
|
||||
for e in entries
|
||||
if getattr(e, "module", None) is None or module_is_available(org, e.module)
|
||||
)
|
||||
|
||||
|
||||
def object_module(key=None, *, target=None):
|
||||
"""The module of the built-in entry with this key, or with this custom-field
|
||||
target, read from the unfiltered list."""
|
||||
field, value = ("key", key) if target is None else ("custom_field_target", target)
|
||||
return next((e.module for e in BUILT_IN if getattr(e, field) == value), None)
|
||||
|
||||
|
||||
def register_hook(hook: Callable[[object], Iterable]) -> None:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from django.utils import timezone
|
|||
from automation.capture import suppress_rules
|
||||
from cases.models import CasePipeline, CaseStage
|
||||
from common.models import CustomFieldDefinition, Org, PackApplication, Tags
|
||||
from common.modules import model_module, module_is_available, module_off_message
|
||||
from common.object_registry import object_module
|
||||
from common.packs.schema import (
|
||||
SAMPLE_DATES_KEY,
|
||||
SAMPLE_ENTITY_KEYS,
|
||||
|
|
@ -63,6 +65,10 @@ def _apply_pipelines(org, pack: dict, report: _Report) -> None:
|
|||
if not spec:
|
||||
continue
|
||||
name = spec["name"]
|
||||
module = model_module(PipelineModel._meta.label)
|
||||
if module is not None and not module_is_available(org, module):
|
||||
report.add_skipped(key, name, reason=module_off_message(org, module))
|
||||
continue
|
||||
if PipelineModel.objects.filter(org=org, name=name).exists():
|
||||
# Stages are only created beneath a pipeline we just created, so a
|
||||
# skipped pipeline takes its stages with it. This avoids
|
||||
|
|
@ -99,6 +105,12 @@ def _apply_pipelines(org, pack: dict, report: _Report) -> None:
|
|||
|
||||
def _apply_custom_fields(org, pack: dict, report: _Report) -> None:
|
||||
for field in pack.get("custom_fields") or []:
|
||||
module = object_module(target=field["target"])
|
||||
if module is not None and not module_is_available(org, module):
|
||||
report.add_skipped(
|
||||
"custom_field", field["key"], reason=module_off_message(org, module)
|
||||
)
|
||||
continue
|
||||
exists = CustomFieldDefinition.objects.filter(
|
||||
org=org, target_model=field["target"], key=field["key"]
|
||||
).exists()
|
||||
|
|
@ -135,7 +147,16 @@ def _apply_tags(org, pack: dict, report: _Report) -> None:
|
|||
|
||||
|
||||
def _apply_products(org, pack: dict, report: _Report) -> None:
|
||||
# Products follow deals or invoices; with both off the message names invoices.
|
||||
off = not (
|
||||
module_is_available(org, "invoices") or module_is_available(org, "deals")
|
||||
)
|
||||
for product in pack.get("products") or []:
|
||||
if off:
|
||||
report.add_skipped(
|
||||
"product", product["name"], reason=module_off_message(org, "invoices")
|
||||
)
|
||||
continue
|
||||
# sku is mandatory in a manifest. See schema._validate_product.
|
||||
if Product.objects.filter(org=org, sku=product["sku"]).exists():
|
||||
report.add_skipped("product", product["name"])
|
||||
|
|
@ -248,6 +269,18 @@ def _create_sample(report, type_: str, name: str, build):
|
|||
return obj
|
||||
|
||||
|
||||
def _skip_off_module(report, org, type_: str, name: str, *needs: str) -> bool:
|
||||
"""Report a sample row as skipped when its module, or a module its links
|
||||
need, is off. ``needs`` are further sample types the row points at."""
|
||||
models = dict(_sample_models())
|
||||
for kind in (type_, *needs):
|
||||
key = model_module(models[kind]._meta.label)
|
||||
if key is not None and not module_is_available(org, key):
|
||||
report.add_skipped(type_, name, reason=module_off_message(org, key))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sample_moved_to_today(pack: dict) -> dict:
|
||||
"""A copy of the pack's sample_data with every date moved to the apply day.
|
||||
|
||||
|
|
@ -385,6 +418,8 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
obj.account.contacts.add(obj)
|
||||
|
||||
for spec in sample.get("deals") or []:
|
||||
if _skip_off_module(report, org, "sample_deal", spec["name"]):
|
||||
continue
|
||||
obj = _create_sample(
|
||||
report,
|
||||
"sample_deal",
|
||||
|
|
@ -413,6 +448,8 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
|
||||
case_stages = {s.name: s for s in CaseStage.objects.filter(org=org)}
|
||||
for spec in sample.get("tickets") or []:
|
||||
if _skip_off_module(report, org, "sample_ticket", spec["name"]):
|
||||
continue
|
||||
obj = _create_sample(
|
||||
report,
|
||||
"sample_ticket",
|
||||
|
|
@ -441,6 +478,8 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
lead_stages = {s.name: s for s in LeadStage.objects.filter(org=org)}
|
||||
leads: dict[str, Lead] = {}
|
||||
for spec in sample.get("leads") or []:
|
||||
if _skip_off_module(report, org, "sample_lead", spec["title"]):
|
||||
continue
|
||||
obj = _create_sample(
|
||||
report,
|
||||
"sample_lead",
|
||||
|
|
@ -468,7 +507,15 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None:
|
|||
# Tasks last: they are the only entity that can point at any of the others,
|
||||
# so every parent map is fully populated by the time this runs.
|
||||
task_stages = {s.name: s for s in TaskStage.objects.filter(org=org)}
|
||||
parent_types = {
|
||||
"deal": "sample_deal",
|
||||
"ticket": "sample_ticket",
|
||||
"lead": "sample_lead",
|
||||
}
|
||||
for spec in sample.get("tasks") or []:
|
||||
named = tuple(t for k, t in parent_types.items() if spec.get(k))
|
||||
if _skip_off_module(report, org, "sample_task", spec["title"], *named):
|
||||
continue
|
||||
obj = _create_sample(
|
||||
report,
|
||||
"sample_task",
|
||||
|
|
@ -577,8 +624,10 @@ def clear_sample_data(org, actor=None) -> dict:
|
|||
would raise ProtectedError and abort the entire clear. Any sample row
|
||||
still referenced by a non-sample row is kept and reported as retained.
|
||||
|
||||
Returns {"deleted": {type: n}, "retained": {type: n}}. Retained is a normal
|
||||
outcome, not an error, and the UI is expected to say so.
|
||||
Returns {"deleted": {type: n}, "retained": {type: n}, "switched_off":
|
||||
{type: n}}. Retained is a normal outcome, not an error, and the UI is
|
||||
expected to say so. Rows of a module that is switched off are never deleted
|
||||
and are counted under switched_off instead.
|
||||
|
||||
`actor` is optional (mirrors apply_pack's PackApplication.applied_by) and is
|
||||
used only to attribute the audit record. It plays no part in the delete,
|
||||
|
|
@ -588,6 +637,7 @@ def clear_sample_data(org, actor=None) -> dict:
|
|||
|
||||
deleted: dict[str, int] = {}
|
||||
retained: dict[str, int] = {}
|
||||
switched_off: dict[str, int] = {}
|
||||
# Accumulated across models so a kept child keeps its parent. See
|
||||
# _ids_with_foreign_children.
|
||||
retained_pks: set = set()
|
||||
|
|
@ -596,6 +646,14 @@ def clear_sample_data(org, actor=None) -> dict:
|
|||
for type_, model in reversed(_sample_models()):
|
||||
rows = model.objects.filter(org=org, is_sample=True)
|
||||
ids = list(rows.values_list("pk", flat=True))
|
||||
module = model_module(model._meta.label)
|
||||
if module is not None and not module_is_available(org, module):
|
||||
# An off module's rows are never deleted; keeping them in
|
||||
# retained_pks keeps the sample accounts they point at.
|
||||
if ids:
|
||||
switched_off[type_] = len(ids)
|
||||
retained_pks.update(ids)
|
||||
continue
|
||||
blocked = _ids_with_foreign_children(model, ids, retained_pks)
|
||||
if blocked:
|
||||
retained[type_] = len(blocked)
|
||||
|
|
@ -622,7 +680,7 @@ def clear_sample_data(org, actor=None) -> dict:
|
|||
actor.user if actor is not None else None, org, sum(deleted.values())
|
||||
)
|
||||
|
||||
return {"deleted": deleted, "retained": retained}
|
||||
return {"deleted": deleted, "retained": retained, "switched_off": switched_off}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
|
|
|
|||
|
|
@ -31,12 +31,21 @@ from rest_framework import serializers
|
|||
from accounts.views import account_list_queryset
|
||||
from cases.views import case_list_queryset
|
||||
from common.models import SavedView
|
||||
from common.modules import available_modules, module_off_message
|
||||
from contacts.views import contact_list_queryset
|
||||
from invoices.api_views import filter_invoices
|
||||
from invoices.permissions import visible_invoices_qs
|
||||
from leads.views.lead_views import lead_list_queryset
|
||||
from opportunity.views.opportunity_views import deal_list_queryset
|
||||
|
||||
# The lists that belong to a module an admin can switch off.
|
||||
SAVED_VIEW_MODULES = {
|
||||
"leads": "leads",
|
||||
"opportunities": "deals",
|
||||
"cases": "tickets",
|
||||
"invoices": "invoices",
|
||||
}
|
||||
|
||||
MAX_VIEWS_PER_LIST = 25
|
||||
MAX_PARAMS = 30
|
||||
MAX_VALUES_PER_PARAM = 50
|
||||
|
|
@ -166,6 +175,12 @@ LISTS = {
|
|||
}
|
||||
|
||||
|
||||
def off_lists(org):
|
||||
"""The saved-view lists whose module is switched off for ``org``."""
|
||||
available = available_modules(org)
|
||||
return [name for name, key in SAVED_VIEW_MODULES.items() if key not in available]
|
||||
|
||||
|
||||
def _clean_filters(module, value):
|
||||
"""``value`` as ``{param: [text, ...]}``, or raise with what is wrong.
|
||||
|
||||
|
|
@ -263,6 +278,19 @@ class SavedViewSerializer(serializers.ModelSerializer):
|
|||
{"module": "A saved view cannot move to another list."}
|
||||
)
|
||||
module = attrs.get("module") or instance.module
|
||||
if instance is None and module in off_lists(
|
||||
self.context["request"].profile.org
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"module": [
|
||||
module_off_message(
|
||||
self.context["request"].profile.org,
|
||||
SAVED_VIEW_MODULES[module],
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
if "filters" in attrs:
|
||||
try:
|
||||
attrs["filters"] = _clean_filters(module, attrs["filters"])
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from rest_framework.views import APIView
|
|||
from accounts.access import visible_accounts_qs
|
||||
from cases.access import visible_cases_qs
|
||||
from cases.models import Solution
|
||||
from common.modules import only_available
|
||||
from common.permissions import HasOrgContext
|
||||
from contacts.access import visible_contacts_qs
|
||||
from invoices.permissions import visible_invoices_qs
|
||||
|
|
@ -58,7 +59,7 @@ class GlobalSearchView(APIView):
|
|||
results = []
|
||||
|
||||
# Leads
|
||||
leads = visible_leads_qs(profile).filter(
|
||||
leads = only_available(org, "leads", visible_leads_qs(profile)).filter(
|
||||
Q(title__icontains=q)
|
||||
| Q(first_name__icontains=q)
|
||||
| Q(last_name__icontains=q)
|
||||
|
|
@ -78,7 +79,7 @@ class GlobalSearchView(APIView):
|
|||
|
||||
# Deals (Opportunity)
|
||||
deals = (
|
||||
visible_deals_qs(profile)
|
||||
only_available(org, "deals", visible_deals_qs(profile))
|
||||
.select_related("account")
|
||||
.filter(
|
||||
Q(name__icontains=q)
|
||||
|
|
@ -140,7 +141,7 @@ class GlobalSearchView(APIView):
|
|||
|
||||
# Tickets (Case): the module's own read-visibility helper
|
||||
cases = (
|
||||
visible_cases_qs(profile)
|
||||
only_available(org, "tickets", visible_cases_qs(profile))
|
||||
.select_related("account")
|
||||
.filter(
|
||||
Q(name__icontains=q)
|
||||
|
|
@ -162,7 +163,7 @@ class GlobalSearchView(APIView):
|
|||
|
||||
# Invoices
|
||||
invoices = (
|
||||
visible_invoices_qs(profile)
|
||||
only_available(org, "invoices", visible_invoices_qs(profile))
|
||||
.select_related("account")
|
||||
.filter(
|
||||
Q(invoice_number__icontains=q)
|
||||
|
|
@ -187,9 +188,9 @@ class GlobalSearchView(APIView):
|
|||
)
|
||||
|
||||
# Knowledge base (Solution): org-wide, every member reads
|
||||
solutions = Solution.objects.filter(org=org).filter(
|
||||
Q(title__icontains=q) | Q(description__icontains=q)
|
||||
)[:PER_TYPE]
|
||||
solutions = only_available(
|
||||
org, "knowledge_base", Solution.objects.filter(org=org)
|
||||
).filter(Q(title__icontains=q) | Q(description__icontains=q))[:PER_TYPE]
|
||||
for solution in solutions:
|
||||
results.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ from common.models import (
|
|||
Teams,
|
||||
User,
|
||||
)
|
||||
from common.object_registry import BUILT_IN
|
||||
from common.modules import module_is_available, module_off_message
|
||||
from common.object_registry import BUILT_IN, object_module
|
||||
from common.permissions import is_org_admin
|
||||
from common.utils import CURRENCY_SYMBOLS, validate_upload_size
|
||||
from common.validators import flexible_phone_validator, validate_help_center_slug
|
||||
|
|
@ -507,6 +508,13 @@ class CustomFieldDefinitionSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
read_only_fields = ("id", "created_at", "updated_at")
|
||||
|
||||
def validate_target_model(self, value):
|
||||
org = self.context.get("org")
|
||||
module = object_module(target=value)
|
||||
if org is not None and module and not module_is_available(org, module):
|
||||
raise serializers.ValidationError(module_off_message(org, module))
|
||||
return value
|
||||
|
||||
def validate_key(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("key is required")
|
||||
|
|
|
|||
|
|
@ -201,6 +201,12 @@ def rls_org(org):
|
|||
_apply_rls_context(previous)
|
||||
|
||||
|
||||
def set_modules_off(org, *keys) -> None:
|
||||
"""Switch the named modules off for ``org``, touching nothing else."""
|
||||
org.modules_off = list(keys)
|
||||
org.save(update_fields=["modules_off"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _use_db(db):
|
||||
"""Ensure all tests have database access."""
|
||||
|
|
|
|||
104
backend/common/tests/test_custom_fields_module_off.py
Normal file
104
backend/common/tests/test_custom_fields_module_off.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Custom-field targets follow their module: an off module's fields leave the
|
||||
list and its totals, refuse a new definition, and answer not found."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cases.models import Case
|
||||
from common.models import CustomFieldDefinition
|
||||
from common.testing import set_modules_off
|
||||
|
||||
URL = "/api/custom-fields/"
|
||||
OFF = "Tickets is switched off for this organization."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_fields(org_a):
|
||||
ticket = CustomFieldDefinition.objects.create(
|
||||
org=org_a, target_model="Case", key="sev", label="Sev", field_type="text"
|
||||
)
|
||||
lead = CustomFieldDefinition.objects.create(
|
||||
org=org_a, target_model="Lead", key="src", label="Src", field_type="text"
|
||||
)
|
||||
return ticket, lead
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
body = {
|
||||
"target_model": "Case",
|
||||
"key": "tier",
|
||||
"label": "Tier",
|
||||
"field_type": "text",
|
||||
}
|
||||
return {**body, **overrides}
|
||||
|
||||
|
||||
def test_the_list_leaves_out_off_module_targets(admin_client, org_a, two_fields):
|
||||
ticket, lead = two_fields
|
||||
Case.objects.create(name="One", org=org_a, status="New")
|
||||
body = admin_client.get(URL).json()
|
||||
assert {d["key"] for d in body["definitions"]} == {"sev", "src"}
|
||||
assert body["totals"]["count"] == 2
|
||||
assert body["totals"]["models_extended"] == 2
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
body = admin_client.get(URL).json()
|
||||
assert [d["key"] for d in body["definitions"]] == ["src"]
|
||||
assert body["totals"]["count"] == 1
|
||||
assert body["totals"]["models_extended"] == 1
|
||||
assert str(ticket.id) not in {d["id"] for d in body["definitions"]}
|
||||
assert admin_client.get(URL, {"target_model": "Case"}).json()["definitions"] == []
|
||||
|
||||
|
||||
def test_the_counts_never_touch_an_off_target(
|
||||
admin_client, org_a, two_fields, monkeypatch
|
||||
):
|
||||
from common.views import custom_field_views
|
||||
|
||||
seen = []
|
||||
real = custom_field_views._records_missing_map
|
||||
|
||||
def spy(org, definitions):
|
||||
seen.extend(d.target_model for d in definitions)
|
||||
return real(org, definitions)
|
||||
|
||||
monkeypatch.setattr(custom_field_views, "_records_missing_map", spy)
|
||||
set_modules_off(org_a, "tickets")
|
||||
admin_client.get(URL)
|
||||
assert seen == ["Lead"]
|
||||
|
||||
|
||||
def test_creating_a_field_on_an_off_module_is_refused(admin_client, org_a):
|
||||
ok = admin_client.post(URL, _payload(), format="json")
|
||||
assert ok.status_code == 201
|
||||
set_modules_off(org_a, "tickets")
|
||||
refused = admin_client.post(URL, _payload(key="tier2"), format="json")
|
||||
assert refused.status_code == 400
|
||||
assert OFF in refused.json()["errors"]["target_model"]
|
||||
assert not CustomFieldDefinition.objects.filter(key="tier2").exists()
|
||||
assert (
|
||||
admin_client.post(URL, _payload(target_model="Lead"), format="json").status_code
|
||||
== 201
|
||||
)
|
||||
|
||||
|
||||
def test_a_member_still_cannot_create(user_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert user_client.post(URL, _payload(), format="json").status_code == 403
|
||||
|
||||
|
||||
def test_an_off_module_field_answers_not_found(admin_client, org_a, two_fields):
|
||||
ticket, _ = two_fields
|
||||
url = f"{URL}{ticket.id}/"
|
||||
assert admin_client.get(url).status_code == 200
|
||||
set_modules_off(org_a, "tickets")
|
||||
for response in (
|
||||
admin_client.get(url),
|
||||
admin_client.put(url, {"label": "New"}, format="json"),
|
||||
admin_client.delete(url),
|
||||
):
|
||||
assert response.status_code == 404
|
||||
assert response.json()["errors"] == "Custom field not found"
|
||||
ticket.refresh_from_db()
|
||||
assert (ticket.label, ticket.is_active) == ("Sev", True)
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.get(url).status_code == 200
|
||||
615
backend/common/tests/test_module_deletes.py
Normal file
615
backend/common/tests/test_module_deletes.py
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
"""No delete reaches an off module's records.
|
||||
|
||||
Deleting an account, a ticket or a person cascades into, or is blocked by,
|
||||
records in other modules. While such a module is off the delete answers 409
|
||||
and names what it would reach; with the module on it behaves as it always has.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.db import connection, models
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.approvals import Approval, ApprovalRule
|
||||
from cases.models import Case, CaseWatcher, TimeEntry
|
||||
from common.models import Profile
|
||||
from common.module_deletes import DELETE_GUARDS, ENTRY_POINTS, Guard, cascades
|
||||
from common.modules import model_module
|
||||
from common.testing import set_modules_off
|
||||
from invoices.models import Estimate, Invoice, RecurringInvoice
|
||||
from macros.models import Macro
|
||||
from opportunity.models import Opportunity
|
||||
from tasks.models import Board, BoardMember
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
LEAVE_THE_CHILD = (
|
||||
models.SET_NULL,
|
||||
models.SET_DEFAULT,
|
||||
models.DO_NOTHING,
|
||||
models.DB_SET_NULL,
|
||||
models.DB_SET_DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
def _reaches_child(on_delete):
|
||||
"""Fails closed: any handler but these, one added later included, deletes
|
||||
the child or blocks the delete, so it must be guarded."""
|
||||
if on_delete in LEAVE_THE_CHILD:
|
||||
return False
|
||||
deconstruct = getattr(on_delete, "deconstruct", None)
|
||||
return not (deconstruct and deconstruct()[0] == "django.db.models.SET")
|
||||
|
||||
|
||||
def _cross_module_links():
|
||||
"""Every (parent, child, field) whose delete reaches another module's rows."""
|
||||
links = set()
|
||||
for model in apps.get_models():
|
||||
child = model._meta.label
|
||||
child_module = model_module(child)
|
||||
if child_module is None:
|
||||
continue
|
||||
for field in model._meta.get_fields():
|
||||
if not isinstance(field, models.ForeignKey) or not field.concrete:
|
||||
continue
|
||||
if not _reaches_child(field.remote_field.on_delete):
|
||||
continue
|
||||
parent = field.related_model._meta.label
|
||||
if parent == "common.Org" or model_module(parent) == child_module:
|
||||
continue
|
||||
links.add((parent, child, field.name))
|
||||
return links
|
||||
|
||||
|
||||
def _guarded(guards):
|
||||
return {(parent, g.child, g.field) for parent, row in guards.items() for g in row}
|
||||
|
||||
|
||||
def test_every_cross_module_cascade_is_guarded():
|
||||
"""Exactly the links the walk finds are guarded: a new one fails here by
|
||||
name, and so does a guard that names no such link."""
|
||||
links, guarded = _cross_module_links(), _guarded(DELETE_GUARDS)
|
||||
assert sorted(links - guarded) == []
|
||||
assert sorted(guarded - links) == []
|
||||
|
||||
|
||||
def test_the_walk_can_fail():
|
||||
"""Dropping a guard surfaces its link, so the walk above is not vacuous."""
|
||||
dropped = ("cases.Case", "cases.TimeEntry", "case")
|
||||
assert dropped in _cross_module_links()
|
||||
trimmed = {
|
||||
parent: tuple(g for g in row if (parent, g.child, g.field) != dropped)
|
||||
for parent, row in DELETE_GUARDS.items()
|
||||
}
|
||||
assert sorted(_cross_module_links() - _guarded(trimmed)) == [dropped]
|
||||
|
||||
|
||||
def test_only_the_handlers_that_leave_the_child_are_skipped():
|
||||
for handler in (models.CASCADE, models.DB_CASCADE, models.PROTECT, models.RESTRICT):
|
||||
assert _reaches_child(handler), handler
|
||||
for handler in (*LEAVE_THE_CHILD, models.SET(None), models.SET(lambda: None)):
|
||||
assert not _reaches_child(handler), handler
|
||||
|
||||
|
||||
def test_a_database_cascade_is_followed_like_a_django_one(monkeypatch):
|
||||
guard = DELETE_GUARDS["cases.Case"][0]
|
||||
remote = TimeEntry._meta.get_field("case").remote_field
|
||||
for handler, expected in (
|
||||
(models.CASCADE, True),
|
||||
(models.DB_CASCADE, True),
|
||||
(models.PROTECT, False),
|
||||
(models.SET_NULL, False),
|
||||
):
|
||||
monkeypatch.setattr(remote, "on_delete", handler)
|
||||
assert cascades(guard) is expected, handler
|
||||
|
||||
|
||||
def _unreached(guards, entry_points):
|
||||
"""Guarded models no delete consults: neither an entry point nor reached
|
||||
from one through cascading guards, as `delete_refusal` recurses."""
|
||||
reached, todo = set(entry_points), list(entry_points)
|
||||
while todo:
|
||||
for guard in guards.get(todo.pop(), ()):
|
||||
if guard.child not in reached and cascades(guard):
|
||||
reached.add(guard.child)
|
||||
todo.append(guard.child)
|
||||
return sorted(set(guards) - reached)
|
||||
|
||||
|
||||
def test_every_guarded_model_is_checked_when_deleted():
|
||||
assert _unreached(DELETE_GUARDS, ENTRY_POINTS) == [], (
|
||||
"Nothing consults these models' guards. Call delete_refusal from their "
|
||||
"delete path and add them to ENTRY_POINTS, or extend the recursion so a "
|
||||
"guarded delete cascades into them."
|
||||
)
|
||||
|
||||
|
||||
def test_the_entry_point_check_can_fail():
|
||||
stray = {**DELETE_GUARDS, "contacts.Contact": (Guard("x.Y", "contact", "y", "ys"),)}
|
||||
assert _unreached(stray, ENTRY_POINTS) == ["contacts.Contact"]
|
||||
assert _unreached(DELETE_GUARDS, ENTRY_POINTS - {"common.Profile"}) == [
|
||||
"common.Profile"
|
||||
]
|
||||
# An account's delete cascades into its tickets, so they are reached anyway.
|
||||
assert _unreached(DELETE_GUARDS, ENTRY_POINTS - {"cases.Case"}) == []
|
||||
|
||||
|
||||
# Accounts
|
||||
|
||||
|
||||
@pytest.fixture(params=["admin", "member creator"])
|
||||
def deleter(request, admin_user, regular_user, admin_client, user_client):
|
||||
"""Who deletes the account or ticket: an admin, or a member who made it."""
|
||||
if request.param == "admin":
|
||||
return admin_client, admin_user
|
||||
return user_client, regular_user
|
||||
|
||||
|
||||
def _account(org, creator, name="Acme"):
|
||||
return Account.objects.create(name=name, org=org, created_by=creator)
|
||||
|
||||
|
||||
def _ticket(org, creator=None, account=None, name="Ticket"):
|
||||
return Case.objects.create(
|
||||
name=name,
|
||||
status="New",
|
||||
priority="Normal",
|
||||
org=org,
|
||||
account=account,
|
||||
created_by=creator,
|
||||
)
|
||||
|
||||
|
||||
def _entry(case, profile, minutes_ago=60):
|
||||
started = timezone.now() - timedelta(minutes=minutes_ago)
|
||||
return TimeEntry.objects.create(
|
||||
org=case.org,
|
||||
case=case,
|
||||
profile=profile,
|
||||
started_at=started,
|
||||
ended_at=started + timedelta(minutes=30),
|
||||
)
|
||||
|
||||
|
||||
def test_an_account_with_tickets_is_kept_while_tickets_is_off(org_a, deleter):
|
||||
client, creator = deleter
|
||||
account = _account(org_a, creator)
|
||||
for n in range(3):
|
||||
_ticket(org_a, account=account, name=f"T{n}")
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
"This account has 3 tickets. Tickets is switched off, so switch it "
|
||||
"on to review them before deleting."
|
||||
),
|
||||
"code": "module_off",
|
||||
"module": "tickets",
|
||||
}
|
||||
assert Account.objects.filter(id=account.id).exists()
|
||||
assert Case._base_manager.filter(account=account).count() == 3
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not Account.objects.filter(id=account.id).exists()
|
||||
assert not Case._base_manager.filter(org=org_a).exists()
|
||||
|
||||
|
||||
def test_an_account_with_one_deal_is_kept_while_deals_is_off(org_a, deleter):
|
||||
client, creator = deleter
|
||||
account = _account(org_a, creator)
|
||||
Opportunity.objects.create(name="D", org=org_a, account=account, stage="PROPOSAL")
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["errors"] == (
|
||||
"This account has 1 deal. Pipeline is switched off, so switch it on to "
|
||||
"review it before deleting."
|
||||
)
|
||||
assert response.json()["module"] == "deals"
|
||||
assert Opportunity._base_manager.filter(account=account).count() == 1
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not Opportunity._base_manager.filter(org=org_a).exists()
|
||||
|
||||
|
||||
def test_the_first_guard_in_order_is_the_one_named(admin_client, admin_user, org_a):
|
||||
account = _account(org_a, admin_user)
|
||||
Opportunity.objects.create(name="D", org=org_a, account=account, stage="PROPOSAL")
|
||||
_ticket(org_a, account=account)
|
||||
set_modules_off(org_a, "deals", "tickets")
|
||||
|
||||
response = admin_client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["module"] == "tickets"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "extra", "noun"),
|
||||
[
|
||||
(Invoice, {"invoice_number": "INV-1"}, "invoice"),
|
||||
(Estimate, {"estimate_number": "EST-1"}, "estimate"),
|
||||
(RecurringInvoice, {"title": "Monthly retainer"}, "recurring invoice"),
|
||||
],
|
||||
ids=["invoice", "estimate", "recurring-invoice"],
|
||||
)
|
||||
def test_an_invoiced_account_names_the_off_module(org_a, deleter, model, extra, noun):
|
||||
client, creator = deleter
|
||||
account = _account(org_a, creator)
|
||||
model.objects.create(org=org_a, account=account, **extra)
|
||||
set_modules_off(org_a, "invoices")
|
||||
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
f"This account has 1 {noun}. Invoices is switched off, so switch it "
|
||||
"on to review it before deleting."
|
||||
),
|
||||
"code": "module_off",
|
||||
"module": "invoices",
|
||||
}
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
"This account can't be deleted while it still has invoices, "
|
||||
"estimates or recurring invoices linked to it."
|
||||
),
|
||||
}
|
||||
assert Account.objects.filter(id=account.id).exists()
|
||||
|
||||
|
||||
def test_an_account_whose_tickets_hold_time_entries_is_kept_while_the_timesheet_is_off(
|
||||
admin_client, admin_user, admin_profile, org_a
|
||||
):
|
||||
"""The account's tickets would go with it, and their time entries with them."""
|
||||
account = _account(org_a, admin_user)
|
||||
ticket = _ticket(org_a, account=account)
|
||||
_entry(ticket, admin_profile, minutes_ago=120)
|
||||
_entry(ticket, admin_profile)
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
response = admin_client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["errors"] == (
|
||||
"This account has 2 time entries. Timesheet is switched off, so switch "
|
||||
"it on to review them before deleting."
|
||||
)
|
||||
assert response.json()["module"] == "timesheet"
|
||||
assert TimeEntry.objects.filter(case=ticket).count() == 2
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = admin_client.delete(f"/api/accounts/{account.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not TimeEntry.objects.filter(org=org_a).exists()
|
||||
|
||||
|
||||
# Tickets
|
||||
|
||||
|
||||
def test_a_ticket_with_time_entries_is_kept_while_the_timesheet_is_off(
|
||||
org_a, deleter, admin_profile
|
||||
):
|
||||
client, creator = deleter
|
||||
ticket = _ticket(org_a, creator=creator)
|
||||
_entry(ticket, admin_profile, minutes_ago=120)
|
||||
_entry(ticket, admin_profile)
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
response = client.delete(f"/api/cases/{ticket.id}/")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
"This ticket has 2 time entries. Timesheet is switched off, so switch "
|
||||
"it on to review them before deleting."
|
||||
),
|
||||
"code": "module_off",
|
||||
"module": "timesheet",
|
||||
}
|
||||
assert Case._base_manager.filter(id=ticket.id).exists()
|
||||
assert TimeEntry.objects.filter(case=ticket).count() == 2
|
||||
|
||||
set_modules_off(org_a)
|
||||
response = client.delete(f"/api/cases/{ticket.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not Case._base_manager.filter(id=ticket.id).exists()
|
||||
assert not TimeEntry.objects.filter(org=org_a).exists()
|
||||
|
||||
|
||||
def test_pin_bulk_ticket_delete_keeps_time_entries(admin_client, admin_profile, org_a):
|
||||
"""Pin: the bulk delete is a soft delete, so it reaches no time entry."""
|
||||
ticket = _ticket(org_a)
|
||||
_entry(ticket, admin_profile)
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/cases/bulk/delete/",
|
||||
{"ids": [str(ticket.id)]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted"] == 1
|
||||
ticket.refresh_from_db()
|
||||
assert ticket.is_active is False
|
||||
assert TimeEntry.objects.filter(case=ticket).count() == 1
|
||||
|
||||
|
||||
# People
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def send_email():
|
||||
with patch("common.views.user_views.send_email_user_delete") as mocked:
|
||||
yield mocked
|
||||
|
||||
|
||||
def _person_url(user):
|
||||
return f"/api/user/{user.id}/"
|
||||
|
||||
|
||||
def test_a_person_with_off_module_records_is_kept(
|
||||
admin_client, admin_profile, regular_user, user_profile, org_a, send_email
|
||||
):
|
||||
board = Board.objects.create(org=org_a, name="Launch", owner=user_profile)
|
||||
set_modules_off(org_a, "tasks")
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
"This person has 1 board. Tasks is switched off, so switch it on to "
|
||||
"review it before deleting."
|
||||
),
|
||||
"code": "module_off",
|
||||
"module": "tasks",
|
||||
}
|
||||
assert Profile.objects.filter(id=user_profile.id).exists()
|
||||
assert Board.objects.filter(id=board.id).exists()
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
board.delete()
|
||||
_entry(_ticket(org_a), user_profile)
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["errors"] == (
|
||||
"This person has 1 time entry. Timesheet is switched off, so switch it "
|
||||
"on to review it before deleting."
|
||||
)
|
||||
assert response.json()["module"] == "timesheet"
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
|
||||
def test_a_person_with_time_entries_names_tickets_while_tickets_is_off(
|
||||
admin_client, regular_user, user_profile, org_a, send_email
|
||||
):
|
||||
"""The timesheet is unavailable because Tickets is off, so Tickets is what
|
||||
the admin has to switch on."""
|
||||
_entry(_ticket(org_a), user_profile)
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["errors"] == (
|
||||
"This person has 1 time entry. Tickets is switched off, so switch it on "
|
||||
"to review it before deleting."
|
||||
)
|
||||
assert response.json()["module"] == "tickets"
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
|
||||
def _approval_requested(org, profile, admin_profile):
|
||||
rule = ApprovalRule.objects.create(name="Sign-off", org=org)
|
||||
Approval.objects.create(org=org, case=_ticket(org), rule=rule, requested_by=profile)
|
||||
|
||||
|
||||
def _approval_assigned(org, profile, admin_profile):
|
||||
rule = ApprovalRule.objects.create(name="Sign-off", org=org)
|
||||
Approval.objects.create(
|
||||
org=org,
|
||||
case=_ticket(org),
|
||||
rule=rule,
|
||||
requested_by=admin_profile,
|
||||
approver=profile,
|
||||
)
|
||||
|
||||
|
||||
def _watching(org, profile, admin_profile):
|
||||
CaseWatcher.objects.create(org=org, case=_ticket(org), profile=profile)
|
||||
|
||||
|
||||
def _saved_reply(org, profile, admin_profile):
|
||||
Macro.objects.create(
|
||||
org=org, title="Thanks", scope=Macro.SCOPE_PERSONAL, owner=profile
|
||||
)
|
||||
|
||||
|
||||
def _board_member(org, profile, admin_profile):
|
||||
board = Board.objects.create(org=org, name="Shared", owner=admin_profile)
|
||||
BoardMember.objects.create(org=org, board=board, profile=profile)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("make", "module", "noun"),
|
||||
[
|
||||
(_approval_requested, "tickets", "approval"),
|
||||
(_approval_assigned, "tickets", "approval"),
|
||||
(_watching, "tickets", "watched ticket"),
|
||||
(_saved_reply, "tickets", "saved reply"),
|
||||
(_board_member, "tasks", "board membership"),
|
||||
],
|
||||
ids=["requested", "approver", "watcher", "saved-reply", "board-member"],
|
||||
)
|
||||
def test_each_record_a_person_holds_names_its_module(
|
||||
admin_client,
|
||||
admin_profile,
|
||||
regular_user,
|
||||
user_profile,
|
||||
org_a,
|
||||
send_email,
|
||||
make,
|
||||
module,
|
||||
noun,
|
||||
):
|
||||
make(org_a, user_profile, admin_profile)
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 409
|
||||
label = {"tickets": "Tickets", "tasks": "Tasks"}[module]
|
||||
assert response.json()["errors"] == (
|
||||
f"This person has 1 {noun}. {label} is switched off, so switch it on to "
|
||||
"review it before deleting."
|
||||
)
|
||||
assert response.json()["module"] == module
|
||||
assert Profile.objects.filter(id=user_profile.id).exists()
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
|
||||
def test_pin_with_the_modules_on_a_person_is_deleted_as_before(
|
||||
admin_client, regular_user, user_profile, org_a, send_email
|
||||
):
|
||||
board = Board.objects.create(org=org_a, name="Launch", owner=user_profile)
|
||||
Macro.objects.create(
|
||||
org=org_a, title="Thanks", scope=Macro.SCOPE_PERSONAL, owner=user_profile
|
||||
)
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not Profile.objects.filter(id=user_profile.id).exists()
|
||||
assert not Board.objects.filter(id=board.id).exists()
|
||||
send_email.delay.assert_called_once()
|
||||
|
||||
|
||||
def test_pin_with_the_timesheet_on_time_entries_keep_the_existing_refusal(
|
||||
admin_client, regular_user, user_profile, org_a, send_email
|
||||
):
|
||||
_entry(_ticket(org_a), user_profile)
|
||||
|
||||
response = admin_client.delete(_person_url(regular_user))
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": (
|
||||
"This user can't be deleted while they still have time entries or "
|
||||
"approvals linked to them."
|
||||
),
|
||||
}
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
|
||||
# Permission first
|
||||
|
||||
|
||||
def test_permission_comes_before_the_module_refusal(
|
||||
admin_user, admin_profile, user_client, user_profile, org_a, send_email
|
||||
):
|
||||
"""Pin: callers who may not delete get the 403 they got before, not the 409."""
|
||||
account = _account(org_a, admin_user)
|
||||
account.assigned_to.add(user_profile)
|
||||
_ticket(org_a, account=account)
|
||||
ticket = _ticket(org_a, creator=admin_user)
|
||||
ticket.assigned_to.add(user_profile)
|
||||
_entry(ticket, admin_profile)
|
||||
Board.objects.create(org=org_a, name="Launch", owner=admin_profile)
|
||||
set_modules_off(org_a, "tickets", "timesheet", "tasks")
|
||||
|
||||
assert user_client.delete(f"/api/accounts/{account.id}/").status_code == 403
|
||||
|
||||
set_modules_off(org_a, "timesheet", "tasks")
|
||||
assert user_client.delete(f"/api/cases/{ticket.id}/").status_code == 403
|
||||
|
||||
response = user_client.delete(_person_url(admin_user))
|
||||
assert response.status_code == 403
|
||||
assert Account.objects.filter(id=account.id).exists()
|
||||
assert Profile.objects.filter(id=admin_profile.id).exists()
|
||||
send_email.delay.assert_not_called()
|
||||
|
||||
|
||||
# Cost
|
||||
|
||||
|
||||
def _delete_url(target, org, admin_user, admin_profile, regular_user, user_profile):
|
||||
"""One record of ``target`` holding a row behind each guard it can carry
|
||||
without being protected, and the URL that deletes it."""
|
||||
if target == "account":
|
||||
account = _account(org, admin_user)
|
||||
_entry(_ticket(org, account=account), admin_profile)
|
||||
Opportunity.objects.create(name="D", org=org, account=account, stage="PROPOSAL")
|
||||
return f"/api/accounts/{account.id}/"
|
||||
if target == "ticket":
|
||||
ticket = _ticket(org)
|
||||
_entry(ticket, admin_profile)
|
||||
return f"/api/cases/{ticket.id}/"
|
||||
Board.objects.create(org=org, name="Launch", owner=user_profile)
|
||||
Macro.objects.create(
|
||||
org=org, title="Thanks", scope=Macro.SCOPE_PERSONAL, owner=user_profile
|
||||
)
|
||||
return _person_url(regular_user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "sqlite", "postgresql"),
|
||||
[("account", 58, 58), ("ticket", 21, 21), ("person", 58, 64)],
|
||||
)
|
||||
def test_with_every_module_on_the_guard_adds_no_query(
|
||||
admin_client,
|
||||
admin_user,
|
||||
admin_profile,
|
||||
regular_user,
|
||||
user_profile,
|
||||
org_a,
|
||||
send_email,
|
||||
django_assert_num_queries,
|
||||
target,
|
||||
sqlite,
|
||||
postgresql,
|
||||
):
|
||||
"""Each count is the delete's own, measured with `delete_refusal` patched
|
||||
to return None. A person's delete runs more queries on PostgreSQL."""
|
||||
url = _delete_url(
|
||||
target, org_a, admin_user, admin_profile, regular_user, user_profile
|
||||
)
|
||||
expected = {"sqlite": sqlite, "postgresql": postgresql}[connection.vendor]
|
||||
|
||||
with django_assert_num_queries(expected):
|
||||
response = admin_client.delete(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
91
backend/common/tests/test_module_off_public.py
Normal file
91
backend/common/tests/test_module_off_public.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Two routes the middleware cannot gate: the anonymous calendar feed, which
|
||||
answers its unknown-link 404 while Tasks is off, and the knowledge base's
|
||||
settings under the exempt ``/api/org/`` prefix, which answers the signed-in
|
||||
module 404 on every method."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from common.models import CalendarFeedToken
|
||||
from common.modules import module_off_body
|
||||
from common.testing import set_modules_off
|
||||
|
||||
HELP_CENTER = "/api/org/help-center/"
|
||||
SLUG_CHANGE = {"help_center_enabled": True, "help_center_slug": "acme"}
|
||||
METHODS = ("get", "head", "options", "post", "put", "patch", "delete")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_throttle():
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
|
||||
def test_calendar_feed_closes_with_tasks(user_client, org_a):
|
||||
created = user_client.post("/api/profile/calendar-feed/")
|
||||
assert created.status_code == 201
|
||||
feed = urlparse(created.json()["url"]).path
|
||||
anon = APIClient()
|
||||
assert anon.get(feed).status_code == 200
|
||||
CalendarFeedToken.objects.update(last_used_at=None)
|
||||
unknown = anon.get("/api/public/calendar/nope.ics")
|
||||
assert (unknown.status_code, unknown.json()) == (404, {"error": "Not found"})
|
||||
|
||||
set_modules_off(org_a, "tasks")
|
||||
response = anon.get(feed)
|
||||
assert (response.status_code, response.content) == (404, unknown.content)
|
||||
assert CalendarFeedToken.objects.get().last_used_at is None
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert anon.get(feed).status_code == 200
|
||||
|
||||
|
||||
def _help_center_unchanged(org):
|
||||
org.refresh_from_db()
|
||||
return (org.help_center_enabled, org.help_center_slug) == (False, None)
|
||||
|
||||
|
||||
def test_help_center_settings_answer_module_off(
|
||||
admin_client, user_client, unauthenticated_client, org_a
|
||||
):
|
||||
assert admin_client.get(HELP_CENTER).status_code == 200
|
||||
assert user_client.get(HELP_CENTER).status_code == 200
|
||||
refused = user_client.patch(HELP_CENTER, SLUG_CHANGE, format="json")
|
||||
assert refused.status_code == 403
|
||||
|
||||
set_modules_off(org_a, "knowledge_base")
|
||||
off = (404, module_off_body(org_a, "knowledge_base"))
|
||||
for client in (admin_client, user_client):
|
||||
response = client.get(HELP_CENTER)
|
||||
assert (response.status_code, response.json()) == off
|
||||
response = client.patch(HELP_CENTER, SLUG_CHANGE, format="json")
|
||||
assert (response.status_code, response.json()) == off
|
||||
assert _help_center_unchanged(org_a)
|
||||
assert unauthenticated_client.get(HELP_CENTER).status_code == 403
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert user_client.get(HELP_CENTER).status_code == 200
|
||||
saved = admin_client.patch(HELP_CENTER, SLUG_CHANGE, format="json")
|
||||
assert saved.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", METHODS)
|
||||
def test_help_center_settings_answer_one_404_for_every_method(
|
||||
admin_client, org_a, method
|
||||
):
|
||||
set_modules_off(org_a, "knowledge_base")
|
||||
send = getattr(admin_client, method)
|
||||
if method in ("get", "head"):
|
||||
response = send(HELP_CENTER)
|
||||
else:
|
||||
response = send(HELP_CENTER, SLUG_CHANGE, format="json")
|
||||
assert response.status_code == 404
|
||||
if method == "head":
|
||||
assert response.content == b""
|
||||
else:
|
||||
assert response.json() == module_off_body(org_a, "knowledge_base")
|
||||
assert _help_center_unchanged(org_a)
|
||||
764
backend/common/tests/test_module_off_related.py
Normal file
764
backend/common/tests/test_module_off_related.py
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
"""An off module's records stay out of other modules' responses.
|
||||
|
||||
Every key stays: lists read ``[]``, counts ``0``, single links and summaries
|
||||
``null``. Nothing stored changes, and a client that sends back what it loaded
|
||||
never erases a link it was shown as null.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.models import Case, Solution, TimeEntry
|
||||
from common.models import Comment
|
||||
from common.testing import set_modules_off
|
||||
from contacts.models import Contact
|
||||
from invoices.models import (
|
||||
Estimate,
|
||||
Invoice,
|
||||
InvoiceLineItem,
|
||||
Product,
|
||||
RecurringInvoice,
|
||||
)
|
||||
from leads.models import Lead
|
||||
from opportunity.models import DealPipeline, Opportunity
|
||||
from tasks.models import Board, BoardColumn, BoardTask, Task
|
||||
from webforms.models import WebForm, WebFormSubmission
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
WON_ON = datetime.date(2024, 6, 1)
|
||||
PAST_DUE = datetime.date(2020, 1, 1)
|
||||
MODELS = (
|
||||
Account,
|
||||
Contact,
|
||||
Lead,
|
||||
Opportunity,
|
||||
Case,
|
||||
Solution,
|
||||
TimeEntry,
|
||||
Task,
|
||||
BoardTask,
|
||||
Invoice,
|
||||
Estimate,
|
||||
RecurringInvoice,
|
||||
Product,
|
||||
Comment,
|
||||
WebFormSubmission,
|
||||
)
|
||||
|
||||
|
||||
def _rows():
|
||||
return {m.__name__: list(m.objects.order_by("pk").values()) for m in MODELS}
|
||||
|
||||
|
||||
@pytest.fixture(params=["admin", "member"])
|
||||
def viewer(request):
|
||||
"""The caller and their profile; every record below is assigned to them,
|
||||
so a member reads exactly what an admin reads."""
|
||||
if request.param == "admin":
|
||||
names = ("admin_client", "admin_profile")
|
||||
else:
|
||||
names = ("user_client", "user_profile")
|
||||
return tuple(request.getfixturevalue(n) for n in names)
|
||||
|
||||
|
||||
def _mine(record, profile):
|
||||
record.assigned_to.add(profile)
|
||||
return record
|
||||
|
||||
|
||||
def _get(client, url, **params):
|
||||
response = client.get(url, params)
|
||||
assert response.status_code == 200, response.content
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(org_a, viewer):
|
||||
return _mine(Account.objects.create(name="Acme", org=org_a), viewer[1])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def contact(org_a, viewer, account):
|
||||
contact = Contact.objects.create(
|
||||
first_name="Ada", last_name="Buyer", email="ada@x.com", org=org_a
|
||||
)
|
||||
contact.account = account
|
||||
contact.save(update_fields=["account"])
|
||||
return _mine(contact, viewer[1])
|
||||
|
||||
|
||||
def _deal(org, profile, name, stage, amount, **extra):
|
||||
deal = Opportunity.objects.create(
|
||||
org=org, name=name, stage=stage, amount=amount, currency="USD", **extra
|
||||
)
|
||||
return _mine(deal, profile)
|
||||
|
||||
|
||||
def _ticket(org, profile, name="Broken", **extra):
|
||||
ticket = Case.objects.create(
|
||||
org=org, name=name, status="New", priority="Normal", **extra
|
||||
)
|
||||
return _mine(ticket, profile)
|
||||
|
||||
|
||||
def _task(org, profile, **extra):
|
||||
task = Task.objects.create(
|
||||
org=org, title="Call back", status="New", priority="Medium", **extra
|
||||
)
|
||||
return _mine(task, profile)
|
||||
|
||||
|
||||
def _overdue_invoice(org, profile, account, number="INV-1", due=Decimal("30")):
|
||||
invoice = Invoice.objects.create(
|
||||
invoice_title=number,
|
||||
invoice_number=number,
|
||||
org=org,
|
||||
account=account,
|
||||
status="Sent",
|
||||
currency="USD",
|
||||
due_date=PAST_DUE,
|
||||
)
|
||||
Invoice.objects.filter(pk=invoice.pk).update(
|
||||
total_amount=due, amount_paid=Decimal("0"), amount_due=due
|
||||
)
|
||||
return _mine(invoice, profile)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def on_account(org_a, viewer, account):
|
||||
"""One of each module's records on the account: a won and an open deal, an
|
||||
open ticket, a task and an overdue invoice."""
|
||||
profile = viewer[1]
|
||||
_deal(org_a, profile, "Won", "CLOSED_WON", Decimal("100"), account=account)
|
||||
Opportunity.objects.filter(name="Won").update(closed_on=WON_ON)
|
||||
_deal(org_a, profile, "Open", "PROPOSAL", Decimal("50"), account=account)
|
||||
_ticket(org_a, profile, account=account)
|
||||
_task(org_a, profile, account=account)
|
||||
_overdue_invoice(org_a, profile, account)
|
||||
|
||||
|
||||
ACCOUNT_LISTS = [
|
||||
pytest.param("tickets", ("cases",), ("cases",), id="tickets"),
|
||||
pytest.param("deals", ("opportunity_list",), ("opportunities",), id="deals"),
|
||||
pytest.param("tasks", ("tasks",), ("tasks",), id="tasks"),
|
||||
pytest.param("invoices", ("invoices",), (), id="invoices"),
|
||||
]
|
||||
ALL_ACCOUNT_KEYS = {"cases", "opportunity_list", "tasks", "invoices"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("module", "keys", "obj_keys"), ACCOUNT_LISTS)
|
||||
def test_account_detail_hides_off_module_lists(
|
||||
viewer, org_a, account, on_account, module, keys, obj_keys
|
||||
):
|
||||
client = viewer[0]
|
||||
url = f"/api/accounts/{account.id}/"
|
||||
on = _get(client, url)
|
||||
for key in ALL_ACCOUNT_KEYS:
|
||||
assert on[key], key
|
||||
for key in ("cases", "opportunities", "tasks"):
|
||||
assert on["account_obj"][key], key
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
off = _get(client, url)
|
||||
|
||||
for key in keys:
|
||||
assert off[key] == []
|
||||
for key in ALL_ACCOUNT_KEYS - set(keys):
|
||||
assert off[key] == on[key], key
|
||||
for key in ("cases", "opportunities", "tasks"):
|
||||
expected = [] if key in obj_keys else on["account_obj"][key]
|
||||
assert off["account_obj"][key] == expected, key
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_account_detail_offers_no_stages_while_pipeline_is_off(viewer, org_a, account):
|
||||
client = viewer[0]
|
||||
url = f"/api/accounts/{account.id}/"
|
||||
set_modules_off(org_a, "deals")
|
||||
assert _get(client, url)["stages"] == []
|
||||
assert not DealPipeline.objects.filter(org=org_a).exists()
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert _get(client, url)["stages"]
|
||||
|
||||
|
||||
FIGURES_ON = {
|
||||
"won_count": 1,
|
||||
"open_deal_count": 1,
|
||||
"open_tickets": 1,
|
||||
"first_won_on": WON_ON.isoformat(),
|
||||
"won_amount": Decimal("100"),
|
||||
"open_pipeline": Decimal("50"),
|
||||
"overdue_amount": Decimal("30"),
|
||||
}
|
||||
FIGURES_OFF = {
|
||||
"deals": {
|
||||
"won_count": 0,
|
||||
"open_deal_count": 0,
|
||||
"first_won_on": None,
|
||||
"won_amount": Decimal("0"),
|
||||
"open_pipeline": Decimal("0"),
|
||||
},
|
||||
"tickets": {"open_tickets": 0},
|
||||
"invoices": {"overdue_amount": Decimal("0")},
|
||||
}
|
||||
|
||||
|
||||
def _figures(client, account, where):
|
||||
if where == "detail":
|
||||
return _get(client, f"/api/accounts/{account.id}/")["account_obj"]["rollups"]
|
||||
rows = _get(client, "/api/accounts/")["active_accounts"]["open_accounts"]
|
||||
return next(row for row in rows if row["id"] == str(account.id))["rollups"]
|
||||
|
||||
|
||||
def _read_figures(rollups):
|
||||
return {
|
||||
key: Decimal(rollups[key]) if isinstance(value, Decimal) else rollups[key]
|
||||
for key, value in FIGURES_ON.items()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("where", ["list", "detail"])
|
||||
@pytest.mark.parametrize("module", ["deals", "tickets", "invoices"])
|
||||
def test_account_figures_count_only_on_modules(
|
||||
viewer, org_a, account, on_account, where, module
|
||||
):
|
||||
client = viewer[0]
|
||||
assert _read_figures(_figures(client, account, where)) == FIGURES_ON
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
assert _read_figures(_figures(client, account, where)) == {
|
||||
**FIGURES_ON,
|
||||
**FIGURES_OFF[module],
|
||||
}
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("where", ["list", "detail"])
|
||||
def test_account_figures_with_every_figure_module_off(
|
||||
viewer, org_a, account, on_account, where
|
||||
):
|
||||
set_modules_off(org_a, "deals", "tickets", "invoices")
|
||||
|
||||
rollups = _figures(viewer[0], account, where)
|
||||
|
||||
assert rollups["by_currency"] == []
|
||||
assert _read_figures(rollups) == {
|
||||
key: value for module in FIGURES_OFF.values() for key, value in module.items()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("leads_on", [True, False])
|
||||
def test_account_list_offers_leads_only_while_leads_is_on(viewer, org_a, leads_on):
|
||||
_mine(Lead.objects.create(org=org_a, title="Lead", first_name="Lee"), viewer[1])
|
||||
if not leads_on:
|
||||
set_modules_off(org_a, "leads")
|
||||
|
||||
body = _get(viewer[0], "/api/accounts/")
|
||||
|
||||
assert len(body["leads"]) == (1 if leads_on else 0)
|
||||
|
||||
|
||||
CONTACT_LISTS = [
|
||||
pytest.param("deals", id="deals"),
|
||||
pytest.param("tickets", id="tickets"),
|
||||
pytest.param("tasks", id="tasks"),
|
||||
]
|
||||
ZERO_DEALS = {"amount": "0", "count": 0, "by_currency": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", CONTACT_LISTS)
|
||||
def test_contact_detail_hides_off_module_lists(viewer, org_a, contact, module):
|
||||
profile = viewer[1]
|
||||
_deal(org_a, profile, "Open", "PROPOSAL", Decimal("50")).contacts.add(contact)
|
||||
_ticket(org_a, profile).contacts.add(contact)
|
||||
_task(org_a, profile).contacts.add(contact)
|
||||
url = f"/api/contacts/{contact.id}/"
|
||||
on = _get(viewer[0], url)
|
||||
assert len(on["opportunities"]) == 1
|
||||
assert on["open_deals"]["count"] == 1
|
||||
assert on["opportunity_count"] == 1
|
||||
assert len(on["cases"]) == 1
|
||||
assert len(on["tasks"]) == 1
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
off = _get(viewer[0], url)
|
||||
|
||||
hidden = {
|
||||
"deals": {
|
||||
"opportunities": [],
|
||||
"open_deals": ZERO_DEALS,
|
||||
"opportunity_count": 0,
|
||||
},
|
||||
"tickets": {"cases": []},
|
||||
"tasks": {"tasks": []},
|
||||
}[module]
|
||||
for key in ("opportunities", "open_deals", "opportunity_count", "cases", "tasks"):
|
||||
assert off[key] == hidden.get(key, on[key]), key
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_ticket_detail_hides_articles_while_the_knowledge_base_is_off(viewer, org_a):
|
||||
ticket = _ticket(org_a, viewer[1])
|
||||
article = Solution.objects.create(
|
||||
org=org_a, title="Reset", description="d", status="approved"
|
||||
)
|
||||
article.cases.add(ticket)
|
||||
url = f"/api/cases/{ticket.id}/"
|
||||
assert [s["id"] for s in _get(viewer[0], url)["solutions"]] == [str(article.id)]
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "knowledge_base")
|
||||
|
||||
assert _get(viewer[0], url)["solutions"] == []
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def _logged(org, ticket, profile, invoice=None):
|
||||
started = timezone.now() - datetime.timedelta(hours=2)
|
||||
return TimeEntry.objects.create(
|
||||
org=org,
|
||||
case=ticket,
|
||||
profile=profile,
|
||||
started_at=started,
|
||||
ended_at=started + datetime.timedelta(minutes=30),
|
||||
billable=True,
|
||||
hourly_rate=Decimal("60"),
|
||||
currency="USD",
|
||||
invoice=invoice,
|
||||
)
|
||||
|
||||
|
||||
def test_ticket_rows_hide_time_while_the_timesheet_is_off(viewer, org_a):
|
||||
client, profile = viewer
|
||||
ticket = _ticket(org_a, profile)
|
||||
_logged(org_a, ticket, profile)
|
||||
|
||||
def summaries():
|
||||
rows = _get(client, "/api/cases/", slim="true")["cases"]
|
||||
detail = _get(client, f"/api/cases/{ticket.id}/")["cases_obj"]
|
||||
return [row["time_summary"] for row in rows] + [detail["time_summary"]]
|
||||
|
||||
assert [s["total_minutes"] for s in summaries()] == [30, 30]
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
assert summaries() == [None, None]
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
TASK_LINKS = [
|
||||
pytest.param(
|
||||
"opportunity",
|
||||
"deals",
|
||||
"opportunity",
|
||||
lambda org, p: _deal(org, p, "Deal", "PROPOSAL", Decimal("5")),
|
||||
id="deal",
|
||||
),
|
||||
pytest.param("case", "tickets", "case", _ticket, id="ticket"),
|
||||
pytest.param(
|
||||
"lead",
|
||||
"leads",
|
||||
"lead",
|
||||
lambda org, p: _mine(
|
||||
Lead.objects.create(org=org, title="Lead", first_name="Lee"), p
|
||||
),
|
||||
id="lead",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _task_views(client, task, contact):
|
||||
"""The task as each response that embeds it shows it."""
|
||||
rows = _get(client, "/api/tasks/", slim="true")["tasks"]
|
||||
return [
|
||||
next(row for row in rows if row["id"] == str(task.id)),
|
||||
_get(client, f"/api/tasks/{task.id}/")["task_obj"],
|
||||
_get(client, f"/api/contacts/{contact.id}/")["tasks"][0],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "kind", "make"), TASK_LINKS)
|
||||
def test_task_links_hide_off_modules(viewer, org_a, contact, field, module, kind, make):
|
||||
client, profile = viewer
|
||||
parent = make(org_a, profile)
|
||||
task = _task(org_a, profile, **{field: parent})
|
||||
task.contacts.add(contact)
|
||||
for shown in _task_views(client, task, contact):
|
||||
assert shown[field]["id"] == str(parent.id)
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
for shown in _task_views(client, task, contact):
|
||||
assert shown[field] is None
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_pin_a_task_on_an_account_keeps_it_while_other_modules_are_off(
|
||||
viewer, org_a, account, contact
|
||||
):
|
||||
"""A pin: the account link was never hidden, and must not become so."""
|
||||
client, profile = viewer
|
||||
task = _task(org_a, profile, account=account)
|
||||
task.contacts.add(contact)
|
||||
set_modules_off(org_a, "deals", "tickets", "leads")
|
||||
|
||||
for shown in _task_views(client, task, contact):
|
||||
assert shown["account"]["id"] == str(account.id)
|
||||
card = _kanban_card(client, task)
|
||||
assert card["related_entity"]["type"] == "account"
|
||||
|
||||
|
||||
def _kanban_card(client, task):
|
||||
columns = _get(client, "/api/tasks/kanban/")["columns"]
|
||||
cards = [card for column in columns for card in column["tasks"]]
|
||||
return next(card for card in cards if card["id"] == str(task.id))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "kind", "make"), TASK_LINKS)
|
||||
def test_a_task_card_hides_off_module_names(viewer, org_a, field, module, kind, make):
|
||||
client, profile = viewer
|
||||
parent = make(org_a, profile)
|
||||
task = _task(org_a, profile, **{field: parent})
|
||||
assert _kanban_card(client, task)["related_entity"]["type"] == kind
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
assert _kanban_card(client, task)["related_entity"] is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def documents(org_a, viewer, account, contact):
|
||||
"""An invoice, an estimate and a recurring invoice, each on a deal."""
|
||||
profile = viewer[1]
|
||||
deal = _deal(org_a, profile, "Deal", "CLOSED_WON", Decimal("10"), account=account)
|
||||
today = timezone.localdate()
|
||||
common = {"org": org_a, "account": account, "contact": contact, "opportunity": deal}
|
||||
invoice = Invoice.objects.create(invoice_title="I", currency="USD", **common)
|
||||
estimate = Estimate.objects.create(
|
||||
title="E",
|
||||
currency="USD",
|
||||
issue_date=today,
|
||||
expiry_date=today + datetime.timedelta(days=30),
|
||||
**common,
|
||||
)
|
||||
recurring = RecurringInvoice.objects.create(
|
||||
title="R",
|
||||
frequency="MONTHLY",
|
||||
start_date=today,
|
||||
next_generation_date=today,
|
||||
currency="USD",
|
||||
**common,
|
||||
)
|
||||
return deal, [_mine(r, profile) for r in (invoice, estimate, recurring)]
|
||||
|
||||
|
||||
def _document_deals(client, documents):
|
||||
_, (invoice, estimate, recurring) = documents
|
||||
estimates = _get(client, "/api/invoices/estimates/")["results"]
|
||||
return [
|
||||
_get(client, f"/api/invoices/{invoice.id}/")["invoice"]["opportunity"],
|
||||
next(e for e in estimates if e["id"] == str(estimate.id))["opportunity"],
|
||||
_get(client, f"/api/invoices/estimates/{estimate.id}/")["estimate"][
|
||||
"opportunity"
|
||||
],
|
||||
_get(client, f"/api/invoices/recurring/{recurring.id}/")["recurring_invoice"][
|
||||
"opportunity"
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_an_invoice_hides_its_deal_while_deals_is_off(viewer, org_a, documents):
|
||||
deal = documents[0]
|
||||
assert [d["id"] for d in _document_deals(viewer[0], documents)] == [
|
||||
str(deal.id)
|
||||
] * 4
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
assert _document_deals(viewer[0], documents) == [None] * 4
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "kind", "make"), TASK_LINKS)
|
||||
def test_a_task_sent_back_as_loaded_keeps_its_hidden_link(
|
||||
viewer, org_a, field, module, kind, make
|
||||
):
|
||||
client, profile = viewer
|
||||
parent = make(org_a, profile)
|
||||
task = _task(org_a, profile, **{field: parent})
|
||||
set_modules_off(org_a, module)
|
||||
loaded = _get(client, f"/api/tasks/{task.id}/")["task_obj"]
|
||||
assert loaded[field] is None
|
||||
|
||||
body = {key: loaded[key] for key in ("title", "status", "priority", "description")}
|
||||
body.update(
|
||||
{
|
||||
name: (loaded[name] or {}).get("id")
|
||||
for name in ("account", "opportunity", "case", "lead")
|
||||
}
|
||||
)
|
||||
response = client.put(f"/api/tasks/{task.id}/", body, format="json")
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
task.refresh_from_db()
|
||||
assert getattr(task, f"{field}_id") == parent.id
|
||||
|
||||
|
||||
def test_a_document_sent_back_as_loaded_keeps_its_hidden_deal(viewer, org_a, documents):
|
||||
client = viewer[0]
|
||||
deal, (invoice, estimate, recurring) = documents
|
||||
set_modules_off(org_a, "deals")
|
||||
targets = [
|
||||
(invoice, f"/api/invoices/{invoice.id}/", "invoice", "invoice_title"),
|
||||
(estimate, f"/api/invoices/estimates/{estimate.id}/", "estimate", "title"),
|
||||
(
|
||||
recurring,
|
||||
f"/api/invoices/recurring/{recurring.id}/",
|
||||
"recurring_invoice",
|
||||
"title",
|
||||
),
|
||||
]
|
||||
for record, url, key, title in targets:
|
||||
loaded = _get(client, url)[key]
|
||||
assert loaded["opportunity"] is None
|
||||
body = {
|
||||
title: loaded[title],
|
||||
"currency": loaded["currency"],
|
||||
"account_id": loaded["account"]["id"],
|
||||
"contact_id": loaded["contact"]["id"],
|
||||
"opportunity_id": None,
|
||||
}
|
||||
response = client.put(url, body, format="json")
|
||||
assert response.status_code == 200, (url, response.content)
|
||||
record.refresh_from_db()
|
||||
assert record.opportunity_id == deal.id, url
|
||||
|
||||
|
||||
def test_a_time_entry_hides_its_invoice_while_invoices_is_off(viewer, org_a, account):
|
||||
"""The invoice goes, but ``invoiced`` stays, since an invoiced entry is
|
||||
still locked against edits."""
|
||||
client, profile = viewer
|
||||
ticket = _ticket(org_a, profile, account=account)
|
||||
invoice = Invoice.objects.create(
|
||||
org=org_a, account=account, invoice_title="Time", invoice_number="INV-7"
|
||||
)
|
||||
billed = _logged(org_a, ticket, profile, invoice=invoice)
|
||||
unbilled = _logged(org_a, ticket, profile)
|
||||
|
||||
def shown():
|
||||
"""``{entry id: (list, timesheet, CSV)}``, each list and timesheet
|
||||
entry as ``(invoice, invoiced)`` and each CSV row as its Invoice cell."""
|
||||
listed = _get(client, f"/api/cases/{ticket.id}/time-entries/")
|
||||
days = _get(client, "/api/time-entries/timesheet/")["days"]
|
||||
week = {e["id"]: e for day in days for e in day["entries"]}
|
||||
export = client.get("/api/time-entries/report/export/")
|
||||
assert export.status_code == 200, export.content
|
||||
rows = list(
|
||||
csv.reader(io.StringIO(b"".join(export.streaming_content).decode()))
|
||||
)[1:]
|
||||
return {
|
||||
e["id"]: (
|
||||
(e["invoice"], e["invoiced"]),
|
||||
(week[e["id"]]["invoice"], week[e["id"]]["invoiced"]),
|
||||
)
|
||||
for e in listed
|
||||
}, sorted(row[-1] for row in rows)
|
||||
|
||||
entries, cells = shown()
|
||||
assert entries[str(billed.id)] == (
|
||||
(str(invoice.id), True),
|
||||
({"id": str(invoice.id), "invoice_number": "INV-7"}, True),
|
||||
)
|
||||
assert entries[str(unbilled.id)] == ((None, False), (None, False))
|
||||
assert cells == ["", "INV-7"]
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "invoices")
|
||||
|
||||
entries, cells = shown()
|
||||
assert entries[str(billed.id)] == ((None, True), (None, True))
|
||||
assert entries[str(unbilled.id)] == ((None, False), (None, False))
|
||||
assert cells == ["", ""]
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_a_deal_hides_its_next_task_while_tasks_is_off(viewer, org_a):
|
||||
client, profile = viewer
|
||||
deal = _deal(org_a, profile, "Deal", "PROPOSAL", Decimal("5"))
|
||||
task = _task(org_a, profile, opportunity=deal)
|
||||
|
||||
def next_activity():
|
||||
rows = _get(client, "/api/opportunities/")["opportunities"]
|
||||
return next(row for row in rows if row["id"] == str(deal.id))["next_activity"]
|
||||
|
||||
assert next_activity()["id"] == str(task.id)
|
||||
|
||||
set_modules_off(org_a, "tasks")
|
||||
|
||||
assert next_activity() is None
|
||||
|
||||
|
||||
def test_an_article_hides_its_tickets_while_tickets_is_off(viewer, org_a):
|
||||
client, profile = viewer
|
||||
article = Solution.objects.create(
|
||||
org=org_a, title="Reset", description="d", status="approved"
|
||||
)
|
||||
article.cases.add(_ticket(org_a, profile))
|
||||
|
||||
def shown():
|
||||
rows = _get(client, "/api/cases/solutions/")["results"]
|
||||
detail = _get(client, f"/api/cases/solutions/{article.id}/")
|
||||
row = next(r for r in rows if r["id"] == str(article.id))
|
||||
return row["case_count"], detail["case_count"], len(detail["linked_cases"])
|
||||
|
||||
assert shown() == (1, 1, 1)
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
assert shown() == (0, 0, 0)
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_a_board_card_hides_its_deal_while_deals_is_off(viewer, org_a):
|
||||
client, profile = viewer
|
||||
board = Board.objects.create(org=org_a, name="Board", owner=profile)
|
||||
column = BoardColumn.objects.create(org=org_a, board=board, name="Doing")
|
||||
deal = _deal(org_a, profile, "Deal", "PROPOSAL", Decimal("5"))
|
||||
BoardTask.objects.create(org=org_a, column=column, title="Card", opportunity=deal)
|
||||
url = f"/api/boards/columns/{column.id}/tasks/"
|
||||
assert _get(client, url)[0]["opportunity"] == str(deal.id)
|
||||
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
assert _get(client, url)[0]["opportunity"] is None
|
||||
|
||||
|
||||
def test_web_form_submissions_hide_off_module_records(viewer, org_a):
|
||||
client, profile = viewer
|
||||
form = WebForm.objects.create(name="Contact us", org=org_a)
|
||||
lead = Lead.objects.create(org=org_a, title="Lead", first_name="Lee")
|
||||
ticket = _ticket(org_a, profile, name="Help")
|
||||
for link in ({"lead": lead}, {"case": ticket}):
|
||||
WebFormSubmission.objects.create(
|
||||
org=org_a, form=form, status=WebFormSubmission.ACCEPTED, **link
|
||||
)
|
||||
url = f"/api/webforms/{form.id}/submissions/"
|
||||
|
||||
def shown():
|
||||
rows = _get(client, url)["results"]
|
||||
return sorted(
|
||||
((r["lead"], r["lead_name"], r["case"], r["case_name"]) for r in rows),
|
||||
key=repr,
|
||||
)
|
||||
|
||||
lead_row = (str(lead.id), str(lead), None, None)
|
||||
ticket_row = (None, None, str(ticket.id), "Help")
|
||||
assert shown() == sorted([lead_row, ticket_row], key=repr)
|
||||
|
||||
set_modules_off(org_a, "leads", "tickets")
|
||||
|
||||
assert shown() == [(None, None, None, None)] * 2
|
||||
|
||||
|
||||
def test_a_person_page_hides_off_module_records(viewer, org_a):
|
||||
client, profile = viewer
|
||||
deal = _deal(org_a, profile, "Deal", "PROPOSAL", Decimal("5"))
|
||||
ticket = _ticket(org_a, profile)
|
||||
account = Account.objects.create(name="Kept", org=org_a)
|
||||
for target in (deal, ticket, account):
|
||||
Comment.objects.create(
|
||||
org=org_a, content_object=target, comment="Note", commented_by=profile
|
||||
)
|
||||
url = f"/api/user/{profile.user_id}/"
|
||||
|
||||
def shown():
|
||||
data = _get(client, url)["data"]
|
||||
types = sorted(c["content_type"] for c in data["comments"])
|
||||
return len(data["opportunity_list"]), len(data["cases"]), types
|
||||
|
||||
assert shown() == (1, 1, ["account", "case", "opportunity"])
|
||||
before = _rows()
|
||||
|
||||
set_modules_off(org_a, "deals", "tickets")
|
||||
|
||||
assert shown() == (0, 0, ["account"])
|
||||
assert _rows() == before
|
||||
|
||||
|
||||
def test_a_product_counts_no_invoices_while_invoices_is_off(viewer, org_a, account):
|
||||
client = viewer[0]
|
||||
product = Product.objects.create(org=org_a, name="Seat", price=Decimal("5"))
|
||||
invoice = Invoice.objects.create(org=org_a, account=account, invoice_title="I")
|
||||
InvoiceLineItem.objects.create(
|
||||
org=org_a,
|
||||
invoice=invoice,
|
||||
product=product,
|
||||
name="Seat",
|
||||
quantity=Decimal("1"),
|
||||
unit_price=Decimal("5"),
|
||||
)
|
||||
|
||||
def used_on():
|
||||
rows = _get(client, "/api/invoices/products/")["results"]
|
||||
detail = _get(client, f"/api/invoices/products/{product.id}/")
|
||||
row = next(r for r in rows if r["id"] == str(product.id))
|
||||
return row["used_on"], detail["used_on"]
|
||||
|
||||
assert used_on() == (1, 1)
|
||||
|
||||
set_modules_off(org_a, "invoices")
|
||||
|
||||
assert used_on() == (0, 0)
|
||||
|
||||
|
||||
def _three_of_each(org, profile):
|
||||
for n in range(3):
|
||||
deal = _deal(org, profile, f"Deal {n}", "PROPOSAL", Decimal("5"))
|
||||
_task(org, profile, opportunity=deal)
|
||||
ticket = _ticket(org, profile, name=f"Ticket {n}")
|
||||
_logged(org, ticket, profile)
|
||||
Solution.objects.create(
|
||||
org=org, title=f"Article {n}", description="d", status="approved"
|
||||
).cases.add(ticket)
|
||||
|
||||
|
||||
ROW_LISTS = [
|
||||
pytest.param("/api/tasks/?slim=true", ("deals",), id="tasks"),
|
||||
pytest.param("/api/tasks/kanban/", ("deals",), id="task-board"),
|
||||
pytest.param("/api/cases/?slim=true", ("timesheet",), id="tickets"),
|
||||
pytest.param("/api/opportunities/", ("tasks",), id="deals"),
|
||||
pytest.param("/api/cases/solutions/", ("tickets",), id="articles"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("url", "off"), ROW_LISTS)
|
||||
def test_hiding_adds_no_queries(admin_client, admin_profile, org_a, url, off):
|
||||
_three_of_each(org_a, admin_profile)
|
||||
|
||||
def count():
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
_get(admin_client, url)
|
||||
return len(queries)
|
||||
|
||||
on = count()
|
||||
set_modules_off(org_a, *off)
|
||||
|
||||
assert count() <= on
|
||||
273
backend/common/tests/test_module_off_surfaces.py
Normal file
273
backend/common/tests/test_module_off_surfaces.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Shared surfaces leave off modules out: search, the home figures, Today, the
|
||||
activity feed, saved views and tag usage."""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from django.http import QueryDict
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.models import Case, Solution
|
||||
from common.models import Activity, Document, SavedView, Tags
|
||||
from common.modules import model_module
|
||||
from common.saved_views import LISTS, SAVED_VIEW_MODULES
|
||||
from common.testing import set_modules_off
|
||||
from contacts.models import Contact
|
||||
from invoices.models import Invoice
|
||||
from leads.models import Lead
|
||||
from opportunity.models import DealPipeline, Opportunity, SalesGoal
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
TICKETS_OFF = "Tickets is switched off for this organization."
|
||||
|
||||
|
||||
def _stamp(obj, user):
|
||||
type(obj).objects.filter(pk=obj.pk).update(created_by=user)
|
||||
obj.refresh_from_db()
|
||||
return obj
|
||||
|
||||
|
||||
def test_search_leaves_out_off_modules(admin_client, org_a):
|
||||
token = "Zephyr"
|
||||
Lead.objects.create(
|
||||
first_name=token,
|
||||
last_name="L",
|
||||
status="in process",
|
||||
company_name="X",
|
||||
org=org_a,
|
||||
)
|
||||
Case.objects.create(
|
||||
name=f"{token} ticket", status="New", priority="Normal", org=org_a
|
||||
)
|
||||
account = Account.objects.create(name=f"{token} Corp", org=org_a)
|
||||
Contact.objects.create(first_name=token, last_name="P", email="c@x.com", org=org_a)
|
||||
Invoice.objects.create(
|
||||
invoice_title=f"{token} invoice",
|
||||
account=account,
|
||||
currency="USD",
|
||||
status="Draft",
|
||||
org=org_a,
|
||||
)
|
||||
Solution.objects.create(
|
||||
org=org_a, title=f"{token} guide", description="d", status="approved"
|
||||
)
|
||||
|
||||
def types():
|
||||
body = admin_client.get("/api/search/", {"q": token}).json()
|
||||
return {r["type"] for r in body["results"]}
|
||||
|
||||
assert types() == {"lead", "ticket", "account", "contact", "invoice", "solution"}
|
||||
set_modules_off(org_a, "leads", "tickets", "invoices", "knowledge_base")
|
||||
assert types() == {"account", "contact"}
|
||||
|
||||
|
||||
def test_home_figures_leave_out_off_modules(admin_client, org_a, admin_profile):
|
||||
today = timezone.localdate()
|
||||
Lead.objects.create(first_name="A", last_name="B", status="assigned", org=org_a)
|
||||
Opportunity.objects.create(name="Deal", stage="PROSPECTING", org=org_a)
|
||||
from tasks.models import Task
|
||||
|
||||
Task.objects.create(
|
||||
title="T",
|
||||
status="New",
|
||||
priority="Low",
|
||||
due_date=today + timedelta(days=1),
|
||||
org=org_a,
|
||||
)
|
||||
SalesGoal.objects.create(
|
||||
name="G",
|
||||
goal_type="REVENUE",
|
||||
target_value=Decimal("100"),
|
||||
period_type="MONTHLY",
|
||||
period_start=today - timedelta(days=1),
|
||||
period_end=today + timedelta(days=5),
|
||||
assigned_to=admin_profile,
|
||||
currency="USD",
|
||||
org=org_a,
|
||||
)
|
||||
body = admin_client.get("/api/dashboard/").json()
|
||||
assert body["leads_count"] == 1
|
||||
assert body["opportunities_count"] == 1
|
||||
assert len(body["goal_summary"]) == 1
|
||||
assert len(body["tasks"]) == 1
|
||||
|
||||
set_modules_off(org_a, "leads", "deals", "tasks")
|
||||
body = admin_client.get("/api/dashboard/").json()
|
||||
assert body["leads_count"] == 0
|
||||
assert body["opportunities_count"] == 0
|
||||
assert body["goal_summary"] == []
|
||||
assert body["tasks"] == []
|
||||
assert body["hot_leads"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_name", ["admin_client", "user_client"])
|
||||
def test_home_pipeline_is_empty_and_not_created_while_pipeline_is_off(
|
||||
request, org_a, client_name
|
||||
):
|
||||
client = request.getfixturevalue(client_name)
|
||||
|
||||
def pipeline():
|
||||
response = client.get("/api/dashboard/")
|
||||
assert response.status_code == 200
|
||||
return response.json()["pipeline_by_stage"]
|
||||
|
||||
set_modules_off(org_a, "deals")
|
||||
assert pipeline() == {}
|
||||
assert not DealPipeline.objects.filter(org=org_a).exists()
|
||||
|
||||
set_modules_off(org_a)
|
||||
stages = pipeline()
|
||||
assert stages
|
||||
assert all(row["label"] and row["kind"] for row in stages.values())
|
||||
|
||||
|
||||
def test_today_leaves_out_off_modules(admin_client, org_a):
|
||||
yesterday = timezone.localdate() - timedelta(days=1)
|
||||
account = Account.objects.create(name="Co", org=org_a)
|
||||
Invoice.objects.create(
|
||||
invoice_title="Late",
|
||||
account=account,
|
||||
currency="USD",
|
||||
status="Sent",
|
||||
due_date=yesterday,
|
||||
total_amount=Decimal("10"),
|
||||
org=org_a,
|
||||
)
|
||||
Case.objects.create(name="Help", status="New", priority="Normal", org=org_a)
|
||||
url = "/api/dashboard/today/"
|
||||
body = admin_client.get(url).json()
|
||||
assert {i["id"].split("-")[0] for i in body["queue"]} == {"invoice", "case"}
|
||||
|
||||
set_modules_off(org_a, "tickets", "invoices")
|
||||
body = admin_client.get(url).json()
|
||||
assert body["queue"] == []
|
||||
assert body["summary"]["sources"] == []
|
||||
assert body["summary"]["count"] == 0
|
||||
|
||||
|
||||
def test_the_import_prompt_ignores_leads_while_off(admin_client, org_a):
|
||||
Lead.objects.create(first_name="A", last_name="B", status="assigned", org=org_a)
|
||||
url = "/api/dashboard/"
|
||||
assert admin_client.get(url).json()["empty_workspace"] is False
|
||||
set_modules_off(org_a, "leads")
|
||||
assert admin_client.get(url).json()["empty_workspace"] is True
|
||||
|
||||
|
||||
def test_the_activity_feed_leaves_out_off_modules(
|
||||
admin_client, user_client, org_a, admin_profile, regular_user
|
||||
):
|
||||
case = _stamp(
|
||||
Case.objects.create(name="Mine", status="New", priority="Normal", org=org_a),
|
||||
regular_user,
|
||||
)
|
||||
document = Document.objects.create(title="Doc", document_file="d/x.pdf", org=org_a)
|
||||
account = _stamp(Account.objects.create(name="Acme", org=org_a), regular_user)
|
||||
for entity_type, record, name in (
|
||||
("Case", case, "Mine"),
|
||||
("Document", document, "Doc"),
|
||||
("Account", account, "Acme"),
|
||||
):
|
||||
Activity.objects.create(
|
||||
user=admin_profile,
|
||||
action="UPDATE",
|
||||
entity_type=entity_type,
|
||||
entity_id=record.id,
|
||||
entity_name=name,
|
||||
org=org_a,
|
||||
)
|
||||
|
||||
def names(client):
|
||||
rows = client.get("/api/activities/").json()["activities"]
|
||||
return {r["entity_name"] for r in rows}
|
||||
|
||||
assert names(admin_client) == {"Mine", "Doc", "Acme"}
|
||||
assert "Mine" in names(user_client)
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert names(admin_client) == {"Doc", "Acme"}
|
||||
assert names(user_client) == {"Acme"}
|
||||
|
||||
set_modules_off(org_a, "tickets", "documents")
|
||||
assert names(admin_client) == {"Acme"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def views(org_a, user_profile):
|
||||
return {
|
||||
module: SavedView.objects.create(
|
||||
org=org_a, profile=user_profile, module=module, name=module, filters={}
|
||||
)
|
||||
for module in ("leads", "cases", "contacts")
|
||||
}
|
||||
|
||||
|
||||
def test_saved_views_of_an_off_module_are_hidden(user_client, org_a, views):
|
||||
url = "/api/saved-views/"
|
||||
set_modules_off(org_a, "tickets")
|
||||
listed = user_client.get(url).json()["saved_views"]
|
||||
assert {v["module"] for v in listed} == {"leads", "contacts"}
|
||||
assert user_client.get(url, {"module": "cases"}).json()["saved_views"] == []
|
||||
detail = f"{url}{views['cases'].id}/"
|
||||
assert user_client.get(detail).status_code == 404
|
||||
assert user_client.patch(detail, {"name": "new"}, format="json").status_code == 404
|
||||
assert user_client.delete(detail).status_code == 404
|
||||
assert SavedView.objects.filter(pk=views["cases"].pk, name="cases").exists()
|
||||
assert user_client.get(f"{url}{views['leads'].id}/").status_code == 200
|
||||
|
||||
org_a.modules_off = []
|
||||
org_a.save(update_fields=["modules_off"])
|
||||
assert user_client.get(detail).status_code == 200
|
||||
|
||||
|
||||
def test_saving_a_view_for_an_off_module_is_refused(user_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
response = user_client.post(
|
||||
"/api/saved-views/",
|
||||
{"module": "cases", "name": "Open", "filters": {}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"module": [TICKETS_OFF]}
|
||||
ok = user_client.post(
|
||||
"/api/saved-views/",
|
||||
{"module": "contacts", "name": "Open", "filters": {}},
|
||||
format="json",
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", sorted(LISTS))
|
||||
def test_each_saved_view_list_follows_its_models_module(
|
||||
module, admin_user, admin_profile
|
||||
):
|
||||
"""A list missing from SAVED_VIEW_MODULES would stay on with its module off."""
|
||||
build, _ = LISTS[module]
|
||||
queryset = build(
|
||||
SimpleNamespace(profile=admin_profile, user=admin_user), QueryDict()
|
||||
)
|
||||
assert SAVED_VIEW_MODULES.get(module) == model_module(queryset.model._meta.label)
|
||||
|
||||
|
||||
def test_tag_usage_leaves_out_off_modules(admin_client, org_a):
|
||||
tag = Tags.objects.create(name="Vip", slug="vip", org=org_a)
|
||||
case = Case.objects.create(name="C", status="New", priority="Normal", org=org_a)
|
||||
case.tags.add(tag)
|
||||
|
||||
def body():
|
||||
return admin_client.get("/api/tags/").json()
|
||||
|
||||
row = body()["tags"][0]
|
||||
assert row["usage"]["cases"] == 1
|
||||
assert {"macros", "solutions"} <= set(row["usage"])
|
||||
assert body()["totals"]["unused"] == 0
|
||||
|
||||
set_modules_off(org_a, "tickets", "knowledge_base")
|
||||
data = body()
|
||||
usage = data["tags"][0]["usage"]
|
||||
assert not {"cases", "macros", "solutions"} & set(usage)
|
||||
assert "accounts" in usage
|
||||
assert data["totals"]["unused"] == 0
|
||||
327
backend/common/tests/test_module_routes.py
Normal file
327
backend/common/tests/test_module_routes.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""The route map and the refusal ``RequireOrgContext`` gives a signed-in
|
||||
request to a module that is off: one 404 body for every method and credential.
|
||||
|
||||
The guard tests walk the live URLconf. A route under a new root of
|
||||
``common_urls``, ``api_common`` or ``portal``, or under a new namespace, is
|
||||
unclassified and fails; a map prefix that no live route starts with fails; and
|
||||
a module route the middleware cannot see (an exempt path, the portal, an exempt
|
||||
view) must be on the view-checked list. A new route under a root or namespace
|
||||
already in the map takes that entry's requirement.
|
||||
"""
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import URLResolver, get_resolver
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from cases.models import Case
|
||||
from common.middleware.rls_context import RequireOrgContext
|
||||
from common.models import Comment, PersonalAccessToken
|
||||
from common.modules import (
|
||||
CORE,
|
||||
KEYS,
|
||||
ROUTE_MODULES,
|
||||
VIEW_CHECKED_ROUTES,
|
||||
Requires,
|
||||
module_off_body,
|
||||
requirement_for,
|
||||
)
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
|
||||
SAMPLE_ID = "6f1c2d3e-4a5b-4c6d-8e7f-901a2b3c4d5e"
|
||||
METHODS = ("get", "head", "options", "post", "put", "patch", "delete")
|
||||
|
||||
|
||||
def _api_routes():
|
||||
"""``(route, namespaces, view name)`` for every ``api/`` route, joined the
|
||||
way ``ResolverMatch.route`` and ``ResolverMatch.namespaces`` are."""
|
||||
found = []
|
||||
|
||||
def walk(patterns, prefix, namespaces):
|
||||
for entry in patterns:
|
||||
route = prefix + str(entry.pattern).removeprefix("^" if prefix else "")
|
||||
if isinstance(entry, URLResolver):
|
||||
inner = (
|
||||
(*namespaces, entry.namespace) if entry.namespace else namespaces
|
||||
)
|
||||
walk(entry.url_patterns, route, inner)
|
||||
elif route.startswith("api/"):
|
||||
name = ":".join((*namespaces, entry.name)) if entry.name else None
|
||||
found.append((route, namespaces, name))
|
||||
|
||||
walk(get_resolver().url_patterns, "", ())
|
||||
return found
|
||||
|
||||
|
||||
def test_every_api_route_belongs_to_a_module_or_core():
|
||||
unclassified = sorted(
|
||||
f"{route} {list(namespaces)}"
|
||||
for route, namespaces, _ in _api_routes()
|
||||
if requirement_for(route, namespaces) is None
|
||||
)
|
||||
assert not unclassified, (
|
||||
"API routes with no entry in common.modules (add the innermost namespace "
|
||||
"to NAMESPACE_MODULES or a prefix to ROUTE_MODULES):\n"
|
||||
+ "\n".join(unclassified)
|
||||
)
|
||||
|
||||
|
||||
def test_view_checked_routes_are_the_exempt_module_routes():
|
||||
middleware = RequireOrgContext(get_response=None)
|
||||
unseen = set()
|
||||
for route, namespaces, view_name in _api_routes():
|
||||
if requirement_for(route, namespaces) in (None, CORE):
|
||||
continue
|
||||
sample = "/" + re.sub(r"<[^>]+>", SAMPLE_ID, route)
|
||||
if (
|
||||
middleware._is_exempt(sample)
|
||||
or namespaces[:1] == ("portal",)
|
||||
or view_name in RequireOrgContext.EXEMPT_VIEW_NAMES
|
||||
):
|
||||
unseen.add(route)
|
||||
assert unseen == VIEW_CHECKED_ROUTES
|
||||
|
||||
|
||||
CU = "common_urls"
|
||||
|
||||
ROUTE_CASES = [
|
||||
("api/opportunities/goals/", (CU, "api_opportunities"), Requires(("deals", "goals"))),
|
||||
("api/opportunities/goals/leaderboard/", (CU, "api_opportunities"), Requires(("deals", "goals"))),
|
||||
("api/cases/solutions/<uid:pk>/publish/", (CU, "api_cases"), Requires(("knowledge_base",))),
|
||||
("api/cases/<uid:pk>/solutions/<uid:solution_pk>/", (CU, "api_cases"), Requires(("tickets", "knowledge_base"))),
|
||||
("api/cases/<uid:pk>/solution-suggestions/", (CU, "api_cases"), Requires(("tickets", "knowledge_base"))),
|
||||
("api/cases/<uid:pk>/time-entries/start/", (CU, "api_cases"), Requires(("tickets", "timesheet"))),
|
||||
("api/cases/<uid:pk>/time-summary/", (CU, "api_cases"), Requires(("tickets", "timesheet"))),
|
||||
("api/invoices/products/<uuid:pk>/", (CU, "api_invoices"), Requires(any_of=("invoices", "deals"))),
|
||||
("api/invoices/from-opportunity/<uuid:opportunity_id>/", (CU, "api_invoices"), Requires(("invoices", "deals"))),
|
||||
("api/invoices/from-time-entries/", (CU, "api_invoices"), Requires(("invoices", "tickets", "timesheet"))),
|
||||
("api/documents/<uid:pk>/download/", (CU, "api_common"), Requires(("documents",))),
|
||||
("api/profile/calendar-feed/", (CU, "api_common"), Requires(("tasks",))),
|
||||
("api/public/calendar/<str:token>.ics", (CU, "api_common"), Requires(("tasks",))),
|
||||
("api/public/csat/<str:token>/", (CU,), Requires(("tickets",))),
|
||||
("api/org/help-center/", (CU, "api_common"), Requires(("knowledge_base",))),
|
||||
("api/portal/cases/<uid:pk>/comment/", ("portal",), Requires(("tickets",))),
|
||||
("api/portal/articles/suggest/", ("portal",), Requires(("knowledge_base",))),
|
||||
("api/schema/redoc/", (), CORE),
|
||||
# One route per module namespace.
|
||||
("api/leads/kanban/", (CU, "api_leads"), Requires(("leads",))),
|
||||
("api/opportunities/<uid:pk>/", (CU, "api_opportunities"), Requires(("deals",))),
|
||||
("api/tasks/", (CU, "api_tasks"), Requires(("tasks",))),
|
||||
("api/boards/<uid:pk>/", (CU, "api_boards"), Requires(("tasks",))),
|
||||
("api/cases/<uid:pk>/", (CU, "api_cases"), Requires(("tickets",))),
|
||||
("api/business-hours/calendar/", (CU, "api_business_hours"), Requires(("tickets",))),
|
||||
("api/macros/", (CU, "api_macros"), Requires(("tickets",))),
|
||||
("api/time-entries/timesheet/", (CU, "api_time_entries"), Requires(("tickets", "timesheet"))),
|
||||
("api/invoices/<uuid:pk>/", (CU, "api_invoices"), Requires(("invoices",))),
|
||||
("api/public/help/<str:slug>/", ("public_help",), Requires(("knowledge_base",))),
|
||||
("api/public/estimate/<str:token>/accept/", ("public_invoices",), Requires(("invoices",))),
|
||||
# Core: each root of common_urls and api_common, and core namespaces.
|
||||
("api/activities/", (CU, "api_common"), CORE),
|
||||
("api/api-settings/<uid:pk>/", (CU, "api_common"), CORE),
|
||||
("api/attachments/<uid:pk>/download/", (CU, "api_common"), CORE),
|
||||
("api/auth/magic-link/verify/", (CU, "api_common"), CORE),
|
||||
("api/custom-fields/<uid:pk>/", (CU, "api_common"), CORE),
|
||||
("api/dashboard/today/", (CU, "api_common"), CORE),
|
||||
("api/notifications/read-all/", (CU, "api_common"), CORE),
|
||||
("api/org/modules/", (CU, "api_common"), CORE),
|
||||
("api/packs/<str:pack_id>/apply/", (CU, "api_common"), CORE),
|
||||
("api/profile/tokens/<uuid:pk>/", (CU, "api_common"), CORE),
|
||||
("api/saved-views/", (CU, "api_common"), CORE),
|
||||
("api/search/", (CU,), CORE),
|
||||
("api/tags/<uid:pk>/merge/", (CU, "api_common"), CORE),
|
||||
("api/teams/", (CU, "api_common"), CORE),
|
||||
("api/user/<uid:pk>/status/", (CU, "api_common"), CORE),
|
||||
("api/users/get-teams-and-users/", (CU, "api_common"), CORE),
|
||||
("api/accounts/", (CU, "api_accounts"), CORE),
|
||||
("api/portal/login/<uuid:org_id>/request/", ("portal",), CORE),
|
||||
# Unclassified: only the innermost namespace counts, and common_urls,
|
||||
# api_common and portal classify nothing by namespace.
|
||||
("api/newapp/things/", (CU, "api_newapp"), None),
|
||||
("api/newroot/", (CU, "api_common"), None),
|
||||
("api/newroot/", (CU,), None),
|
||||
("api/portal/invoices/", ("portal",), None),
|
||||
("api/unmounted/", (), None),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route,namespaces,expected", ROUTE_CASES)
|
||||
def test_route_requirements(route, namespaces, expected):
|
||||
assert requirement_for(route, namespaces) == expected
|
||||
|
||||
|
||||
def test_route_requirements_exercise_every_prefix():
|
||||
routes = [route for route, _, _ in ROUTE_CASES]
|
||||
assert not [p for p in ROUTE_MODULES if not any(r.startswith(p) for r in routes)]
|
||||
|
||||
|
||||
def test_every_route_prefix_matches_a_live_route():
|
||||
"""A prefix left behind by a URL rename would let the route fall back to its
|
||||
namespace's requirement."""
|
||||
routes = [route for route, _, _ in _api_routes()]
|
||||
stale = [p for p in ROUTE_MODULES if not any(r.startswith(p) for r in routes)]
|
||||
assert not stale, f"ROUTE_MODULES keys no live route starts with: {stale}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ticket(admin_user, org_a):
|
||||
return Case.objects.create(
|
||||
name="Printer on fire",
|
||||
status="New",
|
||||
priority="Normal",
|
||||
org=org_a,
|
||||
created_by=admin_user,
|
||||
)
|
||||
|
||||
|
||||
def test_an_off_module_answers_one_404(admin_client, user_client, org_a):
|
||||
for client in (admin_client, user_client):
|
||||
assert client.get("/api/cases/").status_code == 200
|
||||
set_modules_off(org_a, "tickets")
|
||||
for client in (admin_client, user_client):
|
||||
response = client.get("/api/cases/")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"detail": "Tickets is switched off for this organization.",
|
||||
"code": "module_off",
|
||||
"module": "tickets",
|
||||
}
|
||||
|
||||
|
||||
def test_a_refused_request_never_sets_the_org_context(admin_client, org_a):
|
||||
"""The refusal returns before ``_set_org_context``, so a refused request
|
||||
leaves no ``app.current_org`` behind."""
|
||||
with patch.object(
|
||||
RequireOrgContext, "_set_org_context", autospec=True
|
||||
) as set_context:
|
||||
assert admin_client.get("/api/cases/").status_code == 200
|
||||
assert set_context.call_count == 1
|
||||
set_context.reset_mock()
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert admin_client.get("/api/cases/").status_code == 404
|
||||
set_context.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ("list", "detail"))
|
||||
@pytest.mark.parametrize("method", METHODS)
|
||||
def test_every_method_answers_the_same_404(admin_client, org_a, ticket, method, target):
|
||||
url = "/api/cases/" if target == "list" else f"/api/cases/{ticket.id}/"
|
||||
if (method, target) == ("delete", "list"):
|
||||
assert admin_client.delete(url).status_code == 405
|
||||
set_modules_off(org_a, "tickets")
|
||||
send = getattr(admin_client, method)
|
||||
if method in ("get", "head"):
|
||||
response = send(url)
|
||||
else:
|
||||
response = send(url, {"name": "Renamed", "comment": "Hi"}, format="json")
|
||||
assert response.status_code == 404
|
||||
if method == "head":
|
||||
assert response.content == b""
|
||||
else:
|
||||
assert response.json() == module_off_body(org_a, "tickets")
|
||||
ticket.refresh_from_db()
|
||||
assert (ticket.name, ticket.is_active) == ("Printer on fire", True)
|
||||
assert not Comment.objects.filter(object_id=ticket.id).exists()
|
||||
|
||||
|
||||
def test_a_members_admin_write_is_404_not_403(user_client, org_a, user_profile):
|
||||
url = "/api/cases/escalation-policies/"
|
||||
payload = {
|
||||
"priority": "Urgent",
|
||||
"first_response_action": "notify",
|
||||
"resolution_action": "notify",
|
||||
"first_response_target_id": str(user_profile.id),
|
||||
"resolution_target_id": str(user_profile.id),
|
||||
}
|
||||
assert user_client.post(url, payload, format="json").status_code == 403
|
||||
set_modules_off(org_a, "tickets")
|
||||
response = user_client.post(url, payload, format="json")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == module_off_body(org_a, "tickets")
|
||||
|
||||
|
||||
def test_tokens_meet_the_same_gate(admin_profile, org_a):
|
||||
raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli")
|
||||
pat = APIClient()
|
||||
pat.credentials(HTTP_AUTHORIZATION=f"Bearer {raw}")
|
||||
key = APIClient()
|
||||
key.credentials(HTTP_TOKEN=org_a.api_key)
|
||||
assert pat.get("/api/leads/").status_code == 200
|
||||
assert key.get("/api/leads/").status_code == 200
|
||||
|
||||
set_modules_off(org_a, "leads")
|
||||
lead = {"title": "Gate", "first_name": "Ga", "last_name": "Te"}
|
||||
for response in (
|
||||
pat.get("/api/leads/"),
|
||||
pat.post("/api/leads/", lead, format="json"),
|
||||
key.get("/api/leads/"),
|
||||
):
|
||||
assert response.status_code == 404
|
||||
assert response.json() == module_off_body(org_a, "leads")
|
||||
assert not Lead.objects.filter(org=org_a).exists()
|
||||
# The key is read-only: its scope refusal still comes first.
|
||||
assert key.post("/api/leads/", lead, format="json").status_code == 403
|
||||
|
||||
|
||||
def test_a_need_names_the_module_that_is_off(admin_client, org_a, ticket):
|
||||
suggestions = f"/api/cases/{ticket.id}/solution-suggestions/?q=x"
|
||||
cases = (
|
||||
("deals", "/api/opportunities/goals/", "deals"),
|
||||
("goals", "/api/opportunities/goals/", "goals"),
|
||||
("tickets", "/api/time-entries/timesheet/", "tickets"),
|
||||
("knowledge_base", suggestions, "knowledge_base"),
|
||||
)
|
||||
for off, url, named in cases:
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.get(url).status_code == 200, url
|
||||
set_modules_off(org_a, off)
|
||||
response = admin_client.get(url)
|
||||
assert response.status_code == 404, url
|
||||
assert response.json() == module_off_body(org_a, named)
|
||||
assert admin_client.get(f"/api/cases/{ticket.id}/").status_code == 200
|
||||
|
||||
|
||||
def test_products_stay_while_deals_or_invoices_is_on(admin_client, org_a):
|
||||
url = "/api/invoices/products/"
|
||||
set_modules_off(org_a, "deals")
|
||||
assert admin_client.get(url).status_code == 200
|
||||
set_modules_off(org_a, "invoices")
|
||||
assert admin_client.get(url).status_code == 200
|
||||
response = admin_client.get("/api/invoices/")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == module_off_body(org_a, "invoices")
|
||||
set_modules_off(org_a, "deals", "invoices")
|
||||
response = admin_client.get(url)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == module_off_body(org_a, "invoices")
|
||||
|
||||
|
||||
def test_on_modules_and_core_routes_answer_as_before(admin_client, org_a):
|
||||
set_modules_off(org_a, *KEYS)
|
||||
for url in (
|
||||
"/api/accounts/",
|
||||
"/api/contacts/",
|
||||
"/api/search/?q=ab",
|
||||
"/api/dashboard/today/",
|
||||
"/api/org/modules/",
|
||||
):
|
||||
assert admin_client.get(url).status_code == 200, url
|
||||
|
||||
|
||||
def test_the_refusal_uses_the_org_term(admin_client, org_a):
|
||||
org_a.terminology = {"lead.plural": "Enquiries"}
|
||||
org_a.save(update_fields=["terminology"])
|
||||
set_modules_off(org_a, "leads")
|
||||
response = admin_client.get("/api/leads/")
|
||||
assert response.status_code == 404
|
||||
assert (
|
||||
response.json()["detail"] == "Enquiries is switched off for this organization."
|
||||
)
|
||||
|
||||
|
||||
def test_another_org_is_not_gated(admin_client, org_b_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert admin_client.get("/api/cases/").status_code == 404
|
||||
assert org_b_client.get("/api/cases/").status_code == 200
|
||||
183
backend/common/tests/test_modules.py
Normal file
183
backend/common/tests/test_modules.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import crum
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.db.models import Model
|
||||
|
||||
from common.audit_log import SecurityAuditLog, audit_log
|
||||
from common.modules import (
|
||||
KEYS,
|
||||
MODEL_MODULES,
|
||||
available_modules,
|
||||
enabled_modules,
|
||||
model_module,
|
||||
module_label,
|
||||
module_off_body,
|
||||
module_off_message,
|
||||
module_on_since,
|
||||
only_available,
|
||||
ordered_modules,
|
||||
org_of,
|
||||
)
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
|
||||
ALL = [
|
||||
"deals",
|
||||
"leads",
|
||||
"goals",
|
||||
"tasks",
|
||||
"tickets",
|
||||
"knowledge_base",
|
||||
"documents",
|
||||
"invoices",
|
||||
"timesheet",
|
||||
]
|
||||
|
||||
|
||||
def test_a_new_org_has_every_module_on(org_a):
|
||||
assert org_a.modules_off == []
|
||||
assert org_a.module_order == []
|
||||
assert org_a.modules_on_at == {}
|
||||
assert available_modules(org_a) == frozenset(KEYS)
|
||||
|
||||
|
||||
def test_default_order_is_todays_sidebar():
|
||||
assert KEYS == tuple(ALL)
|
||||
|
||||
|
||||
def test_a_module_needs_its_parent(org_a):
|
||||
set_modules_off(org_a, "deals")
|
||||
assert "goals" in enabled_modules(org_a)
|
||||
assert "goals" not in available_modules(org_a)
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert "timesheet" in enabled_modules(org_a)
|
||||
assert "timesheet" not in available_modules(org_a)
|
||||
set_modules_off(org_a, "goals")
|
||||
assert "deals" in available_modules(org_a)
|
||||
|
||||
|
||||
def test_unknown_or_malformed_stored_keys_are_ignored(org_a):
|
||||
set_modules_off(org_a, "crm", 3, None)
|
||||
assert available_modules(org_a) == frozenset(KEYS)
|
||||
|
||||
|
||||
def test_labels_follow_terminology(org_a):
|
||||
try:
|
||||
org_a.terminology = {"lead.plural": "Enquiries"}
|
||||
assert module_label(org_a, "leads") == "Enquiries"
|
||||
assert (
|
||||
module_off_message(org_a, "leads")
|
||||
== "Enquiries is switched off for this organization."
|
||||
)
|
||||
for bad in ("", 5):
|
||||
org_a.terminology = {"lead.plural": bad}
|
||||
assert module_label(org_a, "leads") == "Leads"
|
||||
assert module_label(org_a, "deals") == "Pipeline"
|
||||
finally:
|
||||
crum.set_current_request(None)
|
||||
|
||||
|
||||
def test_off_body(org_a):
|
||||
assert module_off_body(org_a, "tickets") == {
|
||||
"detail": "Tickets is switched off for this organization.",
|
||||
"code": "module_off",
|
||||
"module": "tickets",
|
||||
}
|
||||
|
||||
|
||||
def test_org_order_applies_within_groups(org_a):
|
||||
org_a.module_order = ["leads", "timesheet", "invoices"]
|
||||
assert [m.key for m in ordered_modules(org_a)] == [
|
||||
"leads",
|
||||
"deals",
|
||||
"goals",
|
||||
"tasks",
|
||||
"tickets",
|
||||
"knowledge_base",
|
||||
"documents",
|
||||
"timesheet",
|
||||
"invoices",
|
||||
]
|
||||
|
||||
|
||||
def test_module_on_since_reads_the_stamp(org_a):
|
||||
org_a.modules_on_at = {
|
||||
"invoices": "2026-10-01T09:00:00+00:00",
|
||||
"leads": "yesterday",
|
||||
}
|
||||
assert module_on_since(org_a, "invoices") == datetime(2026, 10, 1, 9, 0, tzinfo=UTC)
|
||||
assert module_on_since(org_a, "leads") is None
|
||||
assert module_on_since(org_a, "tasks") is None
|
||||
|
||||
|
||||
def test_only_available_empties_an_off_module(org_a):
|
||||
Lead.objects.create(title="L", org=org_a)
|
||||
assert only_available(org_a, "leads", Lead.objects.all()).count() == 1
|
||||
set_modules_off(org_a, "leads")
|
||||
assert only_available(org_a, "leads", Lead.objects.all()).count() == 0
|
||||
|
||||
|
||||
def test_org_of_prefers_the_request_org(org_a, django_assert_num_queries):
|
||||
lead = Lead.objects.create(title="L", org=org_a)
|
||||
lead = Lead.objects.get(pk=lead.pk)
|
||||
try:
|
||||
crum.set_current_request(SimpleNamespace(profile=SimpleNamespace(org=org_a)))
|
||||
with django_assert_num_queries(0):
|
||||
assert org_of(lead) is org_a
|
||||
crum.set_current_request(None)
|
||||
assert org_of(lead) == lead.org
|
||||
finally:
|
||||
crum.set_current_request(None)
|
||||
|
||||
|
||||
def test_every_model_of_a_module_app_is_placed():
|
||||
for app in (
|
||||
"leads",
|
||||
"opportunity",
|
||||
"tasks",
|
||||
"cases",
|
||||
"business_hours",
|
||||
"macros",
|
||||
"invoices",
|
||||
):
|
||||
for model in apps.get_app_config(app).get_models():
|
||||
label = model._meta.label
|
||||
if label == "invoices.Product":
|
||||
assert model_module(label) is None
|
||||
else:
|
||||
assert label in MODEL_MODULES, label
|
||||
assert issubclass(apps.get_model("common.Document"), Model)
|
||||
assert model_module("common.Document") == "documents"
|
||||
|
||||
|
||||
def test_modules_changed_is_audited(org_a, admin_user):
|
||||
audit_log.modules_changed(admin_user, org_a, ["tickets"], ["invoices"], True)
|
||||
row = SecurityAuditLog.objects.get(event_type="MODULES_CHANGED")
|
||||
assert (
|
||||
row.description
|
||||
== "Switched off Tickets; Switched on Invoices; Reordered modules"
|
||||
)
|
||||
assert row.metadata == {
|
||||
"switched_off": ["tickets"],
|
||||
"switched_on": ["invoices"],
|
||||
"reordered": True,
|
||||
}
|
||||
assert row.org == org_a
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", [[["x"]], [{}], ["nope", 3, None], "leads", {"a": 1}, None]
|
||||
)
|
||||
def test_malformed_stored_order_is_ignored(org_a, bad):
|
||||
org_a.module_order = bad
|
||||
assert [m.key for m in ordered_modules(org_a)] == ALL
|
||||
|
||||
|
||||
def test_deals_stops_copy_reads_with_the_default_label(org_a):
|
||||
from common.modules import module
|
||||
|
||||
text = module("deals").stops.format(label=module_label(org_a, "deals"))
|
||||
assert text.startswith("Everything in Pipeline leaves every menu")
|
||||
470
backend/common/tests/test_modules_api.py
Normal file
470
backend/common/tests/test_modules_api.py
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
"""GET and PUT /api/org/modules/: who may read and change an org's modules,
|
||||
the 400 envelope, the refusals, and what a change leaves alone."""
|
||||
|
||||
import pytest
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.models import Case, InboundMailbox
|
||||
from common.audit_log import SecurityAuditLog
|
||||
from common.models import User
|
||||
from common.modules import KEYS
|
||||
from common.testing import (
|
||||
_make_authenticated_client,
|
||||
clear_rls_context,
|
||||
set_modules_off,
|
||||
)
|
||||
from invoices.models import Invoice
|
||||
from leads.models import Lead
|
||||
from tasks.models import Task
|
||||
from webforms.models import WebForm
|
||||
|
||||
URL = "/api/org/modules/"
|
||||
ROW_KEYS = {"key", "label", "group", "covers", "stops", "enabled", "available", "needs"}
|
||||
DEFAULT_ORDER = [
|
||||
"deals", "leads", "goals", "tasks", "tickets", "knowledge_base", "documents",
|
||||
"invoices", "timesheet",
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def put(client, off, order=()):
|
||||
return client.put(URL, {"off": list(off), "order": list(order)}, format="json")
|
||||
|
||||
|
||||
def rows(response):
|
||||
return {r["key"]: r for r in response.json()["modules"]}
|
||||
|
||||
|
||||
def form(org, name, target="lead", published=True):
|
||||
return WebForm.objects.create(
|
||||
org=org, name=name, target=target, is_published=published
|
||||
)
|
||||
|
||||
|
||||
class TestRead:
|
||||
def test_any_member_reads_the_modules(self, user_client, admin_client):
|
||||
response = user_client.get(URL)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert [r["key"] for r in body["modules"]] == DEFAULT_ORDER
|
||||
assert all(set(r) == ROW_KEYS for r in body["modules"])
|
||||
assert [r["group"] for r in body["modules"]] == (
|
||||
["sell"] * 3 + ["serve"] * 4 + ["bill"] * 2
|
||||
)
|
||||
assert body["can_edit"] is False
|
||||
assert admin_client.get(URL).json()["can_edit"] is True
|
||||
|
||||
def test_the_rows_carry_the_contract_values(self, user_client):
|
||||
goals = rows(user_client.get(URL))["goals"]
|
||||
assert goals == {
|
||||
"key": "goals",
|
||||
"label": "Goals",
|
||||
"group": "sell",
|
||||
"covers": "Sales goals, the leaderboard and goal history.",
|
||||
"stops": "Goals leave every menu, and goal milestone emails stop.",
|
||||
"enabled": True,
|
||||
"available": True,
|
||||
"needs": "deals",
|
||||
}
|
||||
|
||||
def test_no_session_is_403(self, unauthenticated_client):
|
||||
for response in (
|
||||
unauthenticated_client.get(URL),
|
||||
put(unauthenticated_client, []),
|
||||
):
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
def test_a_session_with_no_org_is_403(self, db):
|
||||
user = User.objects.create_user(email="lone@test.com", password="x")
|
||||
client = _make_authenticated_client(user, None, None)
|
||||
assert client.get(URL).status_code == 403
|
||||
|
||||
def test_patch_is_405(self, admin_client):
|
||||
assert admin_client.patch(URL, {"off": []}, format="json").status_code == 405
|
||||
|
||||
def test_labels_and_stops_follow_terminology_and_never_leak_the_template(
|
||||
self, admin_client, org_a
|
||||
):
|
||||
for terminology in (
|
||||
{},
|
||||
{"lead.plural": "Enquiries", "opportunity.plural": "Jobs"},
|
||||
):
|
||||
org_a.terminology = terminology
|
||||
org_a.save(update_fields=["terminology"])
|
||||
for response in (admin_client.get(URL), put(admin_client, [], KEYS)):
|
||||
body = response.json()
|
||||
assert all("{" not in r["stops"] for r in body["modules"])
|
||||
assert all("{label}" not in r["stops"] for r in body["modules"])
|
||||
by_key = rows(admin_client.get(URL))
|
||||
assert by_key["leads"]["label"] == "Enquiries"
|
||||
assert (
|
||||
by_key["leads"]["stops"] == "Enquiries leave every menu, search and Today."
|
||||
)
|
||||
assert by_key["deals"]["label"] == "Jobs"
|
||||
assert by_key["deals"]["stops"].startswith("Everything in Jobs leaves")
|
||||
assert by_key["invoices"]["stops"].endswith(
|
||||
"Payment reminders that fall due while Invoices is off are not sent later."
|
||||
)
|
||||
assert by_key["invoices"]["stops"].count("Invoices") == 2
|
||||
|
||||
|
||||
class TestWho:
|
||||
def test_a_member_cannot_switch_modules(self, user_client, org_a):
|
||||
response = put(user_client, ["tickets"])
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"error": True,
|
||||
"errors": "Only admins can switch modules on or off.",
|
||||
}
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == []
|
||||
assert not SecurityAuditLog.objects.filter(event_type="MODULES_CHANGED")
|
||||
|
||||
def test_a_member_is_refused_before_the_body_is_read(self, user_client):
|
||||
response = user_client.put(URL, {"off": "nonsense"}, format="json")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestSwitching:
|
||||
def test_an_admin_switches_tickets_off_and_on(self, admin_client, org_a):
|
||||
response = put(admin_client, ["tickets"])
|
||||
assert response.status_code == 200
|
||||
assert response.json() == admin_client.get(URL).json()
|
||||
by_key = rows(response)
|
||||
assert by_key["tickets"]["enabled"] is False
|
||||
assert by_key["timesheet"]["enabled"] is True
|
||||
assert by_key["timesheet"]["available"] is False
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == ["tickets"]
|
||||
assert org_a.modules_on_at == {}
|
||||
|
||||
by_key = rows(put(admin_client, []))
|
||||
assert by_key["tickets"]["available"] and by_key["timesheet"]["available"]
|
||||
org_a.refresh_from_db()
|
||||
assert set(org_a.modules_on_at) == {"tickets", "timesheet"}
|
||||
stamps = dict(org_a.modules_on_at)
|
||||
|
||||
assert put(admin_client, []).status_code == 200
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_on_at == stamps
|
||||
|
||||
def test_switching_deals_off_keeps_the_goals_setting(self, admin_client):
|
||||
response = put(admin_client, ["deals"])
|
||||
assert response.status_code == 200
|
||||
goals = rows(response)["goals"]
|
||||
assert goals["enabled"] is True
|
||||
assert goals["available"] is False
|
||||
|
||||
def test_goals_cannot_switch_on_while_deals_is_off(self, admin_client, org_a):
|
||||
set_modules_off(org_a, "deals", "goals")
|
||||
response = put(admin_client, ["deals"])
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"off": ["Goals needs Pipeline. Switch Pipeline on first."]
|
||||
}
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == ["deals", "goals"]
|
||||
response = put(admin_client, [])
|
||||
assert response.status_code == 200
|
||||
assert rows(response)["goals"]["available"] is True
|
||||
|
||||
NEEDS = {"off": ["Goals needs Pipeline. Switch Pipeline on first."]}
|
||||
|
||||
def test_goals_cannot_switch_on_as_pipeline_switches_off(self, admin_client, org_a):
|
||||
set_modules_off(org_a, "goals")
|
||||
response = put(admin_client, ["deals"])
|
||||
assert response.status_code == 400
|
||||
assert response.json() == self.NEEDS
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == ["goals"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored, sent, status",
|
||||
[
|
||||
([], ["deals"], 200),
|
||||
(["goals", "deals"], ["goals"], 200),
|
||||
(["goals", "deals"], [], 200),
|
||||
(["goals", "deals"], ["deals"], 400),
|
||||
],
|
||||
)
|
||||
def test_goals_needs_pipeline_on_the_new_off_list(
|
||||
self, admin_client, org_a, stored, sent, status
|
||||
):
|
||||
set_modules_off(org_a, *stored)
|
||||
response = put(admin_client, sent)
|
||||
assert response.status_code == status
|
||||
if status == 400:
|
||||
assert response.json() == self.NEEDS
|
||||
else:
|
||||
goals = rows(response)["goals"]
|
||||
assert (goals["enabled"], goals["available"]) == (
|
||||
"goals" not in sent,
|
||||
not sent,
|
||||
)
|
||||
|
||||
def test_timesheet_needs_tickets_the_same_way(self, admin_client, org_a):
|
||||
needs = {"off": ["Timesheet needs Tickets. Switch Tickets on first."]}
|
||||
set_modules_off(org_a, "timesheet")
|
||||
response = put(admin_client, ["tickets"])
|
||||
assert response.status_code == 400 and response.json() == needs
|
||||
set_modules_off(org_a, "timesheet", "tickets")
|
||||
assert put(admin_client, ["tickets"]).json() == needs
|
||||
assert put(admin_client, ["timesheet"]).status_code == 200
|
||||
assert put(admin_client, []).status_code == 200
|
||||
assert put(admin_client, ["tickets"]).status_code == 200
|
||||
assert rows(admin_client.get(URL))["timesheet"]["available"] is False
|
||||
|
||||
def test_the_order_is_saved_and_returned_within_groups(self, admin_client, org_a):
|
||||
order = [
|
||||
"goals",
|
||||
"leads",
|
||||
"deals",
|
||||
"documents",
|
||||
"tasks",
|
||||
"timesheet",
|
||||
"invoices",
|
||||
]
|
||||
response = put(admin_client, [], order)
|
||||
assert response.status_code == 200
|
||||
assert [r["key"] for r in response.json()["modules"]] == [
|
||||
"goals", "leads", "deals",
|
||||
"documents", "tasks", "tickets", "knowledge_base",
|
||||
"timesheet", "invoices",
|
||||
] # fmt: skip
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.module_order == order
|
||||
assert [r["key"] for r in admin_client.get(URL).json()["modules"]][:3] == [
|
||||
"goals", "leads", "deals",
|
||||
] # fmt: skip
|
||||
|
||||
def test_a_module_already_off_does_not_block_a_reorder(self, admin_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
form(org_a, "Support", target="ticket")
|
||||
response = put(admin_client, ["tickets"], ["leads", "deals"])
|
||||
assert response.status_code == 200
|
||||
assert [r["key"] for r in response.json()["modules"]][:2] == ["leads", "deals"]
|
||||
|
||||
|
||||
class TestMalformed:
|
||||
@pytest.mark.parametrize(
|
||||
"body, errors",
|
||||
[
|
||||
({"order": []}, {"off": ["This field is required."]}),
|
||||
({"off": []}, {"order": ["This field is required."]}),
|
||||
({"off": "tickets", "order": []}, {"off": ["Send a list of module keys."]}),
|
||||
({"off": [3], "order": []}, {"off": ["Send a list of module keys."]}),
|
||||
({"off": [], "order": "leads"}, {"order": ["Send a list of module keys."]}),
|
||||
({"off": ["crm"], "order": []}, {"off": ['Unknown module: "crm".']}),
|
||||
(
|
||||
{"off": [], "order": ["leads", "leads"]},
|
||||
{"order": ["Name each module once."]},
|
||||
),
|
||||
(
|
||||
{"off": ["x", "tickets"], "order": ["leads", "leads", "y"]},
|
||||
{"off": ['Unknown module: "x".'], "order": ['Unknown module: "y".']},
|
||||
),
|
||||
(
|
||||
[],
|
||||
{
|
||||
"off": ["This field is required."],
|
||||
"order": ["This field is required."],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_malformed_bodies_are_400(self, admin_client, org_a, body, errors):
|
||||
response = admin_client.put(URL, body, format="json")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == errors
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == [] and org_a.module_order == []
|
||||
assert org_a.modules_on_at == {}
|
||||
assert not SecurityAuditLog.objects.filter(event_type="MODULES_CHANGED")
|
||||
|
||||
|
||||
class TestRefusals:
|
||||
def test_published_forms_refuse_switching_their_module_off(
|
||||
self, admin_client, org_a
|
||||
):
|
||||
contact = form(org_a, "Contact us")
|
||||
form(org_a, "Draft", published=False)
|
||||
response = put(admin_client, ["leads"])
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"off": [
|
||||
"Leads still receives submissions from the published web form "
|
||||
'"Contact us". Unpublish it first, so no submission is lost.'
|
||||
]
|
||||
}
|
||||
form(org_a, "Zed help", target="ticket")
|
||||
form(org_a, "Billing help", target="ticket")
|
||||
response = put(admin_client, ["tickets"])
|
||||
assert response.json() == {
|
||||
"off": [
|
||||
"Tickets still receives submissions from 2 published web forms: "
|
||||
'"Billing help", "Zed help". Unpublish them first, so no '
|
||||
"submission is lost."
|
||||
]
|
||||
}
|
||||
WebForm.objects.filter(org=org_a).update(is_published=False)
|
||||
assert put(admin_client, ["leads", "tickets"]).status_code == 200
|
||||
assert contact.target_module == "leads"
|
||||
|
||||
def test_many_forms_are_capped_at_five_names(self, admin_client, org_a):
|
||||
for n in range(7):
|
||||
form(org_a, f"Form {n}")
|
||||
message = put(admin_client, ["leads"]).json()["off"][0]
|
||||
assert '"Form 4" and 2 more.' in message
|
||||
assert "7 published web forms" in message
|
||||
|
||||
def test_an_active_mailbox_refuses_switching_tickets_off(self, admin_client, org_a):
|
||||
InboundMailbox.objects.create(
|
||||
org=org_a, address="off@acme.test", is_active=False
|
||||
)
|
||||
assert put(admin_client, ["tickets"]).status_code == 200
|
||||
put(admin_client, [])
|
||||
InboundMailbox.objects.create(org=org_a, address="help@acme.test")
|
||||
response = put(admin_client, ["tickets"])
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"off": [
|
||||
"Tickets still receives email at help@acme.test. Switch that "
|
||||
"mailbox off first, so no email is lost."
|
||||
]
|
||||
}
|
||||
InboundMailbox.objects.create(org=org_a, address="billing@acme.test")
|
||||
assert put(admin_client, ["tickets"]).json() == {
|
||||
"off": [
|
||||
"Tickets still receives email at 2 mailboxes: billing@acme.test, "
|
||||
"help@acme.test. Switch them off first, so no email is lost."
|
||||
]
|
||||
}
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == []
|
||||
|
||||
def test_a_dependent_module_is_refused_too(self, admin_client, org_a):
|
||||
form(org_a, "Support", target="ticket")
|
||||
assert put(admin_client, ["tickets", "timesheet"]).status_code == 400
|
||||
|
||||
|
||||
class TestAudit:
|
||||
def test_a_change_is_audited_with_who_and_what(
|
||||
self, admin_client, admin_user, org_a
|
||||
):
|
||||
put(admin_client, ["tickets"])
|
||||
entries = SecurityAuditLog.objects.filter(event_type="MODULES_CHANGED")
|
||||
assert entries.count() == 1
|
||||
entry = entries.get()
|
||||
assert entry.user == admin_user and entry.org == org_a
|
||||
assert entry.metadata == {
|
||||
"switched_off": ["tickets"],
|
||||
"switched_on": [],
|
||||
"reordered": False,
|
||||
}
|
||||
put(admin_client, ["tickets"])
|
||||
put(admin_client, ["tickets"], DEFAULT_ORDER)
|
||||
assert entries.count() == 1
|
||||
put(admin_client, [], ["leads", "deals"])
|
||||
assert entries.count() == 2
|
||||
assert entries.order_by("-created_at").first().metadata == {
|
||||
"switched_off": [],
|
||||
"switched_on": ["tickets"],
|
||||
"reordered": True,
|
||||
}
|
||||
|
||||
def test_the_entry_carries_the_request_and_the_audit_api_returns_it(
|
||||
self, admin_client, org_a
|
||||
):
|
||||
admin_client.put(
|
||||
URL,
|
||||
{"off": ["tickets"], "order": []},
|
||||
format="json",
|
||||
HTTP_USER_AGENT="ModulesTest/1.0",
|
||||
REMOTE_ADDR="203.0.113.9",
|
||||
)
|
||||
entry = SecurityAuditLog.objects.get(event_type="MODULES_CHANGED")
|
||||
assert entry.ip_address == "203.0.113.9"
|
||||
assert entry.user_agent == "ModulesTest/1.0"
|
||||
assert entry.request_path == URL
|
||||
listed = admin_client.get(
|
||||
"/api/org/audit-log/", {"event_type": "MODULES_CHANGED"}
|
||||
).json()["results"]
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["details"] == {
|
||||
"switched_off": ["tickets"],
|
||||
"switched_on": [],
|
||||
"reordered": False,
|
||||
}
|
||||
assert listed[0]["ip_address"] == "203.0.113.9"
|
||||
assert listed[0]["request_path"] == URL
|
||||
|
||||
|
||||
class TestLeavesAlone:
|
||||
def test_switching_off_changes_no_record(self, admin_client, org_a, admin_profile):
|
||||
account = Account.objects.create(name="Acme", org=org_a)
|
||||
lead = Lead.objects.create(
|
||||
title="Lead", first_name="A", last_name="B", org=org_a
|
||||
)
|
||||
case = Case.objects.create(
|
||||
name="Ticket", status="New", priority="Normal", org=org_a, account=account
|
||||
)
|
||||
invoice = Invoice.objects.create(
|
||||
org=org_a, account=account, invoice_title="Work"
|
||||
)
|
||||
task = Task.objects.create(
|
||||
title="Task", status="New", priority="High", org=org_a
|
||||
)
|
||||
records = [lead, case, invoice, task]
|
||||
before = {r.pk: r.updated_at for r in records}
|
||||
off = ["leads", "tickets", "invoices", "tasks"]
|
||||
assert put(admin_client, off).status_code == 200
|
||||
org_a.refresh_from_db()
|
||||
assert set(org_a.modules_off) == set(off)
|
||||
assert put(admin_client, []).status_code == 200
|
||||
for r in records:
|
||||
r.refresh_from_db()
|
||||
assert r.updated_at == before[r.pk]
|
||||
assert Lead.objects.count() == Case.objects.count() == 1
|
||||
assert Invoice.objects.count() == Task.objects.count() == 1
|
||||
|
||||
def test_org_settings_cannot_write_module_fields(self, admin_client, org_a):
|
||||
response = admin_client.patch(
|
||||
"/api/org/settings/",
|
||||
{"modules_off": ["leads"], "module_order": ["leads"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == [] and org_a.module_order == []
|
||||
|
||||
def test_another_org_is_untouched(self, admin_client, org_a, org_b):
|
||||
set_modules_off(org_b, "goals")
|
||||
assert put(admin_client, ["tickets"], ["leads", "deals"]).status_code == 200
|
||||
org_a.refresh_from_db()
|
||||
assert org_a.modules_off == ["tickets"]
|
||||
assert org_a.module_order == ["leads", "deals"]
|
||||
org_b.refresh_from_db()
|
||||
assert org_b.modules_off == ["goals"]
|
||||
assert org_b.module_order == [] and org_b.modules_on_at == {}
|
||||
assert not SecurityAuditLog.objects.filter(org=org_b)
|
||||
|
||||
|
||||
@pytest.mark.postgres_only
|
||||
def test_refusals_see_rows_under_rls(admin_client, org_a):
|
||||
"""Skipped on SQLite. Under a non-superuser role an empty context matches no
|
||||
rows, so a refusal proves the request set the org context itself."""
|
||||
from django.db import connection
|
||||
|
||||
if connection.vendor != "postgresql":
|
||||
pytest.skip("RLS requires PostgreSQL")
|
||||
form(org_a, "Contact us")
|
||||
InboundMailbox.objects.create(org=org_a, address="help@acme.test")
|
||||
clear_rls_context()
|
||||
response = put(admin_client, ["leads"])
|
||||
assert response.status_code == 400
|
||||
assert "Contact us" in response.json()["off"][0]
|
||||
response = put(admin_client, ["tickets"])
|
||||
assert response.status_code == 400
|
||||
assert "help@acme.test" in response.json()["off"][0]
|
||||
|
|
@ -13,12 +13,15 @@ from common.object_registry import (
|
|||
BUILT_IN,
|
||||
RegistryConflict,
|
||||
all_objects,
|
||||
built_in_objects,
|
||||
object_for_key,
|
||||
object_for_record,
|
||||
object_for_target,
|
||||
object_module,
|
||||
register_hook,
|
||||
)
|
||||
from common.serializer import CustomFieldDefinitionSerializer
|
||||
from common.testing import set_modules_off
|
||||
from common.tests.object_records import make_record
|
||||
from conftest import rls_org
|
||||
|
||||
|
|
@ -285,3 +288,81 @@ def test_the_custom_field_choices_carry_labels():
|
|||
assert dict(CustomFieldDefinitionSerializer().fields["target_model"].choices) == {
|
||||
e.custom_field_target: e.label_plural for e in BUILT_IN
|
||||
}
|
||||
|
||||
|
||||
def _keys(entries):
|
||||
return {e.key for e in entries}
|
||||
|
||||
|
||||
def test_entry_module_follows_the_model_map():
|
||||
assert {e.key: e.module for e in BUILT_IN} == {
|
||||
"lead": "leads",
|
||||
"contact": None,
|
||||
"account": None,
|
||||
"deal": "deals",
|
||||
"ticket": "tickets",
|
||||
"task": "tasks",
|
||||
"invoice": "invoices",
|
||||
"estimate": "invoices",
|
||||
"recurring_invoice": "invoices",
|
||||
}
|
||||
|
||||
|
||||
def test_all_objects_drops_off_module_entries(org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
keys = _keys(all_objects(org_a))
|
||||
assert "ticket" not in keys and "lead" in keys
|
||||
set_modules_off(org_a, "invoices")
|
||||
keys = _keys(all_objects(org_a))
|
||||
assert not keys & {"invoice", "estimate", "recurring_invoice"}
|
||||
assert {"contact", "account", "ticket"} <= keys
|
||||
assert _keys(built_in_objects(org_a)) == keys
|
||||
set_modules_off(org_a)
|
||||
assert all_objects(org_a) == BUILT_IN == built_in_objects(org_a)
|
||||
|
||||
|
||||
def test_all_objects_without_an_org_is_the_full_list(org_a):
|
||||
set_modules_off(org_a, "tickets", "invoices")
|
||||
assert all_objects(None) == BUILT_IN
|
||||
|
||||
|
||||
def test_a_hook_entry_without_a_module_stays(monkeypatch, org_a):
|
||||
fake = FakeEntry("obj:fake", custom_field_target="obj:fake")
|
||||
monkeypatch.setattr(object_registry, "_HOOKS", [_hook_for(org_a, fake)])
|
||||
set_modules_off(org_a, "tickets", "invoices", "deals")
|
||||
assert all_objects(org_a)[-1] is fake
|
||||
assert "obj:fake" not in {e.key for e in built_in_objects(org_a)}
|
||||
|
||||
|
||||
def test_object_module_ignores_the_filter(org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert object_module("ticket") == "tickets"
|
||||
assert object_module("contact") is None
|
||||
assert object_module("ghost") is None
|
||||
assert object_module(target="Case") == "tickets"
|
||||
assert object_module(target="RecurringInvoice") == "invoices"
|
||||
assert object_module(target="Contact") is None
|
||||
assert object_module(target="ticket") is None
|
||||
|
||||
|
||||
def test_an_attachment_on_an_off_module_record_is_404(
|
||||
admin_client, admin_profile, org_a
|
||||
):
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from accounts.models import Account
|
||||
from common.utils import create_attachment
|
||||
|
||||
case = Case.objects.create(name="Watched", org=org_a, status="New")
|
||||
account = Account.objects.create(name="Acme", org=org_a)
|
||||
on_case = create_attachment(SimpleUploadedFile("a.txt", b"x"), case, admin_profile)
|
||||
on_account = create_attachment(
|
||||
SimpleUploadedFile("b.txt", b"y"), account, admin_profile
|
||||
)
|
||||
url = "/api/attachments/{}/download/"
|
||||
assert admin_client.get(url.format(on_case.pk)).status_code == 200
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert admin_client.get(url.format(on_case.pk)).status_code == 404
|
||||
assert admin_client.get(url.format(on_account.pk)).status_code == 200
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.get(url.format(on_case.pk)).status_code == 200
|
||||
|
|
|
|||
|
|
@ -79,6 +79,24 @@ class TestPackAPI:
|
|||
res = admin_client.delete("/api/packs/sample-data/")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["deleted"] >= 8
|
||||
assert res.json()["switched_off_by_type"] == {}
|
||||
|
||||
def test_clearing_reports_the_sample_rows_an_off_module_kept(
|
||||
self, admin_client, org_a
|
||||
):
|
||||
from cases.models import Case
|
||||
from common.testing import set_modules_off
|
||||
|
||||
admin_client.post("/api/packs/real-estate/apply/")
|
||||
kept = Case.objects.filter(org=org_a, is_sample=True).count()
|
||||
assert kept
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
res = admin_client.delete("/api/packs/sample-data/")
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["switched_off_by_type"] == {"sample_ticket": kept}
|
||||
assert Case.objects.filter(org=org_a, is_sample=True).count() == kept
|
||||
|
||||
def test_clear_sample_data_does_not_touch_another_org(
|
||||
self, admin_client, org_b_client, org_a, org_b
|
||||
|
|
|
|||
153
backend/common/tests/test_packs_module_off.py
Normal file
153
backend/common/tests/test_packs_module_off.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Applying and clearing a starter pack while modules are off: off parts are
|
||||
reported as skipped with the module message, and no off module's sample rows
|
||||
are ever deleted."""
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.models import Case, CasePipeline
|
||||
from common.models import CustomFieldDefinition
|
||||
from common.modules import module_off_message
|
||||
from common.packs.applier import apply_pack, clear_sample_data
|
||||
from common.packs.loader import get_pack
|
||||
from common.testing import set_modules_off
|
||||
from contacts.models import Contact
|
||||
from invoices.models import Product
|
||||
from leads.models import Lead, LeadPipeline
|
||||
from opportunity.models import Opportunity
|
||||
from tasks.models import Task, TaskPipeline
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack():
|
||||
"""Real estate plus a ticket custom field, trimmed to two sample tickets."""
|
||||
pack = copy.deepcopy(get_pack("real-estate"))
|
||||
pack["custom_fields"] = [
|
||||
*pack["custom_fields"],
|
||||
{
|
||||
"target": "Case",
|
||||
"key": "severity",
|
||||
"label": "Severity",
|
||||
"field_type": "text",
|
||||
},
|
||||
]
|
||||
sample = pack["sample_data"]
|
||||
sample["tickets"] = sample["tickets"][:2]
|
||||
kept = {t["name"] for t in sample["tickets"]}
|
||||
sample["tasks"] = [
|
||||
t for t in sample["tasks"] if not t.get("ticket") or t["ticket"] in kept
|
||||
]
|
||||
return pack
|
||||
|
||||
|
||||
def _skipped(report, org, key):
|
||||
message = module_off_message(org, key)
|
||||
return [(s["type"], s["name"]) for s in report["skipped"] if s["reason"] == message]
|
||||
|
||||
|
||||
def test_a_pack_skips_off_modules_and_says_why(org_a, admin_profile, pack):
|
||||
set_modules_off(org_a, "tickets", "deals")
|
||||
sample = pack["sample_data"]
|
||||
ticket_tasks = [t["title"] for t in sample["tasks"] if t.get("ticket")]
|
||||
deal_tasks = [t["title"] for t in sample["tasks"] if t.get("deal")]
|
||||
assert ticket_tasks and deal_tasks
|
||||
|
||||
report = apply_pack(org_a, pack, admin_profile)
|
||||
|
||||
assert not CasePipeline.objects.filter(org=org_a).exists()
|
||||
assert not CustomFieldDefinition.objects.filter(
|
||||
org=org_a, target_model="Case"
|
||||
).exists()
|
||||
assert not Case.objects.filter(org=org_a).exists()
|
||||
assert not Opportunity.objects.filter(org=org_a).exists()
|
||||
skipped_tickets = _skipped(report, org_a, "tickets")
|
||||
assert ("case_pipeline", pack["case_pipeline"]["name"]) in skipped_tickets
|
||||
assert ("custom_field", "severity") in skipped_tickets
|
||||
assert {("sample_ticket", t["name"]) for t in sample["tickets"]} <= set(
|
||||
skipped_tickets
|
||||
)
|
||||
assert {("sample_task", title) for title in ticket_tasks} <= set(skipped_tickets)
|
||||
skipped_deals = _skipped(report, org_a, "deals")
|
||||
assert {("sample_deal", d["name"]) for d in sample["deals"]} <= set(skipped_deals)
|
||||
assert {("sample_task", title) for title in deal_tasks} <= set(skipped_deals)
|
||||
# The parts of modules that are on are built as before.
|
||||
assert LeadPipeline.objects.filter(org=org_a).exists()
|
||||
assert Lead.objects.filter(org=org_a, is_sample=True).exists()
|
||||
assert Account.objects.filter(org=org_a, is_sample=True).exists()
|
||||
assert Contact.objects.filter(org=org_a, is_sample=True).exists()
|
||||
assert Task.objects.filter(org=org_a, is_sample=True).exists()
|
||||
assert report["failed"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "key", "pipeline"),
|
||||
[
|
||||
("leads", "lead_pipeline", LeadPipeline),
|
||||
("tickets", "case_pipeline", CasePipeline),
|
||||
("tasks", "task_pipeline", TaskPipeline),
|
||||
],
|
||||
)
|
||||
def test_each_pipeline_follows_its_module(
|
||||
org_a, admin_profile, pack, module, key, pipeline
|
||||
):
|
||||
set_modules_off(org_a, module)
|
||||
# Read before the pack applies its terminology.
|
||||
reason = module_off_message(org_a, module)
|
||||
|
||||
report = apply_pack(org_a, pack, admin_profile)
|
||||
|
||||
name = pack[key]["name"]
|
||||
assert not pipeline.objects.filter(org=org_a, name=name).exists()
|
||||
assert {"type": key, "name": name, "reason": reason} in report["skipped"]
|
||||
others = {"lead_pipeline", "case_pipeline", "task_pipeline"} - {key}
|
||||
assert {c["type"] for c in report["created"]} >= others
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("off", "built"),
|
||||
[
|
||||
(("deals",), True),
|
||||
(("invoices",), True),
|
||||
(("deals", "invoices"), False),
|
||||
],
|
||||
)
|
||||
def test_products_skip_only_when_deals_and_invoices_are_both_off(
|
||||
org_a, admin_profile, pack, off, built
|
||||
):
|
||||
set_modules_off(org_a, *off)
|
||||
|
||||
report = apply_pack(org_a, pack, admin_profile)
|
||||
|
||||
assert Product.objects.filter(org=org_a).exists() is built
|
||||
skipped = _skipped(report, org_a, "invoices")
|
||||
assert (("product", pack["products"][0]["name"]) in skipped) is not built
|
||||
|
||||
|
||||
def test_clearing_sample_data_keeps_off_module_rows(org_a, admin_profile, pack):
|
||||
apply_pack(org_a, pack, admin_profile)
|
||||
tickets = Case.objects.filter(org=org_a, is_sample=True)
|
||||
assert tickets.count() == 2
|
||||
ticket_accounts = set(tickets.values_list("account_id", flat=True)) - {None}
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
result = clear_sample_data(org_a)
|
||||
|
||||
assert result["switched_off"] == {"sample_ticket": 2}
|
||||
assert tickets.count() == 2
|
||||
assert ticket_accounts
|
||||
assert ticket_accounts <= set(
|
||||
Account.objects.filter(org=org_a, is_sample=True).values_list("id", flat=True)
|
||||
)
|
||||
for model in (Lead, Opportunity, Task, Contact):
|
||||
assert not model.objects.filter(org=org_a, is_sample=True).exists()
|
||||
assert "sample_ticket" not in result["deleted"]
|
||||
|
||||
|
||||
def test_clearing_with_every_module_on_reports_nothing_switched_off(
|
||||
org_a, admin_profile, pack
|
||||
):
|
||||
apply_pack(org_a, pack, admin_profile)
|
||||
|
||||
assert clear_sample_data(org_a)["switched_off"] == {}
|
||||
|
|
@ -27,6 +27,7 @@ from common.views.document_views import (
|
|||
DocumentDownloadView,
|
||||
DocumentListView,
|
||||
)
|
||||
from common.views.module_views import OrgModulesView
|
||||
from common.views.notification_views import (
|
||||
NotificationDetailView,
|
||||
NotificationListView,
|
||||
|
|
@ -122,6 +123,7 @@ urlpatterns = [
|
|||
HelpCenterSettingsView.as_view(),
|
||||
name="help_center_settings",
|
||||
),
|
||||
path("org/modules/", OrgModulesView.as_view(), name="org_modules"),
|
||||
path("org/tokens/", OrgAccessTokenListView.as_view(), name="org_pat_list"),
|
||||
path("org/audit-log/", SecurityAuditLogListView.as_view(), name="org_audit_log"),
|
||||
path(
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ What leaves the server is chosen field by field rather than dumped:
|
|||
* `description` is not returned. An ORG_SWITCH row's text names the org the
|
||||
user switched from, which is another tenant.
|
||||
* `metadata` is cut down to `SAFE_DETAILS`, keys whose values are ids, counts
|
||||
or sentences the server wrote, plus `EVENT_DETAILS` for the one event that
|
||||
wrote them (a merge's two record names, an API token's name, prefix, scopes
|
||||
and owner). Other keys can hold what a caller supplied:
|
||||
`suspicious_activity(details=...)`, the email a failed login tried, an API
|
||||
or sentences the server wrote, plus `EVENT_DETAILS` for the events that
|
||||
write them (a merge's two record names, an API token's name, prefix, scopes
|
||||
and owner, a module change's switched off and on keys). Other keys can hold
|
||||
what a caller supplied: `suspicious_activity(details=...)`, the email a failed login tried, an API
|
||||
key prefix.
|
||||
* A path under `/api/public/` can carry a bearer token in the URL (the CSAT
|
||||
link does), so only that prefix is shown.
|
||||
|
|
@ -75,6 +75,7 @@ EVENT_DETAILS = {
|
|||
"RECORD_MERGED": ("entity", "kept_id", "kept_name", "merged_id", "merged_name"),
|
||||
"API_TOKEN_CREATED": _API_TOKEN_DETAILS,
|
||||
"API_TOKEN_REVOKED": _API_TOKEN_DETAILS,
|
||||
"MODULES_CHANGED": ("switched_off", "switched_on", "reordered"),
|
||||
}
|
||||
|
||||
PUBLIC_PREFIX = "/api/public/"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from common.audit_log import audit_log
|
|||
from common.calendar_feed import FEED_PATH_PREFIX, render_calendar
|
||||
from common.links import api_url, frontend_url
|
||||
from common.models import CalendarFeedToken, Profile
|
||||
from common.modules import module_is_available
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.permissions import HasOrgContext
|
||||
from common.request_meta import client_ip
|
||||
|
|
@ -64,9 +65,9 @@ MAX_EVENTS = 1000
|
|||
TOUCH_INTERVAL = timedelta(hours=1)
|
||||
|
||||
# One body for every miss: a malformed token, an unknown one, a regenerated or
|
||||
# disabled one, and one whose member, user or org is no longer active. Telling
|
||||
# any two apart would tell a stranger holding an old URL something about the
|
||||
# account behind it.
|
||||
# disabled one, one whose member, user or org is no longer active, and one in
|
||||
# an org with Tasks switched off. Telling any two apart would tell a stranger
|
||||
# holding an old URL something about the account behind it.
|
||||
NOT_FOUND = {"error": "Not found"}
|
||||
|
||||
|
||||
|
|
@ -194,6 +195,8 @@ class PublicCalendarFeedView(FirstRefusalThrottleMixin, APIView):
|
|||
profile = feed.profile
|
||||
if not (profile.is_active and profile.user.is_active and profile.org.is_active):
|
||||
return None
|
||||
if not module_is_available(profile.org, "tasks"):
|
||||
return None
|
||||
return feed
|
||||
|
||||
@extend_schema(
|
||||
|
|
|
|||
|
|
@ -13,15 +13,18 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from common.models import CustomFieldDefinition
|
||||
from common.object_registry import BUILT_IN, all_objects
|
||||
from common.object_registry import all_objects, built_in_objects
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import CustomFieldDefinitionSerializer
|
||||
|
||||
|
||||
def _built_in_definitions(**filters):
|
||||
"""Rows on non-built-in targets are never exposed here."""
|
||||
def _built_in_definitions(org, **filters):
|
||||
"""Rows on custom-object targets, and on targets whose module is off for
|
||||
`org`, are never exposed here."""
|
||||
return CustomFieldDefinition.objects.filter(
|
||||
target_model__in=[e.custom_field_target for e in BUILT_IN], **filters
|
||||
target_model__in=[e.custom_field_target for e in built_in_objects(org)],
|
||||
org=org,
|
||||
**filters,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,7 +89,7 @@ class CustomFieldDefinitionListCreateView(APIView):
|
|||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
org = request.profile.org
|
||||
qs = _built_in_definitions(org=org)
|
||||
qs = _built_in_definitions(org)
|
||||
target_model = request.query_params.get("target_model")
|
||||
if target_model:
|
||||
qs = qs.filter(target_model=target_model)
|
||||
|
|
@ -108,7 +111,7 @@ class CustomFieldDefinitionListCreateView(APIView):
|
|||
# records_missing_value + totals are computed over the org's FULL
|
||||
# definition set (independent of the target_model/active_only filters)
|
||||
# so the stat cards stay correct when the list is filtered.
|
||||
all_defs = list(_built_in_definitions(org=org))
|
||||
all_defs = list(_built_in_definitions(org))
|
||||
missing_by_id = _records_missing_map(org, all_defs)
|
||||
|
||||
for row in rows:
|
||||
|
|
@ -148,7 +151,7 @@ class CustomFieldDefinitionDetailView(APIView):
|
|||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
def _get_object(self, pk, org):
|
||||
return _built_in_definitions(pk=pk, org=org).first()
|
||||
return _built_in_definitions(org, pk=pk).first()
|
||||
|
||||
@extend_schema(
|
||||
tags=["CustomFields"], responses={200: CustomFieldDefinitionSerializer}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ from cases.access import visible_cases_qs, writable_cases_qs
|
|||
from cases.models import Case
|
||||
from common import serializer, swagger_params
|
||||
from common.models import Activity
|
||||
from common.modules import (
|
||||
MODEL_MODULES,
|
||||
available_modules,
|
||||
module_is_available,
|
||||
only_available,
|
||||
)
|
||||
from common.object_registry import all_objects
|
||||
from common.permissions import HasOrgContext, can_mass_import, is_org_admin
|
||||
from contacts.access import visible_contacts_qs
|
||||
|
|
@ -63,7 +69,9 @@ def import_prompt_facts(profile):
|
|||
"""
|
||||
has_records = (
|
||||
visible_contacts_qs(profile).filter(is_sample=False).exists()
|
||||
or visible_leads_qs(profile).filter(is_sample=False).exists()
|
||||
or only_available(
|
||||
profile.org, "leads", visible_leads_qs(profile).filter(is_sample=False)
|
||||
).exists()
|
||||
)
|
||||
return {
|
||||
"empty_workspace": not has_records,
|
||||
|
|
@ -100,6 +108,10 @@ def _readable(queryset, visible):
|
|||
return queryset.filter(pk__in=visible.values("pk"))
|
||||
|
||||
|
||||
# Activity rows store the bare model name, not the registry label.
|
||||
_MODEL_NAME_MODULES = {label.split(".")[1]: key for label, key in MODEL_MODULES.items()}
|
||||
|
||||
|
||||
def _readable_activities(profile):
|
||||
"""The org's activities that ``profile`` may see: one query, whatever the size.
|
||||
|
||||
|
|
@ -114,7 +126,9 @@ def _readable_activities(profile):
|
|||
"""
|
||||
qs = Activity.objects.filter(org=profile.org)
|
||||
if is_org_admin(profile):
|
||||
return qs
|
||||
available = available_modules(profile.org)
|
||||
off = [n for n, key in _MODEL_NAME_MODULES.items() if key not in available]
|
||||
return qs.exclude(entity_type__in=off) if off else qs
|
||||
readable = Q()
|
||||
for entry in all_objects(profile.org):
|
||||
if entry.activity_feed:
|
||||
|
|
@ -171,12 +185,19 @@ class ApiHomeView(APIView):
|
|||
)
|
||||
# Kept separate from `leads` because the conversion rate below needs
|
||||
# converted leads, which `leads` deliberately excludes.
|
||||
all_leads = _readable(Lead.objects.filter(org=org), visible_leads_qs(profile))
|
||||
all_leads = _readable(
|
||||
only_available(org, "leads", Lead.objects.filter(org=org)),
|
||||
visible_leads_qs(profile),
|
||||
)
|
||||
leads = all_leads.exclude(Q(status="converted") | Q(status="closed"))
|
||||
opportunities = _readable(
|
||||
Opportunity.objects.filter(org=org), visible_deals_qs(profile)
|
||||
only_available(org, "deals", Opportunity.objects.filter(org=org)),
|
||||
visible_deals_qs(profile),
|
||||
)
|
||||
tasks = _readable(
|
||||
only_available(org, "tasks", Task.objects.filter(org=org)),
|
||||
visible_tasks_qs(profile),
|
||||
)
|
||||
tasks = _readable(Task.objects.filter(org=org), visible_tasks_qs(profile))
|
||||
|
||||
# Decides only whether the goal summary below includes org-wide goals.
|
||||
is_admin = is_org_admin(profile)
|
||||
|
|
@ -225,32 +246,35 @@ class ApiHomeView(APIView):
|
|||
in_org_currency = (
|
||||
Q(currency=org_currency) | Q(currency__isnull=True) | Q(currency="")
|
||||
)
|
||||
default_pipeline = DealPipeline.default_for(org)
|
||||
grouped = {
|
||||
row["stage"]: row
|
||||
for row in opportunities.filter(pipeline=default_pipeline)
|
||||
.values("stage")
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
# Value only for deals in the org's currency.
|
||||
value=Coalesce(
|
||||
Sum("amount", filter=in_org_currency),
|
||||
0,
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
)
|
||||
.order_by()
|
||||
}
|
||||
context["pipeline_by_stage"] = {
|
||||
stage.code: {
|
||||
"count": grouped.get(stage.code, {}).get("count", 0),
|
||||
"value": float(grouped.get(stage.code, {}).get("value") or 0),
|
||||
"label": stage.label,
|
||||
"kind": stage.kind,
|
||||
"order": stage.order,
|
||||
context["pipeline_by_stage"] = {}
|
||||
# Skipped while Pipeline is off, so `default_for` creates no pipeline.
|
||||
if module_is_available(org, "deals"):
|
||||
default_pipeline = DealPipeline.default_for(org)
|
||||
grouped = {
|
||||
row["stage"]: row
|
||||
for row in opportunities.filter(pipeline=default_pipeline)
|
||||
.values("stage")
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
# Value only for deals in the org's currency.
|
||||
value=Coalesce(
|
||||
Sum("amount", filter=in_org_currency),
|
||||
0,
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
)
|
||||
.order_by()
|
||||
}
|
||||
context["pipeline_by_stage"] = {
|
||||
stage.code: {
|
||||
"count": grouped.get(stage.code, {}).get("count", 0),
|
||||
"value": float(grouped.get(stage.code, {}).get("value") or 0),
|
||||
"label": stage.label,
|
||||
"kind": stage.kind,
|
||||
"order": stage.order,
|
||||
}
|
||||
for stage in default_pipeline.stages.all()
|
||||
}
|
||||
for stage in default_pipeline.stages.all()
|
||||
}
|
||||
|
||||
# Revenue metrics (filtered by org's default currency), across every
|
||||
# pipeline: open and won are the stage's kind, whatever it is called.
|
||||
|
|
@ -348,11 +372,15 @@ class ApiHomeView(APIView):
|
|||
goal_filter |= Q(assigned_to__isnull=True, team__isnull=True)
|
||||
|
||||
active_goals = SalesGoal.attach_progress(
|
||||
SalesGoal.objects.filter(
|
||||
org=org,
|
||||
is_active=True,
|
||||
period_start__lte=today,
|
||||
period_end__gte=today,
|
||||
only_available(
|
||||
org,
|
||||
"goals",
|
||||
SalesGoal.objects.filter(
|
||||
org=org,
|
||||
is_active=True,
|
||||
period_start__lte=today,
|
||||
period_end__gte=today,
|
||||
),
|
||||
)
|
||||
.filter(goal_filter)
|
||||
.select_related("assigned_to", "team")
|
||||
|
|
@ -463,23 +491,38 @@ class ApiTodayView(APIView):
|
|||
|
||||
# ── base, org-scoped querysets ──────────────────────────────────────
|
||||
opportunities = _readable(
|
||||
Opportunity.objects.filter(stage_kind_q(OPEN), org=org), visible_deals
|
||||
only_available(
|
||||
org, "deals", Opportunity.objects.filter(stage_kind_q(OPEN), org=org)
|
||||
),
|
||||
visible_deals,
|
||||
)
|
||||
# The one source whose action needs more than reading: "Reply" is a
|
||||
# write, so a ticket the caller only watches would be a dead button.
|
||||
cases = _readable(
|
||||
Case.objects.filter(org=org, status__in=OPEN_CASE_STATUSES),
|
||||
only_available(
|
||||
org,
|
||||
"tickets",
|
||||
Case.objects.filter(org=org, status__in=OPEN_CASE_STATUSES),
|
||||
),
|
||||
writable_cases_qs(profile),
|
||||
)
|
||||
invoices = _readable(
|
||||
Invoice.objects.filter(
|
||||
org=org, status__in=UNPAID_STATUSES, due_date__lt=today
|
||||
only_available(
|
||||
org,
|
||||
"invoices",
|
||||
Invoice.objects.filter(
|
||||
org=org, status__in=UNPAID_STATUSES, due_date__lt=today
|
||||
),
|
||||
),
|
||||
visible_invoices,
|
||||
)
|
||||
tasks = _readable(
|
||||
Task.objects.filter(
|
||||
org=org, status__in=["New", "In Progress"], due_date__lte=today
|
||||
only_available(
|
||||
org,
|
||||
"tasks",
|
||||
Task.objects.filter(
|
||||
org=org, status__in=["New", "In Progress"], due_date__lte=today
|
||||
),
|
||||
),
|
||||
visible_tasks,
|
||||
)
|
||||
|
|
@ -615,13 +658,21 @@ class ApiTodayView(APIView):
|
|||
# Tasks have no completed_at, so proxy with "marked Completed and last
|
||||
# touched yesterday"; cases carry a real closed_on date.
|
||||
cleared_tasks = _readable(
|
||||
Task.objects.filter(
|
||||
org=org, status="Completed", updated_at__date=yesterday
|
||||
only_available(
|
||||
org,
|
||||
"tasks",
|
||||
Task.objects.filter(
|
||||
org=org, status="Completed", updated_at__date=yesterday
|
||||
),
|
||||
),
|
||||
visible_tasks,
|
||||
)
|
||||
cleared_cases = _readable(
|
||||
Case.objects.filter(org=org, status="Closed", closed_on=yesterday),
|
||||
only_available(
|
||||
org,
|
||||
"tickets",
|
||||
Case.objects.filter(org=org, status="Closed", closed_on=yesterday),
|
||||
),
|
||||
visible_cases,
|
||||
)
|
||||
|
||||
|
|
@ -638,26 +689,36 @@ class ApiTodayView(APIView):
|
|||
# ── "later this week" (due tomorrow … +7 days) ──────────────────────
|
||||
soon = Q(due_date__gt=today, due_date__lte=week_end)
|
||||
later_tasks = _readable(
|
||||
Task.objects.filter(org=org, status__in=["New", "In Progress"]).filter(
|
||||
soon
|
||||
),
|
||||
only_available(
|
||||
org,
|
||||
"tasks",
|
||||
Task.objects.filter(org=org, status__in=["New", "In Progress"]),
|
||||
).filter(soon),
|
||||
visible_tasks,
|
||||
)
|
||||
later_opps = _readable(
|
||||
Opportunity.objects.filter(
|
||||
stage_kind_q(OPEN),
|
||||
org=org,
|
||||
closed_on__gt=today,
|
||||
closed_on__lte=week_end,
|
||||
only_available(
|
||||
org,
|
||||
"deals",
|
||||
Opportunity.objects.filter(
|
||||
stage_kind_q(OPEN),
|
||||
org=org,
|
||||
closed_on__gt=today,
|
||||
closed_on__lte=week_end,
|
||||
),
|
||||
),
|
||||
visible_deals,
|
||||
)
|
||||
later_invoices = _readable(
|
||||
Invoice.objects.filter(
|
||||
org=org,
|
||||
status__in=UNPAID_STATUSES,
|
||||
due_date__gt=today,
|
||||
due_date__lte=week_end,
|
||||
only_available(
|
||||
org,
|
||||
"invoices",
|
||||
Invoice.objects.filter(
|
||||
org=org,
|
||||
status__in=UNPAID_STATUSES,
|
||||
due_date__gt=today,
|
||||
due_date__lte=week_end,
|
||||
),
|
||||
),
|
||||
visible_invoices,
|
||||
)
|
||||
|
|
|
|||
43
backend/common/views/module_views.py
Normal file
43
backend/common/views/module_views.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.module_settings import change_modules, modules_payload
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
|
||||
|
||||
class OrgModulesView(APIView):
|
||||
"""Which parts of the CRM an org uses. Any member reads, only an admin
|
||||
writes. The org is always ``request.profile.org``."""
|
||||
|
||||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
@extend_schema(
|
||||
tags=["organization"],
|
||||
operation_id="org_modules_retrieve",
|
||||
responses={200: dict},
|
||||
)
|
||||
def get(self, request):
|
||||
return Response(modules_payload(request.profile.org, request.profile))
|
||||
|
||||
@extend_schema(
|
||||
tags=["organization"],
|
||||
operation_id="org_modules_update",
|
||||
request=dict,
|
||||
responses={200: dict},
|
||||
)
|
||||
def put(self, request):
|
||||
profile = request.profile
|
||||
if not is_org_admin(profile):
|
||||
return Response(
|
||||
{"error": True, "errors": "Only admins can switch modules on or off."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
errors = change_modules(profile.org, profile, request.data, request=request)
|
||||
if errors:
|
||||
return Response(errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
org = profile.org
|
||||
org.refresh_from_db(fields=["modules_off", "module_order", "modules_on_at"])
|
||||
return Response(modules_payload(org, profile))
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
from django.db import IntegrityError, transaction
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.modules import module_is_available, module_off_body
|
||||
from common.org_time import selectable_timezones
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import (
|
||||
|
|
@ -71,6 +73,14 @@ class HelpCenterSettingsView(APIView):
|
|||
|
||||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
# `/api/org/` is exempt from the middleware's module gate, so the
|
||||
# knowledge base's own refusal is here, after auth, for every method.
|
||||
super().initial(request, *args, **kwargs)
|
||||
org = request.profile.org
|
||||
if not module_is_available(org, "knowledge_base"):
|
||||
raise NotFound(module_off_body(org, "knowledge_base"))
|
||||
|
||||
def _can_edit(self, request):
|
||||
return is_org_admin(request.profile)
|
||||
|
||||
|
|
|
|||
|
|
@ -86,5 +86,6 @@ class PackSampleDataView(APIView):
|
|||
"deleted": sum(result["deleted"].values()),
|
||||
"deleted_by_type": result["deleted"],
|
||||
"retained_by_type": result["retained"],
|
||||
"switched_off_by_type": result["switched_off"],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ checked in ``common/saved_views.py``.
|
|||
"""
|
||||
|
||||
from django.db.models.functions import Lower
|
||||
from django.http import Http404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
|
|
@ -24,7 +25,12 @@ from rest_framework.views import APIView
|
|||
from common.lookups import get_scoped_or_404
|
||||
from common.models import SavedView
|
||||
from common.permissions import HasOrgContext
|
||||
from common.saved_views import LISTS, MAX_VIEWS_PER_LIST, SavedViewSerializer
|
||||
from common.saved_views import (
|
||||
LISTS,
|
||||
MAX_VIEWS_PER_LIST,
|
||||
SavedViewSerializer,
|
||||
off_lists,
|
||||
)
|
||||
|
||||
|
||||
class SavedViewListView(APIView):
|
||||
|
|
@ -42,7 +48,9 @@ class SavedViewListView(APIView):
|
|||
)
|
||||
def get(self, request):
|
||||
profile = request.profile
|
||||
views = SavedView.objects.filter(org=profile.org, profile=profile)
|
||||
views = SavedView.objects.filter(org=profile.org, profile=profile).exclude(
|
||||
module__in=off_lists(profile.org)
|
||||
)
|
||||
module = request.query_params.get("module")
|
||||
if module is not None:
|
||||
if module not in LISTS:
|
||||
|
|
@ -77,9 +85,12 @@ class SavedViewDetailView(APIView):
|
|||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
def get_view(self, request, pk):
|
||||
return get_scoped_or_404(
|
||||
view = get_scoped_or_404(
|
||||
SavedView, pk, request.profile.org, profile=request.profile
|
||||
)
|
||||
if view.module in off_lists(request.profile.org):
|
||||
raise Http404("No such saved view.")
|
||||
return view
|
||||
|
||||
@extend_schema(tags=["Saved views"], responses=SavedViewSerializer)
|
||||
def get(self, request, pk):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from cases.models import Case, Solution
|
|||
from common import swagger_params
|
||||
from common.lookups import get_scoped_or_404
|
||||
from common.models import APISettings, Tags
|
||||
from common.modules import available_modules, model_module
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import TagsSerializer
|
||||
from contacts.models import Contact
|
||||
|
|
@ -52,6 +53,15 @@ _TAGGABLE = (
|
|||
)
|
||||
|
||||
|
||||
def _usage_map(tag, org):
|
||||
shown = {None, *available_modules(org)}
|
||||
return {
|
||||
key: getattr(tag, f"_u_{key}")
|
||||
for key, model in _TAGGABLE
|
||||
if model_module(model._meta.label) in shown
|
||||
}
|
||||
|
||||
|
||||
def _usage_subquery(model, org):
|
||||
"""Per-tag count of `model` rows in `org` that carry the tag.
|
||||
|
||||
|
|
@ -160,7 +170,7 @@ class TagsListView(APIView, LimitOffsetPagination):
|
|||
rows = TagsSerializer(tags_qs, many=True).data
|
||||
# Attach usage from the annotations (serializer order matches the qs).
|
||||
for row, obj in zip(rows, tags_qs):
|
||||
row["usage"] = {key: getattr(obj, f"_u_{key}") for key, _ in _TAGGABLE}
|
||||
row["usage"] = _usage_map(obj, request.profile.org)
|
||||
return Response(
|
||||
{
|
||||
"tags_count": len(rows),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from cases.models import Case
|
|||
from cases.serializer import CaseSerializer, parent_access_context
|
||||
from common import swagger_params
|
||||
from common.models import Comment, PersonalAccessToken, Profile, Teams, User
|
||||
from common.module_deletes import delete_refusal
|
||||
from common.modules import MODEL_MODULES, available_modules, only_available
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import (
|
||||
BillingAddressSerializer,
|
||||
|
|
@ -50,6 +52,19 @@ def _valid_token_counts_by_profile(org):
|
|||
return {str(r["profile_id"]): r["n"] for r in rows}
|
||||
|
||||
|
||||
def _comments_on_off_modules(org):
|
||||
"""Matches a comment on a record of a module that is off for ``org``."""
|
||||
available = available_modules(org)
|
||||
matches = Q(pk__in=[])
|
||||
for label, key in MODEL_MODULES.items():
|
||||
if key not in available:
|
||||
app_label, model = label.split(".")
|
||||
matches |= Q(
|
||||
content_type__app_label=app_label, content_type__model=model.lower()
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
class GetTeamsAndUsersView(APIView):
|
||||
permission_classes = (IsAuthenticated, HasOrgContext)
|
||||
|
||||
|
|
@ -323,9 +338,10 @@ class UserDetailView(APIView):
|
|||
).values("id", "user__email")
|
||||
context = {}
|
||||
context["profile_obj"] = ProfileSerializer(profile_obj).data
|
||||
org = request.profile.org
|
||||
# Security fix: Add org filter to prevent cross-org data leakage
|
||||
opportunity_list = Opportunity.objects.filter(
|
||||
assigned_to=profile_obj, org=request.profile.org
|
||||
opportunity_list = only_available(
|
||||
org, "deals", Opportunity.objects.filter(assigned_to=profile_obj, org=org)
|
||||
)
|
||||
context["opportunity_list"] = OpportunitySerializer(
|
||||
opportunity_list, many=True
|
||||
|
|
@ -334,13 +350,15 @@ class UserDetailView(APIView):
|
|||
assigned_to=profile_obj, org=request.profile.org
|
||||
)
|
||||
context["contacts"] = ContactSerializer(contacts, many=True).data
|
||||
cases = Case.objects.filter(assigned_to=profile_obj, org=request.profile.org)
|
||||
cases = only_available(
|
||||
org, "tickets", Case.objects.filter(assigned_to=profile_obj, org=org)
|
||||
)
|
||||
context["cases"] = CaseSerializer(
|
||||
cases, many=True, context=parent_access_context(request.profile, cases)
|
||||
).data
|
||||
context["assigned_data"] = assigned_data
|
||||
comments = Comment.objects.filter(
|
||||
commented_by=profile_obj, org=request.profile.org
|
||||
comments = Comment.objects.filter(commented_by=profile_obj, org=org).exclude(
|
||||
_comments_on_off_modules(org)
|
||||
)
|
||||
context["comments"] = CommentSerializer(comments, many=True).data
|
||||
context["countries"] = COUNTRIES
|
||||
|
|
@ -518,6 +536,9 @@ class UserDetailView(APIView):
|
|||
{"error": True, "errors": "Permission Denied"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
refusal = delete_refusal(self.object, "This person")
|
||||
if refusal:
|
||||
return Response(refusal, status=status.HTTP_409_CONFLICT)
|
||||
deleted_by = self.request.profile.user.email
|
||||
recipient = self.object.user.email
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from common.models import (
|
|||
Tags,
|
||||
Teams,
|
||||
)
|
||||
from common.modules import only_available
|
||||
from common.money import currency_block, deal_currency, group_by_currency
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from common.serializer import (
|
||||
|
|
@ -320,14 +321,11 @@ class ContactDetailView(APIView):
|
|||
open are listed, as on every related list on this page.
|
||||
"""
|
||||
org = self.request.profile.org
|
||||
deals = list(self._deals(contact).order_by("-created_at")[:10])
|
||||
# Label and kind from the deal's own pipeline, read once for the list.
|
||||
stages = stage_index(org.id)
|
||||
stages = stage_index(org.id) if deals else {}
|
||||
rows = []
|
||||
for deal in (
|
||||
visible_deals_qs(self.request.profile)
|
||||
.filter(contacts=contact)
|
||||
.order_by("-created_at")[:10]
|
||||
):
|
||||
for deal in deals:
|
||||
stage = deal.current_stage(stages)
|
||||
rows.append(
|
||||
{
|
||||
|
|
@ -352,10 +350,7 @@ class ContactDetailView(APIView):
|
|||
"""
|
||||
org = self.request.profile.org
|
||||
visible_ids = (
|
||||
visible_deals_qs(self.request.profile)
|
||||
.filter(contacts=contact)
|
||||
.exclude(stage_kind_q(*CLOSED_KINDS))
|
||||
.values("id")
|
||||
self._deals(contact).exclude(stage_kind_q(*CLOSED_KINDS)).values("id")
|
||||
)
|
||||
groups = group_by_currency(
|
||||
Opportunity.objects.filter(org=org, id__in=visible_ids),
|
||||
|
|
@ -365,7 +360,18 @@ class ContactDetailView(APIView):
|
|||
)
|
||||
return currency_block(groups, money=("amount",), counts=("count",))
|
||||
|
||||
def _deals(self, contact):
|
||||
"""Deals naming this contact that the viewer may open, while Deals is on."""
|
||||
profile = self.request.profile
|
||||
return only_available(
|
||||
profile.org, "deals", visible_deals_qs(profile).filter(contacts=contact)
|
||||
)
|
||||
|
||||
def related_cases(self, contact):
|
||||
profile = self.request.profile
|
||||
cases = only_available(
|
||||
profile.org, "tickets", visible_cases_qs(profile).filter(contacts=contact)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(case.id),
|
||||
|
|
@ -374,9 +380,7 @@ class ContactDetailView(APIView):
|
|||
"priority": case.priority,
|
||||
"created_at": case.created_at,
|
||||
}
|
||||
for case in visible_cases_qs(self.request.profile)
|
||||
.filter(contacts=contact)
|
||||
.order_by("-created_at")[:10]
|
||||
for case in cases.order_by("-created_at")[:10]
|
||||
]
|
||||
|
||||
def related_colleagues(self, contact):
|
||||
|
|
@ -577,7 +581,11 @@ class ContactDetailView(APIView):
|
|||
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
||||
"assigned_data": assigned_data,
|
||||
"tasks": TaskSerializer(
|
||||
visible_tasks_qs(self.request.profile).filter(contacts=contact_obj),
|
||||
only_available(
|
||||
request.profile.org,
|
||||
"tasks",
|
||||
visible_tasks_qs(request.profile).filter(contacts=contact_obj),
|
||||
),
|
||||
many=True,
|
||||
).data,
|
||||
"users_mention": users_mention,
|
||||
|
|
@ -595,9 +603,7 @@ class ContactDetailView(APIView):
|
|||
"open_deals": self.open_deal_summary(contact_obj),
|
||||
# Uncapped, unlike `opportunities`: the edit form says how many
|
||||
# deals it is not editing.
|
||||
"opportunity_count": visible_deals_qs(self.request.profile)
|
||||
.filter(contacts=contact_obj)
|
||||
.count(),
|
||||
"opportunity_count": self._deals(contact_obj).count(),
|
||||
"cases": self.related_cases(contact_obj),
|
||||
"colleagues": self.related_colleagues(contact_obj),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,13 +24,12 @@ works under the non-superuser role production connects as.
|
|||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from common.models import Org
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.tasks import clear_rls_context, set_rls_context
|
||||
from invoices.models import RecurringInvoice
|
||||
from invoices.schedules import advance_missed
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
@ -56,7 +55,7 @@ class Command(BaseCommand):
|
|||
set_rls_context(org.id)
|
||||
activate_org_timezone(org)
|
||||
today = timezone.localdate()
|
||||
moved = self._advance_org(org, today, dry_run)
|
||||
moved = advance_missed(org, today, dry_run=dry_run)
|
||||
if moved:
|
||||
self.stdout.write(
|
||||
f"Org {org.id} ({org.name}): {len(moved)} template(s) {verb}"
|
||||
|
|
@ -69,28 +68,3 @@ class Command(BaseCommand):
|
|||
clear_rls_context()
|
||||
prefix = "DRY RUN, nothing changed. " if dry_run else ""
|
||||
self.stdout.write(f"{prefix}Total: {total} template(s) {verb}")
|
||||
|
||||
def _advance_org(self, org, today, dry_run):
|
||||
moved = []
|
||||
with transaction.atomic():
|
||||
templates = RecurringInvoice.objects.filter(
|
||||
org=org, is_active=True, next_generation_date__lt=today
|
||||
).order_by("next_generation_date", "id")
|
||||
for recurring in templates:
|
||||
if recurring.end_date and recurring.end_date < today:
|
||||
continue
|
||||
old = recurring.next_generation_date
|
||||
while recurring.next_generation_date < today:
|
||||
recurring.next_generation_date = recurring.calculate_next_date()
|
||||
new = recurring.next_generation_date
|
||||
if not dry_run:
|
||||
# One column, matched on the value just read, so a run
|
||||
# racing the task cannot move a template twice. update()
|
||||
# skips save(), so no audit stamps or signals fire.
|
||||
updated = RecurringInvoice.objects.filter(
|
||||
pk=recurring.pk, org=org, next_generation_date=old
|
||||
).update(next_generation_date=new)
|
||||
if not updated:
|
||||
continue
|
||||
moved.append((recurring.pk, old, new))
|
||||
return moved
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from common.models import Org
|
||||
from common.modules import module_is_available
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.portal_tokens import resolve_portal_org
|
||||
from common.request_meta import client_ip, user_agent
|
||||
|
|
@ -74,7 +75,7 @@ class PublicInvoiceView(APIView):
|
|||
public_token=token, public_link_enabled=True
|
||||
).first()
|
||||
|
||||
if not invoice:
|
||||
if not invoice or not module_is_available(invoice.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -186,7 +187,7 @@ class PublicInvoicePDFView(APIView):
|
|||
public_token=token, public_link_enabled=True
|
||||
).first()
|
||||
|
||||
if not invoice:
|
||||
if not invoice or not module_is_available(invoice.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Invoice not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -229,7 +230,7 @@ class PublicEstimateView(APIView):
|
|||
public_token=token, public_link_enabled=True
|
||||
).first()
|
||||
|
||||
if not estimate:
|
||||
if not estimate or not module_is_available(estimate.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -329,7 +330,7 @@ class PublicEstimatePDFView(APIView):
|
|||
public_token=token, public_link_enabled=True
|
||||
).first()
|
||||
|
||||
if not estimate:
|
||||
if not estimate or not module_is_available(estimate.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -386,7 +387,7 @@ class PublicEstimateAcceptView(APIView):
|
|||
.first()
|
||||
)
|
||||
|
||||
if not estimate:
|
||||
if not estimate or not module_is_available(estimate.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -476,7 +477,7 @@ class PublicEstimateDeclineView(APIView):
|
|||
.first()
|
||||
)
|
||||
|
||||
if not estimate:
|
||||
if not estimate or not module_is_available(estimate.org, "invoices"):
|
||||
return Response(
|
||||
{"error": True, "message": "Estimate not found or link expired"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
|
|
|
|||
71
backend/invoices/schedules.py
Normal file
71
backend/invoices/schedules.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Moving recurring-invoice schedules past periods that must not be billed,
|
||||
and starting Invoices fresh when it comes back on.
|
||||
|
||||
``generate_recurring_invoices`` bills one period per run and then advances one
|
||||
period, so a schedule left behind (beat not running, or Invoices switched off)
|
||||
would bill every missed period, one a night, until it caught up.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from common.org_time import org_timezone
|
||||
from invoices.models import RecurringInvoice
|
||||
from invoices.tasks import expiry_candidates, overdue_candidates
|
||||
|
||||
|
||||
def advance_missed(
|
||||
org, today: date, *, include_today: bool = False, dry_run: bool = False
|
||||
) -> list[tuple[UUID, date, date]]:
|
||||
"""Move ``org``'s active schedules due before ``today`` (or on it, with
|
||||
``include_today``) to their first period past that bound, billing nothing.
|
||||
|
||||
Returns ``(pk, old date, new date)`` for each schedule moved. One whose
|
||||
``end_date`` has passed is left for ``generate_recurring_invoices`` to
|
||||
deactivate.
|
||||
"""
|
||||
|
||||
def missed(day):
|
||||
return day <= today if include_today else day < today
|
||||
|
||||
bound = "next_generation_date__lte" if include_today else "next_generation_date__lt"
|
||||
moved = []
|
||||
with transaction.atomic():
|
||||
templates = RecurringInvoice.objects.filter(
|
||||
org=org, is_active=True, **{bound: today}
|
||||
).order_by("next_generation_date", "id")
|
||||
for recurring in templates:
|
||||
if recurring.end_date and recurring.end_date < today:
|
||||
continue
|
||||
old = recurring.next_generation_date
|
||||
while missed(recurring.next_generation_date):
|
||||
recurring.next_generation_date = recurring.calculate_next_date()
|
||||
new = recurring.next_generation_date
|
||||
if not dry_run:
|
||||
# One column, matched on the value just read, so a run racing
|
||||
# the task cannot move a schedule twice. update() skips save(),
|
||||
# so no audit stamps or signals fire.
|
||||
updated = RecurringInvoice.objects.filter(
|
||||
pk=recurring.pk, org=org, next_generation_date=old
|
||||
).update(next_generation_date=new)
|
||||
if not updated:
|
||||
continue
|
||||
moved.append((recurring.pk, old, new))
|
||||
return moved
|
||||
|
||||
|
||||
def start_fresh(org) -> None:
|
||||
"""Invoices is back on: move every schedule to its next date after today,
|
||||
so nothing that fell due while it was off is billed later, and mark what
|
||||
fell overdue or expired meanwhile."""
|
||||
with org_timezone(org):
|
||||
today = timezone.localdate()
|
||||
advance_missed(org, today, include_today=True)
|
||||
# update(), not save(): a status catching up after the fact sends no
|
||||
# webhook, runs no rule and emails no one.
|
||||
now = timezone.now()
|
||||
overdue_candidates(org, today).update(status="Overdue", updated_at=now)
|
||||
expiry_candidates(org, today).update(status="Expired", updated_at=now)
|
||||
|
|
@ -6,6 +6,7 @@ from rest_framework import serializers
|
|||
|
||||
from accounts.access import visible_accounts_qs
|
||||
from accounts.serializer import AccountPickerSerializer
|
||||
from common.modules import module_is_available, module_off_message, org_of
|
||||
from common.serializer import (
|
||||
OrganizationSerializer,
|
||||
ProfileSerializer,
|
||||
|
|
@ -39,7 +40,15 @@ from opportunity.models import Opportunity
|
|||
|
||||
|
||||
class OpportunityMinimalSerializer(serializers.ModelSerializer):
|
||||
"""Minimal Opportunity serializer for invoice context - avoids circular import"""
|
||||
"""A document's deal, defined here to avoid a circular import.
|
||||
|
||||
Null while Deals is off; `keep_hidden_deal` keeps that null from erasing it.
|
||||
"""
|
||||
|
||||
def to_representation(self, instance):
|
||||
if not module_is_available(org_of(instance), "deals"):
|
||||
return None
|
||||
return super().to_representation(instance)
|
||||
|
||||
class Meta:
|
||||
model = Opportunity
|
||||
|
|
@ -87,8 +96,10 @@ class ProductSerializer(serializers.ModelSerializer):
|
|||
SET_NULL); this count is why a retired product is still worth listing.
|
||||
`ProductListView` annotates `used_on_count` to avoid an N+1 across the
|
||||
list, so read that when present and fall back to a query on the single
|
||||
detail views.
|
||||
detail views. Zero while Invoices is off.
|
||||
"""
|
||||
if not module_is_available(org_of(obj), "invoices"):
|
||||
return 0
|
||||
annotated = getattr(obj, "used_on_count", None)
|
||||
if annotated is not None:
|
||||
return annotated
|
||||
|
|
@ -649,8 +660,30 @@ def validate_document_account(account_id, profile, instance):
|
|||
)
|
||||
|
||||
|
||||
def keep_hidden_deal(value, org, instance):
|
||||
"""A null deal, while Deals is off, keeps the stored one.
|
||||
|
||||
A client that sends back what it loaded sees the deal as null, so honouring
|
||||
that null would erase a link the admin only hid.
|
||||
"""
|
||||
if (
|
||||
value is None
|
||||
and instance is not None
|
||||
and org is not None
|
||||
and not module_is_available(org, "deals")
|
||||
):
|
||||
return instance.opportunity_id
|
||||
return value
|
||||
|
||||
|
||||
def validate_document_opportunity(opportunity_id, profile, instance):
|
||||
"""The deal an invoice, estimate or recurring invoice is raised against."""
|
||||
"""The deal an invoice, estimate or recurring invoice is raised against.
|
||||
|
||||
With Deals off, the stored deal may be resent but no other can be named.
|
||||
"""
|
||||
stored = instance.opportunity_id if instance is not None else None
|
||||
if opportunity_id != stored and not module_is_available(profile.org, "deals"):
|
||||
raise serializers.ValidationError(module_off_message(profile.org, "deals"))
|
||||
return _validate_document_link(
|
||||
opportunity_id,
|
||||
visible_deals_qs(profile),
|
||||
|
|
@ -988,7 +1021,7 @@ class InvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
def validate_opportunity_id(self, value):
|
||||
"""Validate the deal (if provided) is one the caller may open"""
|
||||
if value is None:
|
||||
return value
|
||||
return keep_hidden_deal(value, self.org, self.instance)
|
||||
if not self.org:
|
||||
raise serializers.ValidationError("Organization context required")
|
||||
return validate_document_opportunity(value, self.profile, self.instance)
|
||||
|
|
@ -1188,7 +1221,7 @@ class EstimateListSerializer(serializers.ModelSerializer):
|
|||
return None
|
||||
|
||||
def get_opportunity(self, obj):
|
||||
if obj.opportunity_id:
|
||||
if obj.opportunity_id and module_is_available(org_of(obj), "deals"):
|
||||
return {"id": str(obj.opportunity_id), "name": obj.opportunity.name}
|
||||
return None
|
||||
|
||||
|
|
@ -1298,7 +1331,7 @@ class EstimateCreateSerializer(serializers.ModelSerializer):
|
|||
def validate_opportunity_id(self, value):
|
||||
"""Validate the deal (if provided) is one the caller may open"""
|
||||
if value is None:
|
||||
return value
|
||||
return keep_hidden_deal(value, self.org, self.instance)
|
||||
if not self.org:
|
||||
raise serializers.ValidationError("Organization context required")
|
||||
return validate_document_opportunity(value, self.profile, self.instance)
|
||||
|
|
@ -1521,7 +1554,7 @@ class RecurringInvoiceCreateSerializer(serializers.ModelSerializer):
|
|||
def validate_opportunity_id(self, value):
|
||||
"""Validate the deal (if provided) is one the caller may open"""
|
||||
if value is None:
|
||||
return value
|
||||
return keep_hidden_deal(value, self.org, self.instance)
|
||||
if not self.org:
|
||||
raise serializers.ValidationError("Organization context required")
|
||||
return validate_document_opportunity(value, self.profile, self.instance)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ Tasks include:
|
|||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
|
|
@ -20,12 +22,31 @@ from django.utils import timezone
|
|||
|
||||
from common.links import frontend_url
|
||||
from common.models import Org, Profile
|
||||
from common.modules import module_is_available, module_on_since
|
||||
from common.org_time import activate_org_timezone, org_timezone
|
||||
from common.tasks import clear_rls_context, set_rls_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def overdue_candidates(org, today):
|
||||
"""The invoices ``check_overdue_invoices`` marks Overdue on ``today``."""
|
||||
from invoices.models import Invoice
|
||||
|
||||
return Invoice.objects.filter(
|
||||
org=org, due_date__lt=today, status__in=["Sent", "Viewed", "Partially_Paid"]
|
||||
)
|
||||
|
||||
|
||||
def expiry_candidates(org, today):
|
||||
"""The estimates ``check_expired_estimates`` marks Expired on ``today``."""
|
||||
from invoices.models import Estimate
|
||||
|
||||
return Estimate.objects.filter(
|
||||
org=org, expiry_date__lt=today, status__in=["Draft", "Sent", "Viewed"]
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email(invoice_id, recipients, org_id, domain="localhost", protocol="http"):
|
||||
"""
|
||||
|
|
@ -109,6 +130,14 @@ def send_invoice_to_client(
|
|||
logger.warning("Invoice %s not found", invoice_id)
|
||||
return
|
||||
|
||||
# Queued while Invoices was on; once it is off, the link in the email no
|
||||
# longer opens, so nothing is sent.
|
||||
if not module_is_available(invoice.org, "invoices"):
|
||||
logger.info(
|
||||
"Invoices is off for org %s; invoice %s not sent", org_id, invoice_id
|
||||
)
|
||||
return
|
||||
|
||||
if not invoice.client_email:
|
||||
logger.warning("Invoice %s has no client email", invoice_id)
|
||||
return
|
||||
|
|
@ -241,6 +270,10 @@ def generate_recurring_invoices():
|
|||
|
||||
try:
|
||||
for org in Org.objects.all():
|
||||
# Switching Invoices back on moves every schedule past that day
|
||||
# (`invoices.schedules.start_fresh`), so nothing here looks back.
|
||||
if not module_is_available(org, "invoices"):
|
||||
continue
|
||||
# Set before any org-scoped query: `recurring_invoice` is
|
||||
# org-scoped and a worker runs no middleware, so under an RLS-bound
|
||||
# role a lookup made with no context matches nothing.
|
||||
|
|
@ -372,13 +405,13 @@ def check_overdue_invoices():
|
|||
Mark invoices as overdue if past due date.
|
||||
Should be scheduled to run daily.
|
||||
"""
|
||||
from invoices.models import Invoice
|
||||
|
||||
logger.info("Checking for overdue invoices")
|
||||
|
||||
count = 0
|
||||
try:
|
||||
for org in Org.objects.all():
|
||||
if not module_is_available(org, "invoices"):
|
||||
continue
|
||||
# Set before any org-scoped query: `invoice` is org-scoped and a
|
||||
# worker runs no middleware, so under an RLS-bound role a lookup
|
||||
# made with no context matches nothing.
|
||||
|
|
@ -388,11 +421,7 @@ def check_overdue_invoices():
|
|||
# invoice overdue four hours before their day ends is the kind of
|
||||
# thing they email about.
|
||||
activate_org_timezone(org)
|
||||
overdue_invoices = Invoice.objects.filter(
|
||||
org=org,
|
||||
due_date__lt=timezone.localdate(),
|
||||
status__in=["Sent", "Viewed", "Partially_Paid"],
|
||||
)
|
||||
overdue_invoices = overdue_candidates(org, timezone.localdate())
|
||||
for pk in overdue_invoices.values_list("pk", flat=True):
|
||||
# Re-checked under a row lock: an invoice paid, cancelled or
|
||||
# given a later due date since the list was read is skipped,
|
||||
|
|
@ -438,6 +467,12 @@ def send_payment_reminder(invoice_id, org_id, domain="localhost", protocol="http
|
|||
logger.warning("Invoice %s not found", invoice_id)
|
||||
return
|
||||
|
||||
if not module_is_available(invoice.org, "invoices"):
|
||||
logger.info(
|
||||
"Invoices is off for org %s; no reminder for %s", org_id, invoice_id
|
||||
)
|
||||
return
|
||||
|
||||
if not invoice.client_email:
|
||||
logger.warning("Invoice %s has no client email", invoice_id)
|
||||
return
|
||||
|
|
@ -505,6 +540,8 @@ def process_payment_reminders():
|
|||
|
||||
try:
|
||||
for org in Org.objects.all():
|
||||
if not module_is_available(org, "invoices"):
|
||||
continue
|
||||
# Set before any org-scoped query: `invoice` is org-scoped and a
|
||||
# worker runs no middleware, so under an RLS-bound role a lookup
|
||||
# made with no context matches nothing.
|
||||
|
|
@ -513,6 +550,8 @@ def process_payment_reminders():
|
|||
# customer's calendar, not the server's.
|
||||
activate_org_timezone(org)
|
||||
today = timezone.localdate()
|
||||
since = module_on_since(org, "invoices")
|
||||
back_on = timezone.localdate(since) if since else None
|
||||
|
||||
# Get invoices with reminders enabled
|
||||
invoices = (
|
||||
|
|
@ -538,6 +577,11 @@ def process_payment_reminders():
|
|||
reminder_interval_days = 9999 # effectively only once
|
||||
elif invoice.reminder_frequency == "WEEKLY":
|
||||
reminder_interval_days = 7
|
||||
last_sent = (
|
||||
timezone.localdate(invoice.last_reminder_sent)
|
||||
if invoice.last_reminder_sent
|
||||
else None
|
||||
)
|
||||
|
||||
# Before due date reminder
|
||||
if days_until_due is not None and days_until_due > 0:
|
||||
|
|
@ -545,14 +589,12 @@ def process_payment_reminders():
|
|||
invoice.reminder_days_before
|
||||
and days_until_due <= invoice.reminder_days_before
|
||||
):
|
||||
# Check if we haven't sent a reminder recently
|
||||
if not invoice.last_reminder_sent or (
|
||||
(
|
||||
today - timezone.localdate(invoice.last_reminder_sent)
|
||||
).days
|
||||
>= reminder_interval_days
|
||||
):
|
||||
should_send = True
|
||||
first_day = invoice.due_date - timedelta(
|
||||
days=invoice.reminder_days_before
|
||||
)
|
||||
should_send = today >= _reminder_day(
|
||||
first_day, last_sent, reminder_interval_days, back_on
|
||||
)
|
||||
|
||||
# After due date reminder
|
||||
elif days_until_due is not None and days_until_due < 0:
|
||||
|
|
@ -561,14 +603,12 @@ def process_payment_reminders():
|
|||
invoice.reminder_days_after
|
||||
and days_overdue >= invoice.reminder_days_after
|
||||
):
|
||||
# Check reminder frequency
|
||||
if not invoice.last_reminder_sent or (
|
||||
(
|
||||
today - timezone.localdate(invoice.last_reminder_sent)
|
||||
).days
|
||||
>= reminder_interval_days
|
||||
):
|
||||
should_send = True
|
||||
first_day = invoice.due_date + timedelta(
|
||||
days=invoice.reminder_days_after
|
||||
)
|
||||
should_send = today >= _reminder_day(
|
||||
first_day, last_sent, reminder_interval_days, back_on
|
||||
)
|
||||
|
||||
if should_send:
|
||||
send_payment_reminder.delay(
|
||||
|
|
@ -588,28 +628,37 @@ def process_payment_reminders():
|
|||
logger.info("Finished processing payment reminders")
|
||||
|
||||
|
||||
def _reminder_day(
|
||||
first_day: date, last_sent: date | None, interval: int, since: date | None
|
||||
) -> date:
|
||||
"""The day the next reminder is due: ``interval`` days after the last one
|
||||
sent, else ``first_day``. A day before ``since`` (the day Invoices came back
|
||||
on) moves forward by whole intervals, so a reminder that fell due while it
|
||||
was off is skipped and the cadence keeps its days."""
|
||||
day = last_sent + timedelta(days=interval) if last_sent else first_day
|
||||
if since is not None and day < since:
|
||||
day += timedelta(days=math.ceil((since - day).days / interval) * interval)
|
||||
return day
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_expired_estimates():
|
||||
"""
|
||||
Mark estimates as expired if past valid_until date.
|
||||
Should be scheduled to run daily.
|
||||
"""
|
||||
from invoices.models import Estimate
|
||||
|
||||
logger.info("Checking for expired estimates")
|
||||
|
||||
count = 0
|
||||
try:
|
||||
for org in Org.objects.all():
|
||||
if not module_is_available(org, "invoices"):
|
||||
continue
|
||||
# Set before any org-scoped query, same as `check_overdue_invoices`.
|
||||
set_rls_context(org.id)
|
||||
# Expired on the org's calendar, not the server's.
|
||||
activate_org_timezone(org)
|
||||
expired_estimates = Estimate.objects.filter(
|
||||
org=org,
|
||||
expiry_date__lt=timezone.localdate(),
|
||||
status__in=["Draft", "Sent", "Viewed"],
|
||||
)
|
||||
expired_estimates = expiry_candidates(org, timezone.localdate())
|
||||
for pk in expired_estimates.values_list("pk", flat=True):
|
||||
# Re-checked under a row lock, as in check_overdue_invoices: an
|
||||
# estimate accepted, declined or given a later expiry date
|
||||
|
|
@ -658,6 +707,12 @@ def send_estimate_to_client(
|
|||
logger.warning("Estimate %s not found", estimate_id)
|
||||
return
|
||||
|
||||
if not module_is_available(estimate.org, "invoices"):
|
||||
logger.info(
|
||||
"Invoices is off for org %s; estimate %s not sent", org_id, estimate_id
|
||||
)
|
||||
return
|
||||
|
||||
if not estimate.client_email:
|
||||
logger.warning("Estimate %s has no client email", estimate_id)
|
||||
return
|
||||
|
|
|
|||
446
backend/invoices/tests/test_module_off_jobs.py
Normal file
446
backend/invoices/tests/test_module_off_jobs.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"""The invoice jobs while Invoices is off, and once it is back on.
|
||||
|
||||
Every job skips an org with Invoices off, and every queued customer email
|
||||
waits for nothing. Switching it back on starts fresh from that day: schedules
|
||||
move past it, what fell overdue or expired meanwhile is marked silently, and
|
||||
no schedule or reminder that fell due while it was off is sent later.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
from automation.models import AutomationRun
|
||||
from common.module_settings import change_modules
|
||||
from common.testing import (
|
||||
clear_rls_context,
|
||||
restore_rls_context,
|
||||
rls_org,
|
||||
set_modules_off,
|
||||
)
|
||||
from invoices.models import (
|
||||
Estimate,
|
||||
Invoice,
|
||||
RecurringInvoice,
|
||||
RecurringInvoiceLineItem,
|
||||
)
|
||||
from invoices.tasks import (
|
||||
check_expired_estimates,
|
||||
check_overdue_invoices,
|
||||
generate_recurring_invoices,
|
||||
process_payment_reminders,
|
||||
send_estimate_to_client,
|
||||
send_invoice_to_client,
|
||||
send_payment_reminder,
|
||||
)
|
||||
from webhooks.models import WebhookDelivery, WebhookEndpoint
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DAY = datetime.timedelta(days=1)
|
||||
WEEK = datetime.timedelta(weeks=1)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = datetime.datetime(2026, 10, 14, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
def advance(self, **delta):
|
||||
self.now += datetime.timedelta(**delta)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
c = Clock()
|
||||
with patch("django.utils.timezone.now", side_effect=lambda: c.now):
|
||||
yield c
|
||||
|
||||
|
||||
def _switch(org, actor, *off):
|
||||
"""Leave exactly ``off`` switched off, through the one writer."""
|
||||
assert change_modules(org, actor, {"off": list(off), "order": []}) == {}
|
||||
org.refresh_from_db()
|
||||
|
||||
|
||||
def _run(task):
|
||||
# Each job clears the RLS context on its way out; the test reads its own
|
||||
# rows afterwards.
|
||||
try:
|
||||
return task()
|
||||
finally:
|
||||
restore_rls_context()
|
||||
|
||||
|
||||
def _schedule(org, next_date, *, lines=False, **extra):
|
||||
schedule = RecurringInvoice.objects.create(
|
||||
title="Hosting",
|
||||
frequency="WEEKLY",
|
||||
start_date=next_date,
|
||||
next_generation_date=next_date,
|
||||
is_active=True,
|
||||
org=org,
|
||||
**extra,
|
||||
)
|
||||
if lines:
|
||||
RecurringInvoiceLineItem.objects.create(
|
||||
recurring_invoice=schedule,
|
||||
org=org,
|
||||
name="Hosting",
|
||||
quantity="1",
|
||||
unit_price="50",
|
||||
)
|
||||
return schedule
|
||||
|
||||
|
||||
def _invoice(org, due_date, **extra):
|
||||
fields = {
|
||||
"invoice_title": "Work",
|
||||
"status": "Sent",
|
||||
"currency": "USD",
|
||||
"client_email": "buyer@example.com",
|
||||
"due_date": due_date,
|
||||
"reminder_days_before": 0,
|
||||
"reminder_days_after": 0,
|
||||
**extra,
|
||||
}
|
||||
return Invoice.objects.create(org=org, **fields)
|
||||
|
||||
|
||||
def _estimate(org, expiry_date, **extra):
|
||||
fields = {"title": "Quote", "status": "Sent", "currency": "USD", **extra}
|
||||
return Estimate.objects.create(org=org, expiry_date=expiry_date, **fields)
|
||||
|
||||
|
||||
def _status(record):
|
||||
record.refresh_from_db()
|
||||
return record.status
|
||||
|
||||
|
||||
def test_invoice_jobs_skip_an_org_with_invoices_off(org_a, org_b, admin_profile):
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
today = timezone.localdate()
|
||||
seeded = {}
|
||||
for org in (org_a, org_b):
|
||||
with rls_org(org):
|
||||
seeded[org.id] = (
|
||||
_schedule(org, today),
|
||||
_invoice(org, today - 10 * DAY, reminder_days_after=7),
|
||||
_estimate(org, today - 3 * DAY),
|
||||
)
|
||||
|
||||
reminded = []
|
||||
with patch("invoices.tasks.send_payment_reminder.delay") as remind:
|
||||
remind.side_effect = lambda invoice_id, org_id, **kw: reminded.append(org_id)
|
||||
for task in (
|
||||
generate_recurring_invoices,
|
||||
check_overdue_invoices,
|
||||
process_payment_reminders,
|
||||
check_expired_estimates,
|
||||
):
|
||||
_run(task)
|
||||
|
||||
schedule, invoice, estimate = seeded[org_a.id]
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.next_generation_date == today
|
||||
assert Invoice.objects.filter(org=org_a).count() == 1
|
||||
assert _status(invoice) == "Sent"
|
||||
assert _status(estimate) == "Sent"
|
||||
|
||||
with rls_org(org_b):
|
||||
schedule, invoice, estimate = seeded[org_b.id]
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.next_generation_date == today + WEEK
|
||||
assert Invoice.objects.filter(org=org_b).count() == 2
|
||||
assert _status(invoice) == "Overdue"
|
||||
assert _status(estimate) == "Expired"
|
||||
assert reminded == [str(org_b.id)]
|
||||
|
||||
|
||||
def test_weekly_schedule_off_for_three_months_bills_nothing_missed(
|
||||
org_a, admin_profile, clock, django_capture_on_commit_callbacks
|
||||
):
|
||||
today = timezone.localdate()
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
schedule = _schedule(
|
||||
org_a,
|
||||
today - 91 * DAY,
|
||||
lines=True,
|
||||
auto_send=True,
|
||||
client_email="buyer@example.com",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("invoices.tasks.send_invoice_to_client.delay") as send,
|
||||
django_capture_on_commit_callbacks(execute=True),
|
||||
):
|
||||
_run(generate_recurring_invoices)
|
||||
assert not Invoice.objects.exists()
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.next_generation_date == today + WEEK
|
||||
_run(generate_recurring_invoices)
|
||||
assert not Invoice.objects.exists()
|
||||
|
||||
clock.advance(weeks=1)
|
||||
_run(generate_recurring_invoices)
|
||||
|
||||
invoice = Invoice.objects.get()
|
||||
assert invoice.issue_date == today + WEEK
|
||||
assert send.call_count == 1
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.invoices_generated == 1
|
||||
assert schedule.next_generation_date == today + 2 * WEEK
|
||||
|
||||
|
||||
def test_a_schedule_due_on_the_switch_on_day_moves_too(org_a, admin_profile, clock):
|
||||
today = timezone.localdate()
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
schedule = _schedule(org_a, today)
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
schedule.refresh_from_db()
|
||||
assert schedule.next_generation_date == today + WEEK
|
||||
_run(generate_recurring_invoices)
|
||||
assert not Invoice.objects.exists()
|
||||
|
||||
|
||||
def test_switching_off_moves_no_schedule(org_a, admin_profile, clock):
|
||||
"""Pin: only a switch-on moves a schedule."""
|
||||
today = timezone.localdate()
|
||||
past = _schedule(org_a, today - 20 * DAY)
|
||||
due_today = _schedule(org_a, today)
|
||||
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
|
||||
past.refresh_from_db()
|
||||
due_today.refresh_from_db()
|
||||
assert past.next_generation_date == today - 20 * DAY
|
||||
assert due_today.next_generation_date == today
|
||||
|
||||
|
||||
def test_switch_on_marks_what_fell_overdue_or_expired_while_off_silently(
|
||||
admin_client, org_a, admin_profile, django_capture_on_commit_callbacks
|
||||
):
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
WebhookEndpoint.objects.create(
|
||||
org=org_a,
|
||||
url="https://hooks.example.com/in",
|
||||
events=["invoice.updated"],
|
||||
created_by=admin_profile.user,
|
||||
)
|
||||
today = timezone.localdate()
|
||||
fell_due = _invoice(org_a, today - 5 * DAY)
|
||||
paid = _invoice(org_a, today - 5 * DAY, status="Paid")
|
||||
due_later = _invoice(org_a, today + 2 * DAY)
|
||||
lapsed = _estimate(org_a, today - 3 * DAY)
|
||||
expires_later = _estimate(org_a, today + DAY)
|
||||
|
||||
with (
|
||||
patch("webhooks.emit.deliver_webhook.delay"),
|
||||
django_capture_on_commit_callbacks(execute=True),
|
||||
):
|
||||
response = admin_client.put(
|
||||
"/api/org/modules/", {"off": [], "order": []}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
assert [_status(i) for i in (fell_due, paid, due_later)] == [
|
||||
"Overdue",
|
||||
"Paid",
|
||||
"Sent",
|
||||
]
|
||||
assert [_status(e) for e in (lapsed, expires_later)] == ["Expired", "Sent"]
|
||||
assert not WebhookDelivery.objects.exists()
|
||||
# No automation adapter covers invoices or estimates today; this holds the
|
||||
# line if one is added.
|
||||
assert not AutomationRun.objects.exists()
|
||||
assert mail.outbox == []
|
||||
|
||||
# The daily job's own change still reaches the endpoint, as it always has.
|
||||
with (
|
||||
patch("django.utils.timezone.now", return_value=timezone.now() + 3 * DAY),
|
||||
patch("webhooks.emit.deliver_webhook.delay"),
|
||||
django_capture_on_commit_callbacks(execute=True),
|
||||
):
|
||||
_run(check_overdue_invoices)
|
||||
assert _status(due_later) == "Overdue"
|
||||
assert list(WebhookDelivery.objects.values_list("event", flat=True)) == [
|
||||
"invoice.updated"
|
||||
]
|
||||
|
||||
|
||||
def test_backdated_documents_created_after_switch_on_are_marked_by_the_next_run(
|
||||
org_a, admin_profile, clock
|
||||
):
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
clock.advance(days=30)
|
||||
_switch(org_a, admin_profile)
|
||||
clock.advance(days=1)
|
||||
today = timezone.localdate()
|
||||
invoice = _invoice(org_a, today - 10 * DAY)
|
||||
estimate = _estimate(org_a, today - 10 * DAY)
|
||||
|
||||
_run(check_overdue_invoices)
|
||||
_run(check_expired_estimates)
|
||||
|
||||
assert (_status(invoice), _status(estimate)) == ("Overdue", "Expired")
|
||||
|
||||
|
||||
def test_an_org_never_switched_off_is_marked_as_before(org_a):
|
||||
"""Pin: with no switch-on stamp the status jobs work as they always have."""
|
||||
today = timezone.localdate()
|
||||
invoice = _invoice(org_a, today - DAY)
|
||||
estimate = _estimate(org_a, today - DAY)
|
||||
|
||||
_run(check_overdue_invoices)
|
||||
_run(check_expired_estimates)
|
||||
|
||||
assert org_a.modules_on_at == {}
|
||||
assert (_status(invoice), _status(estimate)) == ("Overdue", "Expired")
|
||||
|
||||
|
||||
def test_reminders_skip_the_off_period_and_keep_their_cadence(
|
||||
org_a, admin_profile, clock
|
||||
):
|
||||
_switch(org_a, admin_profile, "invoices")
|
||||
clock.advance(days=30)
|
||||
today = timezone.localdate()
|
||||
weekly = _invoice(
|
||||
org_a,
|
||||
today - 30 * DAY,
|
||||
reminder_frequency="WEEKLY",
|
||||
reminder_days_after=7,
|
||||
last_reminder_sent=timezone.now() - 20 * DAY,
|
||||
)
|
||||
once = _invoice(
|
||||
org_a, today - 10 * DAY, reminder_frequency="ONCE", reminder_days_after=7
|
||||
)
|
||||
upcoming = _invoice(
|
||||
org_a, today + 10 * DAY, reminder_frequency="ONCE", reminder_days_before=3
|
||||
)
|
||||
names = {str(weekly.id): "weekly", str(once.id): "once", str(upcoming.id): "up"}
|
||||
_switch(org_a, admin_profile)
|
||||
switched_on = clock.now
|
||||
|
||||
def reminded_on(days_after_switch_on):
|
||||
clock.now = switched_on + days_after_switch_on * DAY
|
||||
sent = []
|
||||
|
||||
def send_now(invoice_id, org_id, **kwargs):
|
||||
sent.append(names[invoice_id])
|
||||
send_payment_reminder(invoice_id, org_id)
|
||||
|
||||
with patch("invoices.tasks.send_payment_reminder.delay", side_effect=send_now):
|
||||
_run(process_payment_reminders)
|
||||
return sent
|
||||
|
||||
# Weekly from 20 days ago: due 13 and 6 days ago while off, then tomorrow.
|
||||
assert reminded_on(0) == []
|
||||
assert reminded_on(1) == ["weekly"]
|
||||
assert reminded_on(6) == []
|
||||
assert reminded_on(7) == ["up"]
|
||||
assert reminded_on(8) == ["weekly"]
|
||||
assert reminded_on(40) == ["weekly"]
|
||||
assert len(mail.outbox) == 4
|
||||
|
||||
|
||||
def _sent_only_once_back_on(org, actor, send, state):
|
||||
"""Run a queued customer email with Invoices off, then on again. While off
|
||||
it sends nothing and ``state()`` does not move."""
|
||||
before = state()
|
||||
_switch(org, actor, "invoices")
|
||||
send()
|
||||
assert (mail.outbox, state()) == ([], before)
|
||||
_switch(org, actor)
|
||||
send()
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
|
||||
def test_a_queued_invoice_email_is_not_sent_while_invoices_is_off(org_a, admin_profile):
|
||||
invoice = _invoice(org_a, timezone.localdate() + 10 * DAY, status="Draft")
|
||||
|
||||
_sent_only_once_back_on(
|
||||
org_a,
|
||||
admin_profile,
|
||||
lambda: send_invoice_to_client(
|
||||
str(invoice.id), str(org_a.id), include_pdf=False
|
||||
),
|
||||
lambda: _status(invoice),
|
||||
)
|
||||
assert _status(invoice) == "Sent"
|
||||
|
||||
|
||||
def test_a_queued_payment_reminder_is_not_sent_while_invoices_is_off(
|
||||
org_a, admin_profile
|
||||
):
|
||||
invoice = _invoice(org_a, timezone.localdate() + 2 * DAY)
|
||||
|
||||
def reminders():
|
||||
invoice.refresh_from_db()
|
||||
return invoice.reminder_count
|
||||
|
||||
_sent_only_once_back_on(
|
||||
org_a,
|
||||
admin_profile,
|
||||
lambda: send_payment_reminder(str(invoice.id), str(org_a.id)),
|
||||
reminders,
|
||||
)
|
||||
assert reminders() == 1
|
||||
|
||||
|
||||
def test_a_queued_estimate_email_is_not_sent_while_invoices_is_off(
|
||||
org_a, admin_profile
|
||||
):
|
||||
estimate = _estimate(
|
||||
org_a,
|
||||
timezone.localdate() + 30 * DAY,
|
||||
status="Draft",
|
||||
client_email="buyer@example.com",
|
||||
)
|
||||
|
||||
_sent_only_once_back_on(
|
||||
org_a,
|
||||
admin_profile,
|
||||
lambda: send_estimate_to_client(
|
||||
str(estimate.id), str(org_a.id), include_pdf=False
|
||||
),
|
||||
lambda: _status(estimate),
|
||||
)
|
||||
assert _status(estimate) == "Sent"
|
||||
|
||||
|
||||
@pytest.mark.postgres_only
|
||||
def test_start_fresh_moves_schedules_under_rls(admin_client, org_a, org_b):
|
||||
"""Skipped on SQLite. Under a non-superuser role an empty context matches no
|
||||
rows, so a moved schedule and a marked invoice prove the switch-on ran under
|
||||
its org's context, and org B's untouched ones that it stayed there."""
|
||||
if connection.vendor != "postgresql":
|
||||
pytest.skip("RLS requires PostgreSQL")
|
||||
past = timezone.localdate() - 20 * DAY
|
||||
ours = _schedule(org_a, past)
|
||||
our_invoice = _invoice(org_a, past)
|
||||
with rls_org(org_b):
|
||||
theirs = _schedule(org_b, past)
|
||||
their_invoice = _invoice(org_b, past)
|
||||
set_modules_off(org_a, "invoices")
|
||||
set_modules_off(org_b, "invoices")
|
||||
clear_rls_context()
|
||||
|
||||
response = admin_client.put(
|
||||
"/api/org/modules/", {"off": [], "order": []}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
with rls_org(org_a):
|
||||
ours.refresh_from_db()
|
||||
assert ours.next_generation_date == past + 3 * WEEK
|
||||
assert _status(our_invoice) == "Overdue"
|
||||
with rls_org(org_b):
|
||||
theirs.refresh_from_db()
|
||||
assert theirs.next_generation_date == past
|
||||
assert _status(their_invoice) == "Sent"
|
||||
108
backend/invoices/tests/test_module_off_public.py
Normal file
108
backend/invoices/tests/test_module_off_public.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""The emailed invoice and estimate links while Invoices is off.
|
||||
|
||||
A customer holding a link gets exactly the answer an unknown token gets, and
|
||||
the same link opens again once the module is back on.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from common.testing import set_modules_off
|
||||
from invoices.models import Estimate, Invoice
|
||||
|
||||
INVOICE_GONE = {"error": True, "message": "Invoice not found or link expired"}
|
||||
ESTIMATE_GONE = {"error": True, "message": "Estimate not found or link expired"}
|
||||
ACCEPT = {"name": "Dana Buyer", "email": "dana@buyer.example"}
|
||||
|
||||
# (record, method, path after the token, the not-found body)
|
||||
ROUTES = [
|
||||
("invoice", "get", "", INVOICE_GONE),
|
||||
("invoice", "get", "pdf/", INVOICE_GONE),
|
||||
("estimate", "get", "", ESTIMATE_GONE),
|
||||
("estimate", "get", "pdf/", ESTIMATE_GONE),
|
||||
("estimate", "post", "accept/", ESTIMATE_GONE),
|
||||
("estimate", "post", "decline/", ESTIMATE_GONE),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invoice(org_a):
|
||||
return Invoice.objects.create(
|
||||
org=org_a,
|
||||
invoice_title="Fit-out",
|
||||
client_email="dana@buyer.example",
|
||||
currency="USD",
|
||||
status="Sent",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def estimate(org_a):
|
||||
return Estimate.objects.create(
|
||||
org=org_a,
|
||||
title="Fit-out",
|
||||
client_name="Dana Buyer",
|
||||
client_email="dana@buyer.example",
|
||||
currency="USD",
|
||||
status="Sent",
|
||||
issue_date=timezone.localdate(),
|
||||
expiry_date=timezone.localdate() + datetime.timedelta(days=30),
|
||||
)
|
||||
|
||||
|
||||
def _send(record, method, tail, token):
|
||||
url = f"/api/public/{record}/{token}/{tail}"
|
||||
with (
|
||||
patch("invoices.public_views.generate_invoice_pdf", return_value=b"%PDF-"),
|
||||
patch("invoices.public_views.generate_estimate_pdf", return_value=b"%PDF-"),
|
||||
):
|
||||
if method == "post":
|
||||
return APIClient().post(url, ACCEPT, format="json")
|
||||
return APIClient().get(url)
|
||||
|
||||
|
||||
def _state(row):
|
||||
row.refresh_from_db()
|
||||
return row.status, row.viewed_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize("record, method, tail, gone", ROUTES)
|
||||
def test_public_links_close_with_invoices(
|
||||
org_a, invoice, estimate, record, method, tail, gone
|
||||
):
|
||||
row = invoice if record == "invoice" else estimate
|
||||
unknown = _send(record, method, tail, "not-a-real-token")
|
||||
assert (unknown.status_code, unknown.json()) == (404, gone)
|
||||
before = _state(row)
|
||||
|
||||
set_modules_off(org_a, "invoices")
|
||||
response = _send(record, method, tail, row.public_token)
|
||||
assert (response.status_code, response.json()) == (404, gone)
|
||||
assert _state(row) == before
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert _send(record, method, tail, row.public_token).status_code == 200
|
||||
|
||||
|
||||
def test_accepting_an_estimate_changes_nothing_while_off(org_a, estimate):
|
||||
set_modules_off(org_a, "invoices")
|
||||
for action in ("accept/", "decline/"):
|
||||
response = _send("estimate", "post", action, estimate.public_token)
|
||||
assert response.status_code == 404
|
||||
estimate.refresh_from_db()
|
||||
assert estimate.status == "Sent"
|
||||
assert (estimate.accepted_at, estimate.accepted_by_name) == (None, "")
|
||||
assert estimate.declined_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("record, method, tail, gone", ROUTES)
|
||||
def test_an_unknown_token_answers_as_before(org_a, record, method, tail, gone):
|
||||
"""Pin: an unknown token has no org to check, on or off."""
|
||||
for off in ((), ("invoices",)):
|
||||
set_modules_off(org_a, *off)
|
||||
response = _send(record, method, tail, "not-a-real-token")
|
||||
assert (response.status_code, response.json()) == (404, gone)
|
||||
232
backend/invoices/tests/test_module_off_writes.py
Normal file
232
backend/invoices/tests/test_module_off_writes.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""Invoicing across modules while one is off: the routes that need two modules,
|
||||
a deal named on a document, and a stored deal that an edit must not erase."""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import Account
|
||||
from cases.models import Case, TimeEntry
|
||||
from common.testing import set_modules_off
|
||||
from contacts.models import Contact
|
||||
from invoices.models import Estimate, Invoice, RecurringInvoice
|
||||
from opportunity.models import DealPipeline, DealStage, Opportunity, OpportunityLineItem
|
||||
|
||||
DEALS_OFF = "Pipeline is switched off for this organization."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(org_a):
|
||||
return Account.objects.create(name="Billed Co", org=org_a)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def contact(org_a, account):
|
||||
contact = Contact.objects.create(first_name="Ada", last_name="Buyer", org=org_a)
|
||||
contact.account = account
|
||||
contact.save(update_fields=["account"])
|
||||
return contact
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def won_deal(org_a, account, contact):
|
||||
pipeline = DealPipeline.objects.create(org=org_a, name="Renewals")
|
||||
for order, (code, kind) in enumerate([("TALKING", "open"), ("SIGNED", "won")], 1):
|
||||
DealStage.objects.create(
|
||||
org=org_a, pipeline=pipeline, code=code, label=code, kind=kind, order=order
|
||||
)
|
||||
deal = Opportunity.objects.create(
|
||||
org=org_a,
|
||||
name="Renewal",
|
||||
stage="SIGNED",
|
||||
pipeline=pipeline,
|
||||
account=account,
|
||||
closed_on=timezone.localdate(),
|
||||
currency="USD",
|
||||
)
|
||||
deal.contacts.add(contact)
|
||||
OpportunityLineItem.objects.create(
|
||||
opportunity=deal,
|
||||
org=org_a,
|
||||
name="Seat",
|
||||
quantity=Decimal("2"),
|
||||
unit_price=Decimal("50"),
|
||||
)
|
||||
return deal
|
||||
|
||||
|
||||
def _is_off(response, key):
|
||||
assert response.status_code == 404, response.content
|
||||
body = response.json()
|
||||
assert (body["code"], body["module"]) == ("module_off", key)
|
||||
|
||||
|
||||
def test_invoicing_a_deal_needs_invoices_and_deals(admin_client, org_a, won_deal):
|
||||
url = f"/api/invoices/from-opportunity/{won_deal.id}/"
|
||||
|
||||
set_modules_off(org_a, "invoices")
|
||||
_is_off(admin_client.post(url), "invoices")
|
||||
|
||||
set_modules_off(org_a, "deals")
|
||||
_is_off(admin_client.post(url), "deals")
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.post(url).status_code == 201
|
||||
assert Invoice.objects.filter(opportunity=won_deal).count() == 1
|
||||
|
||||
|
||||
def test_invoicing_time_needs_the_timesheet(
|
||||
admin_client, org_a, account, admin_profile
|
||||
):
|
||||
case = Case.objects.create(
|
||||
name="Work", status="New", priority="Normal", org=org_a, account=account
|
||||
)
|
||||
entry = TimeEntry.objects.create(
|
||||
org=org_a,
|
||||
case=case,
|
||||
profile=admin_profile,
|
||||
started_at=timezone.now() - datetime.timedelta(hours=2),
|
||||
ended_at=timezone.now() - datetime.timedelta(hours=1),
|
||||
billable=True,
|
||||
hourly_rate=Decimal("100.00"),
|
||||
currency="USD",
|
||||
)
|
||||
set_modules_off(org_a, "timesheet")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/invoices/from-time-entries/",
|
||||
{"account_id": str(account.id), "entry_ids": [str(entry.id)]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
_is_off(response, "timesheet")
|
||||
assert not Invoice.objects.exists()
|
||||
|
||||
|
||||
def _body(kind, account, contact, **extra):
|
||||
today = timezone.localdate()
|
||||
if kind == "estimate":
|
||||
return {
|
||||
"title": "Estimate",
|
||||
"account_id": str(account.id),
|
||||
"contact_id": str(contact.id),
|
||||
"currency": "USD",
|
||||
"issue_date": str(today),
|
||||
"expiry_date": str(today + datetime.timedelta(days=30)),
|
||||
"line_items": [{"name": "Design", "quantity": "1", "unit_price": "10.00"}],
|
||||
**extra,
|
||||
}
|
||||
if kind == "recurring":
|
||||
return {
|
||||
"title": "Retainer",
|
||||
"account_id": str(account.id),
|
||||
"contact_id": str(contact.id),
|
||||
"frequency": "MONTHLY",
|
||||
"start_date": str(today),
|
||||
"next_generation_date": str(today),
|
||||
"payment_terms": "NET_30",
|
||||
"currency": "USD",
|
||||
"is_active": True,
|
||||
**extra,
|
||||
}
|
||||
return {
|
||||
"invoice_title": "Invoice",
|
||||
"account_id": str(account.id),
|
||||
"contact_id": str(contact.id),
|
||||
"currency": "USD",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
KINDS = [
|
||||
pytest.param(Invoice, "invoice", "/api/invoices/", id="invoice"),
|
||||
pytest.param(Estimate, "estimate", "/api/invoices/estimates/", id="estimate"),
|
||||
pytest.param(
|
||||
RecurringInvoice, "recurring", "/api/invoices/recurring/", id="recurring"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "kind", "url"), KINDS)
|
||||
def test_naming_a_deal_on_a_document_is_refused_while_deals_is_off(
|
||||
admin_client, org_a, account, contact, won_deal, model, kind, url
|
||||
):
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
response = admin_client.post(
|
||||
url,
|
||||
_body(kind, account, contact, opportunity_id=str(won_deal.id)),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.content
|
||||
assert response.json()["errors"]["opportunity_id"] == [DEALS_OFF]
|
||||
assert not model.objects.exists()
|
||||
|
||||
|
||||
def test_naming_a_deal_on_an_estimate_is_refused_while_deals_is_off(
|
||||
admin_client, org_a, account, contact, won_deal
|
||||
):
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/invoices/estimates/",
|
||||
_body("estimate", account, contact, opportunity_id=str(won_deal.id)),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.content
|
||||
assert response.json()["errors"]["opportunity_id"] == [DEALS_OFF]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "kind", "url"), KINDS)
|
||||
def test_a_document_keeps_its_stored_deal_on_edit(
|
||||
admin_client, org_a, account, contact, won_deal, model, kind, url
|
||||
):
|
||||
created = admin_client.post(
|
||||
url,
|
||||
_body(kind, account, contact, opportunity_id=str(won_deal.id)),
|
||||
format="json",
|
||||
)
|
||||
assert created.status_code == 201, created.content
|
||||
record = model.objects.get()
|
||||
assert record.opportunity_id == won_deal.id
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
# What a client that loaded the record sends back: the link resent, omitted,
|
||||
# or null (the shape it saw), and none may move or clear the stored deal.
|
||||
for extra in ({"opportunity_id": str(won_deal.id)}, {}, {"opportunity_id": None}):
|
||||
response = admin_client.put(
|
||||
f"{url}{record.id}/", _body(kind, account, contact, **extra), format="json"
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
record.refresh_from_db()
|
||||
assert record.opportunity_id == won_deal.id
|
||||
|
||||
|
||||
def test_naming_a_different_deal_while_deals_is_off_leaves_the_stored_one(
|
||||
admin_client, org_a, account, contact, won_deal
|
||||
):
|
||||
created = admin_client.post(
|
||||
"/api/invoices/",
|
||||
_body("invoice", account, contact, opportunity_id=str(won_deal.id)),
|
||||
format="json",
|
||||
)
|
||||
assert created.status_code == 201, created.content
|
||||
invoice = Invoice.objects.get()
|
||||
other = Opportunity.objects.create(
|
||||
org=org_a, name="Other", stage="", account=account
|
||||
)
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
response = admin_client.put(
|
||||
f"/api/invoices/{invoice.id}/",
|
||||
_body("invoice", account, contact, opportunity_id=str(other.id)),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.content
|
||||
invoice.refresh_from_db()
|
||||
assert invoice.opportunity_id == won_deal.id
|
||||
|
|
@ -8,6 +8,7 @@ from rest_framework.exceptions import ValidationError
|
|||
from accounts.access import has_account_access
|
||||
from accounts.models import Account
|
||||
from common.models import Attachments, Comment
|
||||
from common.modules import module_is_available
|
||||
from contacts.access import has_contact_access, visible_contacts_qs
|
||||
from contacts.models import Contact
|
||||
from opportunity.models import Opportunity
|
||||
|
|
@ -174,8 +175,12 @@ def convert_lead_to_account(lead_obj, request, create_opportunity=True):
|
|||
)
|
||||
)
|
||||
|
||||
# Create Opportunity if requested and lead has opportunity data
|
||||
# Create Opportunity if requested and lead has opportunity data. With Deals
|
||||
# switched off the lead still converts; it just leaves no deal behind.
|
||||
opportunity = None
|
||||
create_opportunity = create_opportunity and module_is_available(
|
||||
request.profile.org, "deals"
|
||||
)
|
||||
if create_opportunity and (
|
||||
lead_obj.opportunity_amount or lead_obj.first_name or lead_obj.company_name
|
||||
):
|
||||
|
|
|
|||
80
backend/leads/tests/test_module_off_convert.py
Normal file
80
backend/leads/tests/test_module_off_convert.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Converting a lead while Deals is off: the account and contact are made, no
|
||||
deal is, and every entry point answers with no deal id."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from accounts.models import Account
|
||||
from common.testing import set_modules_off
|
||||
from contacts.models import Contact
|
||||
from leads.models import Lead
|
||||
from opportunity.models import Opportunity
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
URL = "/api/leads/"
|
||||
EMAIL = "lee@convert.test"
|
||||
BODY = {
|
||||
"first_name": "Lee",
|
||||
"last_name": "Ad",
|
||||
"email": EMAIL,
|
||||
"company_name": "Convert Co",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_mail():
|
||||
with patch("leads.views.lead_views.send_email_to_assigned_user.delay"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lead(org_a):
|
||||
return Lead.objects.create(org=org_a, status="in process", **BODY)
|
||||
|
||||
|
||||
def _convert(client, how, lead):
|
||||
if how == "create":
|
||||
return client.post(
|
||||
URL,
|
||||
{
|
||||
**BODY,
|
||||
"email": "new@convert.test",
|
||||
"title": "Fresh",
|
||||
"status": "converted",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
if how == "put":
|
||||
return client.put(
|
||||
f"{URL}{lead.id}/",
|
||||
{**BODY, "title": "Converted", "status": "converted"},
|
||||
format="json",
|
||||
)
|
||||
return client.patch(f"{URL}{lead.id}/", {"status": "converted"}, format="json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["create", "put", "patch"])
|
||||
def test_converting_with_deals_off_creates_no_deal(admin_client, org_a, lead, how):
|
||||
set_modules_off(org_a, "deals")
|
||||
|
||||
response = _convert(admin_client, how, lead)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
body = response.json()
|
||||
assert body["opportunity_id"] is None
|
||||
assert Account.objects.filter(id=body["account_id"], org=org_a).exists()
|
||||
assert Contact.objects.filter(id=body["contact_id"], org=org_a).exists()
|
||||
assert not Opportunity.objects.filter(org=org_a).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["create", "put", "patch"])
|
||||
def test_converting_with_deals_on_creates_a_deal_as_before(
|
||||
admin_client, org_a, lead, how
|
||||
):
|
||||
response = _convert(admin_client, how, lead)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
deal = Opportunity.objects.get(org=org_a)
|
||||
assert response.json()["opportunity_id"] == str(deal.id)
|
||||
|
|
@ -30,6 +30,7 @@ Postgres.
|
|||
from django.db.models import DateField, F, OuterRef, Subquery, UUIDField
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.modules import module_is_available
|
||||
from opportunity.models import Opportunity
|
||||
from tasks.access import visible_tasks_qs
|
||||
from tasks.models import Task
|
||||
|
|
@ -65,18 +66,23 @@ def attach_next_activity(deals, profile):
|
|||
|
||||
``deals`` is an evaluated page or board, not a queryset: one query for all
|
||||
of them, keyed by id, so the serializer reads the same attributes as it
|
||||
would from an annotated row and nothing was counted with them.
|
||||
would from an annotated row and nothing was counted with them. None while
|
||||
Tasks is off, with no query.
|
||||
"""
|
||||
deals = list(deals)
|
||||
rows = {
|
||||
row["pk"]: row
|
||||
for row in annotate_next_activity(
|
||||
Opportunity.objects.filter(
|
||||
org=profile.org, pk__in=[deal.pk for deal in deals]
|
||||
),
|
||||
profile,
|
||||
).values("pk", "next_activity_id", "next_activity_title", "next_activity_due")
|
||||
}
|
||||
rows = {}
|
||||
if module_is_available(profile.org, "tasks"):
|
||||
rows = {
|
||||
row["pk"]: row
|
||||
for row in annotate_next_activity(
|
||||
Opportunity.objects.filter(
|
||||
org=profile.org, pk__in=[deal.pk for deal in deals]
|
||||
),
|
||||
profile,
|
||||
).values(
|
||||
"pk", "next_activity_id", "next_activity_title", "next_activity_due"
|
||||
)
|
||||
}
|
||||
for deal in deals:
|
||||
row = rows.get(deal.pk, {})
|
||||
deal.next_activity_id = row.get("next_activity_id")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from django.utils import timezone
|
|||
|
||||
from common.links import frontend_url
|
||||
from common.models import Org, Profile
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.modules import module_is_available, module_on_since
|
||||
from common.org_time import activate_org_timezone, org_timezone
|
||||
from opportunity.models import Opportunity, SalesGoal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -58,12 +59,20 @@ def check_stale_opportunities():
|
|||
orgs = Org.objects.filter(is_active=True)
|
||||
|
||||
for org in orgs:
|
||||
if not module_is_available(org, "deals"):
|
||||
continue
|
||||
try:
|
||||
_set_rls_context_safe(str(org.id))
|
||||
|
||||
stages = stage_index(org.id)
|
||||
rotten_q = aging_q(stages.values(), "red")
|
||||
since = module_on_since(org, "deals")
|
||||
if since is not None:
|
||||
# Already rotten when Pipeline came back on: it went stale
|
||||
# while nobody could see it, so it is never alerted.
|
||||
rotten_q &= ~aging_q(stages.values(), "red", since)
|
||||
rotten = Opportunity.objects.filter(
|
||||
aging_q(stages.values(), "red"), org=org, is_active=True
|
||||
rotten_q, org=org, is_active=True
|
||||
).select_related("org")
|
||||
|
||||
stale_opps = []
|
||||
|
|
@ -130,6 +139,57 @@ def send_stale_deals_alert(org, stale_opps):
|
|||
)
|
||||
|
||||
|
||||
def _current_goals(org):
|
||||
"""``org``'s active goals whose period covers today. Call with the org's
|
||||
timezone active."""
|
||||
today = timezone.localdate()
|
||||
return SalesGoal.objects.filter(
|
||||
org=org, is_active=True, period_start__lte=today, period_end__gte=today
|
||||
)
|
||||
|
||||
|
||||
def _mark_milestones(goal):
|
||||
"""Set the milestone flags ``goal``'s progress has newly reached, and
|
||||
return the milestone to announce, if any."""
|
||||
progress_value = goal.compute_progress()
|
||||
if goal.target_value and goal.target_value != 0:
|
||||
percent = min(int(progress_value / goal.target_value * 100), 100)
|
||||
else:
|
||||
percent = 0
|
||||
notifications = []
|
||||
|
||||
if percent >= 100 and not goal.milestone_100_notified:
|
||||
goal.milestone_100_notified = True
|
||||
goal.milestone_90_notified = True
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("100%", percent, progress_value))
|
||||
elif percent >= 90 and not goal.milestone_90_notified:
|
||||
goal.milestone_90_notified = True
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("90%", percent, progress_value))
|
||||
elif percent >= 50 and not goal.milestone_50_notified:
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("50%", percent, progress_value))
|
||||
|
||||
if notifications:
|
||||
goal.save(
|
||||
update_fields=[
|
||||
"milestone_50_notified",
|
||||
"milestone_90_notified",
|
||||
"milestone_100_notified",
|
||||
]
|
||||
)
|
||||
return notifications
|
||||
|
||||
|
||||
def settle_goal_milestones(org) -> None:
|
||||
"""Goals is back on: mark the milestones already reached as announced,
|
||||
sending nothing, so only those reached from now on send an email."""
|
||||
with org_timezone(org):
|
||||
for goal in _current_goals(org):
|
||||
_mark_milestones(goal)
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_goal_milestones():
|
||||
"""Daily: check goal progress milestones and send notifications."""
|
||||
|
|
@ -137,52 +197,17 @@ def check_goal_milestones():
|
|||
|
||||
try:
|
||||
for org in orgs:
|
||||
if not module_is_available(org, "goals"):
|
||||
continue
|
||||
try:
|
||||
_set_rls_context_safe(str(org.id))
|
||||
# A quarter ends when it ends where the org is, so the window is
|
||||
# resolved per org rather than once for the whole sweep.
|
||||
activate_org_timezone(org)
|
||||
today = timezone.localdate()
|
||||
|
||||
goals = SalesGoal.objects.filter(
|
||||
org=org,
|
||||
is_active=True,
|
||||
period_start__lte=today,
|
||||
period_end__gte=today,
|
||||
)
|
||||
|
||||
for goal in goals:
|
||||
progress_value = goal.compute_progress()
|
||||
if goal.target_value and goal.target_value != 0:
|
||||
percent = min(
|
||||
int(progress_value / goal.target_value * 100), 100
|
||||
)
|
||||
else:
|
||||
percent = 0
|
||||
notifications = []
|
||||
|
||||
if percent >= 100 and not goal.milestone_100_notified:
|
||||
goal.milestone_100_notified = True
|
||||
goal.milestone_90_notified = True
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("100%", percent, progress_value))
|
||||
elif percent >= 90 and not goal.milestone_90_notified:
|
||||
goal.milestone_90_notified = True
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("90%", percent, progress_value))
|
||||
elif percent >= 50 and not goal.milestone_50_notified:
|
||||
goal.milestone_50_notified = True
|
||||
notifications.append(("50%", percent, progress_value))
|
||||
|
||||
for goal in _current_goals(org):
|
||||
notifications = _mark_milestones(goal)
|
||||
if notifications:
|
||||
goal.save(
|
||||
update_fields=[
|
||||
"milestone_50_notified",
|
||||
"milestone_90_notified",
|
||||
"milestone_100_notified",
|
||||
]
|
||||
)
|
||||
|
||||
recipients = []
|
||||
if goal.assigned_to:
|
||||
recipients.append(goal.assigned_to)
|
||||
|
|
|
|||
192
backend/opportunity/tests/test_module_off_jobs.py
Normal file
192
backend/opportunity/tests/test_module_off_jobs.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""The deal and goal jobs while their module is off, and once it is back on.
|
||||
|
||||
A deal that went stale while Pipeline was off is never alerted, and a goal
|
||||
milestone reached while Goals was off is settled silently at switch-on.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django.utils import timezone
|
||||
|
||||
from common.module_settings import change_modules
|
||||
from common.testing import restore_rls_context, rls_org
|
||||
from opportunity.models import Opportunity, SalesGoal
|
||||
from opportunity.tasks import check_goal_milestones as _check_goal_milestones
|
||||
from opportunity.tasks import check_stale_opportunities as _check_stale_opportunities
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DAY = datetime.timedelta(days=1)
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = datetime.datetime(2026, 10, 14, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
def advance(self, **delta):
|
||||
self.now += datetime.timedelta(**delta)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
c = Clock()
|
||||
with patch("django.utils.timezone.now", side_effect=lambda: c.now):
|
||||
yield c
|
||||
|
||||
|
||||
def _switch(org, actor, *off):
|
||||
"""Leave exactly ``off`` switched off, through the one writer."""
|
||||
assert change_modules(org, actor, {"off": list(off), "order": []}) == {}
|
||||
org.refresh_from_db()
|
||||
|
||||
|
||||
# Each job leaves the last org's RLS context set; the test reads its own rows
|
||||
# afterwards.
|
||||
def check_stale_opportunities():
|
||||
try:
|
||||
_check_stale_opportunities()
|
||||
finally:
|
||||
restore_rls_context()
|
||||
|
||||
|
||||
def check_goal_milestones():
|
||||
try:
|
||||
_check_goal_milestones()
|
||||
finally:
|
||||
restore_rls_context()
|
||||
|
||||
|
||||
def _deal(org, user, name, days_in_stage):
|
||||
# Prospecting expects 14 days, so a deal is red after 21.
|
||||
return Opportunity.objects.create(
|
||||
name=name,
|
||||
stage="PROSPECTING",
|
||||
org=org,
|
||||
created_by=user,
|
||||
stage_changed_at=timezone.now() - days_in_stage * DAY,
|
||||
)
|
||||
|
||||
|
||||
def _alerted(mock_alert):
|
||||
return [
|
||||
(call.args[0].id, sorted(opp.name for opp, *_ in call.args[1]))
|
||||
for call in mock_alert.call_args_list
|
||||
]
|
||||
|
||||
|
||||
def _goal(org, profile):
|
||||
today = timezone.localdate()
|
||||
return SalesGoal.objects.create(
|
||||
name="Quarter",
|
||||
goal_type="REVENUE",
|
||||
target_value=Decimal("100"),
|
||||
period_type="MONTHLY",
|
||||
period_start=today - 5 * DAY,
|
||||
period_end=today + 25 * DAY,
|
||||
assigned_to=profile,
|
||||
org=org,
|
||||
)
|
||||
|
||||
|
||||
def _won(org, user, profile, amount):
|
||||
deal = Opportunity.objects.create(
|
||||
name=f"Won {amount}",
|
||||
stage="CLOSED_WON",
|
||||
amount=Decimal(amount),
|
||||
closed_on=timezone.localdate(),
|
||||
org=org,
|
||||
created_by=user,
|
||||
)
|
||||
deal.assigned_to.add(profile)
|
||||
|
||||
|
||||
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||
def test_stale_deal_alert_skips_an_org_with_deals_off(
|
||||
mock_alert, org_a, org_b, admin_user, admin_profile, user_b
|
||||
):
|
||||
_switch(org_a, admin_profile, "deals")
|
||||
_deal(org_a, admin_user, "Ours", 30)
|
||||
with rls_org(org_b):
|
||||
_deal(org_b, user_b, "Theirs", 30)
|
||||
|
||||
check_stale_opportunities()
|
||||
|
||||
assert _alerted(mock_alert) == [(org_b.id, ["Theirs"])]
|
||||
|
||||
|
||||
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||
def test_deals_that_went_stale_while_off_are_not_alerted(
|
||||
mock_alert, org_a, admin_user, admin_profile, clock
|
||||
):
|
||||
_switch(org_a, admin_profile, "deals")
|
||||
clock.advance(days=30)
|
||||
_deal(org_a, admin_user, "Went stale while off", 30)
|
||||
_deal(org_a, admin_user, "Turns stale later", 15)
|
||||
_switch(org_a, admin_profile)
|
||||
|
||||
check_stale_opportunities()
|
||||
assert _alerted(mock_alert) == []
|
||||
|
||||
clock.advance(days=7)
|
||||
check_stale_opportunities()
|
||||
assert _alerted(mock_alert) == [(org_a.id, ["Turns stale later"])]
|
||||
|
||||
|
||||
def test_goal_milestones_skip_an_org_with_goals_off(
|
||||
org_a, org_b, admin_user, admin_profile, user_b, profile_b
|
||||
):
|
||||
_switch(org_a, admin_profile, "goals")
|
||||
ours = _goal(org_a, admin_profile)
|
||||
_won(org_a, admin_user, admin_profile, "60")
|
||||
with rls_org(org_b):
|
||||
theirs = _goal(org_b, profile_b)
|
||||
_won(org_b, user_b, profile_b, "60")
|
||||
|
||||
check_goal_milestones()
|
||||
|
||||
ours.refresh_from_db()
|
||||
assert ours.milestone_50_notified is False
|
||||
with rls_org(org_b):
|
||||
theirs.refresh_from_db()
|
||||
assert theirs.milestone_50_notified is True
|
||||
assert [m.to for m in mail.outbox] == [[user_b.email]]
|
||||
|
||||
|
||||
def test_milestones_reached_while_off_send_nothing_after_switch_on(
|
||||
org_a, admin_user, admin_profile
|
||||
):
|
||||
_switch(org_a, admin_profile, "goals")
|
||||
goal = _goal(org_a, admin_profile)
|
||||
_won(org_a, admin_user, admin_profile, "60")
|
||||
check_goal_milestones()
|
||||
goal.refresh_from_db()
|
||||
assert goal.milestone_50_notified is False
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
goal.refresh_from_db()
|
||||
assert (goal.milestone_50_notified, goal.milestone_90_notified) == (True, False)
|
||||
check_goal_milestones()
|
||||
assert mail.outbox == []
|
||||
|
||||
_won(org_a, admin_user, admin_profile, "35")
|
||||
check_goal_milestones()
|
||||
assert [m.subject for m in mail.outbox] == [
|
||||
"[BottleCRM] Goal 'Quarter' reached 90%!"
|
||||
]
|
||||
|
||||
|
||||
def test_switching_pipeline_back_on_settles_goals_too(org_a, admin_user, admin_profile):
|
||||
"""Goals needs Pipeline, so it comes back with it and starts fresh then."""
|
||||
_switch(org_a, admin_profile, "deals")
|
||||
goal = _goal(org_a, admin_profile)
|
||||
_won(org_a, admin_user, admin_profile, "60")
|
||||
|
||||
_switch(org_a, admin_profile)
|
||||
goal.refresh_from_db()
|
||||
assert goal.milestone_50_notified is True
|
||||
check_goal_milestones()
|
||||
assert mail.outbox == []
|
||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "django-crm"
|
||||
version = "1.22.1"
|
||||
version = "1.23.0"
|
||||
# The one-line summary PyPI shows under the project title and in search results,
|
||||
# so it carries the terms people actually search for ("open source CRM",
|
||||
# "self-hosted CRM", "Django CRM") and names the modules rather than the
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ from django.db.models import Count, Q
|
|||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.modules import (
|
||||
available_modules,
|
||||
model_module,
|
||||
module_is_available,
|
||||
module_off_message,
|
||||
org_of,
|
||||
)
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
CommentSerializer,
|
||||
|
|
@ -82,6 +89,14 @@ class BoardTaskSerializer(serializers.ModelSerializer):
|
|||
"opportunity",
|
||||
)
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.opportunity_id and not module_is_available(
|
||||
org_of(instance), "deals"
|
||||
):
|
||||
data["opportunity"] = None
|
||||
return data
|
||||
|
||||
@extend_schema_field(dict)
|
||||
def get_account(self, obj):
|
||||
"""Render the parent account as ``{id, name}`` for the card chip, or
|
||||
|
|
@ -228,22 +243,35 @@ class _MinimalAccountField(serializers.RelatedField):
|
|||
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
|
||||
|
||||
|
||||
class _MinimalOpportunityField(serializers.RelatedField):
|
||||
class _ModuleLinkField(serializers.RelatedField):
|
||||
"""A parent from another module: null while that module is off."""
|
||||
|
||||
module = None
|
||||
|
||||
def to_representation(self, value):
|
||||
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
|
||||
if not module_is_available(org_of(value), self.module):
|
||||
return None
|
||||
return {"id": str(value.pk), "name": self.name_of(value)}
|
||||
|
||||
def name_of(self, value):
|
||||
return getattr(value, "name", "") or ""
|
||||
|
||||
|
||||
class _MinimalCaseField(serializers.RelatedField):
|
||||
def to_representation(self, value):
|
||||
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
|
||||
class _MinimalOpportunityField(_ModuleLinkField):
|
||||
module = "deals"
|
||||
|
||||
|
||||
class _MinimalLeadField(serializers.RelatedField):
|
||||
def to_representation(self, value):
|
||||
class _MinimalCaseField(_ModuleLinkField):
|
||||
module = "tickets"
|
||||
|
||||
|
||||
class _MinimalLeadField(_ModuleLinkField):
|
||||
module = "leads"
|
||||
|
||||
def name_of(self, value):
|
||||
first = getattr(value, "first_name", "") or ""
|
||||
last = getattr(value, "last_name", "") or ""
|
||||
name = f"{first} {last}".strip() or (getattr(value, "email", "") or "")
|
||||
return {"id": str(value.pk), "name": name}
|
||||
return f"{first} {last}".strip() or (getattr(value, "email", "") or "")
|
||||
|
||||
|
||||
class TaskListSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -349,6 +377,20 @@ class TaskCreateSerializer(serializers.ModelSerializer):
|
|||
past the rule.
|
||||
"""
|
||||
attrs = super().validate(attrs)
|
||||
stored = self.instance
|
||||
for name in PARENT_FIELDS:
|
||||
key = model_module(self.fields[name].queryset.model._meta.label)
|
||||
if key is None or module_is_available(self.org, key) or name not in attrs:
|
||||
continue
|
||||
if attrs[name] is None:
|
||||
# An off module's link reads as null, so a client sending the
|
||||
# task back sends null: that keeps the stored link.
|
||||
if stored is not None:
|
||||
del attrs[name]
|
||||
elif attrs[name].pk != getattr(stored, f"{name}_id", None):
|
||||
raise serializers.ValidationError(
|
||||
{name: module_off_message(self.org, key)}
|
||||
)
|
||||
resolved = {}
|
||||
for name in PARENT_FIELDS:
|
||||
if name in attrs:
|
||||
|
|
@ -611,18 +653,20 @@ class TaskKanbanCardSerializer(serializers.ModelSerializer):
|
|||
|
||||
@extend_schema_field(RelatedEntitySerializer(allow_null=True))
|
||||
def get_related_entity(self, obj):
|
||||
"""Return the related entity (account, lead, opportunity, or case) if any."""
|
||||
"""Return the related entity (account, lead, opportunity, or case) if
|
||||
any; null when its module is off."""
|
||||
if obj.account_id:
|
||||
return {"id": obj.account_id, "name": obj.account.name, "type": "account"}
|
||||
if obj.lead_id:
|
||||
available = available_modules(org_of(obj))
|
||||
if obj.lead_id and "leads" in available:
|
||||
return {"id": obj.lead_id, "name": str(obj.lead), "type": "lead"}
|
||||
if obj.opportunity_id:
|
||||
if obj.opportunity_id and "deals" in available:
|
||||
return {
|
||||
"id": obj.opportunity_id,
|
||||
"name": obj.opportunity.name,
|
||||
"type": "opportunity",
|
||||
}
|
||||
if obj.case_id:
|
||||
if obj.case_id and "tickets" in available:
|
||||
return {"id": obj.case_id, "name": obj.case.name, "type": "case"}
|
||||
return None
|
||||
|
||||
|
|
|
|||
123
backend/tasks/tests/test_module_off_links.py
Normal file
123
backend/tasks/tests/test_module_off_links.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""A task's deal, ticket or lead while that module is off: an edit that omits the
|
||||
link or sends null keeps it, so a client that sends back what it loaded never
|
||||
erases a hidden link."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cases.models import Case
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
from opportunity.models import Opportunity
|
||||
from tasks.models import Task
|
||||
|
||||
LINKS = [
|
||||
pytest.param(
|
||||
"opportunity",
|
||||
"deals",
|
||||
lambda org: Opportunity.objects.create(org=org, name="Deal", stage=""),
|
||||
id="deal",
|
||||
),
|
||||
pytest.param(
|
||||
"case",
|
||||
"tickets",
|
||||
lambda org: Case.objects.create(
|
||||
org=org, name="Ticket", status="New", priority="Normal"
|
||||
),
|
||||
id="ticket",
|
||||
),
|
||||
pytest.param(
|
||||
"lead",
|
||||
"leads",
|
||||
lambda org: Lead.objects.create(org=org, title="Lead", first_name="Lee"),
|
||||
id="lead",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "make"), LINKS)
|
||||
@pytest.mark.parametrize("verb", ["patch", "put"])
|
||||
def test_a_task_keeps_its_hidden_link_on_edit(
|
||||
admin_client, org_a, field, module, make, verb
|
||||
):
|
||||
parent = make(org_a)
|
||||
task = Task.objects.create(
|
||||
org=org_a, title="Follow up", status="New", priority="Medium", **{field: parent}
|
||||
)
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
for extra in ({}, {field: None}):
|
||||
response = getattr(admin_client, verb)(
|
||||
f"/api/tasks/{task.id}/",
|
||||
{"title": "Renamed", "status": "New", "priority": "Medium", **extra},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
task.refresh_from_db()
|
||||
assert getattr(task, f"{field}_id") == parent.id
|
||||
assert task.title == "Renamed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "make"), LINKS)
|
||||
def test_a_task_link_clears_on_null_while_its_module_is_on(
|
||||
admin_client, org_a, field, module, make
|
||||
):
|
||||
task = Task.objects.create(
|
||||
org=org_a,
|
||||
title="Follow up",
|
||||
status="New",
|
||||
priority="Medium",
|
||||
**{field: make(org_a)},
|
||||
)
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/tasks/{task.id}/", {field: None}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.content
|
||||
task.refresh_from_db()
|
||||
assert getattr(task, f"{field}_id") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "make"), LINKS)
|
||||
def test_a_task_cannot_be_created_with_an_off_module_record(
|
||||
admin_client, org_a, field, module, make
|
||||
):
|
||||
parent = make(org_a)
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/tasks/",
|
||||
{"title": "New", "status": "New", "priority": "Medium", field: str(parent.id)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.content
|
||||
assert "is switched off for this organization." in str(
|
||||
response.json()["errors"][field]
|
||||
)
|
||||
assert not Task.objects.filter(org=org_a).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("field", "module", "make"), LINKS)
|
||||
def test_a_task_edit_naming_another_off_module_record_is_refused(
|
||||
admin_client, org_a, field, module, make
|
||||
):
|
||||
stored, other = make(org_a), make(org_a)
|
||||
task = Task.objects.create(
|
||||
org=org_a, title="Follow up", status="New", priority="Medium", **{field: stored}
|
||||
)
|
||||
set_modules_off(org_a, module)
|
||||
|
||||
refused = admin_client.patch(
|
||||
f"/api/tasks/{task.id}/", {field: str(other.id)}, format="json"
|
||||
)
|
||||
resent = admin_client.patch(
|
||||
f"/api/tasks/{task.id}/",
|
||||
{field: str(stored.id), "title": "Same"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert refused.status_code == 400, refused.content
|
||||
assert resent.status_code == 200, resent.content
|
||||
task.refresh_from_db()
|
||||
assert getattr(task, f"{field}_id") == stored.id
|
||||
|
|
@ -704,7 +704,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django-crm"
|
||||
version = "1.22.1"
|
||||
version = "1.23.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cairocffi" },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Uses django-cors-headers' `check_request_enabled` signal rather than a bespoke
|
|||
middleware, so there is one CORS implementation in the project and the global
|
||||
`CORS_ALLOWED_ORIGINS` and `CORS_ORIGIN_ALLOW_ALL` settings stay untouched.
|
||||
|
||||
Three deliberate narrowings, each with a test:
|
||||
Four deliberate narrowings, each with a test:
|
||||
|
||||
* Only the `/submit/` route. The embed routes are plain GETs of a document and
|
||||
a script, neither of which CORS applies to, so widening to them would grant
|
||||
|
|
@ -17,13 +17,14 @@ Three deliberate narrowings, each with a test:
|
|||
iframe embed and for server-side callers. Reflecting an arbitrary `Origin`
|
||||
header back as CORS-allowed is a much larger promise on a production API, and
|
||||
an org that wants the script embed can list its origins.
|
||||
* Only a form the public endpoints serve, decided by their own `live_form`, so
|
||||
a form they answer 404 for (its module switched off, say) gets no CORS
|
||||
headers either, and the headers cannot tell an outsider which it was.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from webforms.models import WebForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Anchored at both ends on purpose. A route added later under a longer path
|
||||
|
|
@ -48,18 +49,13 @@ def allow_webform_origin(sender, request, **kwargs):
|
|||
# Imported here rather than at module scope to keep this module importable
|
||||
# from AppConfig.ready() without pulling the ORM in too early.
|
||||
from common.tasks import set_rls_context
|
||||
from webforms.public_views import live_form
|
||||
|
||||
org_id = match.group("org_id")
|
||||
set_rls_context(org_id)
|
||||
|
||||
try:
|
||||
allowed = (
|
||||
WebForm.objects.filter(
|
||||
id=match.group("form_id"), org_id=org_id, is_published=True
|
||||
)
|
||||
.values_list("allowed_origins", flat=True)
|
||||
.first()
|
||||
)
|
||||
form = live_form(org_id, match.group("form_id"))
|
||||
except Exception:
|
||||
# A malformed id reaches the database as a bad UUID and raises. Refusing
|
||||
# is the safe answer: this decides whether to relax a cross-origin
|
||||
|
|
@ -67,4 +63,4 @@ def allow_webform_origin(sender, request, **kwargs):
|
|||
logger.warning("CORS lookup failed for %s", request.path, exc_info=True)
|
||||
return False
|
||||
|
||||
return bool(allowed) and origin in allowed
|
||||
return form is not None and origin in (form.allowed_origins or [])
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class WebForm(BaseOrgModel):
|
|||
(TARGET_LEAD, "Lead"),
|
||||
(TARGET_TICKET, "Ticket"),
|
||||
]
|
||||
TARGET_MODULES = {TARGET_LEAD: "leads", TARGET_TICKET: "tickets"}
|
||||
|
||||
CAPTCHA_NONE = ""
|
||||
CAPTCHA_TURNSTILE = "turnstile"
|
||||
|
|
@ -183,6 +184,10 @@ class WebForm(BaseOrgModel):
|
|||
ordering = ("-created_at",)
|
||||
indexes = [_org_index()]
|
||||
|
||||
@property
|
||||
def target_module(self) -> str:
|
||||
return self.TARGET_MODULES[self.target]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from common.links import api_url
|
||||
from common.modules import module_is_available
|
||||
from common.org_time import activate_org_timezone
|
||||
from common.request_meta import client_ip, referer
|
||||
from common.tasks import set_rls_context
|
||||
|
|
@ -50,16 +51,31 @@ logger = logging.getLogger(__name__)
|
|||
CAPTCHA_TOKEN_FIELD = "cf-turnstile-response"
|
||||
|
||||
|
||||
def live_form(org_id, form_id):
|
||||
"""The published form these endpoints serve, read in one query, or None.
|
||||
|
||||
Missing, unpublished, another org's, and writing into a module the org has
|
||||
switched off are deliberately indistinguishable, so the id space cannot be
|
||||
used to enumerate forms. The CORS receiver asks the same question, so its
|
||||
headers cannot tell them apart either. Set the RLS context first.
|
||||
"""
|
||||
form = (
|
||||
WebForm.objects.select_related("org")
|
||||
.filter(id=form_id, org_id=org_id, is_published=True)
|
||||
.first()
|
||||
)
|
||||
if form is None or not module_is_available(form.org, form.target_module):
|
||||
return None
|
||||
return form
|
||||
|
||||
|
||||
class PublicWebFormMixin:
|
||||
permission_classes = (AllowAny,)
|
||||
authentication_classes: list = []
|
||||
|
||||
def load_form(self, org_id, form_id):
|
||||
"""The published form, or None.
|
||||
|
||||
Sets the RLS context first. Every caller answers 404 for None: missing,
|
||||
unpublished, and belonging to another org are deliberately
|
||||
indistinguishable, so the id space cannot be used to enumerate forms.
|
||||
"""`live_form` under the RLS context of `org_id`. Every caller answers
|
||||
404 for None.
|
||||
|
||||
A found form's org timezone is activated, so the daily view counter and
|
||||
anything the submission writes use the org's day rather than the UTC
|
||||
|
|
@ -67,11 +83,7 @@ class PublicWebFormMixin:
|
|||
when the request ends.
|
||||
"""
|
||||
set_rls_context(org_id)
|
||||
form = (
|
||||
WebForm.objects.select_related("org")
|
||||
.filter(id=form_id, org_id=org_id, is_published=True)
|
||||
.first()
|
||||
)
|
||||
form = live_form(org_id, form_id)
|
||||
if form is not None:
|
||||
activate_org_timezone(form.org)
|
||||
return form
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ from rest_framework import serializers
|
|||
|
||||
from common.links import api_url
|
||||
from common.models import CustomFieldDefinition, Profile, Tags
|
||||
from common.modules import (
|
||||
available_modules,
|
||||
module_is_available,
|
||||
module_off_message,
|
||||
org_of,
|
||||
)
|
||||
from webforms.constants import LEAD_FIELD_VALUES
|
||||
from webforms.dynamic_serializer import CUSTOM_FIELD_TARGET
|
||||
from webforms.models import WebForm, WebFormField, WebFormSubmission
|
||||
|
|
@ -408,6 +414,9 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
|
|||
for the old target. Before that it is allowed, but the field list has
|
||||
to move with it, because rows written for one target mean nothing to
|
||||
the other and the embed would render inputs that write nowhere.
|
||||
|
||||
A target newly chosen, on create or by a change, is refused while its
|
||||
module is off. A form already on that target keeps taking other edits.
|
||||
"""
|
||||
stored = getattr(self.instance, "target", None)
|
||||
target = attrs.get("target", stored or WebForm.TARGET_LEAD)
|
||||
|
|
@ -433,6 +442,12 @@ class WebFormDetailSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
}
|
||||
)
|
||||
org = self.context["org"]
|
||||
module = WebForm.TARGET_MODULES[target]
|
||||
if (stored is None or changed) and not module_is_available(org, module):
|
||||
raise serializers.ValidationError(
|
||||
{"target": module_off_message(org, module)}
|
||||
)
|
||||
|
||||
rows = attrs.get("fields")
|
||||
if rows is None:
|
||||
|
|
@ -572,6 +587,15 @@ class WebFormSubmissionSerializer(serializers.ModelSerializer):
|
|||
# and naming the control that caught a bot is how the next bot gets
|
||||
# past it. Agents see the status, which is what they act on.
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""The lead or ticket a submission made is null while its module is off."""
|
||||
data = super().to_representation(instance)
|
||||
available = available_modules(org_of(instance))
|
||||
for field, key in (("lead", "leads"), ("case", "tickets")):
|
||||
if key not in available:
|
||||
data[field] = data[f"{field}_name"] = None
|
||||
return data
|
||||
|
||||
def get_lead_name(self, obj):
|
||||
if obj.lead is None:
|
||||
return None
|
||||
|
|
|
|||
224
backend/webforms/tests/test_module_off.py
Normal file
224
backend/webforms/tests/test_module_off.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""Web forms follow the module they write into.
|
||||
|
||||
While a form's target module is off, its public endpoints answer the
|
||||
not-found an unknown form gets, CORS headers included, publishing it is
|
||||
refused, and choosing that target is refused. Forms for a module that is on
|
||||
keep working.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from cases.models import Case
|
||||
from common.modules import module_off_message
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
from webforms.cors import allow_webform_origin
|
||||
from webforms.models import WebForm, WebFormDailyStat, WebFormField, WebFormSubmission
|
||||
|
||||
LIST_URL = "/api/webforms/"
|
||||
TICKETS_OFF = "Tickets is switched off for this organization."
|
||||
ORIGIN = "https://customer.example"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_throttle_cache():
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
|
||||
def _form(org, target, *, published=True):
|
||||
form = WebForm.objects.create(
|
||||
name=f"{target} form", org=org, target=target, is_published=published
|
||||
)
|
||||
source = (
|
||||
WebFormField.SOURCE_LEAD if target == "lead" else WebFormField.SOURCE_TICKET
|
||||
)
|
||||
WebFormField.objects.create(
|
||||
form=form,
|
||||
org=org,
|
||||
order=0,
|
||||
source=source,
|
||||
label="Email",
|
||||
is_required=True,
|
||||
**{f"{target}_field": "email"},
|
||||
)
|
||||
return form
|
||||
|
||||
|
||||
def _public(org_id, form_id):
|
||||
return f"/api/public/forms/{org_id}/{form_id}/"
|
||||
|
||||
|
||||
def _answers(org_id, form_id):
|
||||
"""What the three public endpoints say for this form, as (status, body)."""
|
||||
client = APIClient()
|
||||
base = _public(org_id, form_id)
|
||||
responses = (
|
||||
client.post(f"{base}submit/", {"email": "pat@example.com"}, format="json"),
|
||||
client.get(f"{base}embed/"),
|
||||
client.get(f"{base}embed.js"),
|
||||
)
|
||||
return [(r.status_code, r.content) for r in responses]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"closed, still_open, module",
|
||||
[("ticket", "lead", "tickets"), ("lead", "ticket", "leads")],
|
||||
)
|
||||
def test_a_form_closes_with_its_target_module(org_a, closed, still_open, module):
|
||||
off_form = _form(org_a, closed)
|
||||
on_form = _form(org_a, still_open)
|
||||
unknown = _answers(org_a.id, uuid.uuid4())
|
||||
assert unknown[0] == (404, b'{"detail":"Form not found."}')
|
||||
assert [status for status, _ in unknown] == [404, 404, 404]
|
||||
|
||||
set_modules_off(org_a, module)
|
||||
assert _answers(org_a.id, off_form.id) == unknown
|
||||
assert not WebFormSubmission.objects.filter(form=off_form).exists()
|
||||
assert not WebFormDailyStat.objects.filter(form=off_form).exists()
|
||||
created = Case if closed == "ticket" else Lead
|
||||
assert not created.objects.filter(org=org_a).exists()
|
||||
|
||||
assert [status for status, _ in _answers(org_a.id, on_form.id)] == [200, 200, 200]
|
||||
kept = Lead if still_open == "lead" else Case
|
||||
assert kept.objects.filter(org=org_a).count() == 1
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert [status for status, _ in _answers(org_a.id, off_form.id)] == [200, 200, 200]
|
||||
assert created.objects.filter(org=org_a).count() == 1
|
||||
|
||||
|
||||
def _cors(org_id, form_id):
|
||||
"""(status, CORS headers) for a preflight and a POST from a listed origin."""
|
||||
client = APIClient()
|
||||
url = f"{_public(org_id, form_id)}submit/"
|
||||
responses = (
|
||||
client.options(
|
||||
url, HTTP_ORIGIN=ORIGIN, HTTP_ACCESS_CONTROL_REQUEST_METHOD="POST"
|
||||
),
|
||||
client.post(
|
||||
url, {"email": "pat@example.com"}, format="json", HTTP_ORIGIN=ORIGIN
|
||||
),
|
||||
)
|
||||
return [
|
||||
(
|
||||
r.status_code,
|
||||
{
|
||||
name.lower(): value
|
||||
for name, value in r.headers.items()
|
||||
if name.lower().startswith("access-control-") or name.lower() == "vary"
|
||||
},
|
||||
)
|
||||
for r in responses
|
||||
]
|
||||
|
||||
|
||||
def test_cors_closes_with_the_target_module(org_a):
|
||||
form = _form(org_a, "ticket")
|
||||
form.allowed_origins = [ORIGIN]
|
||||
form.save(update_fields=["allowed_origins"])
|
||||
unknown = _cors(org_a.id, uuid.uuid4())
|
||||
assert [s for s, _ in unknown] == [200, 404]
|
||||
assert not any("access-control-allow-origin" in h for _, h in unknown)
|
||||
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert _cors(org_a.id, form.id) == unknown
|
||||
assert not Case.objects.filter(org=org_a).exists()
|
||||
|
||||
set_modules_off(org_a)
|
||||
opened = _cors(org_a.id, form.id)
|
||||
assert [s for s, _ in opened] == [200, 200]
|
||||
assert [h.get("access-control-allow-origin") for _, h in opened] == [ORIGIN] * 2
|
||||
|
||||
|
||||
def test_the_cors_check_reads_the_form_in_one_query(org_a):
|
||||
"""Pin: it runs on every submit request, and read one row before too."""
|
||||
form = _form(org_a, "ticket")
|
||||
form.allowed_origins = [ORIGIN]
|
||||
form.save(update_fields=["allowed_origins"])
|
||||
request = RequestFactory().post(
|
||||
f"{_public(org_a.id, form.id)}submit/", HTTP_ORIGIN=ORIGIN
|
||||
)
|
||||
with CaptureQueriesContext(connection) as captured:
|
||||
assert allow_webform_origin(None, request) is True
|
||||
reads = [q for q in captured.captured_queries if "set_config" not in q["sql"]]
|
||||
assert len(reads) == 1
|
||||
|
||||
|
||||
def test_publishing_a_form_for_an_off_module_is_refused(
|
||||
admin_client, user_client, org_a
|
||||
):
|
||||
form = _form(org_a, "ticket", published=False)
|
||||
url = f"{LIST_URL}{form.id}/publish/"
|
||||
set_modules_off(org_a, "tickets")
|
||||
assert user_client.post(url).status_code == 403
|
||||
|
||||
response = admin_client.post(url)
|
||||
assert (response.status_code, response.json()) == (
|
||||
400,
|
||||
{
|
||||
"error": True,
|
||||
"errors": f"{TICKETS_OFF} Switch it on before publishing this form.",
|
||||
},
|
||||
)
|
||||
form.refresh_from_db()
|
||||
assert form.is_published is False
|
||||
published = _form(org_a, "ticket")
|
||||
response = admin_client.post(f"{LIST_URL}{published.id}/publish/")
|
||||
assert response.json()["errors"] == "This form is already published."
|
||||
|
||||
set_modules_off(org_a)
|
||||
assert admin_client.post(url).status_code == 200
|
||||
form.refresh_from_db()
|
||||
assert form.is_published is True
|
||||
|
||||
|
||||
def test_choosing_an_off_target_is_refused(admin_client, user_client, org_a):
|
||||
ticket_rows = [{"source": "ticket", "ticket_field": "email", "label": "Email"}]
|
||||
new_ticket_form = {"name": "Support", "target": "ticket", "fields": ticket_rows}
|
||||
lead_form = _form(org_a, "lead", published=False)
|
||||
change = {"target": "ticket", "fields": ticket_rows}
|
||||
set_modules_off(org_a, "tickets")
|
||||
|
||||
refused = user_client.post(LIST_URL, new_ticket_form, format="json")
|
||||
assert refused.status_code == 403
|
||||
response = admin_client.post(LIST_URL, new_ticket_form, format="json")
|
||||
assert (response.status_code, response.json()) == (400, {"target": [TICKETS_OFF]})
|
||||
response = admin_client.put(f"{LIST_URL}{lead_form.id}/", change, format="json")
|
||||
assert (response.status_code, response.json()) == (400, {"target": [TICKETS_OFF]})
|
||||
assert list(WebForm.objects.filter(org=org_a)) == [lead_form]
|
||||
lead_form.refresh_from_db()
|
||||
assert lead_form.target == "lead"
|
||||
|
||||
set_modules_off(org_a, "leads")
|
||||
response = admin_client.post(LIST_URL, {"name": "Contact us"}, format="json")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"target": [module_off_message(org_a, "leads")]}
|
||||
|
||||
set_modules_off(org_a)
|
||||
created = admin_client.post(LIST_URL, new_ticket_form, format="json")
|
||||
assert created.status_code == 201
|
||||
response = admin_client.put(f"{LIST_URL}{lead_form.id}/", change, format="json")
|
||||
assert response.status_code == 200, response.content
|
||||
lead_form.refresh_from_db()
|
||||
assert lead_form.target == "ticket"
|
||||
|
||||
|
||||
def test_editing_an_existing_forms_name_still_works_while_off(admin_client, org_a):
|
||||
"""Pin: a write that keeps the target was never refused."""
|
||||
form = _form(org_a, "ticket")
|
||||
set_modules_off(org_a, "tickets")
|
||||
url = f"{LIST_URL}{form.id}/"
|
||||
assert admin_client.put(url, {"name": "Renamed"}, format="json").status_code == 200
|
||||
resent = {"name": "Renamed again", "target": "ticket"}
|
||||
assert admin_client.put(url, resent, format="json").status_code == 200
|
||||
form.refresh_from_db()
|
||||
assert (form.name, form.target) == ("Renamed again", "ticket")
|
||||
|
|
@ -29,6 +29,7 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
|
||||
from common.lookups import get_scoped_or_404
|
||||
from common.modules import module_is_available, module_off_message
|
||||
from common.permissions import HasOrgContext, is_org_admin
|
||||
from webforms.constants import REQUIRED_LEAD_FIELD, REQUIRED_TICKET_FIELD
|
||||
from webforms.models import WebForm, WebFormDailyStat, WebFormSubmission
|
||||
|
|
@ -219,6 +220,19 @@ class WebFormPublishView(WebFormBaseView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
org = request.profile.org
|
||||
if not module_is_available(org, form.target_module):
|
||||
return Response(
|
||||
{
|
||||
"error": True,
|
||||
"errors": (
|
||||
f"{module_off_message(org, form.target_module)} "
|
||||
"Switch it on before publishing this form."
|
||||
),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
form.is_published = True
|
||||
form.save(update_fields=["is_published", "updated_at"])
|
||||
return Response({"error": False}, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from common.models import Org
|
||||
from common.modules import module_is_available
|
||||
from webhooks import events
|
||||
from webhooks.models import WebhookDelivery, WebhookEndpoint
|
||||
from webhooks.tasks import deliver_webhook
|
||||
|
|
@ -100,9 +102,10 @@ def flush(request):
|
|||
|
||||
def publish_entries(entries):
|
||||
endpoints_by_org = {}
|
||||
orgs = {}
|
||||
for entry in entries:
|
||||
try:
|
||||
_publish_entry(entry, endpoints_by_org)
|
||||
_publish_entry(entry, endpoints_by_org, orgs)
|
||||
except Exception:
|
||||
# A failure to queue a webhook must not turn a saved record into a
|
||||
# failed request. Logged without the payload.
|
||||
|
|
@ -111,7 +114,7 @@ def publish_entries(entries):
|
|||
)
|
||||
|
||||
|
||||
def _publish_entry(entry, endpoints_by_org):
|
||||
def _publish_entry(entry, endpoints_by_org, orgs):
|
||||
if entry.org_id not in endpoints_by_org:
|
||||
endpoints_by_org[entry.org_id] = list(
|
||||
WebhookEndpoint.objects.filter(org_id=entry.org_id, is_active=True)
|
||||
|
|
@ -120,6 +123,13 @@ def _publish_entry(entry, endpoints_by_org):
|
|||
if not endpoints:
|
||||
return
|
||||
prefix, build = SPECS[entry.model._meta.label_lower]
|
||||
key = events.EVENT_MODULES.get(prefix)
|
||||
if key is not None:
|
||||
if entry.org_id not in orgs:
|
||||
orgs[entry.org_id] = Org.objects.filter(pk=entry.org_id).first()
|
||||
org = orgs[entry.org_id]
|
||||
if org is None or not module_is_available(org, key):
|
||||
return
|
||||
exists = entry.model._base_manager.filter(pk=entry.pk)
|
||||
|
||||
if DELETED in entry.actions:
|
||||
|
|
|
|||
|
|
@ -10,30 +10,63 @@ import json
|
|||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from common.modules import available_modules, model_module
|
||||
|
||||
PING = "ping"
|
||||
|
||||
# Grouped the way both settings screens draw their checkboxes.
|
||||
# Grouped the way both settings screens draw their checkboxes. Each group names
|
||||
# the model its events describe, whose module the group follows.
|
||||
CATALOGUE = (
|
||||
("lead", "Leads", ("lead.created", "lead.updated", "lead.deleted")),
|
||||
("contact", "Contacts", ("contact.created", "contact.updated", "contact.deleted")),
|
||||
("account", "Accounts", ("account.created", "account.updated", "account.deleted")),
|
||||
("lead", "Leads", "leads.Lead", ("lead.created", "lead.updated", "lead.deleted")),
|
||||
(
|
||||
"contact",
|
||||
"Contacts",
|
||||
"contacts.Contact",
|
||||
("contact.created", "contact.updated", "contact.deleted"),
|
||||
),
|
||||
(
|
||||
"account",
|
||||
"Accounts",
|
||||
"accounts.Account",
|
||||
("account.created", "account.updated", "account.deleted"),
|
||||
),
|
||||
(
|
||||
"deal",
|
||||
"Deals",
|
||||
"opportunity.Opportunity",
|
||||
("deal.created", "deal.updated", "deal.deleted", "deal.won", "deal.lost"),
|
||||
),
|
||||
("ticket", "Tickets", ("ticket.created", "ticket.updated", "ticket.comment_added")),
|
||||
("invoice", "Invoices", ("invoice.created", "invoice.updated", "invoice.paid")),
|
||||
("task", "Tasks", ("task.created", "task.updated", "task.deleted")),
|
||||
(
|
||||
"ticket",
|
||||
"Tickets",
|
||||
"cases.Case",
|
||||
("ticket.created", "ticket.updated", "ticket.comment_added"),
|
||||
),
|
||||
(
|
||||
"invoice",
|
||||
"Invoices",
|
||||
"invoices.Invoice",
|
||||
("invoice.created", "invoice.updated", "invoice.paid"),
|
||||
),
|
||||
("task", "Tasks", "tasks.Task", ("task.created", "task.updated", "task.deleted")),
|
||||
)
|
||||
|
||||
EVENT_NAMES = tuple(name for _, _, names in CATALOGUE for name in names)
|
||||
EVENT_NAMES = tuple(name for *_, names in CATALOGUE for name in names)
|
||||
|
||||
# Event prefix to the module it belongs to; None for a core one.
|
||||
EVENT_MODULES = {prefix: model_module(model) for prefix, _, model, _ in CATALOGUE}
|
||||
|
||||
|
||||
def catalogue_json():
|
||||
def event_module(event):
|
||||
return EVENT_MODULES.get(event.partition(".")[0])
|
||||
|
||||
|
||||
def catalogue_json(org=None):
|
||||
shown = None if org is None else {None, *available_modules(org)}
|
||||
return [
|
||||
{"module": module, "label": label, "events": list(names)}
|
||||
for module, label, names in CATALOGUE
|
||||
{"module": prefix, "label": label, "events": list(names)}
|
||||
for prefix, label, _, names in CATALOGUE
|
||||
if shown is None or EVENT_MODULES[prefix] in shown
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from common.modules import available_modules, module_off_message
|
||||
from webhooks import events, ssrf
|
||||
from webhooks.models import WebhookDelivery, WebhookEndpoint
|
||||
|
||||
|
|
@ -57,6 +58,26 @@ class WebhookEndpointSerializer(serializers.ModelSerializer):
|
|||
return value.strip()
|
||||
|
||||
def validate_events(self, value):
|
||||
org = self.context.get("org")
|
||||
off = {}
|
||||
kept = []
|
||||
if org is not None:
|
||||
available = available_modules(org)
|
||||
off = {
|
||||
name: events.event_module(name)
|
||||
for name in events.EVENT_NAMES
|
||||
if events.event_module(name) not in (None, *available)
|
||||
}
|
||||
stored = self.instance.events if self.instance is not None else []
|
||||
kept = [e for e in stored if e in off]
|
||||
if isinstance(value, list):
|
||||
named = [v for v in value if isinstance(v, str) and v in off]
|
||||
for name in dict.fromkeys(named):
|
||||
if name not in kept:
|
||||
raise serializers.ValidationError(
|
||||
module_off_message(org, off[name])
|
||||
)
|
||||
value = value + [e for e in kept if e not in value]
|
||||
if not isinstance(value, list) or not value:
|
||||
raise serializers.ValidationError("Choose at least one event.")
|
||||
unknown = [
|
||||
|
|
|
|||
124
backend/webhooks/tests/test_module_off.py
Normal file
124
backend/webhooks/tests/test_module_off.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Webhooks follow modules: the catalogue, what an endpoint may subscribe to,
|
||||
and what fires."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from cases.models import Case
|
||||
from common.testing import set_modules_off
|
||||
from leads.models import Lead
|
||||
from webhooks import events
|
||||
from webhooks.emit import SPECS
|
||||
from webhooks.models import WebhookDelivery, WebhookEndpoint
|
||||
|
||||
URL = "/api/webhooks/"
|
||||
TICKETS_OFF = "Tickets is switched off for this organization."
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_broker():
|
||||
with mock.patch("webhooks.emit.deliver_webhook.delay") as delay:
|
||||
yield delay
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _public(public_dns):
|
||||
return public_dns
|
||||
|
||||
|
||||
def _groups(client):
|
||||
return {g["module"] for g in client.get(URL).json()["event_catalogue"]}
|
||||
|
||||
|
||||
def test_the_catalogue_leaves_out_off_modules(admin_client, org_a):
|
||||
assert {"lead", "ticket", "invoice", "deal", "task"} <= _groups(admin_client)
|
||||
set_modules_off(org_a, "tickets", "invoices")
|
||||
groups = _groups(admin_client)
|
||||
assert not {"ticket", "invoice"} & groups
|
||||
assert {"lead", "deal", "task", "contact", "account"} <= groups
|
||||
|
||||
|
||||
def test_each_event_group_names_the_model_that_emits_it():
|
||||
"""A group follows the module of the model it names, so that must be the
|
||||
model whose saves emit the group. A comment emits its ticket's group."""
|
||||
emitted = {
|
||||
prefix: label
|
||||
for label, (prefix, _) in SPECS.items()
|
||||
if label != "common.comment"
|
||||
}
|
||||
named = {prefix: model.lower() for prefix, _, model, _ in events.CATALOGUE}
|
||||
assert named == emitted
|
||||
assert events.EVENT_MODULES == {
|
||||
"lead": "leads",
|
||||
"contact": None,
|
||||
"account": None,
|
||||
"deal": "deals",
|
||||
"ticket": "tickets",
|
||||
"invoice": "invoices",
|
||||
"task": "tasks",
|
||||
}
|
||||
|
||||
|
||||
def test_editing_an_endpoint_keeps_its_off_module_events(
|
||||
admin_client, org_a, admin_profile
|
||||
):
|
||||
hook = WebhookEndpoint.objects.create(
|
||||
org=org_a,
|
||||
url="https://hooks.example.com/in",
|
||||
events=["lead.created", "ticket.created"],
|
||||
created_by=admin_profile.user,
|
||||
)
|
||||
set_modules_off(org_a, "tickets")
|
||||
response = admin_client.patch(
|
||||
f"{URL}{hook.id}/", {"events": ["lead.updated"]}, format="json"
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
hook.refresh_from_db()
|
||||
assert hook.events == ["lead.updated", "ticket.created"]
|
||||
|
||||
|
||||
def test_subscribing_to_an_off_module_event_is_refused(admin_client, org_a):
|
||||
set_modules_off(org_a, "tickets")
|
||||
response = admin_client.post(
|
||||
URL,
|
||||
{"url": "https://hooks.example.com/in", "events": ["ticket.created"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["events"] == [TICKETS_OFF]
|
||||
ok = admin_client.post(
|
||||
URL,
|
||||
{"url": "https://hooks.example.com/in", "events": ["lead.created"]},
|
||||
format="json",
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
|
||||
|
||||
def test_nothing_fires_for_an_off_module(org_a, django_capture_on_commit_callbacks):
|
||||
hook = WebhookEndpoint.objects.create(
|
||||
org=org_a,
|
||||
url="https://hooks.example.com/in",
|
||||
events=["lead.created", "ticket.created"],
|
||||
)
|
||||
set_modules_off(org_a, "tickets")
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
Case.objects.create(name="T", status="New", priority="Normal", org=org_a)
|
||||
assert not WebhookDelivery.objects.filter(endpoint=hook).exists()
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
Lead.objects.create(org=org_a, title="Hi", first_name="Ada")
|
||||
assert list(
|
||||
WebhookDelivery.objects.filter(endpoint=hook).values_list("event", flat=True)
|
||||
) == ["lead.created"]
|
||||
|
||||
|
||||
def test_an_org_with_no_endpoints_pays_no_module_lookup(org_a):
|
||||
from webhooks.emit import _publish_entry
|
||||
|
||||
entry = mock.Mock(org_id=org_a.id, model=Lead)
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
_publish_entry(entry, {}, {})
|
||||
assert len(queries) == 1
|
||||
assert "webhook" in queries[0]["sql"].lower()
|
||||
|
|
@ -69,7 +69,7 @@ class WebhookListCreateView(WebhookBaseView):
|
|||
return Response(
|
||||
{
|
||||
"endpoints": WebhookEndpointSerializer(endpoints, many=True).data,
|
||||
"event_catalogue": events.catalogue_json(),
|
||||
"event_catalogue": events.catalogue_json(request.profile.org),
|
||||
"limit": MAX_ENDPOINTS_PER_ORG,
|
||||
}
|
||||
)
|
||||
|
|
@ -85,7 +85,7 @@ class WebhookListCreateView(WebhookBaseView):
|
|||
return _error(
|
||||
f"An organization can have at most {MAX_ENDPOINTS_PER_ORG} webhooks."
|
||||
)
|
||||
serializer = WebhookEndpointSerializer(data=request.data)
|
||||
serializer = WebhookEndpointSerializer(data=request.data, context={"org": org})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
endpoint = serializer.save(org=org)
|
||||
# The one response that carries the full secret, besides a rotate.
|
||||
|
|
@ -109,7 +109,10 @@ class WebhookDetailView(WebhookBaseView):
|
|||
endpoint = self.get_endpoint(request, pk)
|
||||
was_active, previous_creator_id = endpoint.is_active, endpoint.created_by_id
|
||||
serializer = WebhookEndpointSerializer(
|
||||
endpoint, data=request.data, partial=True
|
||||
endpoint,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
context={"org": request.profile.org},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ What each row returns is chosen field by field rather than copied from the table
|
|||
- **An API token row also names the token** (`EVENT_DETAILS`): `API_TOKEN_CREATED` and
|
||||
`API_TOKEN_REVOKED` rows add `token_id`, `token_prefix`, `token_name`, `scopes`, `owner_id` and
|
||||
`owner_name`. See [Credential changes](#credential-changes). Allowed on those two events only.
|
||||
- **A module change also carries its keys** (`EVENT_DETAILS`): a `MODULES_CHANGED` row adds
|
||||
`switched_off` and `switched_on` (lists of module keys) and `reordered` (a boolean). See
|
||||
[Module changes](#module-changes). Allowed on that event
|
||||
only.
|
||||
- **Paths under `/api/public/` are cut to that prefix.** Some public links carry a token in the URL
|
||||
(a satisfaction-survey link, for one), and the log should not repeat it.
|
||||
|
||||
|
|
@ -132,6 +136,18 @@ member on `/api/org/tokens/`, a `404` token that is not yours or not in your org
|
|||
either. If the audit row cannot be written the action still succeeds; the failure goes to the
|
||||
`security.audit` logger, as for every other event.
|
||||
|
||||
## Module changes
|
||||
|
||||
`PUT /api/org/modules/` writes a `MODULES_CHANGED` row (label Modules Changed) when an admin
|
||||
switches a module off or on, or reorders the modules, with the admin as `actor`. A request that
|
||||
changes nothing, or is refused, writes nothing. See [Modules](modules.md).
|
||||
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| `switched_off` | Keys of the modules this change switched off |
|
||||
| `switched_on` | Keys of the modules this change switched on |
|
||||
| `reordered` | `true` when the order changed |
|
||||
|
||||
## Failed sign-ins
|
||||
|
||||
From django-crm 1.13.0 a refused staff sign-in writes a `LOGIN_FAILURE` row (`AuditLogger.login_failure`,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,15 @@ middleware before any view runs:
|
|||
with status `403`. Deactivating the contact invalidates the token on the next request, because the
|
||||
authentication class re-reads `is_active` every time rather than trusting the claim.
|
||||
|
||||
## Switched-off modules
|
||||
|
||||
An org can switch Tickets or the Knowledge base off (see [Modules](modules.md)). The portal then
|
||||
answers what it already answers for something that does not exist, and never names the module:
|
||||
every portal case endpoint, list and create included, answers `404` `{"error": "Case not found"}`
|
||||
while Tickets is off, and the article list and detail answer `404` `{"error": "Article not found"}`
|
||||
while the Knowledge base is off. Article suggestions answer `200` `{"articles": []}`, as an empty
|
||||
query does. Signing in is unaffected, and so is the rest of the portal.
|
||||
|
||||
## Cases
|
||||
|
||||
All four endpoints below require a portal token. Every query is filtered by both the org from the
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ Interactive versions of the same schema are served by a running backend at
|
|||
| `/api/org/api-key/` | GET, POST |
|
||||
| `/api/org/audit-log/` | GET |
|
||||
| `/api/org/help-center/` | GET, PATCH |
|
||||
| `/api/org/modules/` | GET, PUT |
|
||||
| `/api/org/settings/` | GET, PATCH |
|
||||
| `/api/org/timezones/` | GET |
|
||||
| `/api/org/tokens/` | GET |
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ token creation, for instance, returns `201` (`backend/common/views/pat_views.py:
|
|||
"more correct" than the other here, check the endpoint.
|
||||
|
||||
Beyond that: `400` for a request the server understood but rejected (validation, or a
|
||||
request-shaped error like a missing required field); `401` for a request with no valid credentials
|
||||
at all; `403` for a request that authenticated fine but isn't allowed to do this; `404` for a
|
||||
request-shaped error like a missing required field); `401` only where a view says so itself (an
|
||||
expired or malformed refresh token on the token refresh endpoint); `403` for a
|
||||
request with no credentials or invalid ones, and for one that authenticated fine but isn't allowed
|
||||
to do this; `404` for a
|
||||
resource that doesn't exist, or that exists but the caller must not be able to distinguish from not
|
||||
existing. See [Not found versus forbidden](#not-found-versus-forbidden); `502` where a view depends
|
||||
on an external service and that service is unreachable, for example the Google token exchange in
|
||||
|
|
@ -93,11 +95,36 @@ permission classes almost every org-scoped view lists: `permission_classes = (Is
|
|||
HasOrgContext)`, and often a role check on top such as `IsOrgAdmin`
|
||||
(`backend/common/permissions.py:44-59`, `"You must be an organization administrator to perform this
|
||||
action."`). Whether a failure comes back as `401` or `403` is not decided by which permission class
|
||||
failed. DRF's `APIView.permission_denied()` raises `NotAuthenticated` (`401`) whenever no
|
||||
authenticator on the request actually succeeded, and `PermissionDenied` (`403`, carrying the failing
|
||||
permission class's `message`) whenever one did but the permission check itself failed. In practice:
|
||||
no credentials, or invalid ones → `401`; a valid, authenticated session that isn't allowed to do
|
||||
this → `403`.
|
||||
failed. DRF's `APIView.permission_denied()` raises `NotAuthenticated` whenever no authenticator on
|
||||
the request actually succeeded, and `PermissionDenied` (`403`, carrying the failing permission
|
||||
class's `message`) whenever one did but the permission check itself failed. DRF answers
|
||||
`NotAuthenticated` with `401` only when the view's first authentication class defines
|
||||
`authenticate_header`, and none of the staff classes (`PATAuthentication`, `JWTAuthentication`,
|
||||
`APIKeyAuthentication`) does, so it comes back as `403`. In practice: no credentials, or invalid
|
||||
ones → `403`; a valid, authenticated session that isn't allowed to do this → `403`.
|
||||
|
||||
## Switched-off modules
|
||||
|
||||
An org admin can switch parts of the CRM off (see [Modules](modules.md)). A signed-in request to
|
||||
a route of an off module is a `404` for every method, with a body that says why:
|
||||
|
||||
```json
|
||||
{"detail": "Tickets is switched off for this organization.", "code": "module_off", "module": "tickets"}
|
||||
```
|
||||
|
||||
`code` is always `module_off` and `module` is the module's key, so a client can tell this `404`
|
||||
from a missing record. Public links, the customer portal and inbound email do not use this body:
|
||||
they answer what they give an unknown link.
|
||||
|
||||
A delete that would destroy, or be blocked by, records of an off module is refused with `409`
|
||||
and the same two fields:
|
||||
|
||||
```json
|
||||
{"error": true, "errors": "This account has 3 tickets. Tickets is switched off, so switch it on to review them before deleting.", "code": "module_off", "module": "tickets"}
|
||||
```
|
||||
|
||||
Writes that name an off module's record, such as a webhook event, a saved view or a web form
|
||||
target, are `400` on that field with the sentence `<Label> is switched off for this organization.`
|
||||
|
||||
## Not found versus forbidden
|
||||
|
||||
|
|
|
|||
261
docs/api/modules.md
Normal file
261
docs/api/modules.md
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
# Modules
|
||||
|
||||
An org admin can switch whole parts of BottleCRM off for the org. This page is the API side:
|
||||
reading and changing the setting, what an off module answers, and what keeps running or stops
|
||||
while it is off. The admin's view of the same feature is
|
||||
[Modules](../getting-started/modules.md).
|
||||
|
||||
The catalogue is `backend/common/modules.py`; the endpoints are `OrgModulesView`
|
||||
(`backend/common/views/module_views.py`) and `change_modules`
|
||||
(`backend/common/module_settings.py`).
|
||||
|
||||
Switching a module off never deletes or changes a record. Everything is still stored, and it all
|
||||
comes back when the module is switched on.
|
||||
|
||||
## The modules
|
||||
|
||||
| Key | Label | Covers | Needs |
|
||||
| --- | --- | --- | --- |
|
||||
| `deals` | Pipeline | Deals, deal pipelines, line items, the deal board and stage aging. | |
|
||||
| `leads` | Leads | Leads, lead pipelines, and lead import and export. | |
|
||||
| `goals` | Goals | Sales goals, the leaderboard and goal history. | `deals` |
|
||||
| `tasks` | Tasks | Tasks, boards, the task calendar and its calendar feed. | |
|
||||
| `tickets` | Tickets | Tickets, approvals, ticket analytics, routing, escalation, the reopen policy, business hours, saved replies, inbound email, CSAT and portal tickets. | |
|
||||
| `knowledge_base` | Knowledge base | Articles, the public help center and portal articles. | |
|
||||
| `documents` | Documents | Documents and their downloads. | |
|
||||
| `invoices` | Invoices | Invoices, estimates, recurring invoices, invoice templates, invoice reports, account statements, and public invoice and estimate links. | |
|
||||
| `timesheet` | Timesheet | Time entries, the timesheet and the time report. | `tickets` |
|
||||
|
||||
The groups, in the order they are served, are `sell` (deals, leads, goals), `serve` (tasks,
|
||||
tickets, knowledge base, documents) and `bill` (invoices, timesheet). Within a group the modules
|
||||
follow the org's saved order.
|
||||
|
||||
A label follows the org's terminology: an org that calls deals "Opportunities" sees that name for
|
||||
`deals`, and likewise for `leads` and `invoices`. Every module message uses the org's label.
|
||||
|
||||
**Needs.** A module that needs another is available only while that one is. Goals needs Pipeline
|
||||
and Timesheet needs Tickets. Switching the needed module off makes the dependent one unavailable
|
||||
without changing its own setting, so switching the needed one back on restores it.
|
||||
|
||||
## Read the setting
|
||||
|
||||
`GET /api/org/modules/`, any member of the org.
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"key": "deals",
|
||||
"label": "Pipeline",
|
||||
"group": "sell",
|
||||
"covers": "Deals, deal pipelines, line items, the deal board and stage aging.",
|
||||
"stops": "Everything in Pipeline leaves every menu, search and Today, and stale-deal emails stop. Converting a lead no longer creates a deal, and Goals switches off too.",
|
||||
"enabled": false,
|
||||
"available": false,
|
||||
"needs": null
|
||||
},
|
||||
{
|
||||
"key": "goals",
|
||||
"label": "Goals",
|
||||
"group": "sell",
|
||||
"covers": "Sales goals, the leaderboard and goal history.",
|
||||
"stops": "Goals leave every menu, and goal milestone emails stop.",
|
||||
"enabled": true,
|
||||
"available": false,
|
||||
"needs": "deals"
|
||||
}
|
||||
],
|
||||
"can_edit": true
|
||||
}
|
||||
```
|
||||
|
||||
The response has all nine rows (two are shown). `enabled` is the module's own setting;
|
||||
`available` is `enabled` and its need available. `stops` is the confirmation text an admin reads
|
||||
before switching the module off, already carrying the org's label. `can_edit` is true for admins
|
||||
and false for members.
|
||||
|
||||
A request with no session answers `403` (every unauthenticated API request does), and so does a
|
||||
session with no organization.
|
||||
|
||||
## Change the setting
|
||||
|
||||
`PUT /api/org/modules/`, admins only. Both fields are required, and the body replaces the setting:
|
||||
|
||||
```json
|
||||
{"off": ["tickets", "timesheet"], "order": ["deals", "leads", "goals", "tasks", "tickets", "knowledge_base", "documents", "invoices", "timesheet"]}
|
||||
```
|
||||
|
||||
`off` lists the modules to switch off; every module not named is on. `order` lists module keys in
|
||||
the order the org wants them; modules are only ordered within their group, and a module left out
|
||||
of `order` follows the named ones. A successful `PUT` answers `200` with the same body as the
|
||||
`GET`. `PATCH` is `405`.
|
||||
|
||||
A member is refused with `403`:
|
||||
|
||||
```json
|
||||
{"error": true, "errors": "Only admins can switch modules on or off."}
|
||||
```
|
||||
|
||||
The body is checked before anything is saved, and an invalid request changes nothing. A `400`
|
||||
carries the problems under `off` and `order`:
|
||||
|
||||
| Problem | Field | Message |
|
||||
| --- | --- | --- |
|
||||
| Field missing | `off` or `order` | `This field is required.` |
|
||||
| Not a list of strings | `off` or `order` | `Send a list of module keys.` |
|
||||
| A key that is not a module | `off` or `order` | `Unknown module: "crm".` (one entry per key) |
|
||||
| A key named twice | `order` | `Name each module once.` |
|
||||
| A module switched on while its need stays off | `off` | `Goals needs Pipeline. Switch Pipeline on first.` |
|
||||
| A web form still takes submissions | `off` | `Leads still receives submissions from the published web form "Contact us". Unpublish it first, so no submission is lost.` |
|
||||
| Several such forms | `off` | `Tickets still receives submissions from 2 published web forms: "Billing help", "Zed help". Unpublish them first, so no submission is lost.` |
|
||||
| An active mailbox | `off` | `Tickets still receives email at help@example.com. Switch that mailbox off first, so no email is lost.` |
|
||||
| Several active mailboxes | `off` | `Tickets still receives email at 2 mailboxes: a@example.com, b@example.com. Switch them off first, so no email is lost.` |
|
||||
|
||||
`off` can carry several messages at once. Lists of forms or mailboxes name at most five and then
|
||||
say `and N more`. The form and mailbox checks apply only to a module the change makes
|
||||
unavailable, so an admin can still reorder while a module is already off. Forms for leads count
|
||||
against Leads, and forms for tickets and active inbound mailboxes count against Tickets; because
|
||||
Timesheet needs Tickets, switching Tickets off is refused for the same reasons.
|
||||
|
||||
A body that is not valid JSON answers DRF's own `400`, `{"detail": "JSON parse error ..."}`.
|
||||
|
||||
A change that alters the off list or the order is written to the
|
||||
[security audit log](audit-log.md) as `MODULES_CHANGED`.
|
||||
|
||||
## What an off module answers
|
||||
|
||||
A signed-in request to a route of an unavailable module answers `404` for every method (`GET`,
|
||||
`HEAD`, `OPTIONS`, `POST`, `PUT`, `PATCH`, `DELETE`):
|
||||
|
||||
```json
|
||||
{"detail": "Tickets is switched off for this organization.", "code": "module_off", "module": "tickets"}
|
||||
```
|
||||
|
||||
This applies to a session, a personal access token and the organization API key alike. The module
|
||||
named is the one that is actually off: with Goals on and Pipeline off, a goals route names
|
||||
`deals`. A personal access token's own scope refusal (`403`) still comes first, and the
|
||||
organization API key is read-only, so a write with it is refused as it always was.
|
||||
|
||||
Some routes need more than one module. The time entry routes need Tickets and Timesheet; invoice
|
||||
creation from a deal needs Invoices and Pipeline; invoice creation from time entries needs
|
||||
Invoices, Tickets and Timesheet. Products belong to Pipeline or Invoices and stay reachable while
|
||||
either is on. `GET /api/org/help-center/` is the Knowledge base's settings route and answers the
|
||||
same `404` for every method while it is off.
|
||||
|
||||
Anything outside the nine modules (accounts, contacts, web forms, webhooks, automation, search,
|
||||
the dashboard and the rest of `/api/org/`) is always reachable.
|
||||
|
||||
### Public, portal and inbound entry points
|
||||
|
||||
These are not signed-in requests, so they answer what they already answer for a link that does
|
||||
not exist, and never name the module:
|
||||
|
||||
| Entry point | Module | Answer while off |
|
||||
| --- | --- | --- |
|
||||
| Public invoice and estimate links (view, PDF, accept, decline) | Invoices | `404` `{"error": true, "message": "Invoice not found or link expired"}` (the same body for an estimate link) |
|
||||
| CSAT survey link | Tickets | `410` `{"error": "Survey link is no longer valid."}` |
|
||||
| Inbound email webhook for a mailbox | Tickets | `404` `{"error": true, "errors": "Mailbox not found"}` |
|
||||
| Portal tickets (list, create, detail, comment) | Tickets | `404` `{"error": "Case not found"}` |
|
||||
| Portal articles (list, detail) | Knowledge base | `404` `{"error": "Article not found"}` |
|
||||
| Portal article suggestions | Knowledge base | `200` `{"articles": []}`, as an empty query answers |
|
||||
| Public help center | Knowledge base | `404` `{"error": "Not found"}` |
|
||||
| Calendar feed link | Tasks | `404` `{"error": "Not found"}` |
|
||||
| Web form page, definition and submit | Leads or Tickets, by the form's target | `404`, as for an unknown form |
|
||||
|
||||
A web form's CORS response headers follow the same rule: while the form's target module is off
|
||||
they match an unknown form's, so nothing tells an outsider which case it was. Sign-in to the
|
||||
portal is unaffected.
|
||||
|
||||
## Web forms
|
||||
|
||||
A form belongs to Leads or Tickets by its `target`. While that module is off:
|
||||
|
||||
- Publishing the form is refused with `400`: `Leads is switched off for this organization. Switch
|
||||
it on before publishing this form.`
|
||||
- Creating a form with no `target` while Leads is off is `400` under `target`, because the default
|
||||
target is `lead`. Choosing a target whose module is off, or changing a form to one, is refused
|
||||
the same way, with the module message.
|
||||
- A form that was already published cannot be left behind: switching the module off is refused
|
||||
while it is published (see the table above).
|
||||
|
||||
## Records that mention an off module
|
||||
|
||||
Responses keep their keys when they embed records of an off module; the embedded part is empty:
|
||||
lists are `[]`, counts are `0`, and single links and summaries are `null`. Specific cases:
|
||||
|
||||
- A time entry keeps a read-only `invoiced` boolean while Invoices is off; its `invoice` is `null`.
|
||||
- A deal's `next_activity` is `null` while Tasks is off.
|
||||
- A product's `used_on` is `0` while Invoices is off.
|
||||
- Task webhook payloads still carry the bare ids of a task's deal, ticket or lead.
|
||||
|
||||
A write that omits or sends `null` for a link to an off module's record keeps the stored link. A
|
||||
write that names a different record of an off module is `400` on that field, with the module
|
||||
message. Choosing an off module's webhook event, saved view list, custom field target or web form
|
||||
target is refused the same way.
|
||||
|
||||
Other surfaces follow the modules too:
|
||||
|
||||
- **Saved views** of an off module are left out of the list and answer `404`; creating one is
|
||||
refused.
|
||||
- **Webhooks.** Nothing is emitted for an off module, and the event catalogue leaves it out. An
|
||||
endpoint keeps the events it already stored for an off module, and subscribing to a new one is
|
||||
`400` under `events` with the module message. Deliveries already queued are still sent.
|
||||
- **Automation.** A rule whose object belongs to an off module lists with status `paused` and the
|
||||
text `Paused: Tickets is switched off`, and does not run. It can still be switched on. Creating
|
||||
a rule on such an object, or changing a rule's object, trigger, conditions, actions or match,
|
||||
is `400` with the module message. While Tasks is off, the catalogue's `actions` leaves out
|
||||
`create_task`, and a Create a task step the stored rule does not already have is `400` under
|
||||
`actions[N].type` with the module message. A step the rule already has is kept, and skipped
|
||||
when the rule runs.
|
||||
- **Tags** leave an off module's usage out of the per-module figures, and a tag used only by an
|
||||
off module is never reported as unused.
|
||||
- **Packs and sample data.** Applying a pack reports the parts of an off module as skipped with
|
||||
the module message. Clearing sample data keeps the sample rows of an off module and reports
|
||||
them under `switched_off_by_type`.
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
The scheduled jobs in this table skip an org whose module is off, and a module that comes back on
|
||||
starts fresh: they do not act on dates that passed while it was off.
|
||||
|
||||
| Job | While the module is off | After it is switched on |
|
||||
| --- | --- | --- |
|
||||
| Recurring invoices | Nothing is generated or sent. | Every schedule moves to its next date after today. No backlog is billed. |
|
||||
| Overdue invoices and expired estimates | Not marked. | Invoices past due become Overdue and estimates past expiry become Expired, silently: no webhook, rule or email. |
|
||||
| Payment reminders | None are sent. | A reminder whose day fell while off is not sent later; the next one in the cadence is sent on its day. |
|
||||
| Stale-deal alerts (Pipeline) | None are sent. | A deal already stale at switch-on is never alerted. |
|
||||
| Goal milestone emails (Goals) | None are sent. | Milestones already reached count as announced, so only new ones send. |
|
||||
| SLA escalation (Tickets) | The scan skips the org. | A deadline that passed while off never escalates. |
|
||||
| Stale timers (Timesheet) | A running timer is not stopped. | Timers that went stale while off are left running for their owner to stop. |
|
||||
|
||||
The clean-up jobs do not check modules. They prune read notifications, automation runs and webhook
|
||||
delivery logs past their retention, and expired sign-in tokens, whatever an org has switched off.
|
||||
Webhook deliveries already queued are still sent.
|
||||
|
||||
Customer emails already queued (a survey, a portal notice, an invoice, an estimate, a payment
|
||||
reminder) are dropped if their module is off when they would be sent. Sending a draft invoice or
|
||||
estimate marks it Sent before the email is queued, so one whose email was dropped this way stays
|
||||
marked Sent; send it again once Invoices is back on.
|
||||
|
||||
## Deleting around an off module
|
||||
|
||||
While a module is off its records are out of sight, so a delete that would destroy or be blocked by
|
||||
them is refused instead. Deleting an account, a ticket or a person answers `409`:
|
||||
|
||||
```json
|
||||
{"error": true, "errors": "This account has 3 tickets. Tickets is switched off, so switch it on to review them before deleting.", "code": "module_off", "module": "tickets"}
|
||||
```
|
||||
|
||||
The message is `<Subject> has N <things>. <Label> is switched off, so switch it on to review
|
||||
them before deleting.` (`it ... it` for one). The permission check runs first, so a member who may
|
||||
not delete the record still gets `403`. What can block a delete:
|
||||
|
||||
| Deleting | Reaches |
|
||||
| --- | --- |
|
||||
| An account | Its tickets, deals, invoices, estimates and recurring invoices |
|
||||
| A ticket | Its time entries |
|
||||
| A person (a user's profile in the org) | Their time entries, approvals, watched tickets, saved replies, boards and board memberships |
|
||||
|
||||
Unlinking is unchanged: removing a contact from a ticket, or a deal from an invoice, behaves as it
|
||||
always did. The Django admin site is for operators and is not guarded by modules; deleting a user
|
||||
there removes the user's profiles and their records.
|
||||
|
|
@ -30,6 +30,17 @@ answer requests:
|
|||
See [Email and Celery](../self-hosting/email-and-celery.md#running-a-worker) for the actual run
|
||||
commands and what each scheduled entry does operationally.
|
||||
|
||||
## Jobs and switched-off modules
|
||||
|
||||
An org admin can switch parts of the CRM off (see [Modules](../api/modules.md#scheduled-jobs)).
|
||||
A job that sweeps every org checks `common.modules.module_is_available(org, key)` for the module
|
||||
it serves and skips the org when it is off; a job queued for one record checks before it sends.
|
||||
A module that comes back on starts fresh: the date-driven sweeps ignore dates before
|
||||
`module_on_since(org, key)`, and `change_modules` runs the two start-fresh hooks listed in
|
||||
`START_FRESH` (`backend/common/module_settings.py`) when a module becomes available again. A new
|
||||
job that acts on dates, or emails a customer, belongs in the same pattern, with a test that a
|
||||
date from the off period does nothing.
|
||||
|
||||
## Writing a task
|
||||
|
||||
Celery workers never go through Django's HTTP middleware stack. `GetProfileAndOrg` and
|
||||
|
|
|
|||
|
|
@ -105,4 +105,6 @@ deals, contacts, accounts). It never removes anything else. A real record you cr
|
|||
untouched even if it happens to reference a sample record, and a sample row is **retained rather
|
||||
than deleted** if a non-sample record still depends on it (for example, a real invoice raised
|
||||
against a demo account). Retained rows are reported back, not silently skipped, so the caller
|
||||
knows some sample data was intentionally kept rather than assuming the clear was incomplete.
|
||||
knows some sample data was intentionally kept rather than assuming the clear was incomplete. Sample
|
||||
rows that belong to a module the org has switched off (see [Modules](modules.md)) are kept too, and
|
||||
the response counts them under `switched_off_by_type`.
|
||||
|
|
|
|||
87
docs/getting-started/modules.md
Normal file
87
docs/getting-started/modules.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Modules
|
||||
|
||||
Not every team uses every part of BottleCRM. An organization admin can switch whole parts off, so
|
||||
the people in the org see only what they work with. There are nine modules: Pipeline, Leads,
|
||||
Goals, Tasks, Tickets, Knowledge base, Documents, Invoices and Timesheet. Some orgs rename
|
||||
Pipeline, Leads or Invoices; you see the org's own names throughout.
|
||||
|
||||
This page is for admins. The API behind it is in [Modules (API)](../api/modules.md).
|
||||
|
||||
## Switching a module off or on
|
||||
|
||||
The Modules settings screens arrive with the web and phone apps. Until then, an admin reads the
|
||||
list with `GET /api/org/modules/` and changes it with `PUT /api/org/modules/`, as
|
||||
[Modules (API)](../api/modules.md#change-the-setting) describes. Every member can read the list;
|
||||
only admins can change it. The modules are grouped as Sell, Serve and Bill, and each one says what
|
||||
it covers and what switching it off stops. You can also change the order the modules take in the
|
||||
menu; the order applies within each group.
|
||||
|
||||
**Off means off for everyone in the org.** The module leaves search and Today, and its API routes
|
||||
answer as if they did not exist; the web and phone apps take it out of their menus when their
|
||||
Modules screens arrive. Links you sent to customers (an invoice, a survey, a help center article)
|
||||
stop opening, the customer portal stops showing it, and its reminders and emails stop. A web form,
|
||||
an inbound mailbox or an automation rule that works with the module stops working with it.
|
||||
|
||||
**Nothing is deleted.** The records are exactly where you left them. Switch the module back on and
|
||||
everything is there again, in the same state.
|
||||
|
||||
## Needs
|
||||
|
||||
Two modules depend on another:
|
||||
|
||||
- **Goals needs Pipeline.** With Pipeline off, Goals is unavailable too, although its own switch
|
||||
keeps the setting you gave it.
|
||||
- **Timesheet needs Tickets.** The same applies with Tickets off.
|
||||
|
||||
You cannot switch a module on while the one it needs is off. Switch the needed module on first.
|
||||
|
||||
## When a switch is refused
|
||||
|
||||
A switch-off is refused when it would lose something that is still arriving. The message says what
|
||||
to clear:
|
||||
|
||||
- **Published web forms.** Leads, or Tickets, still receives submissions from a published web
|
||||
form. Unpublish the form first, so no submission is lost. This also covers the website lead key
|
||||
the older embed used, once it has been used.
|
||||
- **Active inbound mailboxes.** Tickets still receives email at an address. Switch that mailbox off
|
||||
first, so no email is lost. Switching Tickets off also takes Timesheet with it, so it is
|
||||
refused for these reasons too.
|
||||
|
||||
Reordering the modules is never refused, even while one is off.
|
||||
|
||||
While a module is off, a few things are held back so nothing is lost by accident:
|
||||
|
||||
- You cannot publish a web form, create one, or point one at a module that is off.
|
||||
- You cannot delete an account, a ticket or a person that still has records in an off module. The
|
||||
message names the count, for example "This account has 3 tickets", and asks you to switch the
|
||||
module on and review them first.
|
||||
- An automation rule on an off module shows as Paused, and does not run. You can still switch it
|
||||
on, but you cannot change what it does until the module is back.
|
||||
- While Tasks is off, an automation rule cannot gain a Create a task step. A step it already has
|
||||
is kept, and skipped when the rule runs.
|
||||
- A webhook keeps the events it already had for the module; you cannot add one for it.
|
||||
- Clearing sample data leaves the sample rows of an off module alone and tells you how many.
|
||||
|
||||
## Starting fresh
|
||||
|
||||
Switching a module back on does not make it catch up. Dates that passed while it was off are not
|
||||
acted on:
|
||||
|
||||
- Recurring invoices move to their next date after today. Nothing from the weeks it was off is
|
||||
billed.
|
||||
- Invoices past their due date become Overdue, and estimates past their expiry become Expired,
|
||||
quietly: no email, no automation and no webhook.
|
||||
- A payment reminder whose day fell while Invoices was off is not sent later; the next reminder is
|
||||
sent on its normal day.
|
||||
- Ticket deadlines that passed while Tickets was off do not escalate.
|
||||
- A deal that was already stale when Pipeline came back is not reported as stale, and goal
|
||||
milestones already reached do not email anyone.
|
||||
- Customer emails that were waiting while their module was off are dropped. An invoice or estimate
|
||||
whose email was dropped stays marked Sent, so send it again.
|
||||
|
||||
A running timer that went stale while Timesheet was off stays running, so its owner can stop it.
|
||||
|
||||
## Every change is logged
|
||||
|
||||
Switching a module off or on, or reordering them, is recorded in the
|
||||
[security audit log](../api/audit-log.md) as Modules Changed, with who did it and what changed.
|
||||
|
|
@ -439,6 +439,14 @@ already on keeps its owner. An installation can register a plan hook
|
|||
`summary` line on the list. The community edition registers none: every rule may be switched on and
|
||||
every switched-on rule runs.
|
||||
|
||||
A rule whose object belongs to a module the org has switched off (see [Modules](../api/modules.md))
|
||||
lists as `paused` with the text `Paused: <Module> is switched off`, and does not run. It can still
|
||||
be switched on, and it resumes when the module does. Creating a rule on such an object, or
|
||||
changing a rule's object, trigger, conditions, actions or match, is a `400` with the module
|
||||
message. While Tasks is off, the catalogue's `actions` leaves out `create_task` and a new Create a
|
||||
task step is a `400` with the module message; a step the rule already has is kept, and skipped when
|
||||
the rule runs.
|
||||
|
||||
The list's `status` is `on`, `off`, `paused` (with the reason as `status_text`), `needs_attention`
|
||||
(with the first problem), or `over_limit` when a plan hook says so.
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,13 @@ an inbound message through that mailbox (a reply to an older case doesn't count
|
|||
address still received mail even if the pipeline discarded it. Neither field is present on
|
||||
`GET /api/cases/mailboxes/{id}/`, which returns only the plain serializer.
|
||||
|
||||
### When Tickets is switched off
|
||||
|
||||
Switching Tickets off is refused while any mailbox is active, so no email is lost: switch the
|
||||
mailbox off first. A notification for a mailbox in an org with Tickets off answers the same `404`
|
||||
an unknown or inactive mailbox does, and nothing is ingested. See
|
||||
[Modules](../api/modules.md#public-portal-and-inbound-entry-points).
|
||||
|
||||
## The SNS webhook
|
||||
|
||||
Point an AWS SES receipt rule's SNS action at a topic, and subscribe the topic to:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,15 @@ validates the form's shape rather than just flipping a flag:
|
|||
site: an embed you have removed from the page is not the same thing as an endpoint that has
|
||||
stopped accepting posts.
|
||||
|
||||
### When the module is switched off
|
||||
|
||||
A form writes into Leads or Tickets by its target. A form whose target module is switched off
|
||||
cannot be published, and a new form with no `target` is refused while Leads is off (the default
|
||||
target is a lead). Switching Leads or Tickets off is itself refused while a published form
|
||||
targets it, so unpublish the form first. If the module is switched off some other way, the form
|
||||
page, its definition and its submit endpoint answer `404` as an unknown form does, and its CORS
|
||||
headers match an unknown form's. See [Modules](../api/modules.md#web-forms).
|
||||
|
||||
## Assigning new leads
|
||||
|
||||
From django-crm 1.13.0 a lead form assigns each new lead in one of two ways, set by
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ An org can have up to 10 webhooks.
|
|||
the stage is called in your pipeline. Saving a deal that is already won does not fire again.
|
||||
- `invoice.paid` fires when an invoice's status becomes Paid.
|
||||
- `ticket.comment_added` fires for **public** replies only. Internal notes are never sent anywhere.
|
||||
- An org can switch the Leads, Pipeline, Tickets, Invoices and Tasks modules off (see
|
||||
[Modules](../api/modules.md#records-that-mention-an-off-module)). Nothing is sent for an off
|
||||
module, the catalogue leaves its events out, and an endpoint keeps the events it already
|
||||
subscribed to; subscribing to a new event of an off module is a `400` under `events`. Deliveries
|
||||
already queued are still sent. A task event still carries the bare ids of the task's deal,
|
||||
ticket or lead.
|
||||
- `ping` is sent only by **Send test** and cannot be subscribed to.
|
||||
|
||||
Events are collected per request and sent once the request has finished, so creating a record and
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ nav:
|
|||
- Manual setup: getting-started/manual-setup.md
|
||||
- First sign-in: getting-started/first-sign-in.md
|
||||
- Demo data and packs: getting-started/demo-data.md
|
||||
- Modules: getting-started/modules.md
|
||||
- Self-hosting:
|
||||
- Requirements: self-hosting/requirements.md
|
||||
- Docker: self-hosting/docker.md
|
||||
|
|
@ -80,6 +81,7 @@ nav:
|
|||
- Authentication: api/authentication.md
|
||||
- Errors: api/errors.md
|
||||
- Tokens and API keys: api/tokens-and-api-keys.md
|
||||
- Modules: api/modules.md
|
||||
- Leads: api/leads.md
|
||||
- Accounts: api/accounts.md
|
||||
- Contacts: api/contacts.md
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ On 2026-09-26 the counts were 90 `+page.svelte` files under `frontend/src/routes
|
|||
| ID | Gap | Missing on | Backend | Opened | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| B10 | Download an invoice's PDF | Mobile | `GET /api/invoices/{id}/pdf/` exists | 2026-10-15 | Found while building account statements (#810). The web's invoice page has Download PDF (`routes/(app)/invoices/[id]/pdf/+server.js`); the phone's invoice detail has no PDF action. The statement screen already saves its PDF through `downloadAttachment`, which is the pattern to reuse. |
|
||||
| B11 | Modules: switch parts of the CRM off for the organization | Web and Mobile | `GET` and `PUT /api/org/modules/` exist, and every route, link and job honours them | 2026-10-23 | The API ships before either screen. Neither client has the settings page, the confirmation copy (served as `stops`), or the nav and not-found handling for a `module_off` 404. The Salesforce import status also lists which object types its modules leave out, and no client shows it. Build web and phone together. |
|
||||
|
||||
## Web-only by design
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: "CRM for AI and People."
|
|||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
version: 1.10.0+14
|
||||
version: 1.11.0+15
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.1
|
||||
|
|
|
|||
Loading…
Reference in a new issue