Skip to content

Commit 95165ca

Browse files
authored
Remote Printing (#712)
This adds support for remotely printing documents, when paired with an in the lab script which scans a generated QR code.
1 parent c70a563 commit 95165ca

File tree

10 files changed

+269
-0
lines changed

10 files changed

+269
-0
lines changed

ocfweb/account/print.py

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import base64
2+
import os.path
3+
import subprocess
4+
import uuid
5+
from email.message import EmailMessage
6+
from email.utils import make_msgid
7+
from io import BytesIO
8+
9+
import qrcode
10+
from django import forms
11+
from django.forms import widgets
12+
from django.http import HttpRequest
13+
from django.http import HttpResponse
14+
from django.shortcuts import render
15+
from ocflib.misc.mail import MAIL_FROM
16+
from ocflib.misc.mail import SENDMAIL_PATH
17+
from paramiko import AuthenticationException
18+
from paramiko import SSHClient
19+
from paramiko.hostkeys import HostKeyEntry
20+
21+
from ocfweb.component.forms import Form
22+
23+
24+
PRINT_FOLDER = '.user_print'
25+
26+
EMAIL_BODY = '''
27+
<html>
28+
<body>
29+
<p>
30+
Hi {username},
31+
</p>
32+
33+
<p>
34+
Below you can find the QR code to start your print in the OCF computer lab.
35+
Show the QR code in front of the camera at the remote printing computer,
36+
and it will start printing your document.
37+
</p>
38+
<img src="cid:{image_cid}">
39+
<p>
40+
Thanks for flying OCF! <br>
41+
~ OCF volunteer staff
42+
</p>
43+
</body>
44+
</html>
45+
'''
46+
47+
48+
def send_qr_mail(username: str, qr_code: bytes) -> None:
49+
"""Send the QR code needed to start the print in the lab to the user.
50+
51+
Based on https://stackoverflow.com/a/49098251/9688107
52+
"""
53+
54+
msg = EmailMessage()
55+
msg['Subject'] = 'OCF Remote Printing QR Code'
56+
msg['From'] = MAIL_FROM
57+
msg['To'] = f'{username}@ocf.berkeley.edu'
58+
59+
msg.set_content(
60+
'Sorry, we were unable to display your QR, please use the QR code on the website!.',
61+
)
62+
63+
image_cid = make_msgid(domain='ocf.berkeley.edu')
64+
msg.add_alternative(EMAIL_BODY.format(username=username, image_cid=image_cid), subtype='html')
65+
msg.get_payload()[1].add_related(
66+
qr_code,
67+
maintype='image',
68+
subtype='png',
69+
cid=image_cid,
70+
)
71+
# we send the message via sendmail because direct traffic to port 25
72+
# is firewalled off
73+
p = subprocess.Popen(
74+
(SENDMAIL_PATH, '-t', '-oi'),
75+
stdin=subprocess.PIPE,
76+
)
77+
p.communicate(msg.as_string().encode('utf8'))
78+
79+
80+
def print(request: HttpRequest) -> HttpResponse:
81+
if request.method == 'POST':
82+
form = PrintForm(
83+
request.POST,
84+
request.FILES,
85+
initial={
86+
'double_or_single': 'single',
87+
},
88+
)
89+
qr_b64 = b''
90+
double_or_single = 'single'
91+
error = ''
92+
if form.is_valid():
93+
username = form.cleaned_data['username']
94+
password = form.cleaned_data['password']
95+
96+
file = request.FILES['file']
97+
98+
double_or_single = form.cleaned_data['double_or_single']
99+
100+
ssh = SSHClient()
101+
102+
host_keys = ssh.get_host_keys()
103+
entry = HostKeyEntry.from_line(
104+
'ssh.ocf.berkeley.edu ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAqMkHVVoMl8md25iky7e2Xe3ARaC4H1PbIpv5Y+xT4KOT17gGvFSmfjGyW9P8ZTyqxq560iWdyELIn7efaGPbkUo9retcnT6WLmuh9nRIYwb6w7BGEEvlblBmH27Fkgt7JQ6+1sr5teuABfIMg22WTQAeDQe1jg0XsPu36OjbC7HjA3BXsiNBpxKDolYIXWzOD+r9FxZLP0lawh8dl//O5FW4ha1IbHklq2i9Mgl79wAH3jxf66kQJTvLmalKnQ0Dbp2+vYGGhIjVFXlGSzKsHAVhuVD6TBXZbxWOYoXanS7CC43MrEtBYYnc6zMn/k/rH0V+WeRhuzTnr/OZGJbBBw==', # noqa
105+
)
106+
assert entry is not None # should never be none as we are passing a static string above
107+
host_keys.add(
108+
'ssh.ocf.berkeley.edu',
109+
'ssh-rsa',
110+
entry.key,
111+
)
112+
113+
try:
114+
ssh.connect(
115+
'ssh.ocf.berkeley.edu',
116+
username=username,
117+
password=password,
118+
)
119+
except AuthenticationException:
120+
error = 'Authentication failed. Did you type the wrong username or password?'
121+
122+
if not error:
123+
sftp = ssh.open_sftp()
124+
try:
125+
folder = sftp.stat(PRINT_FOLDER)
126+
if folder.FLAG_PERMISSIONS != 0o700:
127+
sftp.chmod(PRINT_FOLDER, 0o700)
128+
except FileNotFoundError:
129+
sftp.mkdir(PRINT_FOLDER, 0o700)
130+
try:
131+
rid = uuid.uuid4().hex
132+
filename = f'{rid}-{file.name}'
133+
with sftp.open(os.path.join(PRINT_FOLDER, filename), 'wb+') as dest:
134+
for chunk in file.chunks():
135+
dest.write(chunk)
136+
except OSError as e:
137+
error = 'Failed to open file in user home directory, ' + \
138+
f'please report this to [email protected] with the traceback\n{e}'
139+
else:
140+
qr = qrcode.QRCode(
141+
version=1,
142+
box_size=10,
143+
border=5,
144+
)
145+
qr.add_data(f'{username}:{filename}:{double_or_single}')
146+
qr.make(fit=True)
147+
img = qr.make_image(fill='black', back_color='white')
148+
buff = BytesIO()
149+
img.save(buff, format='PNG')
150+
qr_data = buff.getvalue()
151+
qr_b64 = b'data:image/png;base64,%b' % base64.b64encode(qr_data)
152+
send_qr_mail(
153+
username=username,
154+
qr_code=qr_data,
155+
)
156+
return render(
157+
request,
158+
'account/print/qr.html',
159+
{
160+
'title': 'QR Code',
161+
'qr_b64': qr_b64.decode('utf-8'),
162+
'error': error,
163+
},
164+
)
165+
else:
166+
form = PrintForm(
167+
initial={
168+
'double_or_single': 'single',
169+
},
170+
)
171+
172+
return render(
173+
request,
174+
'account/print/index.html', {
175+
'title': 'Print remotely',
176+
'form': form,
177+
},
178+
)
179+
180+
181+
class PrintForm(Form):
182+
username = forms.CharField(
183+
label='OCF username',
184+
min_length=3,
185+
max_length=16,
186+
)
187+
password = forms.CharField(
188+
widget=forms.PasswordInput,
189+
label='Password',
190+
min_length=8,
191+
max_length=256,
192+
)
193+
194+
file = forms.FileField()
195+
196+
PRINT_CHOICES = (
197+
(
198+
'single',
199+
'single sided -- one page per piece of paper',
200+
),
201+
(
202+
'double',
203+
'double sided -- two pages per piece of paper',
204+
),
205+
)
206+
207+
double_or_single = forms.ChoiceField(
208+
choices=PRINT_CHOICES,
209+
label='Print double or single sided',
210+
widget=widgets.RadioSelect,
211+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{% extends "base.html" %}
2+
{% load bootstrap %}
3+
4+
{% block content %}
5+
<div class="row">
6+
<div class="col-md-8 ocf-content-block">
7+
<h3>Print remotely</h3>
8+
<p>
9+
Please select a PDF file you would like to print and whether you would like to print single or double sided.
10+
11+
Once you press submit, a QR code will displayed that you should bring to the OCF computer lab.
12+
</p>
13+
14+
<form action="{% url 'print' %}" method="post" enctype="multipart/form-data">
15+
{% csrf_token %}
16+
{{form | bootstrap}}
17+
<input class="btn btn-primary" type="submit" value="Submit print to queue" />
18+
</form>
19+
</div>
20+
</div>
21+
{% endblock %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{% extends "base.html" %}
2+
{% load bootstrap %}
3+
4+
{% block content %}
5+
<div class="row">
6+
<div class="col-md-8 ocf-content-block">
7+
<p>
8+
Please bring this QR code to the lab to start your print job. A copy was emailed to you.
9+
</p>
10+
{% if qr_b64 %}
11+
<div class="well">
12+
<img alt="QR code to print" src="{{ qr_b64 }}" />
13+
</div>
14+
{% endif %}
15+
16+
{% if error %}
17+
<p><strong>{{ error|linebreaks }}</strong></p>
18+
{% endif %}
19+
</div>
20+
</div>
21+
{% endblock %}

ocfweb/account/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from ocfweb.account.chpass import change_password
44
from ocfweb.account.commands import commands
5+
from ocfweb.account.print import print
56
from ocfweb.account.register import account_created
67
from ocfweb.account.register import account_pending
78
from ocfweb.account.register import recommend
@@ -19,6 +20,7 @@
1920
urlpatterns = [
2021
url(r'^password/$', change_password, name='change_password'),
2122
url(r'^commands/$', commands, name='commands'),
23+
url(r'^print/$', print, name='print'),
2224

2325
# account creation
2426
url(r'^register/$', request_account, name='register'),

ocfweb/main/templates/main/home.html

+1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ <h3>OCF Computer Lab</h3>
102102
<ul>
103103
<li><a href="{% url 'commands' %}">Check Account</a></li>
104104
<li><a href="{% url 'change_password' %}">Reset Password</a></li>
105+
<li><a href="{% url 'print' %}">Print Remotely</a></li>
105106

106107
{% if is_ocf_ip %}
107108
<li><a href="https://printhost.ocf.berkeley.edu/jobs/">View Print Queue</a></li>

ocfweb/templates/base.html

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181

8282
<li><a href="{% url 'commands' %}">Commands</a></li>
8383
<li><a href="{% url 'change_password' %}">Change Password</a></li>
84+
<li><a href="{% url 'print' %}">Print Remotely</a></li>
8485
<li><a href="{% url 'logout' %}?next={{request_full_path}}">Log Out</a></li>
8586
</ul>
8687
</li>

requirements-dev-minimal.txt

+7
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ pytest-django
77
pytest-env
88
pytest-xdist
99
requirements-tools
10+
types-cryptography
11+
types-enum34
12+
types-ipaddress
1013
types-paramiko
1114
types-python-dateutil
15+
types-pytz
16+
types-PyYAML
1217
types-requests
18+
types-setuptools
19+
types-toml

requirements-dev.txt

+2
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ types-python-dateutil==2.8.0
3434
types-pytz==2021.1.2
3535
types-PyYAML==5.4.10
3636
types-requests==2.25.6
37+
types-setuptools==57.0.2
38+
types-toml==0.1.5
3739
virtualenv==20.7.2

requirements-minimal.txt

+2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ mistune
1414
numpy
1515
ocflib
1616
paramiko
17+
pillow
1718
pyasn1
1819
pycryptodome
1920
pygments
2021
pymysql
2122
python-dateutil
23+
qrcode

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pysnmp==4.4.12
5959
python-dateutil==2.8.2
6060
pytz==2021.1
6161
PyYAML==5.4.1
62+
qrcode==7.3
6263
redis==3.5.3
6364
requests==2.24.0
6465
setuptools==58.0.4

0 commit comments

Comments
 (0)