394 lines
13 KiB
Python
394 lines
13 KiB
Python
"""The module catalogue: nine parts of the CRM an org admin can switch off.
|
|
|
|
State lives in three ``Org`` fields (``modules_off``, ``module_order``,
|
|
``modules_on_at``). Everything here reads them defensively: unknown or
|
|
malformed stored values are ignored, never raised on.
|
|
"""
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
|
|
import crum
|
|
from django.http import JsonResponse
|
|
from rest_framework.response import Response
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Module:
|
|
key: str
|
|
label: str
|
|
group: str
|
|
covers: str
|
|
stops: str
|
|
term: str | None = None
|
|
needs: str | None = None
|
|
|
|
|
|
GROUPS = ("sell", "serve", "bill")
|
|
|
|
MODULES = (
|
|
Module(
|
|
key="deals",
|
|
label="Pipeline",
|
|
term="opportunity.plural",
|
|
group="sell",
|
|
covers="Deals, deal pipelines, line items, the deal board and stage aging.",
|
|
stops=(
|
|
"Everything in {label} leaves every menu, search and Today, and stale-deal emails stop. "
|
|
"Converting a lead no longer creates a deal, and Goals switches off too."
|
|
),
|
|
),
|
|
Module(
|
|
key="leads",
|
|
label="Leads",
|
|
term="lead.plural",
|
|
group="sell",
|
|
covers="Leads, lead pipelines, and lead import and export.",
|
|
stops="{label} leave every menu, search and Today.",
|
|
),
|
|
Module(
|
|
key="goals",
|
|
label="Goals",
|
|
group="sell",
|
|
needs="deals",
|
|
covers="Sales goals, the leaderboard and goal history.",
|
|
stops="{label} leave every menu, and goal milestone emails stop.",
|
|
),
|
|
Module(
|
|
key="tasks",
|
|
label="Tasks",
|
|
group="serve",
|
|
covers="Tasks, boards, the task calendar and its calendar feed.",
|
|
stops=(
|
|
"{label} leave every menu and Today, calendar feeds stop updating, "
|
|
"and automation rules skip their Create a task steps."
|
|
),
|
|
),
|
|
Module(
|
|
key="tickets",
|
|
label="Tickets",
|
|
group="serve",
|
|
covers=(
|
|
"Tickets, approvals, ticket analytics, routing, escalation, the reopen "
|
|
"policy, business hours, saved replies, inbound email, CSAT and portal "
|
|
"tickets."
|
|
),
|
|
stops=(
|
|
"{label} leave every menu and Today, SLA escalation stops, CSAT survey "
|
|
"links stop opening, and customers stop seeing their tickets in the "
|
|
"portal. Timesheet switches off too."
|
|
),
|
|
),
|
|
Module(
|
|
key="knowledge_base",
|
|
label="Knowledge base",
|
|
group="serve",
|
|
covers="Articles, the public help center and portal articles.",
|
|
stops=(
|
|
"Articles leave every menu, the public help center goes offline, and "
|
|
"the portal stops showing articles."
|
|
),
|
|
),
|
|
Module(
|
|
key="documents",
|
|
label="Documents",
|
|
group="serve",
|
|
covers="Documents and their downloads.",
|
|
stops="{label} leave every menu, and their downloads stop opening.",
|
|
),
|
|
Module(
|
|
key="invoices",
|
|
label="Invoices",
|
|
term="invoice.plural",
|
|
group="bill",
|
|
covers=(
|
|
"Invoices, estimates, recurring invoices, invoice templates, invoice "
|
|
"reports, account statements, and public invoice and estimate links."
|
|
),
|
|
stops=(
|
|
"Recurring invoices stop being sent, and links you have already sent to "
|
|
"customers stop opening. Recurring invoices that fall due while {label} "
|
|
"is off are not sent later. Payment reminders that fall due while "
|
|
"{label} is off are not sent later."
|
|
),
|
|
),
|
|
Module(
|
|
key="timesheet",
|
|
label="Timesheet",
|
|
group="bill",
|
|
needs="tickets",
|
|
covers="Time entries, the timesheet and the time report.",
|
|
stops=(
|
|
"Time entries leave every menu and ticket, and running timers are no "
|
|
"longer stopped automatically."
|
|
),
|
|
),
|
|
)
|
|
|
|
KEYS = tuple(m.key for m in MODULES)
|
|
_BY_KEY = {m.key: m for m in MODULES}
|
|
|
|
_LEADS = ("Lead", "LeadPipeline", "LeadStage")
|
|
_DEALS = ("DealPipeline", "DealStage", "Opportunity", "OpportunityLineItem")
|
|
_TASKS = (
|
|
"Board",
|
|
"BoardMember",
|
|
"BoardColumn",
|
|
"BoardTask",
|
|
"TaskPipeline",
|
|
"TaskStage",
|
|
"Task",
|
|
)
|
|
_CASES = (
|
|
"Case", "CaseWatcher", "CsatSurvey", "CasePipeline", "CaseStage", "ReopenPolicy",
|
|
"EscalationPolicy", "InboundMailbox", "EmailMessage", "RoutingRule",
|
|
"RoutingRuleState", "ApprovalRule", "Approval",
|
|
) # fmt: skip
|
|
_INVOICES = (
|
|
"InvoiceTemplate", "Invoice", "InvoiceLineItem", "Payment", "Estimate",
|
|
"EstimateLineItem", "RecurringInvoice", "RecurringInvoiceLineItem",
|
|
"InvoiceHistory",
|
|
) # fmt: skip
|
|
|
|
# invoices.Product is absent on purpose: it follows deals or invoices.
|
|
MODEL_MODULES: dict[str, str] = {
|
|
**{f"leads.{n}": "leads" for n in _LEADS},
|
|
**{f"opportunity.{n}": "deals" for n in _DEALS},
|
|
"opportunity.SalesGoal": "goals",
|
|
**{f"tasks.{n}": "tasks" for n in _TASKS},
|
|
**{f"cases.{n}": "tickets" for n in _CASES},
|
|
"cases.Solution": "knowledge_base",
|
|
"cases.TimeEntry": "timesheet",
|
|
"business_hours.BusinessCalendar": "tickets",
|
|
"business_hours.BusinessHoliday": "tickets",
|
|
"macros.Macro": "tickets",
|
|
**{f"invoices.{n}": "invoices" for n in _INVOICES},
|
|
"common.Document": "documents",
|
|
}
|
|
|
|
|
|
def module(key):
|
|
"""The catalogue entry for ``key``; ``KeyError`` when unknown."""
|
|
return _BY_KEY[key]
|
|
|
|
|
|
def model_module(label: str) -> str | None:
|
|
return MODEL_MODULES.get(label)
|
|
|
|
|
|
def enabled_modules(org) -> frozenset[str]:
|
|
off = org.modules_off
|
|
stored = {k for k in off if isinstance(k, str)} if isinstance(off, list) else set()
|
|
return frozenset(KEYS) - stored
|
|
|
|
|
|
def available_modules(org) -> frozenset[str]:
|
|
enabled = enabled_modules(org)
|
|
return frozenset(
|
|
k for k in enabled if _BY_KEY[k].needs is None or _BY_KEY[k].needs in enabled
|
|
)
|
|
|
|
|
|
def module_is_available(org, key: str) -> bool:
|
|
return key in available_modules(org)
|
|
|
|
|
|
def module_label(org, key) -> str:
|
|
m = _BY_KEY[key]
|
|
terms = org.terminology
|
|
if m.term and isinstance(terms, dict):
|
|
value = terms.get(m.term)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return m.label
|
|
|
|
|
|
def module_off_message(org, key) -> str:
|
|
return f"{module_label(org, key)} is switched off for this organization."
|
|
|
|
|
|
def module_off_body(org, key) -> dict:
|
|
return {
|
|
"detail": module_off_message(org, key),
|
|
"code": "module_off",
|
|
"module": key,
|
|
}
|
|
|
|
|
|
def module_off_response(org, key) -> Response:
|
|
return Response(module_off_body(org, key), status=404)
|
|
|
|
|
|
def ordered_modules(org) -> list[Module]:
|
|
stored = org.module_order if isinstance(org.module_order, list) else []
|
|
result = []
|
|
for group in GROUPS:
|
|
members = [m for m in MODULES if m.group == group]
|
|
first = [
|
|
_BY_KEY[k]
|
|
for k in dict.fromkeys(k for k in stored if isinstance(k, str))
|
|
if k in _BY_KEY and _BY_KEY[k].group == group
|
|
]
|
|
result += first + [m for m in members if m not in first]
|
|
return result
|
|
|
|
|
|
def module_on_since(org, key) -> datetime | None:
|
|
stamps = org.modules_on_at
|
|
raw = stamps.get(key) if isinstance(stamps, dict) else None
|
|
if not isinstance(raw, str):
|
|
return None
|
|
try:
|
|
when = datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
return None
|
|
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
|
|
|
|
|
def only_available(org, key, queryset):
|
|
return queryset if module_is_available(org, key) else queryset.none()
|
|
|
|
|
|
def org_of(record):
|
|
"""The request's org when it is the record's, so a serializer does not
|
|
query once per row."""
|
|
request = crum.get_current_request()
|
|
org = getattr(getattr(request, "profile", None), "org", None)
|
|
if org is not None and org.id == record.org_id:
|
|
return org
|
|
return record.org
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Requires:
|
|
"""The modules a route needs. ``all_of`` lists the needed module first, so
|
|
the refusal names the module that is actually off."""
|
|
|
|
all_of: tuple[str, ...] = ()
|
|
any_of: tuple[str, ...] = ()
|
|
|
|
def blocking(self, available: frozenset[str]) -> str | None:
|
|
for key in self.all_of:
|
|
if key not in available:
|
|
return key
|
|
if self.any_of and not available.intersection(self.any_of):
|
|
return self.any_of[0]
|
|
return None
|
|
|
|
|
|
CORE = Requires()
|
|
|
|
NAMESPACE_MODULES: dict[str, Requires] = {
|
|
"api_leads": Requires(("leads",)),
|
|
"api_opportunities": Requires(("deals",)),
|
|
"api_tasks": Requires(("tasks",)),
|
|
"api_boards": Requires(("tasks",)),
|
|
"api_cases": Requires(("tickets",)),
|
|
"api_business_hours": Requires(("tickets",)),
|
|
"api_macros": Requires(("tickets",)),
|
|
"api_time_entries": Requires(("tickets", "timesheet")),
|
|
"api_invoices": Requires(("invoices",)),
|
|
"public_help": Requires(("knowledge_base",)),
|
|
"public_invoices": Requires(("invoices",)),
|
|
"api_accounts": CORE,
|
|
"api_contacts": CORE,
|
|
"api_webforms": CORE,
|
|
"api_webhooks": CORE,
|
|
"api_automation": CORE,
|
|
"public_webforms": CORE,
|
|
}
|
|
|
|
# Keyed by ``ResolverMatch.route`` prefix; the longest match wins over the
|
|
# namespace. ``common_urls``, ``api_common`` and ``portal`` mix core and module
|
|
# routes, so they have no namespace entry: each of their roots is listed here,
|
|
# and a new root there stays unclassified.
|
|
ROUTE_MODULES: dict[str, Requires] = {
|
|
"api/opportunities/goals/": Requires(("deals", "goals")),
|
|
"api/cases/solutions/": Requires(("knowledge_base",)),
|
|
"api/cases/<uid:pk>/solutions/": Requires(("tickets", "knowledge_base")),
|
|
"api/cases/<uid:pk>/solution-suggestions/": Requires(("tickets", "knowledge_base")),
|
|
"api/cases/<uid:pk>/time-entries/": Requires(("tickets", "timesheet")),
|
|
"api/cases/<uid:pk>/time-summary/": Requires(("tickets", "timesheet")),
|
|
"api/invoices/products/": Requires(any_of=("invoices", "deals")),
|
|
"api/invoices/from-opportunity/": Requires(("invoices", "deals")),
|
|
"api/invoices/from-time-entries/": Requires(("invoices", "tickets", "timesheet")),
|
|
"api/documents/": Requires(("documents",)),
|
|
"api/profile/calendar-feed/": Requires(("tasks",)),
|
|
"api/public/calendar/": Requires(("tasks",)),
|
|
"api/public/csat/": Requires(("tickets",)),
|
|
"api/org/help-center/": Requires(("knowledge_base",)),
|
|
"api/portal/cases/": Requires(("tickets",)),
|
|
"api/portal/articles/": Requires(("knowledge_base",)),
|
|
"api/portal/login/": CORE,
|
|
"api/schema/": CORE,
|
|
"api/activities/": CORE,
|
|
"api/api-settings/": CORE,
|
|
"api/attachments/": CORE,
|
|
"api/auth/": CORE,
|
|
"api/custom-fields/": CORE,
|
|
"api/dashboard/": CORE,
|
|
"api/notifications/": CORE,
|
|
"api/org/": CORE,
|
|
"api/packs/": CORE,
|
|
"api/profile/": CORE,
|
|
"api/saved-views/": CORE,
|
|
"api/search/": CORE,
|
|
"api/tags/": CORE,
|
|
"api/teams/": CORE,
|
|
"api/user/": CORE,
|
|
"api/users/": CORE,
|
|
}
|
|
|
|
|
|
def requirement_for(route: str, namespaces: Sequence[str]) -> Requires | None:
|
|
"""What ``route`` needs: the longest ``ROUTE_MODULES`` prefix it starts
|
|
with, else the entry for its innermost namespace, else None (unclassified).
|
|
|
|
Outer namespaces are never consulted, so an app mounted under a new
|
|
namespace inside ``common_urls`` stays unclassified instead of turning core.
|
|
"""
|
|
prefixes = [p for p in ROUTE_MODULES if route.startswith(p)]
|
|
if prefixes:
|
|
return ROUTE_MODULES[max(prefixes, key=len)]
|
|
return NAMESPACE_MODULES.get(namespaces[-1]) if namespaces else None
|
|
|
|
|
|
# Module routes ``route_refusal`` never sees (exempt paths, the portal, exempt
|
|
# views); each checks its module in its view.
|
|
VIEW_CHECKED_ROUTES: frozenset[str] = frozenset(
|
|
{
|
|
"api/public/csat/<str:token>/",
|
|
"api/public/help/<str:slug>/",
|
|
"api/public/help/<str:slug>/articles/<uid:pk>/",
|
|
"api/public/invoice/<str:token>/",
|
|
"api/public/invoice/<str:token>/pdf/",
|
|
"api/public/estimate/<str:token>/",
|
|
"api/public/estimate/<str:token>/pdf/",
|
|
"api/public/estimate/<str:token>/accept/",
|
|
"api/public/estimate/<str:token>/decline/",
|
|
"api/public/calendar/<str:token>.ics",
|
|
"api/org/help-center/",
|
|
"api/portal/articles/",
|
|
"api/portal/articles/suggest/",
|
|
"api/portal/articles/<uid:pk>/",
|
|
"api/portal/cases/",
|
|
"api/portal/cases/<uid:pk>/",
|
|
"api/portal/cases/<uid:pk>/comment/",
|
|
"api/cases/inbound/<uid:mailbox_id>/",
|
|
}
|
|
)
|
|
|
|
|
|
def route_refusal(request, match) -> JsonResponse | None:
|
|
"""The 404 for a signed-in request (session, token or org key) to a route
|
|
whose module is off. The portal carries an org but no profile, so it and
|
|
anonymous callers pass here and are checked in their views."""
|
|
org = getattr(request, "org", None)
|
|
if org is None or getattr(request, "profile", None) is None:
|
|
return None
|
|
requires = requirement_for(match.route, match.namespaces)
|
|
key = requires.blocking(available_modules(org)) if requires else None
|
|
if key is None:
|
|
return None
|
|
return JsonResponse(module_off_body(org, key), status=404)
|