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...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 Module layout
- 2.2 The hierarchy
- 2.3 Migration history
- 3. Data Model — Entity
- 3.1 Rich instance methods — Entity as an aggregate root
- 3.2 Slug generation — a real inconsistency between code paths
- 3.3 Exception hierarchy
- 3.4 Indian tax compliance validation
- 3.5 Soft-delete cascades to related resources — a genuinely thorough implementation
- 4. HTTP API
- 4.1 CRUD (/api/v1/entities/crud/…) — EntityViewSet
- 4.2 Read-only (/api/v1/entities/read/…)
- 4.3 UI (/api/v1/entities/ui/autocomplete/entities/) — EntityAutocompleteAPIView
- 5. Admin & Forms
- 6. Security & Data-Integrity Notes
- 7. Integration Points
- 8. Known Gaps / Notes for Future Work
Registered as
"entities"inINSTALLED_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
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()— queryfincore.{Address,Contact,TaxProfile}filtered by this entity's mappingadd_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_addressrefuses to remove the currentdefault_address(EntityValidationError(code="remove_default_address")) — you must reassign the default firstadd_contact(contact, user)/remove_contact(contact, user)— same ownership-check patternadd_tax_profile(tax_profile, user)/remove_tax_profile(tax_profile, user)— same patternget_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'sunique_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 erroringentity_country(property) /get_country()/get_headquarter_location()— read-only conveniences offdefault_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:
entities/signals.pypre_save_entity(fires on every save, before the model's ownsave()body runs):if not instance.slug: instance.slug = normalize_text(instance.name)— usesnormalize_text()(strips diacritics/whitespace only — does not lowercase or replace spaces with hyphens).Entity.clean()andEntity.save()(model-level): both independently doif not self.slug: self.slug = slugify(self.name)— properdjango.utils.text.slugify().BaseEntitySerializer.create()/.update()(API layer): explicitly setsvalidated_data["slug"] = slugify(validated_data["name"])before constructing/saving the instance — properslugify(), and sinceslugis aread_only_fieldin the serializerMeta, 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 setsinstance.slug = slugify(name)before callinginstance.save(user=user). By the timepre_save_entityfires,instance.slugis already non-empty (properly slugified), so the signal'snormalize_text()branch is skipped. ✅ Correct, lowercase, hyphenated slugs from the API. - Via Django admin (
EntityForm/EntityAdmin): the form never setsslugexplicitly (it's not inEntityForm.Meta.fields, and is listed asreadonly_fields = ("slug",)inEntityAdmin). On create,instance.slugis empty whenpre_save_entityfires — so the signal sets it vianormalize_text(name), i.e. diacritics stripped but not lowercased, spaces not converted to hyphens. Since this already makesinstance.slugnon-empty, theslugify()calls insideclean()/save()never execute (theirif not self.slugguard is nowFalse). - Net effect: entities created via the admin end up with a
slugvalue 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 sameSlugField(unique=True). BecauseEntity.save()/clean()callself.clean()directly rather than Django'sfull_clean(), theSlugField'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 inclean()ifself.deleted_atis 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 inmodels/entity.pyas written; likely intended forfincore-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()(fromutilities.utils.entities.entity_validations); if valid, additionally cross-checks the GSTIN's 2-digit state code prefix againstself.default_address.city.subregion.region.code(recall from thelocationsdoc:CustomRegion.codeis the GeoNames admin1 code) — a mismatch raisesIndianTaxComplianceError(code="gstin_state_mismatch"). AValidationErrorfromvalidate_gstin()itself is re-raised asIndianTaxComplianceError(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, raisesIndianTaxComplianceError(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:
querysetis scoped todeleted_at__isnull=True, is_active=True(note: both conditions explicitly, unlikelocations/industriesviewsets which only filterdeleted_at__isnull=Trueand rely on the customActiveManager— 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 thanlocations/industries' search overrides get_queryset()addsselect_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 becauseEntityReadSerializerV1always renders nestedchildrenerror_class = EntityValidationError
4.2 Read-only (/api/v1/entities/read/…)
list/entities/—BaseListAPIView, narrower search (name,slugonly) than the CRUD listdetail/entities/<int:pk>/— plainRetrieveAPIView, sameEntityReadSerializerV1history/entities/—BaseHistoryListAPIViewoffEntity.history.all()(works sinceEntityhassimple_history, unlikeindustries.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
companylist-display column readsobj.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. subsidiariescolumn showsobj.get_children().count()(direct children count, an MPTT queryset method).slugis 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 aslocations/industries.
EntityForm layers additional MPTT-specific UX on top of BaseAdminForm:
- Self-parenting guard:
clean()explicitly rejectsparent.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
parentfield 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-textattributes for bothparentandindustryon 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 requiresIsAuthenticated. - Delete is soft-only via the API, and — uniquely among the apps documented so far — genuinely cascades to dependent
fincorerecords (addresses/contacts/tax profiles), not just to theEntityrow itself (§3.5). - The
default_addressHEADQUARTERS-type constraint and the industry-required-for-organizations constraint are both enforced atclean()time (model layer) and independently re-checked inBaseEntitySerializer.validate()(industry check only — the HEADQUARTERS/default-address check is not duplicated at the serializer layer, only in the model'sclean()) — meaning a request that bypassesfull_clean()-style validation but still callsinstance.save()would still be protected by the model's ownclean()call insidesave(), but an admin form or other code path calling.save()withskip_validation-style bypasses (as seen inlocations) is not available here —Entity.save()has noskip_validationkwarg, only the internal_is_soft_deletingbypass, so there's no accidental way to skip validation from calling code the waylocationsmodels allow. - The GSTIN/PAN compliance checks are opt-in in the sense that they only fire when a matching
TaxProfilealready 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.pyis 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 (seecoreapp doc)django-mptt— self-referential tree (parent/children,lft/rght/tree_id/levelinternal bookkeeping, excluded from history)django-simple-history+audit.lifecycle.registry.AUDIT_TRACKED_MODELS— both mechanisms active forEntity(unlikeindustries.CPCCode/HSCode, which have the latter but not the former)industries.Industry— required classification FK for organizational entity typesfincore(forward reference — to be documented in detail in that app's own pass) —Address,Contact,TaxProfile, and the genericFincoreEntityMappinglinking table;Entityis the primary, and so far only known, consumer of this generic mapping patternutilities.utils.entities.entity_validations—validate_gstin(),is_valid_indian_pan()— India-specific validators shared from theutilitiesappdjango-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.pyis 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.