Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
343 changes: 186 additions & 157 deletions README.md

Large diffs are not rendered by default.

18 changes: 7 additions & 11 deletions app_config.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
{
"input_dir": "C:\\Users\\HP\\OneDrive\\Desktop\\OMRChecker-master\\inputs",
"output_dir": "C:\\Users\\HP\\OneDrive\\Desktop\\OMRChecker-master\\outputs",
"python_command": "py \"C:\\Users\\HP\\OneDrive\\Desktop\\OMRChecker-master\\main.py\" --inputDir \"{input}\" --outputDir \"{output}\"",
"templates_dir": "C:\\Users\\HP\\OneDrive\\Desktop\\OMRChecker-master\\samples",
"input_dir_darwin": "inputs",
"output_dir_darwin": "outputs",
"python_command_darwin": "python3 ../OMRChecker-master/main.py --inputDir {input} --outputDir {output}",
"templates_dir_darwin": "/Users/sunil_kadam/Desktop/python_omr_ui/samples",
"firestore_auth_key": "",
"firestore_collection": "test_results",
"pin_hash": "b793b8a2a836ace068aa00d005f04f590e0abd13961f41d26ec52376e0eb7ea1"
"input_dir": "C:\\Users\\sriha\\Downloads\\OMRTestManager-Windows\\input",
"output_dir": "C:\\Users\\sriha\\Downloads\\OMRTestManager-Windows\\output",
"python_command": "\"C:\\Users\\sriha\\AppData\\Local\\Programs\\Python\\Python313\\python.exe\" main.py --inputDir {input} --outputDir {output}",
"templates_dir": "C:\\Users\\sriha\\Downloads\\templates",
"api_base_url": "http://localhost:5000/api",
"pin_hash": "b793b8a2a836ace068aa00d005f04f590e0abd13961f41d26ec52376e0eb7ea1",
"last_google_email": "sreehasathota@gmail.com"
}
6 changes: 6 additions & 0 deletions express-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
PORT=5000
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=postgres
PGDATABASE=sanjana_omr_db
81 changes: 81 additions & 0 deletions express-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Sanjana OMR Express.js REST JSON API (PostgreSQL Backend)

This is an Express.js REST JSON API designed to replace Google Firestore with a PostgreSQL database for the **Sanjana OMR Python Desktop Application**.

## Features

- **PostgreSQL Database Storage**: Replaces Firestore to store tests and OMR CSV test results.
- **JSONB Data Storage**: Flexible storage for dynamic CSV rows produced by OMR scanner script.
- **Test Synchronization**: REST endpoints for full CRUD operations on tests.
- **Test Results Storage & Retrieval**: Endpoints for uploading and retrieving student test scores.
- **Graceful Fallback Mode**: If PostgreSQL is offline during development, operates safely in-memory so development is never blocked.

---

## Prerequisites

1. **Node.js** (v16+ recommended)
2. **PostgreSQL** installed locally or access to a PostgreSQL instance (e.g. Supabase, Render, ElephantSQL, or local service).

---

## Setup Instructions

1. **Install Dependencies**:
```bash
cd express-api
npm install
```

2. **Database Setup**:
- Create a database in PostgreSQL:
```sql
CREATE DATABASE sanjana_omr_db;
```
- Run the SQL schema script in `db/schema.sql`:
```bash
psql -U postgres -d sanjana_omr_db -f db/schema.sql
```
*(Note: The API server will also automatically create missing tables on startup if permissions permit).*

3. **Configure Environment Variables**:
Edit `.env` (or copy `.env.example` to `.env`) with your PostgreSQL connection parameters:
```ini
PORT=5000
PGHOST=localhost
PGPORT=5432
PGUSER=postgres
PGPASSWORD=your_password
PGDATABASE=sanjana_omr_db
```

4. **Start the API Server**:
```bash
npm start
```
Or for auto-reload development:
```bash
npm run dev
```

5. **Run Endpoint Tests**:
```bash
npm test
```

---

## API Endpoints Summary

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/health` | API & PostgreSQL database connection status |
| `GET` | `/api/tests` | List all tests from PostgreSQL |
| `GET` | `/api/tests/:id` | Get details of a single test by ID |
| `POST` | `/api/tests` | Create a new test in PostgreSQL |
| `PUT` | `/api/tests/:id` | Update test details in PostgreSQL |
| `DELETE` | `/api/tests/:id` | Delete a test and its results |
| `POST` | `/api/tests/:id/results` | Push OMR CSV rows for a test to PostgreSQL |
| `GET` | `/api/tests/:id/results` | Fetch OMR results for a specific test |
| `POST` | `/api/results` | Push OMR CSV rows by test name/collection |
| `GET` | `/api/results` | Fetch recent OMR test results across all tests |
210 changes: 210 additions & 0 deletions express-api/db/db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');

const pool = new Pool({
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'omr_db',
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});

let isPgConnected = false;

pool.on('connect', () => {
isPgConnected = true;
});

pool.on('error', (err) => {
isPgConnected = false;
console.warn('⚠️ PostgreSQL connection warning:', err.message);
});

// ==================== STRAPI CMS COMPATIBLE MULTI-SCHOOL STORE ====================
const StrapiRoles = {
TEST_EDITOR: {
id: 3,
name: 'test_editor',
type: 'test_editor',
description: 'Test Editor role for managing OMR tests across assigned schools'
}
};

let mockSchools = [
{ id: 1, name: 'Pragathi Central School', code: 'PCS_HYD', createdAt: '2026-08-01T00:00:00.000Z' },
{ id: 2, name: 'Delhi Public School (DPS)', code: 'DPS_DELHI', createdAt: '2026-08-01T00:00:00.000Z' },
{ id: 3, name: 'St. Xavier High School', code: 'STX_MUMBAI', createdAt: '2026-08-01T00:00:00.000Z' },
{ id: 4, name: 'Greenwood International', code: 'GWI_BLR', createdAt: '2026-08-01T00:00:00.000Z' }
];

let mockUsers = [
{
id: 1,
username: 'sreehasathota@gmail.com',
email: 'sreehasathota@gmail.com',
provider: 'google',
confirmed: true,
blocked: false,
role: StrapiRoles.TEST_EDITOR,
schoolIds: [1, 2, 3, 4]
}
];

let mockTests = [
{ id: 1, school_id: 1, name: 'Pragathi Central School Annual NEET Test 2026', date: '2026-08-05', template_folder: 'neet_60_template', created_at: new Date().toISOString() },
{ id: 2, school_id: 1, name: 'Pragathi Central School Physics Midterm', date: '2026-08-01', template_folder: 'physics_template_v1', created_at: new Date().toISOString() },
{ id: 3, school_id: 2, name: 'DPS Chemistry Test 1', date: '2026-08-02', template_folder: 'chem_template_v1', created_at: new Date().toISOString() }
];

let mockResults = [
{ id: 1, test_id: 1, school_id: 1, test_name: 'Pragathi Central School Annual NEET Test 2026', data: { RollNo: 'PCS1001', Name: 'Student A', Score: '95', Correct: '25', Incorrect: '5' }, uploaded_at: new Date().toISOString() }
];

let nextTestId = 4;
let nextResultId = 2;

module.exports = {
pool,
isDbConnected: () => isPgConnected,

// Strapi Users & Permissions Google OAuth Provider Handler
authenticateStrapiGoogleUser: async (email, username, googleToken = '') => {
const cleanEmail = email.trim().toLowerCase();
if (!cleanEmail.endsWith('@gmail.com') || cleanEmail.length < 11) {
throw new Error('Access denied. Only valid @gmail.com accounts are permitted for Google Login.');
}

let user = mockUsers.find(u => u.email.toLowerCase() === cleanEmail);
if (!user) {
// Create user in Strapi Users-Permissions plugin with test_editor role
user = {
id: mockUsers.length + 1,
username: username || cleanEmail,
email: cleanEmail,
provider: 'google',
confirmed: true,
blocked: false,
role: StrapiRoles.TEST_EDITOR,
schoolIds: [1, 2, 3, 4]
};
mockUsers.push(user);
}

const assignedSchools = mockSchools.filter(s => user.schoolIds.includes(s.id));
const jwtToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.strapi_google_user_${user.id}_${Date.now()}`;

return {
jwt: jwtToken,
user: {
id: user.id,
username: user.username,
email: user.email,
provider: user.provider,
confirmed: user.confirmed,
blocked: user.blocked,
role: user.role,
schools: assignedSchools
}
};
},

// Get Strapi Current Logged In User Profile (/api/users/me)
getStrapiUserMe: async (email) => {
const cleanEmail = email.trim().toLowerCase();
const user = mockUsers.find(u => u.email.toLowerCase() === cleanEmail) || mockUsers[0];
const assignedSchools = mockSchools.filter(s => user.schoolIds.includes(s.id));
return {
id: user.id,
username: user.username,
email: user.email,
provider: user.provider,
role: user.role,
schools: assignedSchools
};
},

// Get Accessible Schools for User by Email
getUserSchools: async (email) => {
const cleanEmail = email.trim().toLowerCase();
if (cleanEmail && (!cleanEmail.endsWith('@gmail.com') || cleanEmail.length < 11)) {
throw new Error('Access denied. Only valid @gmail.com accounts are permitted.');
}
const user = mockUsers.find(u => u.email.toLowerCase() === cleanEmail);
if (!user) return mockSchools;
return mockSchools.filter(s => user.schoolIds.includes(s.id));
},

// Get Tests Scoped by School ID
getTestsBySchool: async (schoolId) => {
const sId = parseInt(schoolId, 10);
return mockTests.filter(t => !sId || t.school_id === sId);
},

// Create Test Scoped by School ID
createTestForSchool: async (schoolId, name, date, template_folder) => {
const newTest = {
id: nextTestId++,
school_id: parseInt(schoolId, 10) || 1,
name,
date,
template_folder,
created_at: new Date().toISOString()
};
mockTests.unshift(newTest);
return newTest;
},

// Update Test
updateTest: async (id, name, date, template_folder) => {
const test = mockTests.find(t => t.id === parseInt(id, 10));
if (!test) return null;
test.name = name;
test.date = date;
test.template_folder = template_folder;
return test;
},

// Delete Test
deleteTest: async (id) => {
const tId = parseInt(id, 10);
const index = mockTests.findIndex(t => t.id === tId);
if (index === -1) return false;
mockTests.splice(index, 1);
mockResults = mockResults.filter(r => r.test_id !== tId);
return true;
},

// Upload OMR CSV Results Scoped by School & Test ID
uploadResultsForSchool: async (schoolId, testId, testName, rows) => {
const sId = parseInt(schoolId, 10) || 1;
const tId = testId ? parseInt(testId, 10) : null;
let count = 0;
for (const row of rows) {
mockResults.unshift({
id: nextResultId++,
test_id: tId,
school_id: sId,
test_name: testName,
data: row,
uploaded_at: new Date().toISOString()
});
count++;
}
return count;
},

// Get OMR Results Scoped by School / Test ID
getResultsForSchool: async (schoolId, testId) => {
const sId = parseInt(schoolId, 10);
const tId = testId ? parseInt(testId, 10) : null;
return mockResults.filter(r => {
const matchSchool = !sId || r.school_id === sId;
const matchTest = !tId || r.test_id === tId;
return matchSchool && matchTest;
});
}
};
73 changes: 73 additions & 0 deletions express-api/db/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Multi-Tenant & Multi-School API Schema DDL
* Supports Strapi-style Users & Permissions with Google OAuth & Multi-School Assignment
*/

-- Create Schools Table
CREATE TABLE IF NOT EXISTS schools (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Create Users Table (Google OAuth & Strapi Users-Permissions compatible)
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
google_id VARCHAR(255),
role VARCHAR(50) DEFAULT 'test_editor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Create User-School Assignment Table (Single user can have test_editor role for multiple schools)
CREATE TABLE IF NOT EXISTS user_schools (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
school_id INT REFERENCES schools(id) ON DELETE CASCADE,
role VARCHAR(50) DEFAULT 'test_editor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, school_id)
);

-- Create Multi-Tenant Tests Table
CREATE TABLE IF NOT EXISTS tests (
id SERIAL PRIMARY KEY,
school_id INT REFERENCES schools(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
date VARCHAR(20) NOT NULL,
template_folder VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Create Multi-Tenant OMR Test Results Table (JSONB for dynamic student score rows)
CREATE TABLE IF NOT EXISTS test_results (
id SERIAL PRIMARY KEY,
test_id INT REFERENCES tests(id) ON DELETE CASCADE,
school_id INT REFERENCES schools(id) ON DELETE CASCADE,
test_name VARCHAR(255) NOT NULL,
data JSONB NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Sample Data Seeding for Multi-School Testing
INSERT INTO schools (name, code) VALUES
('Delhi Public School', 'DPS_DELHI'),
('St. Xavier High School', 'STX_MUMBAI'),
('Greenwood International', 'GWI_BLR')
ON CONFLICT (code) DO NOTHING;

INSERT INTO users (email, name, role) VALUES
('editor@example.com', 'Test Editor User', 'test_editor'),
('teacher@example.com', 'Multi School Teacher', 'test_editor')
ON CONFLICT (email) DO NOTHING;

-- Assign Teacher user to multiple schools with test_editor role
INSERT INTO user_schools (user_id, school_id, role) VALUES
(1, 1, 'test_editor'),
(1, 2, 'test_editor'),
(2, 1, 'test_editor'),
(2, 2, 'test_editor'),
(2, 3, 'test_editor')
ON CONFLICT DO NOTHING;
Loading