rename & restructure cgi and cli

This commit is contained in:
Thomas (Tom) C. Gorordo 2026-05-25 18:31:42 -07:00
parent a54eb0b2b7
commit c4b42d2baa
Signed by: tgorordo
GPG key ID: 0CBED22BB0D94490
7 changed files with 207 additions and 663 deletions

12
src/cgi/carousel.cgi Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ "$QUERY_STRING" == "source" ]]; then
echo "Content-Type: text/plain"
echo
sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' "$0"
exit 0
fi
exec "$SCRIPT_DIR/../../.venv/bin/python" "$SCRIPT_DIR/script.py"

View file

@ -1,24 +0,0 @@
#!/usr/bin/env -S uv run python
import os
from dotenv import load_dotenv
load_dotenv()
logdir = os.getenv("CAROUSEL_LOGDIR")
import cgi, cgitb
cgitb.enable(display=0, logdir=logdir)
form = cgi.FieldStorage()
from ..carousel import *
print("Content-Type: text/html")
print()
print("<title>Carousel Matches!</title>")
print("<h1>Carousel Stable Matches Found:</h1>")
print("TODO - INPUTH ECHO NOT IMPLEMENTED")
print("TODO - OUTPUT NOT IMPLEMENTED")

View file

@ -1 +0,0 @@
CAROUSEL_LOGDIR="$HOME/logs/carousel/"

View file

@ -1,663 +1,75 @@
<!DOCTYPE html>
/* Disclaimer: This file was generated using an LLM */
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Data Entry Form</title>
<title> Carousel - Stable Matcher </title>
<style>
body {
font-family: Georgia, serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 1200px;
margin: 0 auto;
font-family: Arial, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 20px;
background-color: #f8f8f8;
}
h1 {
color: #333;
text-align: center;
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
font-weight: normal;
font-size: 24px;
margin-bottom: 20px;
}
h2 {
color: #333;
font-weight: normal;
label {
display: block;
margin-bottom: 8px;
}
.container {
background: #fff;
border-radius: 3px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-bottom: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
input[type="file"] {
margin-bottom: 15px;
}
.controls-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.controls {
display: flex;
gap: 10px;
}
.fill-default-control {
display: flex;
align-items: center;
margin-top: 5px;
}
.fill-default-btn {
background: #777;
margin-right: 10px;
}
.fill-default-btn:hover {
background: #555;
}
.disabled-table {
opacity: 0.5;
pointer-events: none;
}
button {
background: #555;
color: white;
border: none;
padding: 8px 15px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
font-family: Georgia, serif;
}
button:hover {
background: #333;
}
button.secondary {
background: #999;
}
button.secondary:hover {
background: #777;
}
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 600px;
border: 1px solid #ddd;
}
th, td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
th {
background-color: #f5f5f5;
position: relative;
}
th input {
width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #ccc;
border-radius: 2px;
font-family: Georgia, serif;
}
td input {
width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #ccc;
border-radius: 2px;
font-family: Georgia, serif;
}
.file-upload {
margin-bottom: 20px;
}
.file-upload label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.file-info {
margin-top: 5px;
font-size: 14px;
color: #666;
}
.submit-section {
text-align: center;
border-bottom: none;
}
.submit-button {
background: #444;
color: white;
border: none;
padding: 10px 20px;
border-radius: 3px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s;
font-family: Georgia, serif;
}
.submit-button:hover {
background: #222;
}
.progress-bar {
height: 5px;
background-color: #f1f1f1;
margin-top: 10px;
border-radius: 2px;
overflow: hidden;
display: none;
}
.progress {
height: 100%;
background-color: #666;
width: 0%;
transition: width 0.3s;
}
.message {
margin-top: 15px;
padding: 10px;
border-radius: 3px;
display: none;
}
.success {
background-color: #f1f5f1;
border: 1px solid #ddd;
color: #333;
}
.error {
background-color: #f5f1f1;
border: 1px solid #ddd;
color: #333;
}
.tabs {
display: flex;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
background: #f0f0f0;
border: 1px solid #ddd;
border-bottom: none;
border-radius: 3px 3px 0 0;
margin-right: 5px;
}
.tab.active {
background: #fff;
border-bottom: 1px solid #fff;
position: relative;
top: 1px;
}
.tab-content {
display: none;
border-top: 1px solid #ddd;
padding-top: 20px;
}
.tab-content.active {
display: block;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 20px;
height: 20px;
border-radius: 50%;
border-left-color: #555;
animation: spin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
input[type="submit"] {
padding: 8px 16px;
}
</style>
</head>
<body>
(<a href="https://pages.uoregon.edu/tgorordo">Back to ~/tgorordo</a>)
<div class="container">
<h1>Carousel Stable Matcher</h1>
<h1>Carousel: Stable Matcher - CGI Application Upload</h1>
<div class="tabs">
<div class="tab active" id="table-tab">Table Input</div>
<div class="tab" id="file-tab">File Upload</div>
</div>
<form id="data-form" action="/cgi-bin/carousel.py" method="post" enctype="multipart/form-data">
<div class="tab-content active" id="table-content">
<!-- First Table Section -->
<div class="form-section">
<div class="form-header">
<h2>Applicant Preferences</h2>
<div class="controls">
<button type="button" id="add-column-1">Add Column</button>
<button type="button" id="add-row-1">Add Row</button>
<button type="button" class="secondary" id="remove-column-1">Remove Column</button>
<button type="button" class="secondary" id="remove-row-1">Remove Row</button>
</div>
</div>
<div class="table-container">
<table id="data-table-1">
<thead>
<tr id="header-row-1">
<th><input type="text" placeholder="Applicant 1" name="table1_header_0" required></th>
<th><input type="text" placeholder="Applicant 2" name="table1_header_1" required></th>
<th><input type="text" placeholder="Applicant 3" name="table1_header_2" required></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_0"></td>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_1"></td>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_2"></td>
</tr>
<tr>
<td><input type="text" name="table1_row_1_col_0"></td>
<td><input type="text" name="table1_row_1_col_1"></td>
<td><input type="text" name="table1_row_1_col_2"></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Second Table Section -->
<div class="form-section">
<div class="form-header">
<h2>Reviewer Preferences</h2>
<div class="controls-container">
<div class="controls">
<button type="button" id="add-column-2">Add Column</button>
<button type="button" id="add-row-2">Add Row</button>
<button type="button" class="secondary" id="remove-column-2">Remove Column</button>
<button type="button" class="secondary" id="remove-row-2">Remove Row</button>
</div>
<div class="fill-default-control">
<label for="fill-default">
<button type="button" id="fill-default-btn" class="fill-default-btn">Fill Default</button>
<input type="checkbox" id="fill-default" name="fill_default"> (Use default uniform preferences.)
</label>
</div>
</div>
</div>
<div class="table-container" id="table2-container">
<table id="data-table-2">
<thead>
<tr id="header-row-2">
<th><input type="text" placeholder="Reviewer 1" name="table2_header_0" required></th>
<th><input type="text" placeholder="Reviewer 2" name="table2_header_1" required></th>
<th><input type="text" placeholder="Reviewer 3" name="table2_header_2" required></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_0"></td>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_1"></td>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_2"></td>
</tr>
<tr>
<td><input type="text" name="table2_row_1_col_0"></td>
<td><input type="text" name="table2_row_1_col_1"></td>
<td><input type="text" name="table2_row_1_col_2"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="tab-content" id="file-content">
<div class="form-section">
<div class="file-upload">
<label for="file-input">Upload a CSV or Excel file:</label>
<input type="file" id="file-input" name="file" accept=".csv,.xlsx,.xls">
<div class="file-info" id="file-info"></div>
</div>
</div>
</div>
<div class="form-section submit-section">
<button type="submit" class="submit-button">
<span id="spinner" class="spinner"></span>
<span id="submit-text">Submit Preferences</span>
</button>
<div class="progress-bar" id="progress-bar">
<div class="progress" id="progress"></div>
</div>
<div class="message" id="message"></div>
</div>
<div class="container">
<details>
<summary><b>Input/Output Format Documentation</b></summary>
<p>
The matcher assumes a specific tabular scheme for the input data - to read more about how this works see the
<a href="https://github.com/tgorordo/carousel">README</a>.
You can enter preference information directly in the tables above, or upload an Excel spreadsheet with
two sheets of preferences, or a CSV containing a ranking matrix.
</p> More on each format scheme:
<h3>Tabular Input</h3>
You can enter tables of preferences directly in the first tab of the form above.
TODO
<h4>Example:</h4>
Suppose we want to figure out a Grad TA-to-Class assignment. Graduate students will be our "Applicants"
- each one will be assigned a class to TA -, and classes will be our "Reviewers" - taking a quota of needed TAs.
TODO
<h3>File Upload</h3>
For more complicated input, a file upload might be preferable to table form submission
(e.g. if there's a validation issue you won't have to manually re-enter the table, just tweak and re-upload the file).
Two file formats are supported, Excel and CSV:
<h4>Excel</h4>
TODO
<h4>CSV</h4>
TODO
</details>
</div>
<div class="form-container">
<form action="./carousel.cgi" method="POST" enctype="multipart/form-data">
<label for="spreadsheet">Upload an assignment preference spreadsheet (.xlsx, .xls, .csv):</label>
<input type="file" id="spreadsheet" name="spreadsheet" accept=".xlsx,.xls,.csv" required>
<br>
<input type="submit" value="Upload and Match!">
</form>
</div>
<script>
// Tab switching functionality
document.getElementById('table-tab').addEventListener('click', () => {
switchTab('table');
});
<div class="explanation-container">
document.getElementById('file-tab').addEventListener('click', () => {
switchTab('file');
});
<h2>About</h2>
<p>This is an upload form for the <a href="https://github.com/tgorordo/carousel">carousel</a> stable matcher for ,
mainly to help with <a href="https://blogs.uoregon.edu/physicsgsg/">UO Physics TA assignments</a>.
This form uses the <a href="https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069">UO pages.uoregon.edu CGI Capability</a>,
so the implementation of smithy being invoked can be <a href="https://pages.uoregon.edu/tgorordo/files/carousel/"> inspected here</a>
and you may also inspect the source of this page to verify that <a href="https://pages.uoregon.edu/tgorordo/files/carousel/src/cgi/carousel.cgi?source">this script<a> is called to invoke it - though
you have to trust it to <a href="https://en.wikipedia.org/wiki/Quine_(computing)">quine</a> itself faithfully.
</p>
function switchTab(tabName) {
// Hide all tabs and contents
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
<p> A <a href=https://en.wikipedia.org/wiki/Stable_matching_problem>stable matching (of the "college admissions" problem)</a> is one in which there is no pair of, say, TA and course which would prefer
each other over their respective assignment given by the matching. This form runs a version of the
<a href="https://en.wikipedia.org/wiki/Gale%E2%80%93Shapley_algorithm">Gale-Shapley (1962) deferred-acceptance algorithm</a>, which finds the stable matching
that is optimal for the TA preferrences. Other stable solutions can be found by using <a href="https://github.com/tgorordo/carousel">the underlying
python application manually</a>.
</p>
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
<h3>Input: Expected Spreadsheet Format</h3>
<p>A matching-preference spreadsheet should be organized as a list of TA assignment preferences
and a list of course preferences/constraints, i.e. with the following structure:
TODO</p>
// Show the selected tab and content
document.getElementById(`${tabName}-tab`).classList.add('active');
document.getElementById(`${tabName}-content`).classList.add('active');
}
// Table manipulation functions
function setupTableControls(tableNumber) {
const addColumnBtn = document.getElementById(`add-column-${tableNumber}`);
const addRowBtn = document.getElementById(`add-row-${tableNumber}`);
const removeColumnBtn = document.getElementById(`remove-column-${tableNumber}`);
const removeRowBtn = document.getElementById(`remove-row-${tableNumber}`);
const dataTable = document.getElementById(`data-table-${tableNumber}`);
const headerRow = document.getElementById(`header-row-${tableNumber}`);
addColumnBtn.addEventListener('click', () => {
const colCount = headerRow.cells.length;
// Add header cell
const newHeader = document.createElement('th');
newHeader.innerHTML = `<input type="text" placeholder="Column ${colCount + 1}" name="table${tableNumber}_header_${colCount}" required>`;
headerRow.appendChild(newHeader);
// Add cell to each row
const rows = dataTable.querySelectorAll('tbody tr');
rows.forEach((row, rowIndex) => {
const newCell = document.createElement('td');
newCell.innerHTML = `<input type="text" name="table${tableNumber}_row_${rowIndex}_col_${colCount}">`;
row.appendChild(newCell);
});
});
addRowBtn.addEventListener('click', () => {
const tbody = dataTable.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
const rowCount = rows.length;
const colCount = headerRow.cells.length;
// Create new row
const newRow = document.createElement('tr');
// Add cells to the new row
for (let i = 0; i < colCount; i++) {
const newCell = document.createElement('td');
newCell.innerHTML = `<input type="text" name="table${tableNumber}_row_${rowCount}_col_${i}">`;
newRow.appendChild(newCell);
}
tbody.appendChild(newRow);
});
removeColumnBtn.addEventListener('click', () => {
const colCount = headerRow.cells.length;
if (colCount <= 1) {
alert('Table must have at least one column.');
return;
}
// Remove the last header cell
headerRow.deleteCell(colCount - 1);
// Remove the last cell from each row
const rows = dataTable.querySelectorAll('tbody tr');
rows.forEach(row => {
row.deleteCell(colCount - 1);
});
});
removeRowBtn.addEventListener('click', () => {
const tbody = dataTable.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
if (rows.length <= 1) {
alert('Table must have at least one row.');
return;
}
// Remove the last row
tbody.removeChild(rows[rows.length - 1]);
});
}
// Setup controls for both tables
setupTableControls(1);
setupTableControls(2);
// Fill Default functionality for Table 2
const fillDefaultCheckbox = document.getElementById('fill-default');
const fillDefaultBtn = document.getElementById('fill-default-btn');
const table2Container = document.getElementById('table2-container');
fillDefaultBtn.addEventListener('click', () => {
fillDefaultCheckbox.checked = !fillDefaultCheckbox.checked;
toggleTable2State();
});
fillDefaultCheckbox.addEventListener('change', toggleTable2State);
function toggleTable2State() {
if (fillDefaultCheckbox.checked) {
table2Container.classList.add('disabled-table');
} else {
table2Container.classList.remove('disabled-table');
}
}
// File upload handling
const fileInput = document.getElementById('file-input');
const fileInfo = document.getElementById('file-info');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
fileInfo.textContent = `Selected: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
} else {
fileInfo.textContent = '';
}
});
// Form submission
const dataForm = document.getElementById('data-form');
const submitButton = document.querySelector('.submit-button');
const spinner = document.getElementById('spinner');
const submitText = document.getElementById('submit-text');
const progressBar = document.getElementById('progress-bar');
const progress = document.getElementById('progress');
const message = document.getElementById('message');
dataForm.addEventListener('submit', (e) => {
e.preventDefault();
// Show spinner and disable button
spinner.style.display = 'inline-block';
submitButton.disabled = true;
submitText.textContent = 'Submitting...';
progressBar.style.display = 'block';
message.style.display = 'none';
message.className = 'message';
// Collect form data
const formData = new FormData(dataForm);
// Add a flag to indicate which tab is active
formData.append('input_type', document.getElementById('table-content').classList.contains('active') ? 'table' : 'file');
// If table input is active, add table structure info
if (document.getElementById('table-content').classList.contains('active')) {
// Table 1 info
const colCount1 = document.getElementById('header-row-1').cells.length;
const rowCount1 = document.getElementById('data-table-1').querySelectorAll('tbody tr').length;
formData.append('table1_col_count', colCount1);
formData.append('table1_row_count', rowCount1);
// Table 2 info
const colCount2 = document.getElementById('header-row-2').cells.length;
const rowCount2 = document.getElementById('data-table-2').querySelectorAll('tbody tr').length;
formData.append('table2_col_count', colCount2);
formData.append('table2_row_count', rowCount2);
formData.append('table2_use_default', document.getElementById('fill-default').checked);
}
// Simulate upload progress (in a real application, you'd track actual upload progress)
let width = 0;
const progressInterval = setInterval(() => {
if (width >= 90) {
clearInterval(progressInterval);
} else {
width += 5;
progress.style.width = width + '%';
}
}, 100);
// Send form data to server
fetch(dataForm.action, {
method: 'POST',
body: formData
})
.then(response => {
clearInterval(progressInterval);
progress.style.width = '100%';
if (!response.ok) {
throw new Error('Server error: ' + response.status);
}
return response.json();
})
.then(data => {
// Show success message
message.textContent = data.message || 'Data submitted successfully!';
message.classList.add('success');
message.style.display = 'block';
})
.catch(error => {
// Show error message
console.error('Error:', error);
message.textContent = 'An error occurred while submitting the data. Please try again.';
message.classList.add('error');
message.style.display = 'block';
})
.finally(() => {
// Hide spinner and re-enable button
setTimeout(() => {
spinner.style.display = 'none';
submitButton.disabled = false;
submitText.textContent = 'Submit Data';
progressBar.style.display = 'none';
}, 500);
});
});
</script>
<h3>Output: Matching Format</h3>
<p>The form will return a matching of TAs to course assignments of the form: TODO</p>
</div>
</body>
<footer>
<hr>
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
</footer>
</html>

145
src/cgi/script.py Normal file
View file

@ -0,0 +1,145 @@
import traceback
import sys, os
import html
import re
import polars as pl
sys.path.insert(
0,
os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../carousel/src")
),
)
from carousel import match_from_prefs
print("Content-Type: text/html\n")
message = ""
content_type = os.environ.get("CONTENT_TYPE", "")
content_length = int(os.environ.get("CONTENT_LENGTH", 0) or 0)
boundary = content_type.split("boundary=")[-1].encode()
body = sys.stdin.buffer.read(content_length)
parts = body.split(b"--" + boundary)
spreadsheet = None
for part in parts:
if b"Content-Disposition" in part and b'name="spreadsheet"' in part:
header, _, data = part.partition(b"\r\n\r\n")
filename_match = re.search(rb'filename="([^"]+)"', header)
if filename_match:
filename = filename_match.group(1).decode()
filedata = data.rstrip(b"\r\n--")
spreadsheet = (filename, filedata)
break
if spreadsheet is not None:
filename, filedata = spreadsheet
if filename and filedata:
filepath = os.path.join("/tmp", filename)
with open(filepath, "wb") as f:
f.write(filedata)
try:
if filename.endswith(".csv"):
df = pl.read_csv(filepath)
elif filename.endswith((".xlsx", ".xls")):
df = pl.read_excel(filepath)
else:
message = """
<h1>Error</h1>
<p>File extension is not valid. Use CSV (.csv) or Excel (.xlsx, .xls).</p>
<p><a href="form.html">Go Back</a></p>
"""
# TODO: unpack df appropriately
if df is not None:
# Normalize
df = df.with_columns(
[
pl.col(c)
.cast(pl.Utf8)
.str.strip_chars()
.cast(pl.Int64, strict=False)
.fill_null(0)
for c in df.columns
]
)
match = match_from_prefs(df) # Solve!
message = f"""
<h1>The TA-optimal (Gale-Shapley) match is:</h1>
{match}
<p><a href="form.html">Go Back</a></p>
"""
else:
message = """
<h1>Error</h1>
<p>DataFrame was empty.</p>
<p><a href="form.html">Go Back</a></p>
"""
except Exception as e:
message = f"""
<h1>Error</h1>
<p>Internal Error Encountered: {e}
<p><a href="form.html">Go Back</a></p>
"""
traceback.print_exc()
else:
message = """
<h1>Error</h1>
<p>Filename or File Data not found/valid in form submission.</p>
<p><a href="form.html">Go Back</a></p>
"""
else:
message = """
<h1>Error</h1>
<p>No file field found in the form.</p>
<p><a href="form.html">Go Back</a></p>
"""
print("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> Carousel - Stable Matcher </title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
}
</style>
</head>
<body>
""")
print(message)
print("""
</body>
<footer>
<hr>
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
</footer>
</html>
""")