diff --git a/backend/accounts/serializer.py b/backend/accounts/serializer.py index 752d117c..54b74be8 100644 --- a/backend/accounts/serializer.py +++ b/backend/accounts/serializer.py @@ -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: diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 0d1b12ec..cb88ca6c 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -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//` 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, diff --git a/backend/automation/actions.py b/backend/automation/actions.py index 917e3018..23be5675 100644 --- a/backend/automation/actions.py +++ b/backend/automation/actions.py @@ -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) diff --git a/backend/automation/objects.py b/backend/automation/objects.py index e2326fd3..fd441075 100644 --- a/backend/automation/objects.py +++ b/backend/automation/objects.py @@ -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] diff --git a/backend/automation/serializers.py b/backend/automation/serializers.py index 8e04f22b..8477cceb 100644 --- a/backend/automation/serializers.py +++ b/backend/automation/serializers.py @@ -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"]}) diff --git a/backend/automation/tests/test_module_off_actions.py b/backend/automation/tests/test_module_off_actions.py new file mode 100644 index 00000000..c35dd26a --- /dev/null +++ b/backend/automation/tests/test_module_off_actions.py @@ -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" diff --git a/backend/automation/tests/test_module_off_status.py b/backend/automation/tests/test_module_off_status.py new file mode 100644 index 00000000..46a41cb7 --- /dev/null +++ b/backend/automation/tests/test_module_off_status.py @@ -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] diff --git a/backend/automation/validation.py b/backend/automation/validation.py index 3fef9e4f..6aebde42 100644 --- a/backend/automation/validation.py +++ b/backend/automation/validation.py @@ -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)} diff --git a/backend/automation/views.py b/backend/automation/views.py index 0f60f334..10afa12d 100644 --- a/backend/automation/views.py +++ b/backend/automation/views.py @@ -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 diff --git a/backend/cases/csat_views.py b/backend/cases/csat_views.py index 92eed990..d5fea67e 100644 --- a/backend/cases/csat_views.py +++ b/backend/cases/csat_views.py @@ -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." diff --git a/backend/cases/help_center_views.py b/backend/cases/help_center_views.py index 56998e8d..61c13739 100644 --- a/backend/cases/help_center_views.py +++ b/backend/cases/help_center_views.py @@ -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): diff --git a/backend/cases/inbound_views.py b/backend/cases/inbound_views.py index eed69d15..555676d3 100644 --- a/backend/cases/inbound_views.py +++ b/backend/cases/inbound_views.py @@ -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 diff --git a/backend/cases/portal_views.py b/backend/cases/portal_views.py index 4b44ab9d..f4c86df4 100644 --- a/backend/cases/portal_views.py +++ b/backend/cases/portal_views.py @@ -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() diff --git a/backend/cases/serializer.py b/backend/cases/serializer.py index 87bfde30..a5986d91 100644 --- a/backend/cases/serializer.py +++ b/backend/cases/serializer.py @@ -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", diff --git a/backend/cases/solution_serializers.py b/backend/cases/solution_serializers.py index be93bc27..04c7cc18 100644 --- a/backend/cases/solution_serializers.py +++ b/backend/cases/solution_serializers.py @@ -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) diff --git a/backend/cases/tasks.py b/backend/cases/tasks.py index c5a4da86..b4426b15 100644 --- a/backend/cases/tasks.py +++ b/backend/cases/tasks.py @@ -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 diff --git a/backend/cases/tests/test_module_off_jobs.py b/backend/cases/tests/test_module_off_jobs.py new file mode 100644 index 00000000..485feabb --- /dev/null +++ b/backend/cases/tests/test_module_off_jobs.py @@ -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"]] diff --git a/backend/cases/tests/test_module_off_public.py b/backend/cases/tests/test_module_off_public.py new file mode 100644 index 00000000..6f51e4a2 --- /dev/null +++ b/backend/cases/tests/test_module_off_public.py @@ -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 \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: \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 diff --git a/backend/cases/time_reports.py b/backend/cases/time_reports.py index 7f33c262..04277605 100644 --- a/backend/cases/time_reports.py +++ b/backend/cases/time_reports.py @@ -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 "", ] diff --git a/backend/cases/time_views.py b/backend/cases/time_views.py index effd42c8..ef26aa27 100644 --- a/backend/cases/time_views.py +++ b/backend/cases/time_views.py @@ -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, ) diff --git a/backend/cases/views.py b/backend/cases/views.py index 300d09aa..1e627995 100644 --- a/backend/cases/views.py +++ b/backend/cases/views.py @@ -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", diff --git a/backend/common/audit_log.py b/backend/common/audit_log.py index ee0f59a3..a251c82b 100644 --- a/backend/common/audit_log.py +++ b/backend/common/audit_log.py @@ -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 diff --git a/backend/common/middleware/rls_context.py b/backend/common/middleware/rls_context.py index 36b12eaf..80c9dffe 100644 --- a/backend/common/middleware/rls_context.py +++ b/backend/common/middleware/rls_context.py @@ -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) diff --git a/backend/common/migrations/0055_org_modules.py b/backend/common/migrations/0055_org_modules.py new file mode 100644 index 00000000..abb1ac3f --- /dev/null +++ b/backend/common/migrations/0055_org_modules.py @@ -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, + ), + ), + ] diff --git a/backend/common/models.py b/backend/common/models.py index 09fd6c6d..9793a618 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -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/` 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 diff --git a/backend/common/module_deletes.py b/backend/common/module_deletes.py new file mode 100644 index 00000000..542f3854 --- /dev/null +++ b/backend/common/module_deletes.py @@ -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 + ) diff --git a/backend/common/module_settings.py b/backend/common/module_settings.py new file mode 100644 index 00000000..c7b97717 --- /dev/null +++ b/backend/common/module_settings.py @@ -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 {} diff --git a/backend/common/modules.py b/backend/common/modules.py new file mode 100644 index 00000000..18f94629 --- /dev/null +++ b/backend/common/modules.py @@ -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//solutions/": Requires(("tickets", "knowledge_base")), + "api/cases//solution-suggestions/": Requires(("tickets", "knowledge_base")), + "api/cases//time-entries/": Requires(("tickets", "timesheet")), + "api/cases//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//", + "api/public/help//", + "api/public/help//articles//", + "api/public/invoice//", + "api/public/invoice//pdf/", + "api/public/estimate//", + "api/public/estimate//pdf/", + "api/public/estimate//accept/", + "api/public/estimate//decline/", + "api/public/calendar/.ics", + "api/org/help-center/", + "api/portal/articles/", + "api/portal/articles/suggest/", + "api/portal/articles//", + "api/portal/cases/", + "api/portal/cases//", + "api/portal/cases//comment/", + "api/cases/inbound//", + } +) + + +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) diff --git a/backend/common/object_registry.py b/backend/common/object_registry.py index 373e10ba..554dfc39 100644 --- a/backend/common/object_registry.py +++ b/backend/common/object_registry.py @@ -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: diff --git a/backend/common/packs/applier.py b/backend/common/packs/applier.py index 8d55fbc3..0b152e30 100644 --- a/backend/common/packs/applier.py +++ b/backend/common/packs/applier.py @@ -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 diff --git a/backend/common/saved_views.py b/backend/common/saved_views.py index 65b5b1af..50aa5122 100644 --- a/backend/common/saved_views.py +++ b/backend/common/saved_views.py @@ -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"]) diff --git a/backend/common/search_views.py b/backend/common/search_views.py index 57c1204d..49e7be87 100644 --- a/backend/common/search_views.py +++ b/backend/common/search_views.py @@ -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( { diff --git a/backend/common/serializer.py b/backend/common/serializer.py index c3e5a9cd..a56bf21c 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -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") diff --git a/backend/common/testing.py b/backend/common/testing.py index 3303900a..db20131c 100644 --- a/backend/common/testing.py +++ b/backend/common/testing.py @@ -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.""" diff --git a/backend/common/tests/test_custom_fields_module_off.py b/backend/common/tests/test_custom_fields_module_off.py new file mode 100644 index 00000000..fa56e135 --- /dev/null +++ b/backend/common/tests/test_custom_fields_module_off.py @@ -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 diff --git a/backend/common/tests/test_module_deletes.py b/backend/common/tests/test_module_deletes.py new file mode 100644 index 00000000..634cebe9 --- /dev/null +++ b/backend/common/tests/test_module_deletes.py @@ -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 diff --git a/backend/common/tests/test_module_off_public.py b/backend/common/tests/test_module_off_public.py new file mode 100644 index 00000000..5fa8eaec --- /dev/null +++ b/backend/common/tests/test_module_off_public.py @@ -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) diff --git a/backend/common/tests/test_module_off_related.py b/backend/common/tests/test_module_off_related.py new file mode 100644 index 00000000..7e6b4f61 --- /dev/null +++ b/backend/common/tests/test_module_off_related.py @@ -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 diff --git a/backend/common/tests/test_module_off_surfaces.py b/backend/common/tests/test_module_off_surfaces.py new file mode 100644 index 00000000..92e6e8d0 --- /dev/null +++ b/backend/common/tests/test_module_off_surfaces.py @@ -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 diff --git a/backend/common/tests/test_module_routes.py b/backend/common/tests/test_module_routes.py new file mode 100644 index 00000000..b286becb --- /dev/null +++ b/backend/common/tests/test_module_routes.py @@ -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//publish/", (CU, "api_cases"), Requires(("knowledge_base",))), + ("api/cases//solutions//", (CU, "api_cases"), Requires(("tickets", "knowledge_base"))), + ("api/cases//solution-suggestions/", (CU, "api_cases"), Requires(("tickets", "knowledge_base"))), + ("api/cases//time-entries/start/", (CU, "api_cases"), Requires(("tickets", "timesheet"))), + ("api/cases//time-summary/", (CU, "api_cases"), Requires(("tickets", "timesheet"))), + ("api/invoices/products//", (CU, "api_invoices"), Requires(any_of=("invoices", "deals"))), + ("api/invoices/from-opportunity//", (CU, "api_invoices"), Requires(("invoices", "deals"))), + ("api/invoices/from-time-entries/", (CU, "api_invoices"), Requires(("invoices", "tickets", "timesheet"))), + ("api/documents//download/", (CU, "api_common"), Requires(("documents",))), + ("api/profile/calendar-feed/", (CU, "api_common"), Requires(("tasks",))), + ("api/public/calendar/.ics", (CU, "api_common"), Requires(("tasks",))), + ("api/public/csat//", (CU,), Requires(("tickets",))), + ("api/org/help-center/", (CU, "api_common"), Requires(("knowledge_base",))), + ("api/portal/cases//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//", (CU, "api_opportunities"), Requires(("deals",))), + ("api/tasks/", (CU, "api_tasks"), Requires(("tasks",))), + ("api/boards//", (CU, "api_boards"), Requires(("tasks",))), + ("api/cases//", (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//", (CU, "api_invoices"), Requires(("invoices",))), + ("api/public/help//", ("public_help",), Requires(("knowledge_base",))), + ("api/public/estimate//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//", (CU, "api_common"), CORE), + ("api/attachments//download/", (CU, "api_common"), CORE), + ("api/auth/magic-link/verify/", (CU, "api_common"), CORE), + ("api/custom-fields//", (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//apply/", (CU, "api_common"), CORE), + ("api/profile/tokens//", (CU, "api_common"), CORE), + ("api/saved-views/", (CU, "api_common"), CORE), + ("api/search/", (CU,), CORE), + ("api/tags//merge/", (CU, "api_common"), CORE), + ("api/teams/", (CU, "api_common"), CORE), + ("api/user//status/", (CU, "api_common"), CORE), + ("api/users/get-teams-and-users/", (CU, "api_common"), CORE), + ("api/accounts/", (CU, "api_accounts"), CORE), + ("api/portal/login//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 diff --git a/backend/common/tests/test_modules.py b/backend/common/tests/test_modules.py new file mode 100644 index 00000000..b4984ae0 --- /dev/null +++ b/backend/common/tests/test_modules.py @@ -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") diff --git a/backend/common/tests/test_modules_api.py b/backend/common/tests/test_modules_api.py new file mode 100644 index 00000000..8f4f7c75 --- /dev/null +++ b/backend/common/tests/test_modules_api.py @@ -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] diff --git a/backend/common/tests/test_object_registry.py b/backend/common/tests/test_object_registry.py index 572cd462..f187df8a 100644 --- a/backend/common/tests/test_object_registry.py +++ b/backend/common/tests/test_object_registry.py @@ -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 diff --git a/backend/common/tests/test_packs_api.py b/backend/common/tests/test_packs_api.py index 1d63ce03..838bff48 100644 --- a/backend/common/tests/test_packs_api.py +++ b/backend/common/tests/test_packs_api.py @@ -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 diff --git a/backend/common/tests/test_packs_module_off.py b/backend/common/tests/test_packs_module_off.py new file mode 100644 index 00000000..54892019 --- /dev/null +++ b/backend/common/tests/test_packs_module_off.py @@ -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"] == {} diff --git a/backend/common/urls.py b/backend/common/urls.py index e97ef70f..974ce9e4 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -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( diff --git a/backend/common/views/audit_log_views.py b/backend/common/views/audit_log_views.py index cc784569..ad408e2c 100644 --- a/backend/common/views/audit_log_views.py +++ b/backend/common/views/audit_log_views.py @@ -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/" diff --git a/backend/common/views/calendar_feed_views.py b/backend/common/views/calendar_feed_views.py index edd11bec..1ae67602 100644 --- a/backend/common/views/calendar_feed_views.py +++ b/backend/common/views/calendar_feed_views.py @@ -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( diff --git a/backend/common/views/custom_field_views.py b/backend/common/views/custom_field_views.py index 997ca9a8..d48013b0 100644 --- a/backend/common/views/custom_field_views.py +++ b/backend/common/views/custom_field_views.py @@ -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} diff --git a/backend/common/views/dashboard_views.py b/backend/common/views/dashboard_views.py index 22dff78f..2da4e9ef 100644 --- a/backend/common/views/dashboard_views.py +++ b/backend/common/views/dashboard_views.py @@ -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, ) diff --git a/backend/common/views/module_views.py b/backend/common/views/module_views.py new file mode 100644 index 00000000..91f133d5 --- /dev/null +++ b/backend/common/views/module_views.py @@ -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)) diff --git a/backend/common/views/org_settings_views.py b/backend/common/views/org_settings_views.py index cf9cbc18..a631a1a8 100644 --- a/backend/common/views/org_settings_views.py +++ b/backend/common/views/org_settings_views.py @@ -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) diff --git a/backend/common/views/pack_views.py b/backend/common/views/pack_views.py index af7e176c..09e8c123 100644 --- a/backend/common/views/pack_views.py +++ b/backend/common/views/pack_views.py @@ -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"], } ) diff --git a/backend/common/views/saved_view_views.py b/backend/common/views/saved_view_views.py index d05ef13c..2c72b636 100644 --- a/backend/common/views/saved_view_views.py +++ b/backend/common/views/saved_view_views.py @@ -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): diff --git a/backend/common/views/tags_views.py b/backend/common/views/tags_views.py index dd460f43..25f82f31 100644 --- a/backend/common/views/tags_views.py +++ b/backend/common/views/tags_views.py @@ -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), diff --git a/backend/common/views/user_views.py b/backend/common/views/user_views.py index 97e6f61a..4bc043b8 100644 --- a/backend/common/views/user_views.py +++ b/backend/common/views/user_views.py @@ -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: diff --git a/backend/contacts/views.py b/backend/contacts/views.py index ac86ae7a..e3cc2e11 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -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), } diff --git a/backend/invoices/management/commands/advance_missed_recurring_invoices.py b/backend/invoices/management/commands/advance_missed_recurring_invoices.py index 73c4de78..6a76c572 100644 --- a/backend/invoices/management/commands/advance_missed_recurring_invoices.py +++ b/backend/invoices/management/commands/advance_missed_recurring_invoices.py @@ -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 diff --git a/backend/invoices/public_views.py b/backend/invoices/public_views.py index dbf60bd0..d689b14e 100644 --- a/backend/invoices/public_views.py +++ b/backend/invoices/public_views.py @@ -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, diff --git a/backend/invoices/schedules.py b/backend/invoices/schedules.py new file mode 100644 index 00000000..4988cf40 --- /dev/null +++ b/backend/invoices/schedules.py @@ -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) diff --git a/backend/invoices/serializer.py b/backend/invoices/serializer.py index 608751ab..669677ed 100644 --- a/backend/invoices/serializer.py +++ b/backend/invoices/serializer.py @@ -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) diff --git a/backend/invoices/tasks.py b/backend/invoices/tasks.py index 843db024..a7b37e8d 100644 --- a/backend/invoices/tasks.py +++ b/backend/invoices/tasks.py @@ -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 diff --git a/backend/invoices/tests/test_module_off_jobs.py b/backend/invoices/tests/test_module_off_jobs.py new file mode 100644 index 00000000..b4e76daa --- /dev/null +++ b/backend/invoices/tests/test_module_off_jobs.py @@ -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" diff --git a/backend/invoices/tests/test_module_off_public.py b/backend/invoices/tests/test_module_off_public.py new file mode 100644 index 00000000..6898122b --- /dev/null +++ b/backend/invoices/tests/test_module_off_public.py @@ -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) diff --git a/backend/invoices/tests/test_module_off_writes.py b/backend/invoices/tests/test_module_off_writes.py new file mode 100644 index 00000000..6535f4d1 --- /dev/null +++ b/backend/invoices/tests/test_module_off_writes.py @@ -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 diff --git a/backend/leads/services.py b/backend/leads/services.py index 3236330b..8b7c4bfa 100644 --- a/backend/leads/services.py +++ b/backend/leads/services.py @@ -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 ): diff --git a/backend/leads/tests/test_module_off_convert.py b/backend/leads/tests/test_module_off_convert.py new file mode 100644 index 00000000..0a684b98 --- /dev/null +++ b/backend/leads/tests/test_module_off_convert.py @@ -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) diff --git a/backend/opportunity/next_activity.py b/backend/opportunity/next_activity.py index 2f18a4c8..8c88d002 100644 --- a/backend/opportunity/next_activity.py +++ b/backend/opportunity/next_activity.py @@ -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") diff --git a/backend/opportunity/tasks.py b/backend/opportunity/tasks.py index cd458ccf..66b395af 100644 --- a/backend/opportunity/tasks.py +++ b/backend/opportunity/tasks.py @@ -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) diff --git a/backend/opportunity/tests/test_module_off_jobs.py b/backend/opportunity/tests/test_module_off_jobs.py new file mode 100644 index 00000000..b37fef1e --- /dev/null +++ b/backend/opportunity/tests/test_module_off_jobs.py @@ -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 == [] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 829897c0..1730b85d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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 diff --git a/backend/tasks/serializer.py b/backend/tasks/serializer.py index d4d7cba0..d1b6dbe3 100644 --- a/backend/tasks/serializer.py +++ b/backend/tasks/serializer.py @@ -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 diff --git a/backend/tasks/tests/test_module_off_links.py b/backend/tasks/tests/test_module_off_links.py new file mode 100644 index 00000000..5f2cdced --- /dev/null +++ b/backend/tasks/tests/test_module_off_links.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index 1a5937f2..69ddc800 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -704,7 +704,7 @@ wheels = [ [[package]] name = "django-crm" -version = "1.22.1" +version = "1.23.0" source = { editable = "." } dependencies = [ { name = "cairocffi" }, diff --git a/backend/webforms/cors.py b/backend/webforms/cors.py index 8fb293da..9f0c660e 100644 --- a/backend/webforms/cors.py +++ b/backend/webforms/cors.py @@ -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 []) diff --git a/backend/webforms/models.py b/backend/webforms/models.py index e1f89212..5b7b4490 100644 --- a/backend/webforms/models.py +++ b/backend/webforms/models.py @@ -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 diff --git a/backend/webforms/public_views.py b/backend/webforms/public_views.py index db26a0a4..cd4a3307 100644 --- a/backend/webforms/public_views.py +++ b/backend/webforms/public_views.py @@ -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 diff --git a/backend/webforms/serializers.py b/backend/webforms/serializers.py index faafaaee..1ba5fbf7 100644 --- a/backend/webforms/serializers.py +++ b/backend/webforms/serializers.py @@ -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 diff --git a/backend/webforms/tests/test_module_off.py b/backend/webforms/tests/test_module_off.py new file mode 100644 index 00000000..0f4727b9 --- /dev/null +++ b/backend/webforms/tests/test_module_off.py @@ -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") diff --git a/backend/webforms/views.py b/backend/webforms/views.py index 5eaefa01..c04d3202 100644 --- a/backend/webforms/views.py +++ b/backend/webforms/views.py @@ -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) diff --git a/backend/webhooks/emit.py b/backend/webhooks/emit.py index 6df8ef51..c163bc5c 100644 --- a/backend/webhooks/emit.py +++ b/backend/webhooks/emit.py @@ -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: diff --git a/backend/webhooks/events.py b/backend/webhooks/events.py index 46ad3894..7b61c60c 100644 --- a/backend/webhooks/events.py +++ b/backend/webhooks/events.py @@ -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 ] diff --git a/backend/webhooks/serializers.py b/backend/webhooks/serializers.py index cf364b33..3582457f 100644 --- a/backend/webhooks/serializers.py +++ b/backend/webhooks/serializers.py @@ -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 = [ diff --git a/backend/webhooks/tests/test_module_off.py b/backend/webhooks/tests/test_module_off.py new file mode 100644 index 00000000..26338566 --- /dev/null +++ b/backend/webhooks/tests/test_module_off.py @@ -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() diff --git a/backend/webhooks/views.py b/backend/webhooks/views.py index 7c6359fe..4a81e202 100644 --- a/backend/webhooks/views.py +++ b/backend/webhooks/views.py @@ -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 diff --git a/docs/api/audit-log.md b/docs/api/audit-log.md index e9b03edc..3b2de949 100644 --- a/docs/api/audit-log.md +++ b/docs/api/audit-log.md @@ -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`, diff --git a/docs/api/customer-portal.md b/docs/api/customer-portal.md index 5a42ed87..771ffa65 100644 --- a/docs/api/customer-portal.md +++ b/docs/api/customer-portal.md @@ -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 diff --git a/docs/api/endpoint-index.md b/docs/api/endpoint-index.md index 2485f7fc..0053f823 100644 --- a/docs/api/endpoint-index.md +++ b/docs/api/endpoint-index.md @@ -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 | diff --git a/docs/api/errors.md b/docs/api/errors.md index d1208b32..aeb93e43 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -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 `