Let an org switch whole modules off (1.23.0)

This commit is contained in:
Ashwin 2026-10-23 15:22:09 +05:30
parent cffe004746
commit 0e8735bf4e
100 changed files with 7948 additions and 370 deletions

View file

@ -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:

View file

@ -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,

View file

@ -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)

View file

@ -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]

View file

@ -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"]})

View 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"

View 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]

View file

@ -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)}

View file

@ -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

View file

@ -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."

View file

@ -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):

View file

@ -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

View file

@ -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()

View file

@ -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",

View file

@ -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)

View file

@ -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

View 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"]]

View 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

View file

@ -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 "",
]

View file

@ -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,
)

View file

@ -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",

View file

@ -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

View file

@ -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)

View 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,
),
),
]

View file

@ -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

View 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
)

View 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
View 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)

View file

@ -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:

View file

@ -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

View file

@ -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"])

View file

@ -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(
{

View file

@ -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")

View file

@ -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."""

View 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

View 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

View 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)

View 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

View 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

View 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

View 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")

View 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]

View file

@ -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

View file

@ -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

View 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"] == {}

View file

@ -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(

View file

@ -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/"

View file

@ -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(

View file

@ -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}

View file

@ -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,
)

View 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))

View file

@ -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)

View file

@ -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"],
}
)

View file

@ -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):

View file

@ -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),

View file

@ -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:

View file

@ -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),
}

View file

@ -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

View file

@ -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,

View 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)

View file

@ -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)

View file

@ -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

View 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"

View 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)

View 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

View file

@ -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
):

View 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)

View file

@ -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")

View file

@ -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)

View 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 == []

View file

@ -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

View file

@ -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

View 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

View file

@ -704,7 +704,7 @@ wheels = [
[[package]]
name = "django-crm"
version = "1.22.1"
version = "1.23.0"
source = { editable = "." }
dependencies = [
{ name = "cairocffi" },

View file

@ -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 [])

View file

@ -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

View file

@ -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

View file

@ -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

View 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")

View file

@ -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)

View file

@ -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:

View file

@ -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
]

View file

@ -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 = [

View 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()

View file

@ -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

View file

@ -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`,

View file

@ -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

View file

@ -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 |

View file

@ -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
View 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.

View file

@ -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

View file

@ -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`.

View 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.

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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