djangoplay-web / Apps / industries — Industry / Trade Classification Reference Data
DocsDjangoPlay WebAppsindustries — Industry / Trade Classification Reference Data

industries — Industry / Trade Classification Reference Data

industries is a second reference-data app (alongside locations) — it models standardized economic/trade classification hierarchies so other domain apps (entities, invoices, etc.) can tag a business...

10 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture
  3. 2.1 Module layout
  4. 2.2 Orphaned/dead code — confirmed, not speculative
  5. 2.3 Migration history
  6. 3. Data Model
  7. 3.1 Industry (ISIC Rev.5, primary classification)
  8. 3.2 CPCCode (CPC Ver. 3.0)
  9. 3.3 HSCode (HS 2022)
  10. 4. HTTP API
  11. 4.1 CRUD (/api/v1/industries/crud/industries/) — IndustryViewSet
  12. 4.2 Read-only (/api/v1/industries/read/…)
  13. 4.3 Serializers — base/read/write layering (same pattern as locations)
  14. 5. Admin & Forms
  15. 6. Security Notes
  16. 7. Integration Points

Registered as "industries" in INSTALLED_APPS · verbose_name = "Industries"

1. Summary

  • ISIC Rev.5 (UN International Standard Industrial Classification) — the primary model, Industry
  • CPC 3.0 (UN Central Product Classification) — CPCCode, optionally linked from Industry
  • HS 2022 (WCO Harmonized System, for customs/trade) — HSCode, optionally linked from Industry

Only Industry has a public API (CRUD + list/detail/history); CPCCode and HSCode exist purely as admin-managed reference tables linkable from Industry records — they have no DRF endpoints of their own.


2. Architecture

2.1 Module layout

plaintext
industries/
├── admin/
│   ├── industry.py                # IndustryAdmin
│   └── classification.py          # CPCCodeAdmin, HSCodeAdmin
├── constants/industry.py          # TextChoices enums + SECTION_TO_SECTOR map (see §3.2)
├── exceptions.py                  # InvalidIndustryData (closed error-code list, same pattern as locations)
├── forms/                         # IndustryForm, CPCCodeForm, HSCodeForm (Select2 AJAX widgets)
├── migrations/                    # 6 migrations — see §2.3, includes a genuinely additive schema evolution
├── models/
│   ├── industry.py                # Industry (ISIC Rev.5, self-referential)
│   └── classification.py          # CPCCode, HSCode (both self-referential)
├── serializers/                   # base → v1/read, v1/write (see §4.3) — CURRENT, wired
├── serializers.py                 # ⚠️ ORPHANED flat file, shadowed by the package above (see §2.2)
├── views/
│   ├── api/v1/crud|read/…         # CURRENT, wired versioned views (see §4)
│   ├── viewsets.py                # ⚠️ ORPHANED near-duplicate of crud/industry.py
│   ├── details.py                 # ⚠️ ORPHANED near-duplicate of read/detail/industry.py
│   └── list_views.py              # ⚠️ ORPHANED near-duplicate of read/list/industry.py
└── urls.py → views/api/v1/__init__.py (mounts crud/, read/ only — no ui/ or ops/ namespace, unlike locations)

2.2 Orphaned/dead code — confirmed, not speculative

This app has a complete parallel set of legacy flat files left over from before it was restructured into the versioned views/api/v1/{crud,read}/ layout used today. This was verified, not assumed:

  • industries/serializers.py (flat module, defines IndustrySerializer) coexists with industries/serializers/ (a package). In Python, when both a module and a same-named package exist in one directory, the package's __init__.py always wins the import — confirmed here by direct import test. serializers.py is unreachable dead code.
  • views/viewsets.py, views/details.py, views/list_views.py define IndustryViewSet, IndustryDetailAPIView, IndustryListAPIView — the exact same class names as the ones actually wired into urls.py via views/api/v1/crud/industry.py, .../read/detail/industry.py, .../read/list/industry.py. A diff between the old and new IndustryViewSet shows the new one is a near-identical, slightly cleaned-up copy (updated imports to the new serializer package paths, added Request type hints, minor BaseSchema call-signature changes) — i.e. the versioned files superseded the flat ones without the flat ones being deleted. None of the three flat files are imported by urls.py or anything else (confirmed by grep) — they are inert and safe to delete.

2.3 Migration history

0001_initial → 0002_initial → 0003_historicalindustry (added simple_history tracking to Industry after the fact) → 0004_cpccode_hscode_and_more (added the entire CPC/HS model set, 2026-05-29) → 0005/0006 (backfilled created_by/deleted_by/etc. audit FKs onto CPCCode/HSCode after their initial creation, mirroring the AuditFieldsModel pattern). The in-code comments in models/industry.py and models/classification.py explicitly document this as an additive, non-breaking upgrade path: classification_scheme defaults to ISIC5 but is nullable so pre-migration rows read as legacy ISIC4; cpc_code/hs_code are nullable FKs so existing Industry rows are unaffected; IndustryLevel.SUBCLASS is a new choice no old row uses.


3. Data Model

3.1 Industry (ISIC Rev.5, primary classification)

Inherits TimeStampedModel + AuditFieldsModel; has HistoricalRecords(). Registered in AUDIT_TRACKED_MODELS (via apps.py.ready(), alongside CPCCode/HSCode) — so, unlike locations, changes here do flow into the platform's centralized audit.AuditEvent log in addition to the per-model simple_history table.

Field Type Notes
code CharField(10), unique Length/format is level-dependent (see below)
description TextField Required, non-blank enforced in clean()
level choices: SECTION/DIVISION/GROUP/CLASS/SUBCLASS SUBCLASS is new in this release (ISIC Rev.5)
sector choices: PRIMARY/SECONDARY/TERTIARY Cross-checked against level=SECTION via SECTION_TO_SECTOR
parent FK → self, CASCADE, related_name="children" Nullable; level-chain enforced (see below)
classification_scheme choices: ISIC5/ISIC4/CPC3/HS22, default ISIC5 Nullable — NULL means "legacy pre-migration row, treat as ISIC4"
cpc_code FK → CPCCode, SET_NULL, nullable Optional cross-reference into the CPC 3.0 tree
hs_code FK → HSCode, SET_NULL, nullable Optional cross-reference into the HS 2022 tree, trade/customs contexts only

Code format by level (enforced in clean()): SECTION = 1 letter (e.g. "A"), DIVISION = 2 digits, GROUP = 3 digits, CLASS = 4 digits, SUBCLASS = 5 digits (new). Parent-chain enforcement: a DIVISION's parent must be a SECTION, a GROUP's parent must be a DIVISION, a CLASS's parent must be a GROUP, a SUBCLASS's parent must be a CLASS; a SECTION must have no parent. Parent is technically nullable at the DB level even for non-SECTION rows — the model comment is explicit that this is intentionally left to the serializer/form layer to require, not the DB constraint.

Sector auto-mapping: SECTION_TO_SECTOR (in constants/industry.py) maps all 21 ISIC sections (A–U) to Primary/Secondary/Tertiary — e.g. A/B (agriculture, mining) → Primary; C/D/E/F (manufacturing, utilities, construction) → Secondary; G–U (all services) → Tertiary. clean() cross-checks that a SECTION-level row's declared sector matches this table, but only warns via a ValidationError if there's a mismatch — it does not silently correct it. A _derive_sector() helper exists (inherit sector from parent, or look up via SECTION_TO_SECTOR for sections) but is defined and never called anywhere in save()/clean() — dead code, not wired into the save path; sector is not actually auto-derived today despite the helper's presence.

Soft-delete is inherited, not overridden: Industry.soft_delete()/.restore() are fully commented out in the source (~40 lines of dead code left in place) — the model falls back to the plain TimeStampedModel.soft_delete(user=None, reason=None) behavior. The CRUD viewset's destroy() still calls instance.soft_delete(user=request.user) directly (bypassing the base SoftDeleteMixin) and works correctly against the inherited method — just without any industries-specific logging/validation the commented-out override would have added.

3.2 CPCCode (CPC Ver. 3.0)

Inherits TimeStampedModel + AuditFieldsModel. No HistoricalRecords() — unlike Industry, this model has no per-row simple_history shadow table, even though it is in AUDIT_TRACKED_MODELS. That means its change history lives only in the centralized audit.AuditEvent log, not in a queryable CPCCode.history manager (so there is no possible /read/history/cpc-codes/ endpoint to build the way locations/Industry have one).

Field Notes
code Unique, length = level (1–5 digits: Section→Sub-class)
title Free text
level SECTION/DIVISION/GROUP/CLASS/SUBCLASS (CPCLevel)
parent FK → self, CASCADE; Sections must have no parent (enforced in clean())

save() always calls self.full_clean() before super().save() — validation is not skippable via a skip_validation kwarg the way locations models allow.

3.3 HSCode (HS 2022)

Same base classes, same no-HistoricalRecords() situation as CPCCode.

Field Notes
code Numeric, blank-allowed (Section-level nodes use section_label instead), length = level (Chapter=2, Heading=4, Subheading=6)
section_label Roman-numeral label (e.g. "I", "XXI"), only for SECTION level
title Free text commodity description
level SECTION/CHAPTER/HEADING/SUBHEAD (HSLevel)
parent FK → self, CASCADE
standard_unit e.g. "kg", from UN Comtrade standard units

The model docstring is explicit that this table is "entirely inert until you populate it" — it exists to support future trade/customs/import-export features and isn't required for the core platform to function.


4. HTTP API

Mounted at path("api/v1/industries/", include("industries.urls")). Only Industry is exposed via API — CPCCode/HSCode are admin-only (see §5).

4.1 CRUD (/api/v1/industries/crud/industries/) — IndustryViewSet

Extends the shared BaseViewSet (same infra as locations — JWT auth, IsAuthenticated, dynamic Django model permissions, CustomThrottle, DjangoFilterBackend/OrderingFilter/SearchFilter, response caching, soft-delete-on-destroy, error_class = InvalidIndustryData). Industry-specific config:

  • filterset_fields = ["code", "level", "sector", "parent"], ordering_fields = ["id", "code", "level"], search_fields = ["code", "description"]
  • Manual list() search override: ?search= filters Q(code__icontains=search) | Q(description__trigram_similar=search) — note this is icontains on code (exact-ish substring) combined with fuzzy trigram matching on description, a slightly different mix than locations' all-trigram approach
  • destroy() is overridden directly (not delegated to the mixin) to call instance.soft_delete(user=request.user) then return 204

4.2 Read-only (/api/v1/industries/read/…)

  • list/industries/ — BaseListAPIView, same filter/search/ordering config as CRUD list, GET-only
  • detail/industries/<int:pk>/ — plain RetrieveAPIView
  • history/industries/ — BaseHistoryListAPIView backed by Industry.history.all() (works because Industry, unlike CPCCode/HSCode, has HistoricalRecords())

4.3 Serializers — base/read/write layering (same pattern as locations)

BaseIndustrySerializer (in serializers/base/industry.py) is the canonical, version-agnostic definition and — notably, more than locations' base serializers — carries real validation and persistence logic, not just a field list:

  • validate_code/validate_description — reject blank/whitespace-only values with InvalidIndustryData
  • validate() (cross-field) — re-checks level/sector against the model's own choice sets (defense-in-depth on top of the model's clean())
  • create()/update() — pull request.user from serializer context and call instance.save(user=user) explicitly, rather than relying on DRF's default .save() — this is how created_by/updated_by actually get populated from API requests

IndustryReadSerializerV1 (v1/read) adds rich nested/derived fields beyond the base set:

  • parent — overridden as a SerializerMethodField returning a small nested dict (id, code, description, level, sector) instead of just the parent's PK
  • children — full list of active (non-deleted) child nodes, each as the same small nested dict shape
  • children_count — count of active children
  • last_updated_timestamp — derived from the history table (obj.history.order_by("-history_date").first()), falling back to updated_at if no history row exists yet

IndustryWriteSerializerV1 (v1/write) narrows to code/description/level/sector/parent only — classification_scheme/cpc_code/hs_code are not writable via the public API today, only via Django admin (see §5). This is worth knowing if API consumers ever need to set those fields — the write serializer would need extending.


5. Admin & Forms

IndustryAdmin, CPCCodeAdmin, HSCodeAdmin (all BaseAdminPage subclasses, shared infra from utilities.admin) are the only way to manage classification_scheme, cpc_code, hs_code on Industry, and the only way to manage CPCCode/HSCode at all, since those two models have no API views.

  • IndustryAdmin.form_layouts groups fields into Classification (level/sector/parent, classification_scheme), Basic (code/description), Cross-References (cpc_code, hs_code), and System (is_active) — a four-tab structure reflecting the model's additive-fields history.
  • auto_load_threshold differs per model: Industry=1000, CPCCode=200 (set twice in the source — once to 1000, then immediately overwritten to 200 two lines later; the second assignment wins, so the effective threshold is 200 — a harmless but obviously copy-pasted redundant line), HSCode=1000. This governs the same "don't auto-render a huge unfiltered changelist" protection seen in locations.
  • All three forms declare SELECT2_CONFIG for AJAX-backed parent/cross-reference pickers (e.g. IndustryForm wires parent→industries.industry, cpc_code→industries.cpccode, hs_code→industries.hscode), consistent with the locations app's cascading-dropdown pattern.

6. Security Notes

  • Same authentication/permission model as locations: IsAuthenticated everywhere, dynamic Django model permissions (industries.add_industry, industries.view_industry, etc.) gating CRUD writes via BaseViewSet.get_permissions().
  • Delete is soft-only via the API (204 after soft_delete()); no hard-delete path exposed.
  • CPCCode/HSCode have zero API attack surface (no views at all) — any exposure risk there is scoped entirely to Django admin, which already requires staff/superuser login and model-level permissions independent of this app.
  • InvalidIndustryData's closed valid_codes list (same defensive pattern as locations.InvalidLocationData) means a typo'd error code= argument anywhere in this app's exception-raising code fails loudly (ValueError) at raise-time rather than silently producing an uncoded error — a deliberate fail-fast guard against silent miscoding of error codes.

7. Integration Points

  • core.models.TimeStampedModel / AuditFieldsModel — soft-delete + created/updated/deleted-by (see core app doc)
  • audit.lifecycle.registry.AUDIT_TRACKED_MODELS — Industry, CPCCode, HSCode all registered here in apps.py.ready(), so all three participate in the platform's centralized audit trail regardless of whether they individually have simple_history (only Industry does)
  • django-simple-history — only on Industry; used both for the /read/history/industries/ endpoint and for IndustryReadSerializerV1.get_last_updated_timestamp()
  • utilities app — BaseViewSet, BaseListAPIView, BaseHistoryListAPIView, StandardResultsSetPagination, CustomThrottle, BaseAdminPage, BaseAdminForm (same shared infra locations uses)
  • apidocs — every wired view carries @extend_schema(tags=["Industries"]), so all of Industry's CRUD/list/detail/history endpoints appear in Swagger/ReDoc; CPCCode/HSCode have no schema entries since they have no views
  • Downstream consumers — Industry.cpc_code/.hs_code are the intended cross-reference points for any future trade/customs feature; worth checking, when documenting entities/invoices, whether either of those apps already FKs into Industry for business classification.