Migration Plan · Revision 2 · incorporates David's answers

Test Generator → Python web

Replace the VB6 desktop tier, the Java servlet tier and the MFC kiosk with a Python web stack — keeping multi-engine database support, and delivering every schema change as a 2.4.21 upgrade in the existing dbup2d8 mechanism. Every claim is traced to a file in the archive; the PostgreSQL schema has been executed against a live server.

✓ Schema validated on PostgreSQL 15.18 5 engines retained Schema baseline resolved: 2.4.20 10 locales Rev 2 · 2026-07-26

00What changed in this revision

Six answers came back. Three of them reverse decisions in revision 1, and one turned out to already be settled by the source. Each is recorded below with what it now means.

Q1 · Which database engine is the live system on?

All of them stay. Oracle, SQL Server 2000+, MySQL and PostgreSQL remain supported. Only Access may be dropped — and only if there is no desktop product. This reverses rev 1's single-PostgreSQL design. The data layer is now explicitly multi-engine, and every PostgreSQL-specific decision in rev 1 (large objects, nextval, int2 booleans) becomes one column in a dialect matrix rather than the rule.

Q2 · Has the schema drifted from ddl_postgres.cls?

Answered by the source — no drift. "That plus whatever dbup2d8.exe updates to" resolves to exactly what was extracted. Verified below in §01: ddl_postgres.cls already contains every column dbup2d8 adds through 2.4.20, and correctly omits the three tables dbup2d8 creates and later drops. The extracted schema is the 2.4.20 baseline.

Q3 · bcrypt in varchar(64), or widen for Argon2id?

Widen the column; ship it as 2.4.21. This removes the rev 1 constraint entirely — Argon2id becomes available. It also corrects a rev 1 error: passwords are not stored in cleartext (see §04).

Q4 · RTF conversion — once, or per request?

Sidecar columns, lazily filled. Add converted-HTML columns in 2.4.21; on read, if null, convert and store, then always read the converted value. RTF becomes write-never after conversion. Design in §05.

Q5 · Foreign keys?

Yes, in 2.4.21, conditional on the new code maintaining integrity — plus a conversion step for existing databases. That conversion is not just ALTER TABLE: existing data has never been constrained, so orphans must be found and resolved first. §03.

Q6 · Which languages?

Ten: English, Spanish, French, Chinese, Italian, German, Russian, Polish, Hungarian, Czech. Nine already exist in the Tester tier (282 .properties files); Italian is the only one with nothing. But the Admin app is localised into Chinese only — nine languages missing there. §07.

01The schema baseline is now settled

Q2 is fully answerable from the archive, so it is no longer an open question. Here is the proof, because the whole plan rests on it.

CheckResult
Does ddl_postgres.cls contain the columns dbup2d8 adds in later versions? Sampled tst_prereqtest, tst_allowreview, q_feedback, feedback_correct, su_passed, tst_passed_when, print_tmpl, cl_enrollment, tst_print_opt, su_current_section, ad_key_org, ta_resetpassword.All 12 present.
Does it contain the three transitional tables dbup2d8 creates and then drops — taker_deny_test, tst_message, section_message?Correctly absent. clsMSSql.cls drops all three at the 2.4.14 step; their content was folded into message and takers_tests.
Does it still contain the retired Essay table?Correctly absent — dbup2d8 issues drop table essay. The separate Essay database is gone.
Does the DDL execute?PostgreSQL 15.18: 34 tables, 16 sequences, 78 indexes, 0 errors — after CREATE EXTENSION lo;.

Conclusion: plan/schema_postgres_extracted.sql is the authoritative 2.4.20 schema. dbup2d8 is the path to it from older databases, not a set of changes layered on top of it.

But PostgreSQL has no upgrade path at all — and that is a hole 2.4.21 must fill Tools/dbup2d8/clsPostgres.cls is a 1.1 KB stub. Its entire logic is: if the database already reports version 2.4.20.0, do nothing; otherwise show "Unable to update Postgres database from version X to 2.4.20.0" and fail. Compare the siblings — clsAccess 60 KB / 20 version blocks, clsMSSql 37 KB / 20, clsMySQL 36 KB / 20, clsOracle 30 KB / 14.

In other words: a PostgreSQL database can only ever be created fresh at 2.4.20. There has never been an in-place PostgreSQL upgrade. The moment 2.4.21 exists, that stub has to become a real implementation, or every PostgreSQL customer is stranded.

02Multi-engine is now a first-class requirement

Rev 1 assumed one engine. It does not. The data layer must abstract five dialects, exactly as CDAC_ADO and DBUtils do today — and the abstraction points are already documented by those two files.

The dialect matrix

Read out of ddl_*.cls, the dbup2d8 engine classes, and DBUtils.java. This is the table the Python data layer implements.

ConceptAccessSQL ServerMySQLOraclePostgreSQL
Booleanyesnobitint(1)number(1)int2
Boolean literal1 / 01 / 0-1 / 0-1 / 0'1' / '0'
Integerlongintegerint(20)number(20)int8
Short stringtext(255)nvarchar(255)varchar(255)varchar2(255)varchar(255)
Long textmemontexttextclobtext
Decimaldoublefloatdoublenumbernumeric(20,4)
BinaryOLE objectimagelongblobbloblo special
New ID@@identity@@identitylast_insert_id()seq.nextvalnextval(seq)
ID timingafter insertafter insertafter insertbefore insertbefore insert
Date literal#…#'…''…'to_date('…','…')'…'
PostgreSQL is the odd one out on binary, and it matters for the port Every other engine stores media as an inline binary column. PostgreSQL uses the lo type — the row holds a large-object OID, and the bytes live in pg_largeobject. Consequences the Python layer must handle and no other engine needs: Hide all three behind one BlobStore interface with five implementations. Do not let lo semantics leak into application code.
Access: what dropping it actually costs Access is the default for the desktop product and the only engine with a full 21-block upgrade chain in dbup2d8. If David wants a desktop version, Access stays and nothing changes. If he does not, Access dies with the desktop — but note that Clean/testgen.mdb, the shipped starter database, is also the fixture every installer seeds from, so the "create a new empty system" path has to be re-provided for a server engine. That is a small piece of work, not a free deletion. Needs David's call

Engine-specific quirks already visible in the schema

03Version 2.4.21 — the schema change release

Three of the six answers require schema changes, and all three are to be delivered the way this product already delivers them. That makes 2.4.21 a concrete, self-contained deliverable that can be built and shipped before any Python code exists.

How the existing mechanism works

Verified from Tools/dbup2d8/. The pattern is a linear chain keyed on admin.ad_version, with idempotent guards:

' clsMSSql.cls — the shape every version block follows
If (currVer = "2.4.1.0") Then
    If Not cdac_obj.columnExists("tst_prereqtest", "tests") Then
        cdac_obj.RunSQL ("alter table tests add tst_prereqtest integer")
    End If
    ' … more guarded alters …
    UpdateCommon2412 cdac_obj          ' cross-engine data migration
    cdac_obj.RunSQL "update admin set ad_version = '2.4.2.0'"
    currVer = "2.4.2.0"
End If

Engine-specific DDL lives in clsAccess / clsMSSql / clsMySQL / clsOracle / clsPostgres; engine-neutral data migrations live in modDBUP2D8.bas as UpdateCommon24xx routines. 2.4.21 follows exactly this shape.

What 2.4.21 contains

2.4.21-a — Widen ta_password for modern hashingNew

Current width is varchar(64) — sized for SHA-256 hex, which is exactly 64 characters. Argon2id's encoded form runs to roughly 95–100 characters. Widen to varchar(255), which leaves room for any PHC-string algorithm and costs nothing on any of the five engines.

Decision Argon2id, since the width constraint is lifted. Migration is lazy, not bulk: existing MD5/SHA-256 values stay; on the next successful login the verified plaintext is re-hashed with Argon2id and written back. A prefix check ($argon2id$) selects the verifier. Bulk re-hashing is impossible — the plaintext is not recoverable from the old hashes.

The old MD5/SHA-256 acceptance path must stay until every active account has logged in once. Add a report to the admin app showing how many accounts remain on legacy hashes, so there is a defensible moment to switch the old path off.

2.4.21-b — RTF sidecar columnsNew

Per Q4: add a converted column beside each RTF column; fill it lazily on first read; read only the converted value afterwards. The columns that hold RTF, verified against the schema:

TableRTF columnNew columnNote
tg_questionsq_textrtfq_texthtmlQuestion stem.
qb_questionsq_textrtfq_texthtmlQuestion bank copy.
tmp_questionsq_textrtfq_texthtmlReport scratch table — see §09.
detailde_answerrtf, de_notesrtf, de_feedbackrtfde_answerhtml, de_noteshtml, de_feedbackhtmlSizing risk — see below.
teststst_print_tmpl—Print template, consumed by the RTF print engine, not displayed as HTML. Leave alone.
adminprint_tmpl—Same.

The trigger is content-sniffed, not column-typed. RTFUtils.convertToHtml() tests s.startsWith("{\\rtf") and passes anything else through as escaped plain text. Any of these columns may legitimately hold plain text. The Python converter must replicate that check — converting a non-RTF string as if it were RTF produces garbage.

One place where "a column per RTF column" is the wrong shape detail holds one row per answered question per attempt — it is by far the largest table in the system, and it gets three new text columns. Meanwhile the existing Java cache (RTFCache) is keyed on MD5(rtf_content), not on row identity, precisely because the same feedback and note strings repeat across thousands of rows.

Recommendation: apply sidecar columns as directed for the three authoring tables, and for detail use the same lazy-fill logic pointing at a shared content-addressed table — rtf_html(rtf_md5 char(32) primary key, html text, converted_date …). Same behaviour, same "convert once, read forever" contract, without tripling the widest table. David's call — if he prefers uniform sidecar columns everywhere, that works too; it just costs storage.
2.4.21-c — Foreign keysNewHighest-risk item

The schema has zero foreign keys today — verified by grep across the extracted DDL. Adding them is the single largest stability improvement available, and it is the change most likely to fail on real customer data, because nothing has ever prevented orphans from accumulating.

The conversion step must therefore be three stages, not one:

  1. Audit. For every intended FK, count violating rows first. Ship this as a read-only report the customer can run before upgrading — select count(*) from detail d where not exists (select 1 from summary s where s.su_id = d.su_id) and its equivalents. No ALTER runs until the audit is clean or the disposition is agreed.
  2. Resolve. Orphans are either deleted or re-parented. This is a customer decision per table — orphaned detail rows are junk, but orphaned summary rows are somebody's exam result and deleting them silently is not acceptable.
  3. Constrain. Only then add the constraints, engine by engine.

Start with the relationships that are unambiguous and high-value: detail.su_id → summary.su_id, summary.tst_id → tests.tst_id, summary.ta_id → takers.ta_id, tg_questions.tst_id → tests.tst_id, sections.tst_id → tests.tst_id, summary_section.su_id → summary.su_id, and the join tables takers_classes / tests_classes / takers_tests / takers_topics.

Explicitly excluded: the four tmp_* scratch tables and surveyorder. They are populated and truncated per report run (§09) and constraining them would only add lock contention. Also excluded are the five tables with no primary key at all — admin, logobin, mediabin, standalone, uld_history — which cannot be an FK target until they have one.

Condition attached to Q5 The answer was "if the converted code is able to ensure that records are inserted and managed properly, then sure". That condition is met by P1.3 — every multi-table write becomes one explicit transaction. But it should be demonstrated, not assumed: sequence 2.4.21-c after the Tester port is proven, so the constraints are switched on against code already known to write correctly. Adding FKs first would surface every legacy insert-order bug at once, in production.
2.4.21-d — Implement the PostgreSQL upgrade pathNewBlocking

Per the finding in §01, clsPostgres.updateDB is a stub that can only assert "already at 2.4.20". Since 2.4.21 introduces the first-ever PostgreSQL schema change, that stub must become a real version block or PostgreSQL customers cannot be upgraded at all.

The good news is the chain is short: PostgreSQL databases can only exist at 2.4.20, so clsPostgres needs exactly one block — 2.4.20.0 → 2.4.21.0 — not the twenty its siblings carry.

Why 2.4.21 goes first, before any Python It is deliverable by the existing VB6 toolchain, testable against all five engines with the existing product, and it leaves the database in the exact shape the Python code expects. It also means the old and new systems can run side by side on one database throughout the port — because after 2.4.21 the schema stops moving.

04Target architecture

LayerChoiceReason
Data accessSQLAlchemy Core + per-engine dialectsCore supports all five engines through one API and has no primary-key requirement — which matters because 11 tables have composite PKs and 5 have none. Django's ORM cannot map a PK-less table at all, which rules it out on this schema regardless of other merits.
Driverspsycopg 3 · pyodbc / pymssql · mysqlclient · oracledbOne per engine, selected by config, mirroring how CDAC_ADO chooses a provider today.
Web frameworkFastAPI + UvicornExplicit routing maps 1:1 onto the existing servlet list. The usual reason to prefer Django — its admin — is unavailable here because it is built on the ORM this schema defeats.
TemplatesJinja2The 62 .tmpl pages use var / if / else / unless / loop / include; all have direct Jinja2 equivalents. Mechanical translation.
i18nBabel, seeded from the existing .properties282 translation files already exist, covering 9 of the 10 locales in the Tester tier. §07.
SessionsServer-side; Redis or a database tableExam state must survive a worker restart mid-exam.
ReportsPorted SQL → Jinja2 → WeasyPrint (PDF) · openpyxl (Excel)Crystal .rpt is an unreadable binary format, but the queries behind it are plain text in modReportSQL.bas. §09.
KioskPython + PySide6 (QtWebEngine), Windows§10 — a browser tab cannot do this job.
Correction to revision 1: passwords are not stored in cleartext Rev 1 stated that ta_password holds plaintext, based on getTaker.sql comparing the column directly. That was wrong, and the correction matters for the migration design. UpdateCommon2412 in modDBUP2D8.bas MD5-hashes every taker password during the 2.4.12 upgrade, and Taker.loadTaker() accepts either MD5(password) or SHA256(password), case-insensitively — which is why the column is varchar(64), the exact width of SHA-256 hex. The SQL-level comparison is skipped at login because the caller passes sso=1, which suppresses that branch of the template.

What remains true, and still needs fixing: the hashes are unsalted and use two broken-for-passwords algorithms, so identical passwords collide across accounts and the whole table is rainbow-table material. 2.4.21-a addresses it. Also still true: forgetpassword.sql selects ta_password to mail out — harmless now that it is a hash, but the flow should still be replaced with a reset token.

05Phase 1 — Data access layer

P1.1 — Table definitions + dialect layer1–2 wk

Generate db/schema.py from the extracted SQL rather than hand-writing it, so the Python definition is provably in step with the vendor DDL. Alongside it, one dialect module implementing the §02 matrix: boolean encoding, date literals, blob access, identifier quoting, and ID allocation.

Do not use runtime reflection. It behaves unpredictably on the five PK-less tables, hides lo columns, and removes the schema from code review.

P1.2 — ID allocation: two different ordersVerifiedincluded

This is the easiest thing to get wrong, because the engines split into two camps and the code must do both:

# Oracle and PostgreSQL — fetch first, then insert explicitly
new_id = conn.execute("select nextval('takers_ta_id_seq')").scalar()
conn.execute(insert(takers).values(ta_id=new_id, …))

# SQL Server, MySQL, Access — insert, then read back
conn.execute(insert(takers).values(…))
new_id = conn.execute("select @@identity").scalar()   # or last_insert_id()

The 16 sequences are free-standing on PostgreSQL — not SERIAL, not GENERATED, not column defaults. Keep it that way; the pre-allocated ID is used to build dependent rows inside the same transaction. After any data load, every sequence's last_value must be ≥ max() of its column, mirroring clsPostgres.doIndexUpdateDbPostgres().

P1.3 — Transactionsincluded

The concrete payoff of the move, and the precondition for 2.4.21-c. Today "start an attempt" writes summary, then detail, then locks, with no transaction spanning them — because the original target was Jet, which had none. Wrap each of these as one unit:

  • Begin attempt — insert summary, seed detail, acquire the locks row.
  • Submit answer — update detail, recompute summary aggregates.
  • Finish attempt — set su_finished, write summary_section, release lock, append to logs.
  • Report run — fill and read the tmp_* tables inside one transaction (§09).

Decision READ COMMITTED, with SELECT … FOR UPDATE on the locks row to serialise concurrent entry into the same test. On MySQL this requires InnoDB — which the schema already specifies, though via the obsolete type=innodb syntax that needs updating.

P1.4 — Port the 138 SQL templates3–4 wk

Convert each .sql template into a Python function returning a SQLAlchemy Core construct with bound parameters. Escape-mode usage across the corpus: integer 231, html 165, sql 92, js 24, date 23, bool 21, decimal 15, url 2.

Decision Do not port the template engine. Binding parameters instead of substituting strings makes SQL injection structurally impossible rather than dependent on 92 correct annotations — and it also deletes the backslash bug below by construction.

Check existing PostgreSQL and MySQL data for doubled backslashes before migrating DBUtils.escapeSQL() replaces \ with \\ when the engine is MySQL or PostgreSQL. That was correct against PostgreSQL 8.x, where standard_conforming_strings defaulted to off. It has defaulted to on since PostgreSQL 9.1, so on any modern server the doubling is never undone and every backslash a user typed has been stored twice. If a customer runs the Java tier on a modern PostgreSQL, their text columns are corrupted in this specific, detectable way. Audit before migrating; repair with a one-off pass if confirmed.
Gate Every ported query has a test asserting identical result rows against the legacy Java path, run against at least PostgreSQL and one of SQL Server / Oracle. The 138 files are the checklist.

06Phase 2 — Tester (candidate app)

Port this first. It is the best-specified piece — the Java tier already implements it, so target behaviour is readable rather than inferred — and it validates the data layer end to end before the much larger admin work starts.

Servlet todayPython routeNotes
LoginServlet, DualLoginServlet, SSOLoginPOST /login, /login/ssoThree modes from the authentication table (dir_type 0 local / 1 LDAP / 2 WINNT). Argon2id with MD5+SHA-256 fallback per 2.4.21-a. Drop the x-tester secret (§10).
TestListServletGET /testsHonours tst_active, tst_active_from/to, enrolment via takers_classes / takers_tests.
ViewDisclaimerServletGET /test/{id}/disclaimertst_disc 0 none / 1 system / 2 per-test.
BeginTest, BeginSectionMessage, SelectMessagePOST /attemptThe transactional boundary from P1.3.
LoadQuestion, DispQuestion, PreviewQuestionGET /attempt/{sid}/q/{n}11 question types. Reads q_texthtml, converting from q_textrtf only if null (2.4.21-b).
PointClickServletPOST /attempt/{sid}/q/{n}/pacHot-spot coordinates against the question image.
ShowTimed, ShowSectionTimed, TimeUp, SectionTimeUpserver-clock endpointsNever trust the browser clock. Deadline computed server-side from su_datestarted + tst_timelimit; tst_accumulatetime changes the arithmetic.
SectionCriteriaEnd, EndSection, EndTestPOST /attempt/{sid}/finishPass/fail per tst_showpassfail — percentage or all-sections-passed.
SummaryList, TestReport, ViewTakerSummaryGET /results/*Shares the report layer (§09).
CertificateServletGET /certificate/{sid}PDF; iText → WeasyPrint. Needs CJK fonts embedded (§07).
MediaBank, ViewQuestionMedia, ViewMedia, ViewLogoGET /media/{id}Addressed two ways — by media_id, and by virtual path walked through media_hier. Both must keep working. Streamed via the BlobStore abstraction.
FeedBack, UserFB, OutEnroll, UnlockmiscDirect ports.
SCORMLauncherGET /scormPath must not change — deployed packages hard-code it (§11).
3 Quartz jobsAPSchedulerScheduleNotification, DeleteObsoleteLocks, ClearRTFCache — the last becomes far cheaper once RTF is converted once and stored.
Gate A full attempt — login → list → disclaimer → all 11 question types → timed section expiry → finish → certificate — runs headless against migrated real data, and the resulting summary/detail rows match the Java tier's output for identical inputs. Repeated on two engines.

07Languages — ten shipped locales

The set is English, Spanish, French, Chinese, Italian, German, Russian, Polish, Hungarian and Czech. Nine of the ten already exist somewhere in the web tier — but the coverage is uneven in a way that matters, because it splits cleanly along the Tester / Admin boundary.

What actually exists

Measured across 282 .properties files, deduplicated to 38 distinct template stems:

Template groupStemsLocales presentGap
Tester — login, test list, disclaimer, questions, timing, summary, feedback, SCORM, unlock…26es fr de ru pl hu cs chItalian missing entirely; Chinese missing on 8 of the 26.
TGAdmin — home, tests, courses, test takers, ad-hoc reports, save/retrieve query, and their _export variants11ch_ZN onlyNine of the ten languages absent. The admin app was localised for a single Chinese customer and never for anyone else.
email1noneNo translation in any language.
LanguageTesterAdminAction
EnglishbaselinebaselineSource strings — extract to a catalogue.
Spanish26 / 260 / 11Import Tester; translate Admin.
French26 / 260 / 11Import Tester; translate Admin.
German26 / 260 / 11Import Tester; translate Admin.
Russian26 / 260 / 11Import Tester; translate Admin.
Polish26 / 260 / 11Import Tester; translate Admin.
Hungarian26 / 260 / 11Import Tester; translate Admin.
Czech26 / 260 / 11Import Tester; translate Admin.
Chinese18 / 2611 / 11The only language with Admin coverage. Merge _ch and _ch_ZN, fill 8 Tester gaps.
Italian0 / 260 / 11Translate from scratch — the only language with nothing at all.
Open question C is now answered: there is only one Chinese locale _ch and _ch_ZN are the same Simplified Chinese, not Simplified vs Traditional. Where both exist, the files are byte-identical — login, duallogin, taker_summary and unlock diff clean; only header differs, by a single line. The text is unambiguously Simplified (登录 / 用户名, not 登錄).

The tag split is by application, not by script: the Tester uses _ch, TGAdmin uses _ch_ZN. Merge both into a single zh-Hans catalogue and drop the distinction. If Traditional Chinese is ever wanted it is a genuinely new translation, not a re-tag.

The real cost is the Admin app, and it should not be paid twice

Naively, the gap is 11 admin stems × 9 missing languages ≈ 99 files, plus 26 Italian Tester files, plus email in ten languages. But Phase 3 rebuilds the admin app (§08), and the new web admin will not have the same 11 pages or the same string inventory as the 2005-era JSP admin — it has to cover the twelve FCOTG_* modules, which the legacy admin never did.

Decision Do not translate the legacy admin .properties. Import the Tester catalogues now (they are directly reusable and represent real paid-for work), and extract the admin string catalogue from the new admin as it is built, translating once at the end of Phase 3. Translating the legacy admin strings first would mean paying for two translation passes over a page set that is being replaced.

That reorders the i18n work into two pieces: Tester i18n (import 8 locales + Italian) sits right after Phase 2, and Admin i18n (10 locales over the new string set) closes out Phase 3.

Import mechanics — three traps in the existing files

Downstream consequences of ten locales

08Phase 3 — Admin / authoring

The largest phase, with no existing web implementation to copy — the Java tgadmin exposes 8 servlets against roughly 61,000 lines of VB6 in MainMenu alone. Scope it module by module against the 12 FCOTG_* tab controls, which are the product's real feature map.

ModuleWeb equivalentDifficulty
FCOTG_TGTest authoring — properties, sections, question editing, bank drawHardest. 6 bank-draw strategies, the retake matrix, timing, pass/fail, and the rich-text question editor. The editor now emits HTML, not RTF (2.4.21-b).
FCOTG_QBANKQuestion bank browse / search / importHigh — shares the editor and the topics taxonomy.
FCOTG_TAKERCandidate records, enrolmentModerate — CRUD plus three join tables and the taker_hier tree.
FCOTG_USERSAccounts, roles, permissionsModerate — ta_usertype 1/2/3 and the packed ta_permissions string. Add the legacy-hash report from 2.4.21-a here.
FCOTG_MEDIAMedia library + folder treeModerate — upload writes through BlobStore.
FCOTG_REPORTSReport launcherThin, once §09 exists.
FCOTG_LOCKSExam lock administrationLow.
FCOTG_LOGSAudit trail viewerLow — logs joined to log_types.
FCOTG_SysControlSystem options (admin table)Low, but admin has no primary key — write it by full-row match inside a transaction, never by a bare UPDATE with no WHERE.
FCOTG_TREEVIEW / TQUICKP / APPHELPNavigation, quick properties, helpAbsorbed into the web UI.
The Unicode fork does not get ported twice The archive maintains parallel ANSI and Unicode builds of both MainMenu and Tester. UTF-8 throughout plus Python 3 strings makes the distinction meaningless — one implementation covers both. Given the five-language requirement, this is not just a saving but a correctness improvement.

09Phase 4 — Reports

P4.1 — Recover the queriesVerified available1 wk

The 40 Crystal .rpt files are a closed binary format with no Python reader — treat them as unrecoverable layouts. The queries, which carry the business logic, are plain VB string literals in TGReports/modReportSQL.bas: 32 getTGRT_* functions, each returning complete SQL with a selstring filter spliced in. Extract all 32 mechanically and parameterise that splice properly.

The 8 .jrxml JasperReports files are XML and are readable — taker summary, test summary, test detail, course summary, each with a Chinese variant. They give the intended web layouts directly.

Cross-engine caveat: the recovered SQL uses Access-style nested parenthesised joins and unqualified date arithmetic. It runs on PostgreSQL as-is, but each of the 32 needs verifying on Oracle and SQL Server too — this is the part of the port most likely to hide dialect bugs.

P4.2 — The scratch-table contractCorrectness-criticalincluded

Item analysis and several other reports do not query live tables. They read tmp_questions, tmp_answer, tmp_summary and surveyorder, which the application fills and then deletes:

' MainMenu/modReports.bas:181-184
delete from tmp_questions where currusr_id = <user id>
delete from tmp_summary   where currusr_id = <user id>
delete from surveyorder   where currusr_id = <user id>
delete from tmp_answer    where currusr_id = <user id>

currusr_id is the only thing separating concurrent users' intermediate results, and it is part of the composite primary key on all four tables. Adequate for a single-user desktop app; not adequate for a web app with a connection pool. It must be bound to the session, filled and consumed inside one transaction, and cleaned up in a finally. Get this wrong and reports silently return another user's rows — with no error.

One oddity to preserve deliberately getTGRT_ItemAnalysis joins tmp_answer.section_id to tmp_questions.q_timeperquestion — a column carrying something other than its name, almost certainly deliberate reuse of a spare integer field. Replicate it and comment it. "Fixing" it changes report output.

10Phase 5 — Replacing SecureTG

The server side becomes Python; the lockdown cannot become a web page What SecureTG does today — a system-wide WH_KEYBOARD_LL hook swallowing Alt+Tab, Ctrl+Esc and the Windows key, disabling Task Manager and the taskbar, refusing to start under Terminal Services, minimising every other window — requires OS privileges no browser grants any page, by design. A plain browser can detect fullscreen exit, tab switching, focus loss and a second monitor, and suppress copy/paste and right-click. That is enough to detect and log that a candidate left the exam. It is not enough to prevent it.
P5.1 — Python kiosk clientDecision3–4 wk

Rewrite SecureTG.exe as a Python desktop app — PySide6 + QtWebEngine — which embeds Chromium and loads the Python web tester. Genuinely Python, while keeping the native privileges the job requires.

SecureTG behaviourPython equivalent
Embedded IE (IWebBrowser2)QWebEngineView, frameless, fullscreen, navigation restricted to an allow-list.
Keyboard hook in SecureTGDLL.dllctypes → SetWindowsHookEx(WH_KEYBOARD_LL). Identical Win32 call, same filter logic, no C++.
Disable Task Manager / taskbarSame registry and Shell_TrayWnd techniques via pywin32 — or better, Windows Assigned Access / Shell Launcher, which is supported and reversible.
Refuse Terminal ServicesGetSystemMetrics(SM_REMOTESESSION). Keep it.
Refuse to run if Outlook is openpsutil scan. Worth revisiting — a 2000s heuristic; a modern policy would look for screen-sharing and remote-control processes.
Blowfish-encrypted SecureTG.iniReplace with a signed config file. The current scheme encrypts keys and values with an in-process key — obfuscation, not security, and it makes support harder.
P5.2 — Replace the shared-secret handshakeMust not be ported as-is3–5 d

Today the kiosk proves itself with two constant MD5 hashes in HTTP headers — MD5("TGAdmin") in X-Tester and MD5("SecureTG") as User-Agent — and the server accepts Referer as a fallback. Anything that can set two headers is indistinguishable from a locked-down exam machine. Replace with either:

  1. Per-session HMAC — server issues a nonce; the kiosk returns HMAC(device_key, nonce ‖ session_id) per request. Device keys provisioned per machine, revocable. No PKI.
  2. Mutual TLS — client certificate per kiosk, verified at the reverse proxy. Stronger, at the cost of running a small CA.

Either way the server records which kiosk an attempt came from, which the current design cannot do at all.

Gate A request forged with hand-set headers is rejected. An attempt started on kiosk A cannot be continued from a browser or from kiosk B.

11Phase 6 — Edges

SCORM 1.2 export

Scorm.bas unzips a skeleton and rewrites launch.htm and imsmanifest.xml to point at <url>/scorm?tst_id=…&cl_id=…. Trivial in Python — but the route must keep that exact shape, because packages already in customers' LMSs have it baked in.

TWAIN scanning (TGScan) Open

Optical mark reading via Orion's TWAIN OCX, with geometry classes for three NCS/LXR sheet layouts. No browser can drive TWAIN. Either keep a small Python helper that scans locally and uploads, or move to scan-to-PDF-then-upload. Confirm it is still used before spending anything.

Licensing

clsKeyManager validates a Blowfish+Radix64 blob whose first 8 chars are MD5(org)[0:8], then an expiry date and a max-user count. A day's work in Python. Worth asking whether per-seat licensing still means anything when there is no client install.

updatebin auto-update

The LAN client-update channel becomes obsolete for the web tier. Keep the table, stop writing to it. The kiosk client does need its own update path — a signed manifest check is enough.

E-mail

SendMail.cls + modMXQuery do their own MX lookup and raw SMTP. Replace with smtplib against a configured relay. The message table drives scheduled notifications; that job moves to APScheduler.

Desktop / stand-alone David's call

If a desktop version survives, Access stays and so does the stand-alone disconnected tester (tst_st, standalone, encrypted .MDB score files). If not, both retire — but a "create a new empty system" path must be re-provided for server engines, since Clean/testgen.mdb is what installers seed from today.

12Still open

Q1–Q6 are answered. These remain, and none of them block starting 2.4.21.

#QuestionWhat hangs on it
ADoes David want a desktop version?Decides whether Access and the stand-alone tester live or die — and whether clsAccess's 21-block chain needs a 2.4.21 entry.
BFor detail, sidecar HTML columns or a shared content-addressed cache table?Storage on the largest table in the system. Behaviour is identical either way.
CWhere is the taker's locale stored — per taker, per class, per test, or browser-negotiated?The legacy tier chose by template suffix per request and persisted nothing. A per-taker preference would be a further schema change, so it needs settling before Phase 2 rather than after. (This slot previously held the _ch/_ch_ZN question — now answered in §07: one locale, inconsistently tagged.)
DIs TWAIN scanning still in use by any customer?Potentially deletes an entire workstream.
EWhich engine versions must be supported at the low end? "SQL Server 2000+" is stated; MySQL and Oracle floors are not.MySQL's type=innodb syntax and Oracle's varchar2/sequence behaviour differ enough across versions to affect the dialect layer.
FDo any customers run the Java tier on PostgreSQL 9.1+ today?Determines whether the backslash-corruption audit (§05) is hypothetical or urgent.

13Sequencing

Durations assume one experienced full-stack developer. Phase 3 is the one that will move.

#PhaseDepends onEst.Exit gate
02.4.21 schema release (VB6, existing toolchain) — password widening, RTF sidecars, PostgreSQL upgrade pathDecision A3–4 wkAll five engines upgrade 2.4.20 → 2.4.21 cleanly and idempotently; existing product still runs on the upgraded schema.
1Data access layer + dialect matrix + 138-query portP04–5 wkAll queries bound-parameter, row-for-row identical to the Java path, on two engines minimum.
2Tester web appP14–6 wkFull attempt across all 11 question types produces identical summary/detail rows.
4ReportsP13–4 wkAll 32 queries verified on PostgreSQL + Oracle + SQL Server; scratch-table isolation proven under concurrency.
2bTester i18n — import 8 existing locales, merge _ch/_ch_ZN to zh-Hans, translate Italian, fill 8 Chinese gapsP2, decision C3–4 wkAll 10 locales render every Tester page; PDFs embed Latin-2, Cyrillic and CJK without blank glyphs.
5Kiosk client + attestationP23–4 wkForged-header request rejected; lockdown verified on the target Windows build.
0c2.4.21-c foreign keys — audit, resolve, constrainP2 proven1–2 wkOrphan audit clean or dispositioned; constraints added on all engines; Tester still passes its gate.
3Admin / authoringP1, P2, P410–16 wkA test authored entirely in the web admin runs correctly in the new tester and reports correctly.
3bAdmin i18n — extract the catalogue from the new admin, translate 10 localesP33–5 wkAll 10 locales render every Admin page. Deliberately after P3 so the string set is translated once, not twice.
6Edges — SCORM, mail, licensing, scanningP2, P3; decisions A, D2–4 wkExisting SCORM packages still launch against the unchanged /scorm route.

Phase 4 runs in parallel with Phase 2 once Phase 1 lands. 2.4.21-c is deliberately split out and scheduled after the Tester port is proven — that is the condition attached to Q5. Adding foreign keys before the new write paths are demonstrated would surface every legacy insert-order bug at once, in production.

Why this stays low-risk After 2.4.21 the schema stops moving, and both the old and new systems read and write the same tables. Old and new can run side by side on one database for as long as needed: cut the Tester over first while authoring stays on VB6, cut authoring over later. There is no big-bang migration day, and rollback at any point is "point users back at the old binary".

Deliverables produced so far