# SME-Hub & Learning Migration Plan — EHC · Moodle · UltimatePOS · osTicket → IILMP

**Status:** Draft for sign-off · **Date:** 2026-06-09
**Source dumps:** `/Users/rtv-lpt-403/Downloads/databases smehub/`
**Decision taken (project lead):** migrate legacy data **into the live platform models** (not read-only archives) — extend IILMP tables so legacy data is first-class and usable. Import subscribers **and** external calls.

> Plan-first, mirroring `docs/repository-drupal-migration-plan.md`. This is for review **before** importers are built.
> Idempotency anchor on every target model: **`legacy_*_id`** (re-running updates, never duplicates) — same pattern as `Document.legacy_drupal_nid`.

---

## 0. The four dumps at a glance (verified against the data)

| Dump | Engine | Target app | Verdict | Action |
|---|---|---|---|---|
| `smehub_ehc.sql` (31 MB) | MariaDB / **Laravel** | `apps/smehub` | **Rich & real** | **Full migration** (core) |
| `db_moodle_db_*.sql` (148 MB) + `moodledata` (1.1 GB) | MariaDB / **Moodle 4.x** | `apps/rep` | **Rich & real** | **Full migration** (2nd workstream) |
| `smehub_accounting.sql` (1.1 MB) | MariaDB / **UltimatePOS** | `apps/smehub/marketplace` | **Test/seed only** | **Minimal** — keep tenant link, token sales import |
| `smehub_support.sql` (1 GB) | MariaDB / **osTicket** | (none) | **Empty demo install** | **Skip** — nothing to migrate |

**Why accounting is minimal:** 125 tenants but only **7 real sale lines** from 2 SMEs, 7 test products ("Tractor", "Cake"), all 2021, all UGX. The only durable value is the EHC→POS tenant link `businesses.accounting_business_id` (124 populated).

**Why osTicket is skipped:** ≤5 demo tickets in a single April-2021 window, **0 FAQ rows**, 9.5 KB of attachments (the osTicket logo + a sample .txt). The 1 GB is `ost_session` PHP blobs. There is no support history. The SME-Hub FAQs the lead wants live in the **EHC** dump (`faqs`, 15 rows), not here. If a helpdesk is wanted later, build `apps/support` green-field; this dump can be discarded.

---

# PART A — EHC → `apps/smehub` (CORE)

The EHC app is a **Laravel** system whose competitions are a **jQuery-formBuilder dynamic-form** engine:

```
calls(id, title[/_fr], excerpt[/_fr], body[/_fr], form=JSON-formSchema,
      call_type∈{Competition,External}, external_url, published_at, expires_at)
 ├─ guides(call_id, data=JSON-formSchema-with-`marks`, passmark)   ← the rubric
 ├─ applications(call_id, user_id, status∈{0,1}, data=JSON answers keyed by form field-name)
 │    └─ results(application_id, judge_id, data=JSON {field-name: score})  ← per-question judge marks
 └─ call_judge(call_id, judge_id)

users(id, name, email, password=$2y$bcrypt, role_id∈{1 Admin,2 Founder,3 Mentor,4 Investor},
      is_judge, alias, avatar, email_verified_at)
businesses(id, user_id→users, name, reg_no, email, website, phone_no, country_code(ISO-2),
      no_of_employees, female_employees, sector, stage, pitch, profile, attachment, logo,
      date_of_birth, gender, is_student, education_institution,
      accounting_business_id→UltimatePOS, verified_at)
mentors(id, user_id, current_company, current_position, linkedin, bio, nationality, languages,
      years_of_experience, awards, publications, resume, gender, date_of_birth, approved_at)
mentorship_categories(id, name)  ⋈  mentors_mentorship_categories(mentor_id, mentorship_category_id)
meeting_requests(requester_id, recipient_type∈{Mentor,Business}, recipient_id, meeting_time, status, reason, meeting_link, agenda)
faqs(question, answer, display_on_frontend)
email_subscribers(email, …)
```

**Key data facts (verified):**
- **4,053 users** — `$2y$` bcrypt (100%); 1,000 email-verified. role split: Founder 3,506 · Investor 454 · Mentor 87 · Admin 6. `is_judge=1` for 12.
- **474 businesses**, all with `user_id`. Sectors: Agriculture 300, Other 43, Services 37, Manufacturing 34, IT 28… Stages: Start 313, Idea 85, Break 43, Profitable 33. **is_student=1 for 348** (strong youth signal). `gender` is **all `male` (defaulted, unreliable)** — do not trust founder gender; `female_employees` is the only real gender datum. 124 link to the POS via `accounting_business_id`. Countries: BJ 93, UG 90, KE 65, NG 35, GH 30…
- **585 applications** (552 status=1, 33 status=0), concentrated in call 3 (339) and call 8 (133).
- **1,576 results** (judge marks). **13 calls** (8 Competition + 5 External), all bilingual (EN/FR).
- **10 mentors**, **9 mentorship categories**, **7 meeting requests** (6 → mentor, 1 → business).
- `investors`, `judges`, `training_certificates`, `founders` tables are **empty** (0–1 rows) — schema only, nothing to import.

## A1. Routing legacy entities → live models

| Legacy table (rows) | Target live model | Notes |
|---|---|---|
| `users` (4,053) | `core.CustomUser` (+ role profiles) | bcrypt preserved; email-keyed dedup |
| `businesses` (474) | `onboarding.EntrepreneurProfile` **+** `onboarding.Business` **+** `BusinessBaseline` | split person-attrs vs business-attrs |
| `mentors` (10) | `onboarding.MentorProfile` *(enriched)* | + `MentorshipCategory` M2M |
| `mentorship_categories` (9) | **new** `onboarding.MentorshipCategory` | controlled list + M2M |
| `calls` Competition (8) | `incubation.Programme` *(extended)* | dynamic form schema preserved on Programme |
| `calls` External (5) | `incubation.Programme` *(extended)* | `call_type=EXTERNAL` + `external_url`, no rubric/apps |
| `guides` (9) | `incubation.ScoringRubric` + `RubricSection` | sections derived from form-fields with `marks>0`; `passmark` |
| `applications` (585) | `incubation.Application` *(extended)* | raw + label-resolved JSON; FK resolved from user→business |
| `results` (1,576) | `incubation.JudgeScore` *(via derived sections)* | explode per-question marks into section scores; raw kept |
| `call_judge` (16) | `incubation.JudgeAssignment` | judge ↔ each application of that call |
| `meeting_requests` (7) | **new** `onboarding.MeetingRequest` | polymorphic recipient |
| `faqs` (15) | **new** `onboarding.Faq` | public FAQ page |
| `email_subscribers` (805) | **new** `onboarding.NewsletterSubscriber` | + CSV export |
| `businesses.accounting_business_id` | `onboarding.Business.accounting_business_id` | bridges to POS migration (Part C) |

**Why incubation.Programme for competitions:** of the live "apply → rubric → judge → score" pipelines (incubation, showcasing), **incubation** is the richest structural match — it already has `Programme · ScoringRubric · RubricSection · Application · JudgeAssignment · JudgeScore · computed_score`. Showcasing/Challenge is the alternative if the lead prefers "competition/winner" semantics; the field additions below are nearly identical either way.

## A2. Proposed model changes (extend live tables to accommodate legacy)

All new fields are nullable/blank → backwards-compatible. `legacy_smehub_id = PositiveBigIntegerField(null=True, blank=True, unique=True, db_index=True)` is the idempotency anchor added to **every** target model below.

**`core.CustomUser`**
```python
legacy_smehub_id   = PositiveBigIntegerField(null=True, blank=True, unique=True, db_index=True)
legacy_alias       = CharField(max_length=255, blank=True)   # users.alias
# password: import $2y$ bcrypt verbatim → normalise prefix to $2b$; enable bcrypt hasher (see A4)
```

**`onboarding.EntrepreneurProfile`** *(founder demographics — M&E youth/gender)*
```python
legacy_smehub_id      = … (anchor)
date_of_birth         = DateField(null=True, blank=True)
gender                = CharField(max_length=12, choices=Gender.choices, blank=True)  # shared Gender choices (male/female/other/undisclosed) — supports all genders; legacy data is all-'male' (FLAG, don't trust for analytics)
is_student            = BooleanField(default=False)              # 348/474 → youth signal
education_institution = CharField(max_length=255, blank=True)
```

**`onboarding.Business`** *(business identity + contact + employment + POS link)*
```python
legacy_smehub_id      = … (anchor)
registration_number   = CharField(max_length=120, blank=True)   # reg_no
business_email        = EmailField(blank=True)                  # email
website               = URLField(max_length=255, blank=True)
phone                 = CharField(max_length=40, blank=True)    # phone_no
total_employees       = PositiveIntegerField(null=True, blank=True)   # no_of_employees
female_employees      = PositiveIntegerField(null=True, blank=True)   # gender-disaggregated M&E
pitch                 = TextField(blank=True)
accounting_business_id= PositiveIntegerField(null=True, blank=True, unique=True, db_index=True)  # → UltimatePOS (Part C)
legacy_stage_raw      = CharField(max_length=64, blank=True)    # keep original {Start,Idea,Break,Profitable}
```
*Stage mapping → `business_stage`:* `Idea→IDEA · Start→MVP · Break→EARLY_REVENUE · Profitable→GROWTH`. *Sector:* free-text legacy values stored on existing `sector` (verbatim). *`country_code` (ISO-2) → `country`* (full name, via ISO map; matches `Document.country` convention). *`logo`/`attachment`/`profile`* → see A3 (files).
*`BusinessBaseline`:* seed `business_stage_at_entry` from mapped stage; the legacy employee counts populate a new optional `total_employees`/`female_employees` mirror (employment baseline) — or fold into the new `Business` fields and leave baseline as the timestamped snapshot.

**`onboarding.MentorProfile`** *(currently lightweight — enrich to hold the full legacy record)*
```python
legacy_smehub_id   = … (anchor)
current_company    = CharField(max_length=255, blank=True)
current_position   = CharField(max_length=255, blank=True)
linkedin           = URLField(max_length=255, blank=True)
phone              = CharField(max_length=40, blank=True)
nationality        = CharField(max_length=100, blank=True)
bio                = TextField(blank=True)
awards             = TextField(blank=True)
publications       = TextField(blank=True)
resume             = FileField(upload_to=…, null=True, blank=True)
gender             = CharField(max_length=12, blank=True)
date_of_birth      = DateField(null=True, blank=True)
approved_at        = DateTimeField(null=True, blank=True)        # → verification_status VERIFIED
categories         = ManyToManyField("MentorshipCategory", blank=True)
# existing: expertise_sectors(JSON), years_experience, languages(JSON)
```

**`onboarding.MentorshipCategory`** *(new)* — `name (unique)`, `legacy_smehub_id`, `created_at`. Seed the 9 legacy categories; M2M from MentorProfile.

**`incubation.Programme`** *(extend to carry a legacy dynamic-form call)*
```python
legacy_smehub_id   = … (anchor)
call_type          = CharField(choices=[COMPETITION, EXTERNAL, NATIVE], default=NATIVE)
external_url       = URLField(max_length=500, blank=True)        # External calls + Competition source link
legacy_form_schema = JSONField(null=True, blank=True)            # calls.form (formBuilder schema)
excerpt            = TextField(blank=True)                       # calls.excerpt
# bilingual (EN/FR) — legacy is fully bilingual:
title_fr           = CharField(max_length=255, blank=True)
excerpt_fr         = TextField(blank=True)
description_fr     = TextField(blank=True)                       # calls.body_fr
expires_at         = DateTimeField(null=True, blank=True)        # → application_deadline
```
*(NATIVE = a normally-authored IILMP programme; COMPETITION/EXTERNAL = legacy-sourced.)*

**`incubation.ScoringRubric`** — add `pass_mark = PositiveIntegerField(null=True, blank=True)` (guides.passmark) + `legacy_form_schema = JSONField(...)` (the marked guide schema, source of truth for section derivation).

**`incubation.Application`** *(extend to hold dynamic answers)*
```python
legacy_smehub_id     = … (anchor)
legacy_call_type     = CharField(max_length=16, blank=True)
legacy_form_data     = JSONField(null=True, blank=True)   # applications.data — raw {field-name: answer}
legacy_resolved_data = JSONField(null=True, blank=True)   # {question_label: answer} resolved via form schema (human-readable)
# existing structured fields (business_description, problem_statement, …) → best-effort filled from
#   resolved answers by label heuristics ("name of business", "describe your business", "country");
#   unmapped answers remain in legacy_resolved_data so nothing is lost.
```
*FK resolution:* `applications.user_id` → CustomUser → `EntrepreneurProfile`; `business` → that user's `businesses` row (1:1 by user_id). *Status:* `0→DRAFT`, `1→SUBMITTED` (FSM-safe seed; do not fire transitions during import — set state directly with `state_=`).

**`incubation.JudgeScore`** — add `legacy_smehub_id` + `legacy_raw = JSONField(null, blank)`. **Section derivation:** for each guide, every form field with `marks>0` becomes a `RubricSection` (title = question label, `max_marks` = marks, grouped under its preceding `header`). Each `results` row (judge × application) then explodes into one `JudgeScore` per scored field; `Application.computed_score` recomputes via the existing signal. The raw result JSON is also stored on the first score row for audit.

**`onboarding.MeetingRequest`** *(new — polymorphic legacy meeting requests)*
```python
legacy_smehub_id = … (anchor)
requester        = FK(CustomUser)
recipient_type   = CharField(choices=[MENTOR, BUSINESS])          # recipient_type
recipient_object_id / recipient_content_type / recipient (GFK)    # Mentor or Business
meeting_time     = DateTimeField(null=True)
status           = CharField(choices=[REQUEST, ACCEPTED, REJECTED, …])
reason           = TextField(blank=True); agenda = TextField(blank=True)
meeting_link     = URLField(blank=True)
```
*(7 rows; low priority. Alternative: route mentor-recipient rows to a future `incubation.MentorSession` booking, but a dedicated model is the cleanest faithful home.)*

**`onboarding.Faq`** *(new)* — `question`, `answer`, `is_published` (display_on_frontend), `order`, `legacy_smehub_id`.

**`onboarding.NewsletterSubscriber`** *(new)* — `email (unique)`, `status`, `subscribed_at`, `legacy_smehub_id`.

## A3. Files

Business `logo`/`attachment`, mentor `resume`, user `avatar`, call cover images are **Laravel `storage/app/public` paths not present in these DB dumps** — same situation the repository migration hit. Options: (a) pull the EHC app's `storage` dir from the server; (b) import metadata now, attach files in a later `--files` pass, flagging missing as `import_warning`. Recommend (b) + a server pull when credentials are available. (Moodle files **are** available — see Part B.)

## A4. Passwords — NOT migrated (decision 2026-06-09)

Legacy passwords are **not** carried over. Both importers create accounts with
an **unusable password**; every migrated user resets on first login (password-
reset email). No `bcrypt` dependency, no `PASSWORD_HASHERS` change. This keeps
identity (email + profiles) without inheriting any legacy hash.

---

# PART B — Moodle → `apps/rep` (LEARNING)

Standard Moodle 4.x (`mdl_` prefix, 222 data tables). `apps/rep` already has a deep model set: `rep_courses` (Programme, Course, Module, Enrolment, Certificate, …), `rep_content` (Lesson, CompletionRecord, **ScormPackage/Attempt/Interaction**, H5PEmbed, …), `rep_assessments` (Quiz, Question, Attempt, Assignment, Submission), `rep_forums`.

**Verified inventory (meaningful tables):**

| Moodle table | Rows | → REP target |
|---|---:|---|
| `mdl_course_categories` | 8 | **new** `CourseCategory`/track tree (self-parent) |
| `mdl_course` | 69 | `Course` |
| `mdl_course_sections` (494) + `mdl_course_modules` (891) | — | `Module` + `Lesson`/`Quiz`/`Assignment` by type |
| `mdl_enrol` (205) + `mdl_user_enrolments` | **242** | `Enrolment` |
| `mdl_role_assignments` | 263 (student 168 · teacher 79 · mgr 16) | instructor + optional `CourseStaff` |
| `mdl_course_completions` | 181 (**timecompleted all NULL**) | derive — see risks |
| `mdl_course_modules_completion` | 447 (**402 done, 79 learners**) | `CompletionRecord` — the real progress signal |
| `mdl_grade_items` (297) + `mdl_grade_grades` | **2,167** | quiz/assign grades route to attempts; rest → optional `LegacyGrade` |
| `mdl_quiz` (35) + `_attempts` (83) | — | `Quiz` + `Attempt` (+ `mdl_question` 523 → Question/Answer) |
| `mdl_assign` (82) + `_submission` (107) | — | `Assignment` + `AssignmentSubmission` |
| `mdl_scorm` (108) + scoes/attempt (1,286/306) | **10,482 files** | `ScormPackage` + `ScormAttempt` — **biggest effort** |
| `mdl_forum`/`_discussions`/`_posts` | 175/20/27 | `Forum`/`Thread`/`Post` (sparse, easy) |
| `mdl_customcert_templates` (1) / `_issues` (**0**) | — | `CertificateTemplate`; **no issued certs to import** |
| `mdl_user` | 21,772 (**21,438 deleted**, ~334 active) | `core.CustomUser` (active set only) |
| `mdl_files` | 17,095 | `moodledata/filedir/<h0:2>/<h2:4>/<contenthash>` |

**Top courses by enrolment:** eLearning Content Development (56), Research Methods (41), Intro to Climate Information Services (32), Scientific Data Management (18), Agricultural Value Chain Analysis (12). 8 tracks incl. Agro Informatics, M&E, AICCRA, Climate Change, Academic & Career Development.

## B1. Proposed REP model changes

Add **`legacy_moodle_id`** (indexed) to: `Course`, `Module`, `Lesson`, `Enrolment`, `Quiz`, `Assignment`, `core.CustomUser` (+ `legacy_moodle_section_id` on Module) — for idempotent re-runs and FK remapping. Plus:
- **`CourseCategory`/`CourseTrack`** (new) with self-FK `parent` — Moodle's 8-category tree has no REP home (`Programme`/`Course.category` is a flat TextChoices). `Course.category` (track) FK → this.
- **`CourseStaff`** (optional) — Moodle has 79 `editingteacher` assignments; `Course.instructor` is single + `PROTECT` non-null. Co-teachers are lost without it (and import must resolve *an* instructor or assign a system user).
- **`LegacyGrade`** (optional holding model) — for `itemtype=course/manual` gradebook rows that don't map to a quiz/assignment attempt.
- Decide handling for Moodle **`lesson`** branching activity (27, 146 branches) — no REP equivalent; flatten to text Lessons (lose branching) or extend.

## B2. Files & passwords (Moodle)
- **Files available:** `moodledata` (1.1 GB) untars to `filedir`; resolve every `mdl_files.contenthash` → blob. SCORM = 108 packages / 10,482 files must be **re-zipped** (or pre-extracted into REP's `ScormPackage.extracted_root`) since REP ingests an uploaded zip while Moodle stores loose SCO files.
- **Passwords NOT preservable:** 21,612 `$2y$` (could carry, but among a 98.5%-deleted set) + 131 `$6$` crypt + **29 literal `restored`** placeholders. **Recommendation: force email-based password reset for all migrated active learners**; do not import Moodle hashes.

## B3. Completion semantics
No formal `timecompleted` and no issued certificates. **"Who completed what" is derived** from `mdl_course_modules_completion` (state=1). Confirm the business rule (e.g. course complete ⇔ all completion-tracked activities state=1) before back-filling `Enrolment.completed_at` / regenerating certificates via REP's own logic.

---

# PART C — UltimatePOS → `apps/smehub/marketplace` (MINIMAL)

Data is **test-grade** (7 real sale lines, 2 SMEs, 2021, UGX, "Tractor"/"Cake" products). Do **not** import the product catalogue or walk-in contacts (would pollute the live marketplace). Do:

1. **Tenant link:** `onboarding.Business.accounting_business_id` (added in Part A) ← EHC `businesses.accounting_business_id` (124 populated). This is the durable, valuable artefact.
2. **Sales (confirmed):** land the `transactions.type='sell'` rows in **`marketplace.SalesRecord`** with `channel=OFFLINE`, deriving `entrepreneur` from the resolved `Business.entrepreneur`. Add to `SalesRecord`:
   ```python
   source_system         = CharField(max_length=24, blank=True)   # "ultimatepos"
   source_transaction_id = PositiveIntegerField(null=True, blank=True, db_index=True)
   source_invoice_no     = CharField(max_length=191, blank=True)
   # + UniqueConstraint(source_system, source_transaction_id)  → idempotent
   ```
   No `PosSale`/`SmeFinancials` model — over-engineering for this volume. Revisit only if a **live ongoing POS sync** is ever planned.

## C1. Re-audit 2026-06-10 — full per-table verdict (DONE; products now imported)

Full inventory of the dump after the user asked to see products/sales in the UI. Verdicts, with row counts:

| Table(s) | Rows | Verdict |
|---|---|---|
| `business` | 125 | Tenant shells auto-created by EHC's signup hook. Only the id matters → already bridged via `Business.accounting_business_id` (124 link). |
| `users` / `roles` / `permissions` / `role_has_permissions` | 128 / 254 / 213 / 776 | POS-internal auth mirroring the EHC accounts + UltimatePOS's stock RBAC fixture. **Skip** — IILMP users come from the EHC import. |
| `contacts` | 137 | **125 are the auto-created "Walk-In Customer" default** (one per tenant). The 12 named rows are mostly test entries ("zzhz zhzhhz", "Gaerf Vadfr", junk phone numbers). **Skip**; customer names already land on `SalesRecord.customer_name`. |
| `products` / `variations` / `variation_location_details` | 7 / 7 / 4 | **3 are junk** (duplicate 150M-UGX "Tractor" rows on the never-selling "martin amitu" test tenant). **4 are real** — Tripxio's *Cake* & *Sweet Chili*, MN Foods' *sweet kick* & *HOT SAUCE* — with real prices and stock. **IMPORTED** → `marketplace.ProductListing` (PUBLISHED, priced, stock-derived availability, `external_ref` anchor = `products.id`, migration 0007). Import rule: only tenants with ≥1 `sell` transaction. |
| `transactions` (`type='sell'`) + `transaction_sell_lines` | 7 + 7 | Already imported as `SalesRecord`; sell lines now also **link each sale to its `ProductListing`** (`sales_linked=7`). |
| `transactions` (other types) | 4 | 1 junk purchase (120M UGX tractor), 3 opening-stock entries — consumed indirectly via stock qty. **Skip.** |
| `cash_register_transactions` / `cash_registers` | 15 / 6 | Till-session bookkeeping for the same 7 sales. **Skip.** |
| `notification_templates`, `invoice_schemes/layouts`, `units`, `currencies`, `reference_counts`, `barcodes`, `system`, `migrations` | ~2,400 | UltimatePOS per-tenant seed fixtures (auto-created at signup) — zero user data. **Skip.** |
| Product images (`products.image`) | 4 filenames | Stored on the POS server's disk, **not in the dump** — same blocker class as the EHC Laravel storage. Importable later via the files stage if the server is ever reachable. |

Bottom line: the dump held exactly **4 real products and 7 real sales from 2 SMEs (2021, UGX)** — everything else is install scaffolding or test junk. Both are now first-class records visible in the marketplace catalogue, product detail, sales list, and the business-detail sales card.

---

# PART D — osTicket → SKIP

Empty demo install (≤5 test tickets, 0 FAQs, 9.5 KB demo files; 1 GB = session blobs). **No migration.** If a helpdesk is wanted, build `apps/support` green-field against IILMP conventions (Barlow/Spectral, `page_header`, status-pill tokens, MFA/RBAC mixins, `paginate_qs`) — decoupled from this dump. Discard per data-handling policy (the dump contains PII in session blobs).

---

# PART E — Importer design

**One management command per source, mirroring `import_drupal_repository`:**
- `apps/smehub/onboarding/management/commands/import_smehub_ehc.py`
- `apps/rep/courses/management/commands/import_moodle.py`
- (POS handled as a `--stage pos` inside the EHC importer, since it hinges on the business link.)

**Loading strategy:** these are MySQL dumps and our `docker-compose` is Postgres. Per the repository plan's recommendation, **load each dump into a throwaway MariaDB container** and read via a read-only connection — the cross-table joins (form↔guide↔application↔result; enrol↔completion↔files) are far too brittle to parse from 148 MB / 1 GB of SQL text. (EHC at 31 MB could be streamed, but a container keeps all importers uniform.)

**Common contract (all importers):**
- Stages, idempotent & resumable, keyed on `legacy_*_id`.
- Flags: `--dump <path>`, `--stage <name>`, `--limit N`, `--dry-run`, `--report <csv>`, `--flush`, `--files` (separate file pass).
- FSM-safe seeding: set status via direct state assignment, **never** fire `@transition` methods during import.
- Email-keyed **person resolution** shared across EHC + Moodle + existing IILMP — a user may exist in all three; match-or-create on email, never clobber an existing IILMP account.
- Per-row transaction; CSV run-report (id, type, resolved FKs, warnings); never delete IILMP data.

**EHC stages:** `roles_users → entrepreneurs_businesses → mentors_categories → competitions(calls+guides→Programme+Rubric) → applications → results(→JudgeScore) → call_judges → meeting_requests → faqs → subscribers → pos(link+sales)`.
**Moodle stages:** `users → categories → courses → modules_lessons → enrolments → quizzes_questions → assignments → completions → grades → scorm(repackage) → forums → files`.

**Tests:** run under a MariaDB test container; fixtures = a handful of representative rows per stage (bilingual competition with form+guide+apps+results; student-founder business with POS link; enriched mentor with categories; SCORM course; derived-completion learner; missing-file business).

---

# PART F — UI follow-ups

> **Status 2026-06-09:** all five SME-Hub UI surfaces BUILT, render-verified, **wired into the SME-Hub topnav dropdown** with role-matched gating, and **access-verified** (admin 200 across all; plain learner: FAQ/mentors 200, newsletter/analytics/legacy 403): FAQ (`smehub_onboarding:faq`), mentor directory + detail, newsletter admin + CSV, cohort analytics, legacy-competition archive (`smehub_incubation:legacy_competition_list`/`_detail`). Also fixed: `nav_visibility` superuser branch was missing `nav_show_smehub` (SME-Hub menu hidden from superusers). Remaining UI: surface the new Business/Entrepreneur fields on the existing business detail.

**SME-Hub (EHC):**
- **Mentor directory + detail** — surface enriched fields (company, position, LinkedIn, bio, publications, categories, photo).
- **Entrepreneur/Business profile** — show registration no., contact, website; **demographics (DOB, gender, student status, education institution) and employment (total/female employees) admin/analytics-only** (M&E, not public — gender is unreliable legacy data).
- **Legacy competition surface** — Programme detail renders the legacy form schema + each application's `legacy_resolved_data` (label→answer) + judged scores; a "Legacy / External" badge; External calls render as link-out announcements.
- **Public FAQ page** (`onboarding.Faq`).
- **Newsletter admin** — subscriber list + CSV export.
- **Analytics / M&E (SP6)** — youth %, gender-disaggregated employment, country & sector distribution from the 474 migrated businesses; cohort sizing from 585 applications.

**REP (Moodle):** *(Status 2026-06-09: track facet + co-teachers BUILT & render-verified)*
- ☑ Course catalogue gains a **Track facet** (the imported `CourseCategory` tree) — `CourseCatalogueView` filters on `?track=<id>`, desktop + mobile facet rows, hidden until tracks exist.
- ☑ **Teaching team** section on course detail (`CourseDetailView.course_staff` → `CourseStaff`), shown when co-teachers exist.
- Learner progress already derives from `CompletionRecord` (importer sets `progress_pct`); SCORM content playable once the `--files` pass re-zips packages.
- ☐ Optional: surface `LegacyGrade` in the instructor gradebook; show the track badge on catalogue cards.

---

# PART G — Risks & open questions

1. **EHC uploaded files** (logos, resumes, attachments, avatars) are not in the DB dumps → need a server pull of Laravel `storage/`, or import-now/attach-later with missing-file flags.
2. **Founder gender is unreliable** (all `male`, defaulted) — capture but exclude from gender analytics; use `female_employees` for any gender M&E.
3. **Cross-source identity** — the same person may be an EHC user, a Moodle learner, and an existing IILMP account. Email-keyed match-or-create is mandatory; define tie-breaks for name/institution conflicts.
4. **Password story** — EHC `$2y$` preservable (enable bcrypt hasher); Moodle `$6$`/`restored` not preservable → reset-on-first-login. Confirm acceptable.
5. **Dynamic-form fidelity** — best-effort label heuristics fill structured `Application` fields; everything is retained in `legacy_resolved_data` so nothing is lost, but the typed fields will be sparse. Confirm that's acceptable vs. curated per-call mapping for the two big calls (3, 8 = 472 of 585 apps).
6. **JudgeScore explosion** — deriving `RubricSection`s from each guide and exploding 1,576 results assumes result keys align with guide field-names (verified on sample). A per-call validation pass should confirm before bulk insert.
7. **Moodle completions derived, not stated**; **0 certificates** to import (regenerate via REP). Confirm the completion rule.
8. **SCORM re-packaging** (108 pkgs / 10,482 files) is the largest single content effort — budget it explicitly.
9. **POS data is test-grade** — confirm whether to import the 7 sales at all, or only keep the tenant link.
10. **osTicket discarded** — confirm no expectation of importing helpdesk history (there is none of value).

---

# PART H — Sequence

1. ☑ **Model migrations** — Part A2 (smehub) + B1 (rep) + C (marketplace `source_*`) shipped & verified in Docker (`core.0050`, `smehub_onboarding.0005/0006`, `smehub_incubation.0005/0006/0007`, `smehub_marketplace.0006`, `rep_courses.0013`, `rep_content.0014`, `rep_assessments.0011`). Passwords not migrated (A4) — no bcrypt/`PASSWORD_HASHERS` change. `PyMySQL` (already a dep) bakes into the next image build for the importers.
2. ☑ **EHC importer** — `manage.py import_smehub_ehc` built & smoke-tested end-to-end against the real dump (dry-run): 4,053 users · 472 entrepreneurs/474 businesses · 9 categories/10 mentors · 13 programmes/9 rubrics/42 sections · **585 applications (0 skipped)** · 1,576 judge assignments + **20,109 exploded judge scores** · 7 meetings · 15 FAQs · 805 subscribers · **7 POS sales** (via `--stage pos` against the accounting dump). Required model tweaks shipped: `Application.business` nullable (`0006`), `RubricSection.legacy_field_name`/`legacy_smehub_id` (`0007`), `Business.accounting_business_id` → BigInteger (`0006`). Importer clamps negative legacy employee counts.
3. ☐ **EHC files** — server pull + `--files` pass (logos / mentor resumes / attachments not in DB dumps).
4. ☑ **Moodle importer** — `manage.py import_moodle` built & run for real against the dump (loaded into a throwaway MariaDB): 8 categories · 334 users · 68 courses · 82 co-teachers · 492 sections/566 lessons/35 quizzes/82 assignments · 298 enrolments · 317 completions · 81 attempts · 96 submissions · 457 gradebook entries. **`--stage files` pass shipped & run (2026-06-09):** the original SCORM zips turned out to live in `mdl_files` (`mod_scorm`/`package` — no re-zipping of the 10,482 loose SCO files needed); all **108 packages saved to `ScormPackage.package_zip` and extracted inline to `status=ready`** (101× SCORM 1.2, 7× 2004) via the platform's own `extract_scorm_package`. Plus **268 lesson attachments** (228 `mod_resource` main files + 40 zipped `mod_folder` sets), **13 submission files**, **12 course thumbnails**. **Forum content imported:** 174 forums (68 earlier shells claimed by `external_ref`), 18 threads, 24 posts with backdated timestamps (3 posts by deleted Moodle accounts skipped). Blob staging: `docker cp moodledata/filedir <web>:/tmp/moodledata/filedir`, then `--stage files --files-root /tmp/moodledata/filedir` (runbook in the command docstring). Idempotent re-run verified (all `already`). **Lesson-content enrichment (same day):** the modules stage now pulls real activity names + intros + bodies per type (page content, label intro, book chapters from `mdl_book_chapters`, mod_lesson pages from `mdl_lesson_pages` excl. cluster/branch markers, url/resource/folder/imscp/h5p names) and inlines `@@PLUGINFILE@@` images ≤1 MB as data URIs from the blob store (unresolvable refs degrade to `#`); IMSCP packages are zipped onto `Lesson.attachment` (content-type `file`) and the lone `.h5p` package attached. Result: generic-titled lessons 229→0, truly-empty lessons 49→4 (those 4 are empty in Moodle itself).
5. ☑ **Role backfill — investors + admins (2026-06-10)** — the `users` stage originally mapped only `role_id` 2→ENTREPRENEUR / 3→MENTOR, leaving **458 imported accounts role-less** (454 investors `role_id=4` + 4 admins `role_id=1`) — orphaned from the SME-Hub nav and with no profile to complete. Fixed `_ROLE_ID_MAP` to add `4→INVESTOR` and `1→SME_ADMIN`, and `_stage_users` now seeds a bare `InvestorProfile` shell (idempotent on the `OneToOne(user)`) so investors self-complete their focus via the existing `RoleProfileCompletionView`. Admins → `SME_ADMIN` (no Django staff/superuser). Re-ran `--stage users` (idempotent): `roles_set=458 investor_profiles=454`. Dev DB now: **0 role-less imported users**, 454 INVESTOR shells, 4 EHC admins → SME_ADMIN (0 staff/0 super). Investor flow render-verified (force-login: `my_dashboard` 200 + Complete-profile CTA, `role_profile_complete` 200, `nav_show_smehub=True`). 4 importer tests green (`test_import_ehc_roles.py`). **No `investment.Investor` directory rows created** (legacy `investors` table empty — would pollute the public directory). The 36 remaining shell-less INVESTOR users are native `user.seed.*` demo accounts (external_ref null), outside migration scope.
6. ☐ **Curator pass** — spot-check the two big competitions, derived completions, stage/sector mappings.
7. ☐ **UI follow-ups** (Part F) per surface.
8. ☐ **Production import** (after sign-off; bcrypt logins + reset-email flow verified).

**Idempotent on `legacy_smehub_id` / `legacy_moodle_id` (re-run updates, never duplicates).**
