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.
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.
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.
| Check | Result |
|---|---|
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.
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.
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.
Read out of ddl_*.cls, the dbup2d8 engine classes, and DBUtils.java. This is the table the Python data layer implements.
| Concept | Access | SQL Server | MySQL | Oracle | PostgreSQL |
|---|---|---|---|---|---|
| Boolean | yesno | bit | int(1) | number(1) | int2 |
| Boolean literal | 1 / 0 | 1 / 0 | -1 / 0 | -1 / 0 | '1' / '0' |
| Integer | long | integer | int(20) | number(20) | int8 |
| Short string | text(255) | nvarchar(255) | varchar(255) | varchar2(255) | varchar(255) |
| Long text | memo | ntext | text | clob | text |
| Decimal | double | float | double | number | numeric(20,4) |
| Binary | OLE object | image | longblob | blob | lo special |
| New ID | @@identity | @@identity | last_insert_id() | seq.nextval | nextval(seq) |
| ID timing | after insert | after insert | after insert | before insert | before insert |
| Date literal | #…# | '…' | '…' | to_date('…','…') | '…' |
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:
lo contrib extension must exist before the schema is created.lo_manage trigger per BLOB column, or every media replacement grows the database permanently. This is a trigger, not a schema change:
CREATE TRIGGER t_mediabin_pic BEFORE UPDATE OR DELETE ON mediabin FOR EACH ROW EXECUTE FUNCTION lo_manage(pic);
logobin.pic and updatebin.data.BlobStore interface with five implementations. Do not let lo semantics leak into application code.
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
primary key (id) on mediabin; PostgreSQL and SQL Server do not. The schema is not uniform across engines today. Any code that assumes a PK on mediabin will work on two engines and fail on two.type=innodb — the old syntax, replaced by ENGINE= in MySQL 5.5 and removed later. Fresh-create on a modern MySQL will need this updated. It is also what makes transactions work at all on MySQL; MyISAM would silently discard them."size" because it is reserved. Column-name quoting rules differ per engine and must live in the dialect layer.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.
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.
ta_password for modern hashingNewCurrent 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.
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:
| Table | RTF column | New column | Note |
|---|---|---|---|
| tg_questions | q_textrtf | q_texthtml | Question stem. |
| qb_questions | q_textrtf | q_texthtml | Question bank copy. |
| tmp_questions | q_textrtf | q_texthtml | Report scratch table — see §09. |
| detail | de_answerrtf, de_notesrtf, de_feedbackrtf | de_answerhtml, de_noteshtml, de_feedbackhtml | Sizing risk — see below. |
| tests | tst_print_tmpl | — | Print template, consumed by the RTF print engine, not displayed as HTML. Leave alone. |
| admin | print_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.
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.
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.
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:
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.detail rows are junk, but orphaned summary rows are somebody's exam result and deleting them silently is not acceptable.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.
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.
| Layer | Choice | Reason |
|---|---|---|
| Data access | SQLAlchemy Core + per-engine dialects | Core 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. |
| Drivers | psycopg 3 · pyodbc / pymssql · mysqlclient · oracledb | One per engine, selected by config, mirroring how CDAC_ADO chooses a provider today. |
| Web framework | FastAPI + Uvicorn | Explicit 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. |
| Templates | Jinja2 | The 62 .tmpl pages use var / if / else / unless / loop / include; all have direct Jinja2 equivalents. Mechanical translation. |
| i18n | Babel, seeded from the existing .properties | 282 translation files already exist, covering 9 of the 10 locales in the Tester tier. §07. |
| Sessions | Server-side; Redis or a database table | Exam state must survive a worker restart mid-exam. |
| Reports | Ported 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. |
| Kiosk | Python + PySide6 (QtWebEngine), Windows | §10 — a browser tab cannot do this job. |
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.
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.
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.
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().
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:
summary, seed detail, acquire the locks row.detail, recompute summary aggregates.su_finished, write summary_section, release lock, append to logs.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.
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.
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.
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 today | Python route | Notes |
|---|---|---|
| LoginServlet, DualLoginServlet, SSOLogin | POST /login, /login/sso | Three 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). |
| TestListServlet | GET /tests | Honours tst_active, tst_active_from/to, enrolment via takers_classes / takers_tests. |
| ViewDisclaimerServlet | GET /test/{id}/disclaimer | tst_disc 0 none / 1 system / 2 per-test. |
| BeginTest, BeginSectionMessage, SelectMessage | POST /attempt | The transactional boundary from P1.3. |
| LoadQuestion, DispQuestion, PreviewQuestion | GET /attempt/{sid}/q/{n} | 11 question types. Reads q_texthtml, converting from q_textrtf only if null (2.4.21-b). |
| PointClickServlet | POST /attempt/{sid}/q/{n}/pac | Hot-spot coordinates against the question image. |
| ShowTimed, ShowSectionTimed, TimeUp, SectionTimeUp | server-clock endpoints | Never trust the browser clock. Deadline computed server-side from su_datestarted + tst_timelimit; tst_accumulatetime changes the arithmetic. |
| SectionCriteriaEnd, EndSection, EndTest | POST /attempt/{sid}/finish | Pass/fail per tst_showpassfail — percentage or all-sections-passed. |
| SummaryList, TestReport, ViewTakerSummary | GET /results/* | Shares the report layer (§09). |
| CertificateServlet | GET /certificate/{sid} | PDF; iText → WeasyPrint. Needs CJK fonts embedded (§07). |
| MediaBank, ViewQuestionMedia, ViewMedia, ViewLogo | GET /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, Unlock | misc | Direct ports. |
| SCORMLauncher | GET /scorm | Path must not change — deployed packages hard-code it (§11). |
| 3 Quartz jobs | APScheduler | ScheduleNotification, DeleteObsoleteLocks, ClearRTFCache — the last becomes far cheaper once RTF is converted once and stored. |
summary/detail rows match the Java tier's output for identical inputs. Repeated on two engines.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.
Measured across 282 .properties files, deduplicated to 38 distinct template stems:
| Template group | Stems | Locales present | Gap |
|---|---|---|---|
| Tester — login, test list, disclaimer, questions, timing, summary, feedback, SCORM, unlock… | 26 | es fr de ru pl hu cs ch | Italian missing entirely; Chinese missing on 8 of the 26. |
TGAdmin — home, tests, courses, test takers, ad-hoc reports, save/retrieve query, and their _export variants | 11 | ch_ZN only | Nine of the ten languages absent. The admin app was localised for a single Chinese customer and never for anyone else. |
email | 1 | none | No translation in any language. |
| Language | Tester | Admin | Action |
|---|---|---|---|
| English | baseline | baseline | Source strings — extract to a catalogue. |
| Spanish | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| French | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| German | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| Russian | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| Polish | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| Hungarian | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| Czech | 26 / 26 | 0 / 11 | Import Tester; translate Admin. |
| Chinese | 18 / 26 | 11 / 11 | The only language with Admin coverage. Merge _ch and _ch_ZN, fill 8 Tester gaps. |
| Italian | 0 / 26 | 0 / 11 | Translate from scratch — the only language with nothing at all. |
_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 登錄).
_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.
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.
Descripción), 26 are UTF-8 with a BOM, and 4 are in legacy single-byte encodings with raw high bytes — taker_summary_es, summarylist_topnav_fr, summarylist_bottomnav_fr and disp_questions_header_fr. Detect per file; do not assume one input encoding.登.iTextAsian.jar; the Latin-2 and Cyrillic halves need verifying, since Crystal and Jasper handled them through the OS font stack..jrxml files exist only as default + _ch_ZN. Rebuilt as HTML/WeasyPrint templates (§09), they inherit the same catalogue as everything else — so report localisation stops being a separate artefact per language.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.
| Module | Web equivalent | Difficulty |
|---|---|---|
| FCOTG_TG | Test authoring — properties, sections, question editing, bank draw | Hardest. 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_QBANK | Question bank browse / search / import | High — shares the editor and the topics taxonomy. |
| FCOTG_TAKER | Candidate records, enrolment | Moderate — CRUD plus three join tables and the taker_hier tree. |
| FCOTG_USERS | Accounts, roles, permissions | Moderate — ta_usertype 1/2/3 and the packed ta_permissions string. Add the legacy-hash report from 2.4.21-a here. |
| FCOTG_MEDIA | Media library + folder tree | Moderate — upload writes through BlobStore. |
| FCOTG_REPORTS | Report launcher | Thin, once §09 exists. |
| FCOTG_LOCKS | Exam lock administration | Low. |
| FCOTG_LOGS | Audit trail viewer | Low — logs joined to log_types. |
| FCOTG_SysControl | System 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 / APPHELP | Navigation, quick properties, help | Absorbed into the web UI. |
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.
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.
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.
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.
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.
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 behaviour | Python equivalent |
|---|---|
Embedded IE (IWebBrowser2) | QWebEngineView, frameless, fullscreen, navigation restricted to an allow-list. |
Keyboard hook in SecureTGDLL.dll | ctypes → SetWindowsHookEx(WH_KEYBOARD_LL). Identical Win32 call, same filter logic, no C++. |
| Disable Task Manager / taskbar | Same registry and Shell_TrayWnd techniques via pywin32 — or better, Windows Assigned Access / Shell Launcher, which is supported and reversible. |
| Refuse Terminal Services | GetSystemMetrics(SM_REMOTESESSION). Keep it. |
| Refuse to run if Outlook is open | psutil scan. Worth revisiting — a 2000s heuristic; a modern policy would look for screen-sharing and remote-control processes. |
Blowfish-encrypted SecureTG.ini | Replace 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. |
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:
HMAC(device_key, nonce ‖ session_id) per request. Device keys provisioned per machine, revocable. No PKI.Either way the server records which kiosk an attempt came from, which the current design cannot do at all.
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.
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.
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-updateThe 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.
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.
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.
Q1–Q6 are answered. These remain, and none of them block starting 2.4.21.
| # | Question | What hangs on it |
|---|---|---|
| A | Does 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. |
| B | For detail, sidecar HTML columns or a shared content-addressed cache table? | Storage on the largest table in the system. Behaviour is identical either way. |
| C | Where 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.) |
| D | Is TWAIN scanning still in use by any customer? | Potentially deletes an entire workstream. |
| E | Which 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. |
| F | Do any customers run the Java tier on PostgreSQL 9.1+ today? | Determines whether the backslash-corruption audit (§05) is hypothetical or urgent. |
Durations assume one experienced full-stack developer. Phase 3 is the one that will move.
| # | Phase | Depends on | Est. | Exit gate |
|---|---|---|---|---|
| 0 | 2.4.21 schema release (VB6, existing toolchain) — password widening, RTF sidecars, PostgreSQL upgrade path | Decision A | 3–4 wk | All five engines upgrade 2.4.20 → 2.4.21 cleanly and idempotently; existing product still runs on the upgraded schema. |
| 1 | Data access layer + dialect matrix + 138-query port | P0 | 4–5 wk | All queries bound-parameter, row-for-row identical to the Java path, on two engines minimum. |
| 2 | Tester web app | P1 | 4–6 wk | Full attempt across all 11 question types produces identical summary/detail rows. |
| 4 | Reports | P1 | 3–4 wk | All 32 queries verified on PostgreSQL + Oracle + SQL Server; scratch-table isolation proven under concurrency. |
| 2b | Tester i18n — import 8 existing locales, merge _ch/_ch_ZN to zh-Hans, translate Italian, fill 8 Chinese gaps | P2, decision C | 3–4 wk | All 10 locales render every Tester page; PDFs embed Latin-2, Cyrillic and CJK without blank glyphs. |
| 5 | Kiosk client + attestation | P2 | 3–4 wk | Forged-header request rejected; lockdown verified on the target Windows build. |
| 0c | 2.4.21-c foreign keys — audit, resolve, constrain | P2 proven | 1–2 wk | Orphan audit clean or dispositioned; constraints added on all engines; Tester still passes its gate. |
| 3 | Admin / authoring | P1, P2, P4 | 10–16 wk | A test authored entirely in the web admin runs correctly in the new tester and reports correctly. |
| 3b | Admin i18n — extract the catalogue from the new admin, translate 10 locales | P3 | 3–5 wk | All 10 locales render every Admin page. Deliberately after P3 so the string set is translated once, not twice. |
| 6 | Edges — SCORM, mail, licensing, scanning | P2, P3; decisions A, D | 2–4 wk | Existing 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.
ddl_postgres.cls, with 49 indexes and 16 CREATE SEQUENCE statements. Executes clean on PostgreSQL 15.18.