port print-web-form app to nested flask structure
Replace stub print/ app with full PDF web print service from ~/print-web-form. Adapt UPLOAD_FOLDER to use Path(__file__).parent, add pypdf dependency, port all tests (10 passing), remove unused static/.
This commit is contained in:
parent
af5d3f148d
commit
b0c19d5642
9 changed files with 340 additions and 0 deletions
105
print/app.py
Normal file
105
print/app.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pypdf
|
||||
from flask import Flask, flash, redirect, render_template, request, url_for
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
UPLOAD_FOLDER = str(Path(__file__).parent / "uploads")
|
||||
ALLOWED_EXTENSIONS = {"pdf"}
|
||||
PRINTER_NAME = "HL-2270DW_series"
|
||||
MAX_PAGES = 10
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
app.config["SECRET_KEY"] = os.urandom(24)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def get_pdf_page_count(filepath):
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
reader = pypdf.PdfReader(f)
|
||||
return len(reader.pages)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Could not read PDF pages: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def upload_and_print():
|
||||
if request.method == "POST":
|
||||
if "file" not in request.files:
|
||||
flash("No file part in the request.", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
file = request.files["file"]
|
||||
|
||||
if file.filename == "":
|
||||
flash("No file selected. Please choose a PDF to print.", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
|
||||
file.save(filepath)
|
||||
|
||||
try:
|
||||
page_count = get_pdf_page_count(filepath)
|
||||
if page_count is None:
|
||||
flash(
|
||||
"Could not process the PDF file. It might be corrupt.", "error"
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
if page_count > MAX_PAGES:
|
||||
flash(
|
||||
f"Error: File has {page_count} pages. The maximum allowed is {MAX_PAGES}.",
|
||||
"error",
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
app.logger.info(
|
||||
f"Sending '{filename}' ({page_count} pages) to printer '{PRINTER_NAME}'"
|
||||
)
|
||||
command = ["lp", "-d", PRINTER_NAME, filepath]
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, text=True, check=True
|
||||
)
|
||||
app.logger.info(f"lp command stdout: {result.stdout}")
|
||||
flash(f"Success! '{filename}' has been sent to the printer.", "success")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
app.logger.error(f"Error printing file: {e}")
|
||||
app.logger.error(f"lp command stderr: {e.stderr}")
|
||||
flash(
|
||||
"Error sending file to printer. The printer may be offline or misconfigured.",
|
||||
"error",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"An unexpected error occurred: {e}")
|
||||
flash("An unexpected server error occurred.", "error")
|
||||
|
||||
finally:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
app.logger.info(f"Cleaned up temporary file: {filepath}")
|
||||
|
||||
return redirect(url_for("upload_and_print"))
|
||||
|
||||
else:
|
||||
flash("Invalid file type. Please upload a PDF file.", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
return render_template("index.html", printer_name=PRINTER_NAME, max_pages=MAX_PAGES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=57928, debug=False)
|
||||
82
print/templates/index.html
Normal file
82
print/templates/index.html
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Print Service</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 text-gray-800 flex items-center justify-center min-h-screen">
|
||||
|
||||
<div class="w-full max-w-lg mx-auto p-4 md:p-8">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto text-blue-500 mb-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
|
||||
<h1 class="text-3xl font-bold text-gray-900">PDF Web Print</h1>
|
||||
<p class="text-gray-500 mt-2">Upload a PDF to print to <strong>{{ printer_name }}</strong>.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">(Max {{ max_pages }} pages)</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="p-4 rounded-lg text-sm {% if category == 'error' %} bg-red-100 text-red-800 {% else %} bg-green-100 text-green-800 {% endif %}" role="alert">
|
||||
<span class="font-medium">{% if category == 'error' %}Oops!{% else %}Success!{% endif %}</span> {{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form action="/" method="post" enctype="multipart/form-data">
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center bg-gray-50 hover:border-blue-500 transition-colors duration-300">
|
||||
<label for="file-upload" class="cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p id="file-name" class="mt-4 text-gray-600">
|
||||
<span class="font-semibold text-blue-600">Click to upload</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">PDF only, max 16MB.</p>
|
||||
</label>
|
||||
<input id="file-upload" name="file" type="file" class="sr-only" accept=".pdf">
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button type="submit" class="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 transition-all duration-300 transform hover:scale-105">
|
||||
Print File
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="text-center mt-6 text-sm text-gray-500">
|
||||
<p>A simple Flask web app for local printing.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fileUpload = document.getElementById('file-upload');
|
||||
const fileNameDisplay = document.getElementById('file-name');
|
||||
|
||||
fileUpload.addEventListener('change', () => {
|
||||
if (fileUpload.files.length > 0) {
|
||||
fileNameDisplay.innerHTML = `<span class="font-semibold text-gray-800">${fileUpload.files[0].name}</span>`;
|
||||
} else {
|
||||
fileNameDisplay.innerHTML = `<span class="font-semibold text-blue-600">Click to upload</span> or drag and drop.`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
0
print/tests/__init__.py
Normal file
0
print/tests/__init__.py
Normal file
59
print/tests/conftest.py
Normal file
59
print/tests/conftest.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import io
|
||||
import tempfile
|
||||
|
||||
import pypdf
|
||||
import pytest
|
||||
|
||||
from app import app as flask_app, get_pdf_page_count
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
flask_app.config["TESTING"] = True
|
||||
flask_app.config["UPLOAD_FOLDER"] = tempfile.mkdtemp()
|
||||
flask_app.config["SECRET_KEY"] = "test-secret"
|
||||
yield flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _make_pdf(num_pages=3):
|
||||
writer = pypdf.PdfWriter()
|
||||
for _ in range(num_pages):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
buf = io.BytesIO()
|
||||
writer.write(buf)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pdf():
|
||||
return _make_pdf(3).read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def large_pdf():
|
||||
return _make_pdf(15).read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def corrupt_file():
|
||||
return b"This is not a PDF at all"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pdf_path(tmp_path):
|
||||
path = tmp_path / "test.pdf"
|
||||
path.write_bytes(_make_pdf(3).read())
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def corrupt_file_path(tmp_path):
|
||||
path = tmp_path / "corrupt.pdf"
|
||||
path.write_bytes(b"not a pdf")
|
||||
return str(path)
|
||||
13
print/tests/test_pdf_utils.py
Normal file
13
print/tests/test_pdf_utils.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from app import get_pdf_page_count
|
||||
|
||||
|
||||
def test_page_count_valid_pdf(sample_pdf_path):
|
||||
assert get_pdf_page_count(sample_pdf_path) == 3
|
||||
|
||||
|
||||
def test_page_count_corrupt_file(corrupt_file_path):
|
||||
assert get_pdf_page_count(corrupt_file_path) is None
|
||||
|
||||
|
||||
def test_page_count_nonexistent_file():
|
||||
assert get_pdf_page_count("/nonexistent/file.pdf") is None
|
||||
69
print/tests/test_routes.py
Normal file
69
print/tests/test_routes.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import io
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_get_index(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b"printer" in resp.data.lower() or b"upload" in resp.data.lower()
|
||||
|
||||
|
||||
def test_post_no_file(client):
|
||||
resp = client.post("/", data={}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"No file part" in resp.data
|
||||
|
||||
|
||||
def test_post_empty_filename(client):
|
||||
resp = client.post("/", data={"file": (io.BytesIO(b""), "")}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"No file selected" in resp.data
|
||||
|
||||
|
||||
def test_post_invalid_extension(client):
|
||||
data = {"file": (io.BytesIO(b"hello"), "document.txt")}
|
||||
resp = client.post("/", data=data, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Invalid file type" in resp.data
|
||||
|
||||
|
||||
@patch("app.subprocess.run")
|
||||
def test_post_valid_pdf(mock_run, client, app, sample_pdf):
|
||||
mock_run.return_value.stdout = "request-id-123"
|
||||
mock_run.return_value.stderr = ""
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
data = {"file": (io.BytesIO(sample_pdf), "test.pdf")}
|
||||
resp = client.post("/", data=data, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Success" in resp.data
|
||||
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
remaining = os.listdir(upload_dir)
|
||||
assert len(remaining) == 0
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "lp"
|
||||
|
||||
|
||||
@patch("app.subprocess.run")
|
||||
def test_post_pdf_exceeds_max_pages(mock_run, client, large_pdf):
|
||||
data = {"file": (io.BytesIO(large_pdf), "big.pdf")}
|
||||
resp = client.post("/", data=data, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"maximum allowed" in resp.data.lower()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_post_corrupt_pdf(client, app, corrupt_file):
|
||||
data = {"file": (io.BytesIO(corrupt_file), "bad.pdf")}
|
||||
resp = client.post("/", data=data, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"corrupt" in resp.data.lower()
|
||||
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
remaining = os.listdir(upload_dir)
|
||||
assert len(remaining) == 0
|
||||
0
print/uploads/.gitkeep
Normal file
0
print/uploads/.gitkeep
Normal file
|
|
@ -6,4 +6,5 @@ dependencies = [
|
|||
"flask>=3.0",
|
||||
"gunicorn>=23.0",
|
||||
"pymongo>=4.0",
|
||||
"pypdf>=4.0",
|
||||
]
|
||||
|
|
|
|||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -173,6 +173,7 @@ dependencies = [
|
|||
{ name = "flask" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pymongo" },
|
||||
{ name = "pypdf" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -180,6 +181,7 @@ requires-dist = [
|
|||
{ name = "flask", specifier = ">=3.0" },
|
||||
{ name = "gunicorn", specifier = ">=23.0" },
|
||||
{ name = "pymongo", specifier = ">=4.0" },
|
||||
{ name = "pypdf", specifier = ">=4.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -252,6 +254,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue