Examination Middleware is a robust, secure, and automated bridge designed to streamline the digitization and submission of physical examination answer sheets to the Moodle Learning Management System (LMS). It acts as an intelligent intermediary between the physical examination hall and the digital grading environment.
The codebase has received several maintenance and UX updates to improve safety, auditing and staff workflows. Highlights:
-
New maintenance scripts:
-
setup_username_reg.pyβ upsert a single Moodleusername -> register_numbermapping. Useful to seed or correct mappings used during student login and pending-list authorization. Example:# interactive python setup_username_reg.py # direct python setup_username_reg.py --username 22007928 --register 212222240047
-
setup_subject_mapping.pyβ interactive workflow to find an assignment by course-module-id (CMID) in Moodle, create or update aSubjectMapping, and optionally fix existing artifacts that reference the wrong assignment id.
-
-
Staff UI changes:
app/templates/staff_upload.htmlnow contains a Reports modal (view/resolve/edit/delete reports) and improved listing behaviour.- The
Total Uploadedstat is computed from the visible (non-deleted) artifacts returned by the listing endpoint, so the navbar count now matches the visible table ("Showing X of Y files"). - Client-side behaviour was hardened: the student/staff login flows no longer call
localStorage.clear(); only session keys are removed on logout.
-
Backend/service changes (important for deploy and troubleshooting):
app/services/artifact_service.pywas hardened to explicitly catchIntegrityErroron DB flush/commit and to rollback safely. This reduces silent failures for duplicate transaction IDs.get_pending_for_student()requires either a valid 12-digitregister_numberOR bothmoodle_user_idandmoodle_username. This prevents ambiguous or leaking results when student identity is unclear.- Admin remediation: if a deterministic
transaction_idcollides with an existing (deleted/archived) artifact, clearing or nulling thetransaction_idin the DB row will allow a re-upload. Use theaudit_logsandExaminationArtifacttable to locate problematic rows before manual edits.
-
API and client notes:
- Staff UI attempts multiple common listing endpoints (e.g.
/api/upload/all,/upload/all,/api/artifacts) to be resilient against different backend deployments. Prefer endpoints that return an array ofartifactsfor best UI compatibility. - Consider returning HTTP 409 for DB integrity conflicts (duplicate transaction / unique constraint) so the client can surface a clear message instead of a generic 500.
- Staff UI attempts multiple common listing endpoints (e.g.
In academic institutions transitioning to digital grading, handling physical answer scripts presents significant logistical challenges:
- Manual Labor: Individually scanning, renaming, and uploading hundreds of answer scripts to specific Moodle assignments is time-consuming and inefficient.
- Human Error: Manual processes are prone to errors such as uploading the wrong file to a student's profile or mislabeling files.
- Security & Integrity: Direct database manipulation or unverified bulk uploads can compromise the chain of custody.
- Student Verification: Students often lack a mechanism to verify that their specific physical paper was scanned and submitted correctly before grading begins.
This middleware solves these issues by decoupling the scanning/uploading process from the submission process, introducing a secure validation layer.
The system utilizes a 3-Step "Upload-Verify-Push" Workflow:
- Bulk Ingestion: Administrative staff upload bulk batches of scanned PDF/Images.
- Intelligent Processing: The system parses filenames (e.g.,
123456_MATH101.pdf) to extract the Student Register Number and Subject Code, automatically mapping them to the correct Moodle Assignment ID. - Student-Led Submission: Students log in using their Moodle credentials. They view only their specific answer scripts and trigger the final submission to Moodle. This ensures non-repudiation and student verification.
graph LR
A[Physical Scans] -->|Bulk Upload| B(Staff Portal / Middleware)
B -->|Parse & Store| C{PostgreSQL DB}
D[Student] -->|Login via Moodle Creds| E(Student Portal)
E -->|Fetch Pending Papers| C
E -->|Trigger Submission| F[Moodle LMS]
F -->|Token Exchange| E
The database is designed for data integrity and auditability. Key models include:
| Model | Description |
|---|---|
ExaminationArtifact |
The core entity representing a scanned paper. Stores UUID, file path, hash (SHA-256), extracted metadata (Reg No, Subject), and current WorkflowStatus (e.g., PENDING, SUBMITTED_TO_LMS). |
SubjectMapping |
Configuration table mapping a Subject Code (e.g., 19AI405) to a specific Moodle Course ID and Assignment ID. |
StaffUser |
Accounts for administrative staff authorized to perform bulk uploads. |
StudentSession |
Manages ephemeral student sessions. Stores encrypted Moodle access tokens used to perform submissions on behalf of the student. |
AuditLog |
A rigid ledger tracking every action (Upload, View, Submit) with IP addresses and timestamps to ensure a chain of custody. |
SubmissionQueue |
A buffer for handling Moodle API failures or maintenance windows, ensuring no submission is lost. |
When you run python init_db.py it executes SQLAlchemy's Base.metadata.create_all() which creates the database tables defined in app/db/models.py. On PostgreSQL, the necessary sequences for integer primary keys are created automatically.
The init_db.py script seeds minimal configuration (default admin user, subject mappings, system config). If you run with the --seed-samples flag it will also add a sample artifact and report_issue audit log useful for local testing.
Below is the full list of tables and sequences that should be present after running init_db.py (example \d output):
public | audit_logs | table | postgres
public | audit_logs_id_seq | sequence | postgres
public | examination_artifacts | table | postgres
public | examination_artifacts_id_seq | sequence | postgres
public | staff_users | table | postgres
public | staff_users_id_seq | sequence | postgres
public | student_sessions | table | postgres
public | student_sessions_id_seq | sequence | postgres
public | student_username_register | table | postgres
public | student_username_register_id_seq | sequence | postgres
public | subject_mappings | table | postgres
public | subject_mappings_id_seq | sequence | postgres
public | submission_queue | table | postgres
public | submission_queue_id_seq | sequence | postgres
public | system_config | table | postgres
public | system_config_id_seq | sequence | postgres
If any of these tables are missing after running init_db.py:
- Ensure your
DATABASE_URLis set and points to the database you initialized. - Confirm the DB user has privileges to create tables in the
publicschema. - Check
init_db.pyoutput for errors and fix any import or connection issues before re-running.
For production deployments we strongly recommend using Alembic for schema migrations instead of create_all() so you can evolve the schema safely across releases.
Below is a dump of information_schema.columns for the public schema taken from a development database. It lists every table, column, data type, nullability and default value. This is useful for developers who want to inspect the actual physical schema created by init_db.py.
audit_logs | id | integer | NO | nextval('audit_logs_id_seq'::regclass)
audit_logs | action | character varying | NO |
audit_logs | action_category | character varying | NO |
audit_logs | description | text | YES |
audit_logs | actor_type | character varying | NO |
audit_logs | actor_id | character varying | YES |
audit_logs | actor_username | character varying | YES |
audit_logs | actor_ip | character varying | YES |
audit_logs | artifact_id | integer | YES |
audit_logs | target_type | character varying | YES |
audit_logs | target_id | character varying | YES |
audit_logs | request_data | jsonb | YES |
audit_logs | response_data | jsonb | YES |
audit_logs | error_details | jsonb | YES |
audit_logs | moodle_api_function | character varying | YES |
audit_logs | moodle_response_code | integer | YES |
audit_logs | created_at | timestamp with time zone | YES | now()
examination_artifacts | id | integer | NO | nextval('examination_artifacts_id_seq'::regclass)
examination_artifacts | artifact_uuid | uuid | NO |
examination_artifacts | raw_filename | character varying | NO |
examination_artifacts | original_filename | character varying | NO |
examination_artifacts | parsed_reg_no | character varying | YES |
examination_artifacts | parsed_subject_code | character varying | YES |
examination_artifacts | file_blob_path | character varying | NO |
examination_artifacts | file_hash | character varying | NO |
examination_artifacts | file_size_bytes | bigint | YES |
examination_artifacts | mime_type | character varying | YES |
examination_artifacts | moodle_user_id | bigint | YES |
examination_artifacts | moodle_username | character varying | YES |
examination_artifacts | moodle_course_id | integer | YES |
examination_artifacts | moodle_assignment_id | integer | YES |
examination_artifacts | workflow_status | USER-DEFINED | NO |
examination_artifacts | moodle_draft_item_id | bigint | YES |
examination_artifacts | moodle_submission_id | character varying | YES |
examination_artifacts | lms_transaction_id | character varying | YES |
examination_artifacts | transaction_id | character varying | YES |
examination_artifacts | uploaded_at | timestamp with time zone | YES | now()
examination_artifacts | validated_at | timestamp with time zone | YES |
examination_artifacts | submit_timestamp | timestamp with time zone | YES |
examination_artifacts | completed_at | timestamp with time zone | YES |
examination_artifacts | uploaded_by_staff_id | integer | YES |
examination_artifacts | submitted_by_user_id | bigint | YES |
examination_artifacts | transaction_log | jsonb | YES |
examination_artifacts | error_message | text | YES |
examination_artifacts | retry_count | integer | YES |
staff_users | id | integer | NO | nextval('staff_users_id_seq'::regclass)
staff_users | username | character varying | NO |
staff_users | email | character varying | NO |
staff_users | hashed_password | character varying | NO |
staff_users | full_name | character varying | YES |
staff_users | role | character varying | YES |
staff_users | is_active | boolean | YES |
staff_users | created_at | timestamp with time zone | YES | now()
staff_users | last_login_at | timestamp with time zone | YES |
student_sessions | id | integer | NO | nextval('student_sessions_id_seq'::regclass)
student_sessions | session_id | character varying | NO |
student_sessions | moodle_user_id | bigint | NO |
student_sessions | moodle_username | character varying | NO |
student_sessions | moodle_fullname | character varying | YES |
student_sessions | encrypted_token | text | NO |
student_sessions | token_expires_at | timestamp with time zone | YES |
student_sessions | ip_address | character varying | YES |
student_sessions | user_agent | character varying | YES |
student_sessions | created_at | timestamp with time zone | YES | now()
student_sessions | last_activity_at | timestamp with time zone | YES | now()
student_sessions | expires_at | timestamp with time zone | NO |
student_sessions | register_number | character varying | YES |
student_username_register | id | integer | NO | nextval('student_username_register_id_seq'::regclass)
student_username_register | moodle_username | character varying | NO |
student_username_register | register_number | character varying | NO |
student_username_register | created_at | timestamp with time zone | YES | now()
student_username_register | updated_at | timestamp with time zone | YES |
subject_mappings | id | integer | NO | nextval('subject_mappings_id_seq'::regclass)
subject_mappings | subject_code | character varying | NO |
subject_mappings | subject_name | character varying | YES |
subject_mappings | moodle_course_id | integer | NO |
subject_mappings | moodle_course_idnumber | character varying | YES |
subject_mappings | moodle_assignment_id | integer | NO |
subject_mappings | moodle_assignment_name | character varying | YES |
subject_mappings | exam_session | character varying | YES |
subject_mappings | is_active | boolean | YES |
subject_mappings | created_at | timestamp with time zone | YES | now()
subject_mappings | updated_at | timestamp with time zone | YES |
subject_mappings | last_verified_at | timestamp with time zone | YES |
submission_queue | id | integer | NO | nextval('submission_queue_id_seq'::regclass)
submission_queue | artifact_id | integer | NO |
submission_queue | status | character varying | YES |
submission_queue | priority | integer | YES |
submission_queue | retry_count | integer | YES |
submission_queue | max_retries | integer | YES |
submission_queue | next_retry_at | timestamp with time zone | YES |
submission_queue | queued_at | timestamp with time zone | YES | now()
submission_queue | processed_at | timestamp with time zone | YES |
submission_queue | last_error | text | YES |
system_config | id | integer | NO | nextval('system_config_id_seq'::regclass)
system_config | key | character varying | NO |
system_config | value | text | YES |
system_config | value_type | character varying | YES |
system_config | description | text | YES |
system_config | updated_at | timestamp with time zone | YES |
This listing is provided for convenienceβyour actual schema may differ slightly depending on the database version and any future schema changes. Always inspect your database with \d (psql) or information_schema.columns when debugging.
- Mapping: Admin configures the
SubjectMappingtable (e.g., SubjectCS101targets Moodle AssignmentID: 55). - Scanning: Examination cell scans answer sheets using the naming convention:
{RegisterNumber}_{SubjectCode}.pdf.
- Login: Staff logs into the Staff Portal.
- Bulk Upload: Staff drags and drops folders of scanned files.
- Validation: The system instantly validates filenames. Invalid files are rejected; valid files are hashed and stored as
ExaminationArtifactswith statusPENDING.
- Login: Student logs into the Student Portal using their university Moodle username and password.
- Dashboard: The system displays all papers tagged with their Register Number.
- Review: Student previews the PDF to ensure it is their paper.
- Submit: Student clicks "Submit".
- Backend Action: The system authenticates with Moodle using the student's token.
- Backend Action: Uploads the file to Moodle's draft area.
- Backend Action: Finalizes the submission for grading.
- Confirmation: The status updates to
SUBMITTED_TO_LMS.
- Python 3.10+
- PostgreSQL 14+
- Moodle LMS with Web Services enabled
- Redis (optional, for background tasks)
cd exam_middlewarepython -m venv venv
# Windows
.\venv\Scripts\activate
# Linux/Mac
source venv/bin/activatepip install -r requirements.txtCopy .env.example to .env and update the values:
copy .env.example .envEdit .env with your settings:
# Database
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/exam_middleware
# Security
SECRET_KEY=your-super-secret-key-change-in-production
ENCRYPTION_KEY=your-32-byte-encryption-key-here
# Moodle Configuration
MOODLE_BASE_URL=https://your-moodle-site.com
MOODLE_ADMIN_TOKEN=your-admin-token
# Subject Mappings (subject_code:assignment_id)
SUBJECT_ASSIGNMENT_MAP=19AI405:4,19AI411:6,ML:2# Create database
psql -U postgres -c "CREATE DATABASE exam_middleware;"These additional steps make it easy for a new developer to get a working local environment.
- Create a dedicated DB role and grant privileges (run in psql as a superuser):
-- Replace <devuser> and <devpassword>
CREATE ROLE devuser WITH LOGIN PASSWORD '<devpassword>';
CREATE DATABASE exam_middleware OWNER devuser;
GRANT ALL PRIVILEGES ON DATABASE exam_middleware TO devuser;
\c exam_middleware
GRANT ALL PRIVILEGES ON SCHEMA public TO devuser;- Export a
DATABASE_URLfor local development (example):
$env:DATABASE_URL = 'postgresql+asyncpg://devuser:<devpassword>@localhost:5432/exam_middleware'
# Linux/macOS
export DATABASE_URL='postgresql+asyncpg://devuser:<devpassword>@localhost:5432/exam_middleware'- Create a virtualenv and install dependencies:
python -m venv .venv
# Windows
.\.venv\Scripts\activate
# Linux/macOS
source .venv/bin/activate
pip install -r requirements.txt- Initialize the database (creates tables and seeds minimal config):
python init_db.py- Seed optional sample data (useful for manual testing):
python init_db.py --seed-samples- Run the app with a production-like server during development:
# Use uvicorn directly (recommended in dev)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadNotes:
- The project uses
Base.metadata.create_allininit_db.pyto create tables. For production, use Alembic migrations instead ofcreate_all. - Replace placeholder passwords and secrets in
.env.examplebefore use; never commit real secrets to git. - Consider running the app behind a reverse-proxy (nginx) and enabling TLS for production.
python init_db.pyThis will:
- Create all required tables
- Create default admin user (username:
admin, password:admin123) - Seed subject-to-assignment mappings
- Configure system settings
python run.pyThe server will start at http://localhost:8000
| Portal | URL |
|---|---|
| Staff Upload Portal | http://localhost:8000/portal/staff |
| Student Portal | http://localhost:8000/portal/student |
| API Documentation | http://localhost:8000/docs |
| ReDoc | http://localhost:8000/redoc |
| Health Check | http://localhost:8000/health |
Uploaded files must follow this naming pattern:
{RegisterNumber}_{SubjectCode}.{extension}
Examples:
611221104088_19AI405.pdf611221104089_ML.jpg611221104090_19AI411.png
Rules:
- Register Number: Exactly 12 digits
- Subject Code: 2-10 alphanumeric characters
- Extensions: pdf, jpg, jpeg, png
- Username/password-based JWT authentication
- Default credentials:
admin/admin123 - Token expires in 8 hours
- Moodle credential verification
- Token exchange with Moodle LMS
- Encrypted token storage for submissions
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/staff/login |
Staff login |
| POST | /auth/student/login |
Student login with Moodle credentials |
| POST | /auth/student/logout |
Student logout |
| Method | Endpoint | Description |
|---|---|---|
| POST | /upload/single |
Upload single file |
| POST | /upload/bulk |
Upload multiple files |
| POST | /upload/validate |
Validate filename |
| Method | Endpoint | Description |
|---|---|---|
| GET | /student/dashboard |
Get assigned papers |
| GET | /student/paper/{id}/view |
View paper content |
| POST | /student/submit/{id} |
Submit paper to Moodle |
| GET | /student/submission/{id}/status |
Check submission status |
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/mappings |
List subject mappings |
| POST | /admin/mappings |
Create mapping |
| GET | /admin/queue |
View submission queue |
| GET | /admin/stats |
System statistics |
-
Enable Web Services
- Site administration β Advanced features β Enable web services
-
Create External Service
- Site administration β Server β Web services β External services
- Create service: "FileUpload"
- Add functions:
core_webservice_get_site_infomod_assign_save_submissionmod_assign_submit_for_grading
-
Create Token
- Site administration β Server β Web services β Manage tokens
- Create token for admin user with "FileUpload" service
-
Enable Upload
- Ensure
webservice/upload.phpis accessible - Configure max upload size in Moodle settings
- Ensure
exam_middleware/
βββ app/
β βββ api/
β β βββ routes/
β β βββ admin.py # Admin endpoints
β β βββ auth.py # Authentication
β β βββ health.py # Health check
β β βββ student.py # Student endpoints
β β βββ upload.py # File upload
β βββ core/
β β βββ config.py # Configuration
β β βββ security.py # Security utilities
β βββ db/
β β βββ database.py # Database connection
β β βββ models.py # SQLAlchemy models
β βββ schemas/
β β βββ schemas.py # Pydantic schemas
β βββ services/
β β βββ artifact_service.py # Artifact management
β β βββ file_processor.py # File processing
β β βββ moodle_client.py # Moodle API client
β β βββ submission_service.py # Submission workflow
β βββ templates/
β β βββ staff_upload.html # Staff portal
β β βββ student_portal.html # Student portal
β βββ main.py # FastAPI application
βββ uploads/ # Temporary upload storage
βββ storage/ # Permanent file storage
βββ .env # Environment configuration
βββ .env.example # Example configuration
βββ init_db.py # Database initialization
βββ run.py # Application runner
βββ requirements.txt # Python dependencies
-
Create test files with correct naming:
611221104088_19AI405.pdf 611221104089_ML.pdf -
Login to Staff Portal with
admin/admin123 -
Upload the test files
-
Login to Student Portal with Moodle student credentials and register number
-
View and submit papers to Moodle
# Staff Login
curl -X POST http://localhost:8000/auth/staff/login \
-F "username=admin" \
-F "password=admin123"
# Upload File (use token from login)
curl -X POST http://localhost:8000/upload/single \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@611221104088_19AI405.pdf" \
-F "exam_session=2024SPRING"
# Health Check
curl http://localhost:8000/health- Staff uploads scanned papers through the Staff Portal
- System extracts metadata from filenames (register number, subject code)
- Papers are validated and stored with unique transaction IDs
- Students login with Moodle credentials and register number
- Students view their assigned papers
- Students submit papers directly to Moodle assignments
- System executes 3-step Moodle submission:
- Upload file to Moodle
- Save submission draft
- Submit for grading
- Password Hashing: bcrypt with 12 rounds
- Token Encryption: AES-256 (Fernet) for Moodle tokens
- JWT Tokens: Short-lived access tokens
- File Validation: Extension and size checks
- Audit Logging: All operations logged
- CORS: Configurable origin whitelist
For production deployment with Celery:
# Start Redis
redis-server
# Start Celery worker
celery -A app.tasks worker --loglevel=info- Health endpoint:
/health - Logs:
exam_middleware.log - Database audit table:
audit_logs
Ensure PostgreSQL is running and credentials in .env are correct
Verify MOODLE_ADMIN_TOKEN has required capabilities
Check Moodle external service configuration
Check file size limits in Moodle
Verify assignment allows file submissions
Not Licensed Yet.
- Fork the repository
- Create feature branch
- Commit changes
- Push to branch
- Create Pull Request