--- since: 1.2.1 --- # `entities` — Business / Organization Registry > Registered as `"entities"` in `INSTALLED_APPS` · `verbose_name = "Businesses"` (single model: `Entity`, `verbose_name = "Business"`) ## 1. Summary `entities` is the platform's model of **who you're doing business with or as** — companies, individuals, government bodies, nonprofits, partnerships — organized as a **self-referential parent/subsidiary tree** (via `django-mptt`). A single `Entity` model carries identity (name/slug/type/status), classification (`industries.Industry`), and hierarchy (`parent`/`children`), while everything financially-adjacent — addresses, contacts, tax profiles — lives in the `fincore` app and is linked back to `Entity` through a **generic string-keyed mapping table** (`FincoreEntityMapping`), not a direct FK. This is the app most other business-workflow apps (`invoices`, `teamcentral`, presumably `helpdesk`) are expected to reference when they need "which organization/individual is this record about." It is also the most India-specific app documented so far: `Entity.clean()` contains dedicated **GSTIN/PAN validation and GSTIN-state-cross-check logic** gated behind `entity_type` and jurisdiction, reflecting the platform's apparent primary compliance target. --- ## 2. Architecture ### 2.1 Module layout ``` entities/ ├── admin/entity.py # EntityAdmin — MPTT-aware indented tree display ├── constants.py # ENTITY_TYPE_CHOICES, ENTITY_STATUS_CHOICES ├── exceptions.py # EntityValidationError + 3 specialized subclasses (see §3.3) ├── forms/entity.py # EntityForm — Select2 parent/industry pickers, MPTT-aware ├── migrations/ # 4 migrations (see §2.3) ├── models/entity.py # Entity (MPTTModel + TimeStampedModel + AuditFieldsModel) ├── serializers/ │ ├── base/entity.py # BaseEntitySerializer — full validation + persistence logic │ └── v1/ │ ├── read/entity.py # + timestamps, audit-by fields, nested children │ ├── read/autocomplete.py # tiny id/label/value serializer (see §4.3 — currently unused by the view) │ └── write/entity.py # narrow writable field subset ├── signals.py # pre_save/post_save/post_delete on Entity (see §3.2 — interacts subtly with model save()) ├── tests/tests.py # effectively empty (2 lines) — no real test coverage yet ├── urls.py → views/api/v1/__init__.py (mounts crud/, read/, ui/) └── views/api/v1/{crud,read,ui}/… ``` ### 2.2 The hierarchy ``` Entity (MPTTModel, self-referential via `parent` → TreeForeignKey, on_delete=SET_NULL) └─ children (MPTT-managed: lft/rght/tree_id/level, excluded from simple_history tracking) Entity.industry ─FK→ industries.Industry (SET_NULL) Entity.default_address ─FK→ fincore.Address (SET_NULL) Entity ←(entity_type="entities.Entity", entity_id=str(pk))── fincore.FincoreEntityMapping ├─→ fincore.Address (1:N, via entity_mapping) ├─→ fincore.Contact (1:N, via entity_mapping) └─→ fincore.TaxProfile (1:N, via entity_mapping) ``` Unlike a direct FK, `FincoreEntityMapping` links by **string `entity_type` + `entity_id`** (a hand-rolled generic-relation pattern, not Django's built-in `ContentType` framework) — `Entity.get_entity_mapping()` looks up (or lazily creates) the mapping row keyed on the literal string `"entities.Entity"` and `str(self.id)`. This means `fincore`'s address/contact/tax-profile machinery is written to be entity-agnostic in principle (any app could plug into it the same way), even though today `entities.Entity` is the only known consumer — worth confirming when documenting `fincore` and any other apps that might use `FincoreEntityMapping`. ### 2.3 Migration history `0001_initial` → `0002_initial` → `0003_historicalentity` (added `simple_history` after initial creation, same pattern seen in `industries`) → `0004_alter_entity_options_alter_historicalentity_options` (Meta-only tweak, no schema change). Nothing as elaborate as `locations`' or `industries`' additive-fields evolution — a comparatively simple migration history. --- ## 3. Data Model — `Entity` Inherits **`MPTTModel`** (django-mptt — adds `lft`/`rght`/`tree_id`/`level` internally-managed fields for efficient tree queries) **+ `TimeStampedModel`** (soft-delete) **+ `AuditFieldsModel`** (created/updated/deleted-by). Has `HistoricalRecords(excluded_fields=["lft", "rght", "tree_id", "level"])` — the MPTT bookkeeping fields are deliberately excluded from the history log (they'd churn on every tree rebalance and add no real audit value). **Registered in `AUDIT_TRACKED_MODELS`** (`apps.py.ready()`) — so `Entity` changes flow into both the per-row `simple_history` table and the centralized `audit.AuditEvent` log. | Field | Type | Notes | |---|---|---| | `name` | `CharField(255)` | Required; normalized (diacritics stripped) on save | | `slug` | `SlugField(255)`, unique | See §3.2 — auto-generation path has a real inconsistency worth knowing | | `entity_type` | choices: `INDIVIDUAL`/`BUSINESS`/`GOVERNMENT`/`NONPROFIT`/`PARTNERSHIP`/`SOLE_PROPRIETORSHIP`/`OTHER`, default `BUSINESS` | | | `status` | choices: `PENDING`/`ACTIVE`/`INACTIVE`/`SUSPENDED`/`ON_HOLD`, default `ACTIVE` | | | `external_id` | `CharField(100)`, unique, nullable | For linking to external systems | | `website` | `URLField` | Auto-prefixed with `https://` if scheme missing | | `registration_number` | `CharField(50)` | e.g. CIN/GSTIN/EIN — free text, cross-checked against `TaxProfile` rows for India (see §3.4) | | `entity_size` | `CharField(50)` | Free text (e.g. "Small"/"Medium"/"Large") — no enum/choices | | `notes` | `TextField` | | | `default_address` | FK → `fincore.Address`, `SET_NULL` | Must belong to this entity's own mapping; for BUSINESS/GOVERNMENT/NONPROFIT/PARTNERSHIP it must specifically be an address of type `HEADQUARTERS` (enforced in `clean()`) | | `parent` | `TreeForeignKey` → self, `SET_NULL`, `related_name="children"` | Standard MPTT parent link | | `industry` | FK → `industries.Industry`, `SET_NULL` | **Required** (enforced in `clean()`) for BUSINESS/GOVERNMENT/NONPROFIT/PARTNERSHIP entity types; optional for INDIVIDUAL/SOLE_PROPRIETORSHIP/OTHER | **Constraints:** `(name, entity_type)` unique where active; DB-level `CHECK` constraints re-enforcing that `entity_type`/`status` are within their choice sets (defense-in-depth alongside Django's own choices validation). ### 3.1 Rich instance methods — `Entity` as an aggregate root Beyond CRUD, `Entity` exposes explicit relationship-management methods that all route through `get_entity_mapping()` and enforce that the related object actually belongs to this entity before mutating: - `get_addresses()` / `get_contacts()` / `get_tax_profiles()` — query `fincore.{Address,Contact,TaxProfile}` filtered by this entity's mapping - `add_address(address, user)` / `remove_address(address, user)` / `set_default_address(address, user)` — validate the address isn't already deleted, belongs to this entity's mapping (for remove/set-default), and isn't the current default (for remove) before acting; `remove_address` refuses to remove the current `default_address` (`EntityValidationError(code="remove_default_address")`) — you must reassign the default first - `add_contact(contact, user)` / `remove_contact(contact, user)` — same ownership-check pattern - `add_tax_profile(tax_profile, user)` / `remove_tax_profile(tax_profile, user)` — same pattern - `get_entity_mapping()` — get-or-create pattern keyed on `(entity_type="entities.Entity", entity_id=str(pk))`; **note it queries `.order_by("id").first()`** rather than relying on the model's `unique_together = ("entity_type", "entity_id")` constraint to guarantee at most one row — defensive, but also an implicit acknowledgment that if that uniqueness were ever violated, this method would silently pick the lowest-ID row rather than erroring - `entity_country` (property) / `get_country()` / `get_headquarter_location()` — read-only conveniences off `default_address` ### 3.2 Slug generation — a real inconsistency between code paths There are **three different places** slug generation logic lives, and they don't agree with each other: 1. **`entities/signals.py` `pre_save_entity`** (fires on *every* save, before the model's own `save()` body runs): `if not instance.slug: instance.slug = normalize_text(instance.name)` — uses `normalize_text()` (strips diacritics/whitespace only — **does not lowercase or replace spaces with hyphens**). 2. **`Entity.clean()`** and **`Entity.save()`** (model-level): both independently do `if not self.slug: self.slug = slugify(self.name)` — proper `django.utils.text.slugify()`. 3. **`BaseEntitySerializer.create()`/`.update()`** (API layer): explicitly sets `validated_data["slug"] = slugify(validated_data["name"])` *before* constructing/saving the instance — proper `slugify()`, and since `slug` is a `read_only_field` in the serializer `Meta`, this is the only place a slug value can originate from an API request. Because Django signal dispatch order means `pre_save` **always fires before** the model's own `save()` method body executes, the practical effect is: **whichever code path sets `instance.slug` first, on the specific save being performed, wins** — and only if `instance.slug` is still empty when the *later* checks run. - **Via the API** (`EntityViewSet.create`): the serializer sets `instance.slug = slugify(name)` **before** calling `instance.save(user=user)`. By the time `pre_save_entity` fires, `instance.slug` is already non-empty (properly slugified), so the signal's `normalize_text()` branch is skipped. ✅ Correct, lowercase, hyphenated slugs from the API. - **Via Django admin** (`EntityForm`/`EntityAdmin`): the form never sets `slug` explicitly (it's not in `EntityForm.Meta.fields`, and is listed as `readonly_fields = ("slug",)` in `EntityAdmin`). On create, `instance.slug` is empty when `pre_save_entity` fires — so the signal sets it via `normalize_text(name)`, i.e. **diacritics stripped but not lowercased, spaces not converted to hyphens**. Since this already makes `instance.slug` non-empty, the `slugify()` calls inside `clean()`/`save()` never execute (their `if not self.slug` guard is now `False`). - **Net effect: entities created via the admin end up with a `slug` value that is not actually URL-slug-shaped** (e.g. `"Acme Corp"` rather than `"acme-corp"`) — a real, DB-persisted divergence from entities created via the API, even though both use the same `SlugField(unique=True)`. Because `Entity.save()`/`clean()` call `self.clean()` directly rather than Django's `full_clean()`, the `SlugField`'s built-in format validator is never invoked, so this passes silently rather than raising. ### 3.3 Exception hierarchy `EntityValidationError` (multiple-inheritance from a custom `EntityBaseException` **and** Django's `ValidationError`, closed `valid_codes` list, same `to_dict()`/`__str__()` shape as `locations`/`industries`) is the base; three specialized subclasses layer app-specific defaults on top: - `InactiveEntityError` (`default_code="inactive_entity"`) — raised in `clean()` if `self.deleted_at` is set (i.e. re-validating an already-soft-deleted instance) - `IndianTaxComplianceError` (`default_code="indian_tax_compliance"`) — GSTIN/PAN failures (§3.4) - `InvalidEntityMappingError` (`default_code="invalid_entity_mapping"`) — defined but **not raised anywhere in `models/entity.py`** as written; likely intended for `fincore`-side mapping validation, worth checking when that app is documented. ### 3.4 Indian tax compliance validation Inside `clean()`, if `registration_number` is set and `entity_type` is `BUSINESS`/`PARTNERSHIP`, the model queries this entity's `TaxProfile`s filtered to `tax_identifier_type in ("GSTIN", "PAN")` and, for each: - **GSTIN**: calls `validate_gstin()` (from `utilities.utils.entities.entity_validations`); if valid, additionally cross-checks the GSTIN's 2-digit state code prefix against `self.default_address.city.subregion.region.code` (recall from the `locations` doc: `CustomRegion.code` is the GeoNames admin1 code) — a mismatch raises `IndianTaxComplianceError(code="gstin_state_mismatch")`. A `ValidationError` from `validate_gstin()` itself is re-raised as `IndianTaxComplianceError(code="invalid_gstin")` — note the code name says "missing" even though the actual failure here is "invalid format," which is a slightly misleading error code choice worth being aware of when consuming this API's error responses. - **PAN**: calls `is_valid_indian_pan()`; on failure, raises `IndianTaxComplianceError(code="missing_pan")` — same naming quirk (the code says "missing" for what is actually an "invalid format" case). This validation only runs for entities that **already have** a `TaxProfile` with a GSTIN/PAN attached — it doesn't require one to exist; it only validates the ones that do. ### 3.5 Soft-delete cascades to related resources — a genuinely thorough implementation `Entity.soft_delete(user=None)` doesn't just flip its own flags — it **cascades** to every associated address, contact, and tax profile (via `get_addresses()`/`get_contacts()`/`get_tax_profiles()`, each individually soft-deleted with the same `user`), all inside one `transaction.atomic()` block. `restore(user=None)` mirrors this: it restores the entity itself first, then restores every address/contact/tax-profile that has a `deleted_at` set. This is more thorough than any cascade logic seen in `locations`/`industries` so far — those apps' models don't own child resources across another app the way `Entity` owns `fincore` records. A class-level `_is_soft_deleting` flag + `_soft_delete_context()` context manager exists specifically to **suppress `self.clean()` from running during the soft-delete's internal `super().save()` calls** (`Entity.save()` checks `if not Entity._is_soft_deleting: self.clean()`) — since soft-deleting intentionally sets `deleted_at`, and `clean()` itself raises `InactiveEntityError` whenever `deleted_at` is set, calling `clean()` during the soft-delete operation would immediately raise on itself. This is a deliberate, if slightly unusual (class-level mutable flag rather than an instance-level or kwarg-based bypass), solution to that self-referential validation problem. --- ## 4. HTTP API Mounted at `path("api/v1/entities/", include("entities.urls"))`. ### 4.1 CRUD (`/api/v1/entities/crud/…`) — `EntityViewSet` Extends the shared `BaseViewSet` (same infra as `locations`/`industries`): JWT auth, `IsAuthenticated`, dynamic Django model permissions, `CustomThrottle`, cached list/retrieve, soft-delete-on-destroy. Entity-specific: - `queryset` is scoped to `deleted_at__isnull=True, is_active=True` (note: **both** conditions explicitly, unlike `locations`/`industries` viewsets which only filter `deleted_at__isnull=True` and rely on the custom `ActiveManager` — functionally similar outcome, just written more defensively here) - `filterset_fields = ["entity_type", "status", "industry", "default_address", "parent"]`, `search_fields = ["name", "slug", "registration_number", "website"]` - Manual trigram search override across **four** fields (`name`, `slug`, `registration_number`, `website`) — broader than `locations`/`industries`' search overrides - `get_queryset()` adds `select_related("industry", "default_address", "parent", "created_by", "updated_by")` + `prefetch_related("children")` — a genuine N+1 optimization not seen as explicitly in the other two apps' CRUD viewsets, presumably because `EntityReadSerializerV1` always renders nested `children` - `error_class = EntityValidationError` ### 4.2 Read-only (`/api/v1/entities/read/…`) - **`list/entities/`** — `BaseListAPIView`, narrower search (`name`, `slug` only) than the CRUD list - **`detail/entities//`** — plain `RetrieveAPIView`, same `EntityReadSerializerV1` - **`history/entities/`** — `BaseHistoryListAPIView` off `Entity.history.all()` (works since `Entity` has `simple_history`, unlike `industries.CPCCode`/`HSCode`) ### 4.3 UI (`/api/v1/entities/ui/autocomplete/entities/`) — `EntityAutocompleteAPIView` Nominally extends `BaseAutocompleteView(dal.autocomplete.Select2QuerySetView)` — the same base class pattern used in `locations`/`industries` for cascading Select2 widgets — **but overrides `get()` entirely** with a hand-rolled implementation instead of relying on `Select2QuerySetView`'s own request-handling machinery: ``` GET /api/v1/entities/ui/autocomplete/entities/?q=acme → [{"id": 1, "label": "Acme Corp", "value": "Acme Corp"}, ...] (max 10 results, ordered by slug) ``` Filtering is `Q(name__icontains=query) | Q(slug__icontains=query)` — plain substring matching, **not** the trigram fuzzy-search pattern `locations`/`industries` autocomplete views use. There's also an unused, fully-defined `EntityAutocompleteSerializer` (`id`/`label`/`value` fields) in `serializers/v1/read/autocomplete.py` that this view doesn't actually use — the response dicts are built by hand instead of via `.data` from that serializer, so the serializer is presently dead code (though it correctly documents the shape the view happens to produce, which is a nice coincidence rather than a wired connection — worth using it explicitly, or removing it, for consistency). --- ## 5. Admin & Forms `EntityAdmin` (`BaseAdminPage` subclass) is genuinely MPTT-tree-aware, not just a flat model admin: - **Indented tree display**: the `company` list-display column reads `obj.level` (MPTT's depth attribute) and renders ` ` indentation × depth with a `↳` prefix for non-root rows (`mark_safe`'d HTML) — so the changelist visually reflects the parent/subsidiary structure. - **`subsidiaries`** column shows `obj.get_children().count()` (direct children count, an MPTT queryset method). - **`slug` is admin-readonly** (`readonly_fields = ("slug",)`) — consistent with slug being intended as a derived, not directly-editable, field (see §3.2 for why the derivation still has an inconsistency). - `auto_load_threshold = 500` — same large-table-protection pattern as `locations`/`industries`. `EntityForm` layers additional MPTT-specific UX on top of `BaseAdminForm`: - **Self-parenting guard**: `clean()` explicitly rejects `parent.pk == self.instance.pk` (an entity can't be its own parent) — a check MPTT itself doesn't automatically prevent at the form layer. - **Disables the parent field for standalone root companies**: if an entity being edited has neither a parent nor any children, the `parent` field is disabled with a "No Parent Company" placeholder — presumably a UX choice to discourage attaching hierarchy to entities that were deliberately created as standalone, though the field remains editable (via direct API/ORM) for any entity that already participates in a hierarchy either way. - Preloads Select2 `data-initial-value`/`data-initial-text` attributes for both `parent` and `industry` on edit, so the AJAX-backed widgets can render the current selection without an extra round-trip. --- ## 6. Security & Data-Integrity Notes - Same authentication/permission baseline as `locations`/`industries`: `IsAuthenticated` + dynamic Django model permissions on all CRUD actions; autocomplete requires `IsAuthenticated`. - Delete is soft-only via the API, and — uniquely among the apps documented so far — **genuinely cascades** to dependent `fincore` records (addresses/contacts/tax profiles), not just to the `Entity` row itself (§3.5). - The `default_address` HEADQUARTERS-type constraint and the industry-required-for-organizations constraint are both enforced at `clean()` time (model layer) **and independently re-checked** in `BaseEntitySerializer.validate()` (industry check only — the HEADQUARTERS/default-address check is **not** duplicated at the serializer layer, only in the model's `clean()`) — meaning a request that bypasses `full_clean()`-style validation but still calls `instance.save()` would still be protected by the model's own `clean()` call inside `save()`, but an admin form or other code path calling `.save()` with `skip_validation`-style bypasses (as seen in `locations`) is **not available here** — `Entity.save()` has no `skip_validation` kwarg, only the internal `_is_soft_deleting` bypass, so there's no accidental way to skip validation from calling code the way `locations` models allow. - The GSTIN/PAN compliance checks are opt-in in the sense that they only fire when a matching `TaxProfile` already exists — there's no enforcement that a BUSINESS/PARTNERSHIP entity operating in India *must* have a GSTIN/PAN on file; it's validated-if-present, not required-if-applicable. - `tests/tests.py` is effectively empty — this app currently has **no automated test coverage**, despite being one of the more behaviorally complex apps documented so far (MPTT tree rules, cross-app cascading soft-delete, India-specific tax validation). Worth flagging as a priority gap if reliability here matters. --- ## 7. Integration Points - **`core.models.TimeStampedModel` / `AuditFieldsModel`** — soft-delete + created/updated/deleted-by (see `core` app doc) - **`django-mptt`** — self-referential tree (`parent`/`children`, `lft`/`rght`/`tree_id`/`level` internal bookkeeping, excluded from history) - **`django-simple-history`** + **`audit.lifecycle.registry.AUDIT_TRACKED_MODELS`** — both mechanisms active for `Entity` (unlike `industries.CPCCode`/`HSCode`, which have the latter but not the former) - **`industries.Industry`** — required classification FK for organizational entity types - **`fincore`** (forward reference — to be documented in detail in that app's own pass) — `Address`, `Contact`, `TaxProfile`, and the generic `FincoreEntityMapping` linking table; `Entity` is the primary, and so far only known, consumer of this generic mapping pattern - **`utilities.utils.entities.entity_validations`** — `validate_gstin()`, `is_valid_indian_pan()` — India-specific validators shared from the `utilities` app - **`django-autocomplete-light` (`dal`)** — nominally used for the autocomplete view's base class, though the view overrides its actual request-handling (§4.3) - **`apidocs`** — CRUD/read views tagged `"Business"`; the UI autocomplete view is tagged `"Entities"` — a small tag-naming inconsistency (two different Swagger/ReDoc tag groups for the same underlying model) worth reconciling if/when the API docs are cleaned up. --- ## 8. Known Gaps / Notes for Future Work - **No automated tests** (`tests/tests.py` is a 2-line stub) for an app with real behavioral complexity (MPTT rules, cross-app cascading delete/restore, India tax compliance) — the highest-risk gap in this app relative to what's been reviewed so far. ---