Changelog¶
Version 0.4.0 (2026-09-06)¶
Added
course --invite-google-classroom(-igc), the first write path from the local database to Google Classroom, with--gc-invite-email/-domain/-section/-classfilters, a numbered selection unless--gc-invite-allis given, and--gc-invite-roleSTUDENTorTEACHER.The invite is idempotent in three layers: the roster and teacher list are read first, pending invitations are matched against the stored
Google_IDbefore anyuserProfiles.getfan-out, and a409 ALREADY_EXISTSis recorded asskipped_pending. A400 FAILED_PRECONDITIONis a skip, a403is course-wide and stops the loop, and429/500/503are retried with bounded backoff.Successful invitations record
Google_ID(filled only when empty), plusGoogle_Invitation_IDandGoogle_Classroom_Invited_At, which describe the latest invitation and are rewritten each time.Added
course --add-csv FILE, a fixed-contract CSV source for the database that needs no Google account:Student IDandNameare required, as is at least one ofEmailandGitHub Username; headers are matched case- and accent-insensitively; and the file is decoded as UTF-8, then Windows-1252, then Latin-1, naming the fallback.Added
course --onboard, one interactive pass from a registration CSV to both platforms: pick a Google Classroom course, pick a Classroom50 classroom, pick the CSV, add whoever is missing. Either platform can be skipped.--onboard-csvskips the file prompt,--onboard-skip-github-checkvalidates usernames by syntax alone, and the course, org, classroom, and report selectors are the existing flags.--onboardnow takes a configured course, org, and classroom as defaults. The two Classroom50 keys were missing from theload_configwhitelist and the resolver returned on the first config file it found, silencing the environment tier; all three selectors now fall through flag, then config, then environment, and a value that was not typed for this run is shown and confirmed before use. Declining falls through to the picker; off a terminal and under--dry-runthe default is taken and named.Google authorization now works without a browser, and finishes in one terminal.
run_local_serveropens the browser before it prints the authorization URL and does not guard that call, so on a headless server the URL was never shown; it also blocks until a browser reaches its loopback port, so the process printing the URL can never also read the answer. A machine with a browser still uses the loopback server. A machine without one starts no listener: it sets the redirect URI tohttp://127.0.0.1:8910/, prints the URL to open elsewhere, and exchanges the failed redirect pasted back at a hidden prompt. The paste is validated, and its state must match the run that printed the URL.--no-open-browserforces that path on a machine that does have a browser.course-gclass-admin authorizefinishes the same way, on its ownhttp://127.0.0.1:8911/redirect, and now looks for a browser instead of assuming one. The paste is held to the boundarycomplete-loopbackenforces and granted scopes are still verified before a token is written.complete-loopbackis unchanged and still serves an authorization that is already listening.The header reader now matches Google Form columns, whose bilingual parentheses and required-question asterisk previously matched two of nine. Each attribute also takes the best-ranked column claiming it, so a form carrying a school address and a personal one lands the school address in
Emailand the personal one inPersonal Email. Every column read is written: the mapped ones under their attribute name, the rest under their own header, so a question added to the form later needs no alias to be kept and a later question about a form column is answered from the database rather than from the sheet. The date of birth is written asDob, the nameread_students_from_excel_csvalready uses, so both import lanes leave one spelling behind.A student who fills the form again to correct a mistake now reaches the database. The merge that runs on every save filled only fields that were empty, so a correction to an answer already stored was dropped without a word. Between two records that both carry a readable
Timestamp, the later one now wins and every replaced value is printed. ATimestampmissing on either side, one that does not parse, and two equal ones all keep the older fill-what-is-empty rule, so the Google Classroom and Classroom50 records, which carry noTimestamp, can neither overwrite nor be overwritten, re-importing the same sheet changes nothing, and the outcome no longer depends on the order rows arrive in.Google_*andAdditional *fields are never overwritten. A newer submission that changesGitHub Usernameclears any storedGitHub ID, becauseroster importre-resolves the id from the username and fails the line when the two disagree. Two conflicting sections are still kept side by side asAdditional Sectionwhen noTimestamptells them apart; between two submissions the later section is a correction and replaces the earlier one.That merge now actually runs on the Google Sheet lane.
read_students_from_excel_csvandread_students_from_pdfeach carried a private copy of the deduplication loop and collapsed every duplicate beforesave_databasewas reached, so the rule above saw no pair to decide and a corrected submission was dropped however often the import ran. Both copies are gone and both lanes hand their rows tosave_database, as--add-csvalready did. The PDF copy also tested two names it never defined, which would have raisedNameErroron the branch that compares two records sharing no student number.Two records are one student when they share a
Google_ID, or aStudent ID, or an email address while at most one of them carries a student number, or a name while neither does.Google_IDis one Google account and so one person whatever the two rows call them; the email key preserves what the deleted sheet-lane copy did, and is refused when both sides carry a student number, because a shared address is a typo or a family and merging two students loses a whole registration.TimestampandDấu thời gianare recognised column headers. The submission time previously survived only through the keep-unknown-columns branch and only for a form written in English; a Vietnamese form lost it, and with it every “the later one wins” decision.No import drops a row in silence any more. A student number that is not 8 digits keeps the registration: the value moves to
Invalid Student ID,Student IDis left empty so the roster export and the grade join stay clean, and the student is named on stdout. A row that names nobody is still dropped, but by name and address.refine_database, which runs on every save, likewise prints what it removed instead of only how many. A barecontinuehere is what made a student who had filled the registration form indistinguishable from one who never did.--add-google-sheetaccepts--dry-run: it reads and merges exactly as a real import would, prints each field a newer submission would replace, and writes nothing.--sync-google-classroommatches onGoogle_IDbefore any string. Matching on display name, name and address alone created a second row for every student who joined Classroom with a personal address and filled the form with the school one – the source of MAT3508’s 70 database rows against 67 Classroom accounts. The display name is now read under both spellings it has been stored as and written under one, so a name the sync had just stored is found on the next run instead of being rewritten every time.The sync no longer guesses when nobody is there to ask. An ambiguous record with no terminal is skipped and reported rather than resolved, because creating a row is the one outcome that re-running cannot undo; the course picker says which flag to pass instead of raising
EOFError. After each sync, rows carrying aGoogle_IDthe course no longer returns, rows carrying none at all, and records skipped as ambiguous are each listed with both counts. Nothing is deleted or marked: only the teacher can tell a student who left from one who filled the form before being enrolled.--list-duplicate-accountscompares people rather than records. It was handed the raw roster, so two rows of one student were compared with each other and the toolkit reported its own duplicate as a student holding two Google accounts – two of MAT3508’s three findings. Records are grouped into people first, each person is named by the row they filled in themselves, and a pair found under both the name and the address signal is reported once.A student number rejected at import is a finding of its own. Its owner is no longer listed as never having answered the form, the new
student_id_invalidcheck says what is wrong with the number rather than only that it is – how many digits it has and how many are missing or spare – and the emptyStudent IDleft behind is not also reported as a formatting fault.The audit asks GitHub whether each registered username exists by default, whenever
ghis installed;--no-audit-check-githubturns it off and an absentghskips it. The check existed but was off unless requested, so a username that is well-formed and belongs to nobody passed every report, and nothing else in the audit can see it.--list-classroom50-membershipcounts from the live roster instead of the local database, and separates four groups: in the organization, invitation pending, on the roster and not yet invited, and in the database but never imported to the roster. The last two were one pile, and they need different commands. Usernames are compared case-insensitively, since the database export lower-cases them and the roster keeps GitHub’s own capitalisation. When the roster cannot be read, the header says the count came from the database.Addresses and GitHub usernames are validated per student, the username against GitHub’s own account rule and then against
gh api /users/<name>; an unreachable API marks the name unverified rather than invalid. Bad rows are skipped with a reason and the run continues; repeat submissions collapse onStudent ID; two students claiming one username drop both.Everything reads before anything writes: the roster is pulled into the database, then
gh teacher roster syncreports, whose exit 2 means changes are pending and stops the run, then both platforms are read into one comparison table shown before the single confirmation.--dry-runcalls neither platform.Added
course-c50-admin roster-importandroster-add, which write the Classroom50 roster and, asgh teacher roster importdoes once the commit lands, invite every listed non-member. Because the commit precedes the invitations, a matching roster alone does not end the run: the organization membership is read too, and--forceskips that check.python -m course_hoanganhduc.c50_agentrefusesroster-importandroster-add, andgclass_agentrefusesinvite; no agent-safe write path to either platform was added.The
gh teacherextension was upgraded from v1.25.1 to v1.45.0 (commita5a51293b65d) and re-pinned, for the invalid-UTF-8 and concurrent-acceptance fixes that bear onroster-import. No toolkit code changed for the upgrade, and the version-coupled constants are now listed in the README.Added
course --import-classroom50-scores, which reads the classroom’s collected marks from<classroom>/scores.jsonin the configuration repository rather than from a downloaded submission tree. That is one authenticated read, the same class of operation as--list-classroom50-roster, soimport-scoresis available on the restrictedc50_agententrypoint wheredownloadstays refused.--classroom50-assignmentlimits it to one slug and--classroom50-scores-reportwrites the JSON report.Marks land in
Classroom50 Grades,Classroom50 Submissions,Classroom50 Submission DetailsandClassroom50 Score Overrides, keyed by assignment slug. Entries inscores.jsonare keyed by repository owner, so a group entry is spread over itsmember_usernames– the set Classroom50 actually credits – less the teacher, HTA and TA teams, which appear on every repository because the graders are organization owners.scores.jsonmoves only when a teacher presses Sync now, so the file’s own last-commit date is stored inClassroom50 Scores Collected At, not the clock of the machine that read it. A snapshot older than an assignment’s deadline recordsunknown (stale)and nevernot submitted, because it cannot tell that apart from a submission made since the last collection; one taken before the assignment opened recordsnot collected yet.final-projecthas grading off and an empty repository, so it never appears inscores.json, not even as an empty bucket. Asking for that slug says so and exits0rather than failing. Its marks are entered through the existing--load-override-gradeslane instead, which gainsOverride Final Projectand the free-textOverride Final Project Reason; both are audited.Added
course --import-classroom50-groups, which reads group membership for themode=groupassignments. That mode is a shared repository through collaborators and not a GitHub Team, sogh teacher teamrefuses it, and membership is the repository’s collaborator list intersected with the classroom team: the owner is always present, and the filter is team membership and never permission, because a founder kept as repository admin to invite teammates is still a student.--classroom50-group-assignmentlimits it to one slug,--classroom50-read-team-jsonalso readsteam.jsonfrom each group repository, and--classroom50-groups-reportwrites the JSON report.A group whose sources disagree, which holds more than five students, or which shares a student with another group keeps whatever it already had. The reason is recorded in
Classroom50 Group ConflictsandMiniProject_Group_Conflict, and the rest of the class still imports. Findings areroster_audit.Issuevalues, so the consequential ones reach the existing announcement text without a second reporting path.Added
course --import-project-issues, which reads the mini-project topic board and matches proposals to students by GitHub username alone, which is all the issue form asks for. Nothing is written back: no label is set and no comment is posted. Staff’s recorded choice of the canonical issue outranks the earliest-valid rule, so an old issue repaired later cannot displace it.--project-issues-reponames the board, orCLASSROOM50_PROJECT_ISSUES_REPOdoes;--project-issues-read-proposalalso readsproposal/proposal.mdat each pinned commit, and--project-issues-reportwrites the JSON report.All three imports honour
--dry-run, which prints the same report and stops before the save, and none of them creates a student: a username the database does not know is reported as unmatched.--export-to-excelgrows aC50: <slug>column per collected assignment beside the Google Classroom and Canvas ones, and every new scalar reaches both it and--export-all-details. The six Classroom50 containers are held back from the plain key loop of both detail views through one shared tuple, rather than the two that had already drifted apart, and both views render them from the same bilingual formatter.
Version 0.3.1 (2026-08-30)¶
course-c50-admin assignment-addnow covers autograded weekly registration:--tests,--feedback-pr/--no-feedback-pr,--pass-threshold,--allowed-files(repeatable, order preserved), and--student-permission.--testsis validated before the call: a readable JSON file holding a bare array of test specs. The pinned CLI’s-stdin form is not offered, because no operand may begin with-.--empty-repois refused together with--template,--tests,--feedback-pr,--allowed-files, or--pass-threshold, matching the pinned CLI, where the empty-repository setting is immutable after creation.Fixed CLI Reference, which listed six
assignment-addflags before they were implemented;--lockedis not a flag ofgh teacher assignment addat all.
Version 0.3.0 (2026-08-29)¶
Added
course-c50-admin, a human-only Classroom50 operator CLI withassignment-add,assignment-remove,invite, anddownload. Each verb refuses in agent mode, requires an interactive terminal unless--dry-runis given, builds one fixedgh teachercommand line, and redacts secrets from captured output.assignment-addrefuses an existing slug, because the pinned CLI replaces the entry in place and cannot set submission mode, so a repeat restores every-push mode and discards a tagged-commit setting made in the web form. Overriding needs--allow-overwriteand an interactive confirmation; there is no--yes.assignment-removerefuses an absent slug and states that student repositories survive.invitepreflights organization targets againstgh teacher member list, since organization invitations are not idempotent; repository targets need no preflight.download --by-patternis permitted only for empty-repository assignments, decided by reading the assignment record rather than by a blanket ban.
Version 0.2.0 (2026-08-28)¶
Assignment creation uses one readable yes/no summary instead of exact typed
AUTH/CREATE/SHAREphrases.--yessupports noninteractive drafts without Drive-sharing effects while publication, scheduling, Drive sharing, and general agent-mode creation continue to fail closed.Added a separate cooperative agent smoke-test path for one minimal draft. It binds an approval envelope to the account, canonical course, OAuth client, token source, and frozen operation; atomically requires an existing token; serializes all-state duplicate detection through strict read-back; and leaves general agent mutation refused.
Hardened the mutation transport against connection retries, redirects, and response-triggered OAuth request replay. The agent-safe path also uses narrow response projections and requires an explicit
ACTIVEcourse state.Verified the minimal no-attachment smoke workflow end to end against the exact allowlisted pilot course: one
DRAFTwas created, fetched by ID, and matched against the approved shape. Offline coursework, auth/transport, admin, restricted agent, and Classroom50 suites pass alongside all CLI parse cases.Added the administrator
course-gclass-admincommand for offline assignment previews, isolated OAuth authorization/status, and confirmed assignment creation.Added complete stable REST v1 assignment request builders for scheduling, due dates, grading, assignees, topics, grading periods, Drive/link/YouTube materials, and optional inline scored or unscored rubrics.
Added zero-retry mutation orchestration. Rubric workflows create a draft first, attach the rubric, and only then publish or schedule; ambiguous and partial outcomes expose recovery identifiers without silently retrying or deleting.
Course aliases are resolved before mutation, and staged releases revalidate scheduling and deadline constraints immediately before publication/scheduling.
Added account-scoped JSON coursework tokens with a fixed, verified grant set, canonical OAuth endpoints, strict POSIX file checks, atomic writes, and per-token locks. The legacy pickle token is not read by this surface.
OAuth replacement now explicitly requests offline consent and preserves existing storage unless Google returns a durable refresh token.
OAuth token parsing accepts Google’s additional
openid/emailidentity scopes without enabling global relaxed-scope handling; unrelated or missing permissions still fail closed.Added a hidden-input, direct-loopback callback helper for headless remote OAuth; OAuth dependency logging is suppressed during the exchange so callback codes and bearer tokens are not emitted by verbose library loggers.
The Google Classroom agent entrypoint now explicitly refuses
create-assignment.Added Classroom50 (foundation50) integration: wrap
gh teacherfor preflight, list classrooms/roster/assignments, roster sync into local DB, C50 CSV export, and human-only submission download (--download-classroom50).Added agent-safe entrypoints:
python -m course_hoanganhduc.c50_agent,canvas_agent,gclass_agent,db_agent(force agent mode; refuse destructive LMS/DB ops; org/course allowlists fail closed).Shared helpers in
course_agent_common; GitHub numeric id kept separate from Username in student identity maps (data.py).CLI flags under the Classroom50 group (see CLI Reference).
Version 0.1.4 (2026-01-25)¶
Enhanced
--list-submission-statusto show attachment details (file count, size, type, upload time).Updated Canvas and Google Classroom sync to fetch detailed attachment info (supports Drive files, Links, Forms, YouTube videos).
Updated
--export-all-detailsto include full attachment breakdown.Refined default sort order for roster exports: Section > First Name > Last Name > Student ID.
Implemented Vietnamese-specific collation for correct alphabetical sorting (e.g., handles ‘Â’, ‘Đ’ correctly).
Version 0.1.3 (2026-01-25)¶
Improved
export_companies_to_vcfwithraw_datafallback logic to extract contact person and phone numbers.VCF exports (students and companies) now skip entries with no phone number information.
Enhanced company VCF export filename detection to use
companies_contacts.vcfby default.Improved company data import column mapping (better Vietnamese keyword support and email priority).
Fixed
UnboundLocalErrorincourse --import-internshipsby adding missing local import.Fixed
UnpicklingErrorincourse --export-vcf(and other commands) when loading SQLite databases withcompaniestable.Added company field mappings for VCF export and student loading.
VCF export now strictly uses
UNIVERSITY_NAMEfrom config (defaults to empty if not set) instead of hardcoded default.
Version 0.1.2 (2026-01-24)¶
Added
--import-registrationscommand to support student internship registration data (skills, wishlist, notes).Added
INTERNSHIP_REGISTRATION_SHEET_URLconfiguration key.Added company contact management with
--import-companiesand--export-companies, stored incompanies.db.Improved export formatting for student details (JSON unwrapping for progress reports, translated labels, hidden empty fields).
Added support for English headers in internship data and registration imports.
Updated documentation and sample data to English.
Version 0.1.1 (2025-12-28)¶
Added multi-file glob support for import/update CLI commands.
Added student detail sort methods and clearer export/report formatting.
Normalized Canvas/Google Classroom sync scores to a 10-point scale when possible.
MAT*.xlsx roster imports now ignore score columns (CC, GK, CK, totals).
MAT Excel updates can infer missing student IDs from VNU University of Science, Hanoi emails.
Added resubmission grading workflow with optional keep-old-grade default.
Canvas sync stores submission comments and rubric evaluations.
Canvas grade parsing now falls back to Unposted Final Score when Final Score is empty/zero (CC/GK/CK).
Final evaluation reports omit assignment-group scores when all component scores are 0.
Final evaluation weights are configurable (
WEIGHT_CC,WEIGHT_GK,WEIGHT_CK) and the TXT output includes the formula.Added course calendar builder (TXT/Markdown/ICS) with holiday exclusions, unofficial holidays, and make-up week logic.
Course calendar titles include course code/name and canceled sessions are tagged.
Added Canvas calendar import from iCal (.ics) files with dry-run and duplicate skipping.
Improved Canvas announcement flow with short input, AI refinement, and confirmation before posting.
Added auto-generated short aliases for long-only CLI flags.
Added
--list-cli-aliasesto display auto-generated short aliases.Added duplicate-name reporting (Name/Google Classroom/Canvas display names) with TXT/CSV/JSON exports.
Documentation and samples updated for new sync, calendar, and local AI tooling.
Version 0.1.0 (2025-12-27)¶
Packaged the original script as a Python CLI with install helpers and standardized flags.
Added course-scoped config/credential storage with cached course codes.
Rebuilt the no-args menu with sections, arrow-key navigation, and numeric quick-jump.
Added
--clear-configand--clear-credentialshelpers plus Windows/Linux compatibility fixes.Canvas/Google Classroom sync now resolves duplicates; Canvas grade sync stores final scores only.
Added override grades (flexible headers/aliases) with clearer reporting in MAT exports.
Added AI model testing/listing, rate-limit fallback, and model details in reports.
Expanded submission quality checks with configurable thresholds and richer diagnostics.
Added backup/restore commands, dry-run mode, validation reports, grade audit history, and grade diff exports.
Added import previews, anonymized exports, and per-run summaries in
run_report.txt.Added weekly automation workflow generation and non-interactive Canvas checks/reminders.
Added local weekly automation with archived reports and flagged-submission evidence.
Weekly workflow now clones the toolkit, archives reports with DB backups, and tags evidence by assignment.
Weekly automation can auto-detect closed assignments not yet in weekly reports.
Added local LLM support (Ollama-compatible) for AI refinement and message generation, with CLI overrides.
Added local model detection via
--detect-local-ai(Ollama or llama.cpp) and optional.ggufscan.Updated docs and samples; added GPL-3.0-only license.