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...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 Module layout
- 2.2 Orphaned/dead code — confirmed, not speculative
- 2.3 Migration history
- 3. Data Model
- 3.1 Industry (ISIC Rev.5, primary classification)
- 3.2 CPCCode (CPC Ver. 3.0)
- 3.3 HSCode (HS 2022)
- 4. HTTP API
- 4.1 CRUD (/api/v1/industries/crud/industries/) — IndustryViewSet
- 4.2 Read-only (/api/v1/industries/read/…)
- 4.3 Serializers — base/read/write layering (same pattern as locations)
- 5. Admin & Forms
- 6. Security Notes
- 7. Integration Points
Registered as
"industries"inINSTALLED_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 fromIndustry - HS 2022 (WCO Harmonized System, for customs/trade) —
HSCode, optionally linked fromIndustry
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
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, definesIndustrySerializer) coexists withindustries/serializers/(a package). In Python, when both a module and a same-named package exist in one directory, the package's__init__.pyalways wins the import — confirmed here by direct import test.serializers.pyis unreachable dead code.views/viewsets.py,views/details.py,views/list_views.pydefineIndustryViewSet,IndustryDetailAPIView,IndustryListAPIView— the exact same class names as the ones actually wired intourls.pyviaviews/api/v1/crud/industry.py,.../read/detail/industry.py,.../read/list/industry.py. A diff between the old and newIndustryViewSetshows the new one is a near-identical, slightly cleaned-up copy (updated imports to the new serializer package paths, addedRequesttype hints, minorBaseSchemacall-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 byurls.pyor 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=filtersQ(code__icontains=search) | Q(description__trigram_similar=search)— note this isicontainson code (exact-ish substring) combined with fuzzy trigram matching on description, a slightly different mix thanlocations' all-trigram approach destroy()is overridden directly (not delegated to the mixin) to callinstance.soft_delete(user=request.user)then return204
4.2 Read-only (/api/v1/industries/read/…)
list/industries/—BaseListAPIView, same filter/search/ordering config as CRUD list, GET-onlydetail/industries/<int:pk>/— plainRetrieveAPIViewhistory/industries/—BaseHistoryListAPIViewbacked byIndustry.history.all()(works becauseIndustry, unlikeCPCCode/HSCode, hasHistoricalRecords())
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 withInvalidIndustryDatavalidate()(cross-field) — re-checkslevel/sectoragainst the model's own choice sets (defense-in-depth on top of the model'sclean())create()/update()— pullrequest.userfrom serializer context and callinstance.save(user=user)explicitly, rather than relying on DRF's default.save()— this is howcreated_by/updated_byactually get populated from API requests
IndustryReadSerializerV1 (v1/read) adds rich nested/derived fields beyond the base set:
parent— overridden as aSerializerMethodFieldreturning a small nested dict (id,code,description,level,sector) instead of just the parent's PKchildren— full list of active (non-deleted) child nodes, each as the same small nested dict shapechildren_count— count of active childrenlast_updated_timestamp— derived from the history table (obj.history.order_by("-history_date").first()), falling back toupdated_atif 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_layoutsgroups 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_thresholddiffers 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 inlocations.- All three forms declare
SELECT2_CONFIGfor AJAX-backed parent/cross-reference pickers (e.g.IndustryFormwiresparent→industries.industry,cpc_code→industries.cpccode,hs_code→industries.hscode), consistent with thelocationsapp's cascading-dropdown pattern.
6. Security Notes
- Same authentication/permission model as
locations:IsAuthenticatedeverywhere, dynamic Django model permissions (industries.add_industry,industries.view_industry, etc.) gating CRUD writes viaBaseViewSet.get_permissions(). - Delete is soft-only via the API (
204aftersoft_delete()); no hard-delete path exposed. CPCCode/HSCodehave 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 closedvalid_codeslist (same defensive pattern aslocations.InvalidLocationData) means a typo'd errorcode=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 (seecoreapp doc)audit.lifecycle.registry.AUDIT_TRACKED_MODELS—Industry,CPCCode,HSCodeall registered here inapps.py.ready(), so all three participate in the platform's centralized audit trail regardless of whether they individually havesimple_history(onlyIndustrydoes)django-simple-history— only onIndustry; used both for the/read/history/industries/endpoint and forIndustryReadSerializerV1.get_last_updated_timestamp()utilitiesapp —BaseViewSet,BaseListAPIView,BaseHistoryListAPIView,StandardResultsSetPagination,CustomThrottle,BaseAdminPage,BaseAdminForm(same shared infralocationsuses)apidocs— every wired view carries@extend_schema(tags=["Industries"]), so all ofIndustry's CRUD/list/detail/history endpoints appear in Swagger/ReDoc;CPCCode/HSCodehave no schema entries since they have no views- Downstream consumers —
Industry.cpc_code/.hs_codeare the intended cross-reference points for any future trade/customs feature; worth checking, when documentingentities/invoices, whether either of those apps already FKs intoIndustryfor business classification.