djangoplay-web / Apps / entities — Business / Organization Registry
DocsDjangoPlay WebAppsentities — Business / Organization Registry

entities — Business / Organization Registry

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

14 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture
  3. 2.1 Module layout
  4. 2.2 The hierarchy
  5. 2.3 Migration history
  6. 3. Data Model — Entity
  7. 3.1 Rich instance methods — Entity as an aggregate root
  8. 3.2 Slug generation — a real inconsistency between code paths
  9. 3.3 Exception hierarchy
  10. 3.4 Indian tax compliance validation
  11. 3.5 Soft-delete cascades to related resources — a genuinely thorough implementation
  12. 4. HTTP API
  13. 4.1 CRUD (/api/v1/entities/crud/…) — EntityViewSet
  14. 4.2 Read-only (/api/v1/entities/read/…)
  15. 4.3 UI (/api/v1/entities/ui/autocomplete/entities/) — EntityAutocompleteAPIView
  16. 5. Admin & Forms
  17. 6. Security & Data-Integrity Notes
  18. 7. Integration Points
  19. 8. Known Gaps / Notes for Future Work

Registered as "entities" in INSTALLED_APPS · verbose_name = "Businesses" (single model: Entity, verbose_name = "Business")

1. Summary

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

plaintext
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

plaintext
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 TaxProfiles 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.

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/<int:pk>/ — 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:

plaintext
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 &nbsp; 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.