--- since: 1.2.1 --- # `industries` — Industry / Trade Classification Reference Data > Registered as `"industries"` in `INSTALLED_APPS` · `verbose_name = "Industries"` ## 1. Summary `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 or transaction with a recognized industry/commodity code instead of free text. It implements three independent, self-referential tree structures from real international standards: - **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 ``` 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//`** — 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. ---