Published: Jan 4, 2024 by Isaac Johnson
At the end of Plane.so Part 2 we started to cover using the REST API to fetch issues and create them in Github Workflows. I wanted to keep building on the last article and show user issues in a more straightforward way.
Today we’ll cover building a containerized Python app that can use the Plane.so REST endpoint to report back on our issues. We’ll start simple and build from there to the point we have a solid app that can run either in Docker or in Kubernetes.
Let’s get started!
Dockerfile
There is a risk, of course, of spammers using a public report for negative ends. I’ll need to keep an eye on that.
I’m going to start simple and pack the plane API key in the container. This certainly would not be a thing I would push to a public CR
import os
import subprocess
import json
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def home():
cmd = '''curl -X GET -H "Content-Type: application/json" -H "X-API-Key: plane_api_aabbccddeeffaabbccddeeffaabb" "https://api.plane.so/api/v1/workspaces/tpk/projects/9ca799e6-52c4-4a9e-8b40-461eef4f57e9/issues/"'''
output = subprocess.check_output(cmd, shell=True)
data = json.loads(output)
table_data = ""
for item in data:
table_data += f"<tr><td>{item['name']}</td><td>{item['description_html']}</td></tr>"
html = f"""
<table>
<thead>
<tr>
<th colspan="2">User Requests</th>
</tr>
</thead>
<tbody>
{table_data}
</tbody>
</table>
"""
return render_template_string(html)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
This then needs a requirements.txt for Flask
$ cat requirements.txt
Flask==1.1.2
Lastly, a Dockerfile to run it
# Use an official Python runtime as a parent image
FROM python:3.7-slim
# Set the working directory in the container to /app
WORKDIR /app
# Add the current directory contents into the container at /app
ADD . /app
# Add curl
RUN apt update && apt install -y curl
# Install any needed packages specified in requirements.txt
RUN pip install --trusted-host pypi.python.org -r requirements.txt
# Make port 80 available to the world outside this container
EXPOSE 80
# Run app.py when the container launches
CMD ["python", "app.py"]
I’ll then do a docker build
$ docker build -t pyplanelist:0.0.1 .
[+] Building 12.8s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 578B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.7-slim 1.8s
=> [1/5] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 2.3s
=> => resolve docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 0.0s
=> => sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 1.86kB / 1.86kB 0.0s
=> => sha256:ffd28e36ef37b3a4a24f6a771a48d7c5499ea42d6309ac911a3f699e122060be 1.37kB / 1.37kB 0.0s
=> => sha256:a255ffcb469f2ec40f2958a76beb0c2bbebfe92ce9af67a9b48d84b4cb695ac8 7.54kB / 7.54kB 0.0s
=> => sha256:a803e7c4b030119420574a882a52b6431e160fceb7620f61b525d49bc2d58886 29.12MB / 29.12MB 0.8s
=> => sha256:bf3336e84c8e00632cdea35b18fec9a5691711bdc8ac885e3ef54a3d5ff500ba 3.50MB / 3.50MB 0.5s
=> => sha256:8973eb85275f19b8d72c6047560629116ad902397e5c1885b2508788197de28b 11.38MB / 11.38MB 0.9s
=> => sha256:f9afc3cc0135aad884dad502f28a5b3d8cd32565116131da818ebf2ea6d46095 244B / 244B 0.6s
=> => sha256:39312d8b4ab77de264678427265a2668073675bb8666caf723da18c9e4b7e3fc 3.13MB / 3.13MB 0.8s
=> => extracting sha256:a803e7c4b030119420574a882a52b6431e160fceb7620f61b525d49bc2d58886 0.7s
=> => extracting sha256:bf3336e84c8e00632cdea35b18fec9a5691711bdc8ac885e3ef54a3d5ff500ba 0.1s
=> => extracting sha256:8973eb85275f19b8d72c6047560629116ad902397e5c1885b2508788197de28b 0.3s
=> => extracting sha256:f9afc3cc0135aad884dad502f28a5b3d8cd32565116131da818ebf2ea6d46095 0.0s
=> => extracting sha256:39312d8b4ab77de264678427265a2668073675bb8666caf723da18c9e4b7e3fc 0.1s
=> [internal] load build context 0.2s
=> => transferring context: 1.58kB 0.0s
=> [2/5] WORKDIR /app 0.2s
=> [3/5] ADD . /app 0.0s
=> [4/5] RUN apt update && apt install -y curl 4.6s
=> [5/5] RUN pip install --trusted-host pypi.python.org -r requirements.txt 3.7s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:03982c031d5436d8a31ead24acce2407519e791b849c8de7fdb0037690e29e54 0.0s
=> => naming to docker.io/library/pyplanelist:0.0.1 0.0s
Let’s give it a test
$ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.1
06c119f1c845cc9108bd247d34cf01fac7f434972602a345feff8a3f0d38ff3f
I can see it crashed right away
$ docker logs 06c119f1c845cc9108bd247d34cf01fac7f434972602a345feff8a3f0d38ff3f
Traceback (most recent call last):
File "app.py", line 4, in <module>
from flask import Flask, render_template_string
File "/usr/local/lib/python3.7/site-packages/flask/__init__.py", line 14, in <module>
from jinja2 import escape
ImportError: cannot import name 'escape' from 'jinja2' (/usr/local/lib/python3.7/site-packages/jinja2/__init__.py)
A post suggested moving to a newer version of Flask that doesn’t require Jinja
$ cat requirements.txt
Flask>=2.2.2
$ docker build -t pyplanelist:0.0.2 .
[+] Building 10.3s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.7-slim 1.5s
=> [1/5] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 116B 0.0s
=> CACHED [2/5] WORKDIR /app 0.0s
=> [3/5] ADD . /app 0.0s
=> [4/5] RUN apt update && apt install -y curl 4.6s
=> [5/5] RUN pip install --trusted-host pypi.python.org -r requirements.txt 3.9s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:3dffb2d9f14c0eab13685211568492f01884a7e08c4a89cf69f1a7d821ec5f44 0.0s
=> => naming to docker.io/library/pyplanelist:0.0.2
Now to test
$ docker rm pyplanetest
pyplanetest
$ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.2
94b7c1ef6022e7f3feedf047acdebd1430d8bf95e08367898e209d582c484320
A little better - I can see it running in Docker
but still an error
Seems the response wasn’t quite right
$ docker logs pyplanetest
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:80
* Running on http://172.17.0.5:80
Press CTRL+C to quit
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 26686 0 26686 0 0 40216 0 --:--:-- --:--:-- --:--:-- 40189
[2023-12-22 13:33:31,025] ERROR in app: Exception on / [GET]
Traceback (most recent call last):
File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 2529, in wsgi_app
response = self.full_dispatch_request()
File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1825, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1823, in full_dispatch_request
rv = self.dispatch_request()
File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1799, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
File "app.py", line 16, in home
table_data += f"<tr><td>{item['name']}</td><td>{item['description_html']}</td></tr>"
TypeError: string indices must be integers
172.17.0.1 - - [22/Dec/2023 13:33:31] "GET / HTTP/1.1" 500 -
172.17.0.1 - - [22/Dec/2023 13:33:31] "GET /favicon.ico HTTP/1.1" 404 -
I just needed to fire the URL off by hand to see the mistake - the results are in ‘results’ not ‘items’
{
"next_cursor": "100:1:0",
"prev_cursor": "100:-1:1",
"next_page_results": false,
"prev_page_results": false,
"count": 31,
"total_pages": 1,
"extra_stats": null,
"results": [
{
"id": "43f0fe47-f833-4d4c-b303-3afc60a6e286",
"created_at": "2023-12-22T06:12:40.097549-06:00",
"updated_at": "2023-12-22T06:12:40.328407-06:00",
"estimate_point": null,
"name": "Does Plane work?",
"description_html": "<p>Hey man, how is that Plane.so doing? :: Requested by isaac.johnson@gmail.com</p>",
"priority": "none",
"start_date": null,
"target_date": null,
"sequence_id": 31,
"sort_order": 365535,
"completed_at": null,
"archived_at": null,
"is_draft": false,
"created_by": "23ab4dc2-0c58-4183-a63a-1e8eab4c3e8b",
"updated_by": "23ab4dc2-0c58-4183-a63a-1e8eab4c3e8b",
"project": "9ca799e6-52c4-4a9e-8b40-461eef4f57e9",
"workspace": "4dab1b78-d62a-481b-8c9e-f2af8648e1cd",
"parent": null,
"state": "6ad12d87-1cff-4544-9c58-cc0ca8489571",
"assignees": [],
"labels": [
"93fcb710-94bf-4e8c-b419-9e6dfbee660f"
]
},
{
"id": "9ddc84a8-cdd0-4370-9b5b-c81b80dee7b5",
I think I’ll also update the code to not only correct the location of issue blocks, but start to externalize the Template and setup variables for the replaceable parts, like workspace name, project ID and our key
$ cat app.py
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
app = Flask(__name__)
projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
table_data = []
for result in results:
table_data.append([result['name'], result['description_html']])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
A requirements to pull in requests and Jinja2
$ cat requirements.txt
Flask>=2.2.2
requests>=2.26.0
Jinja2>=3.0.2
The app.py will now use the HTML template:
$ cat table.html
<!DOCTYPE html>
<html>
<head>
<title>Table Data</title>
</head>
<body>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</body>
</html>
Let’s try running that
$ docker build -t pyplanelist:0.0.3 .
[+] Building 4.7s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.7-slim 0.8s
=> [internal] load build context 0.0s
=> => transferring context: 2.86kB 0.0s
=> [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> [3/4] ADD . /app 0.0s
=> [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt 3.6s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:8859ea70a8564798d0cc54878e2966992e1e147c0fb6d00c389c6ea485629043 0.0s
=> => naming to docker.io/library/pyplanelist:0.0.3
$ docker run -d -p 8999:80 --name pyplanetest pyplanelist:0.0.3
69ab9f377dfa8bebe6b1657b004503e568854015a62b3a55adc695d1561f0913
This works, however, we would not want to show people’s email address on a website.
I can import re
and just replace out that string
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re
app = Flask(__name__)
projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
table_data = []
for result in results:
description_html = re.sub(":: Requested by.*", "", result['description_html'])
table_data.append([result['name'], description_html])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
I also want to make the table just a bit nicer
$ cat table.html
<!DOCTYPE html>
<html>
<head>
<title>Table Data</title>
<style>
.styled-table {
border-collapse: collapse;
margin: 25px 0;
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}
.styled-table thead tr {
background-color: #491ac9;
color: #ffffff;
text-align: left;
}
.styled-table th,
.styled-table td {
padding: 12px 15px;
}
.styled-table tbody tr {
border-bottom: 1px solid #dddddd;
}
.styled-table tbody tr:nth-of-type(even) {
background-color: #dadada;
}
.styled-table tbody tr:last-of-type {
border-bottom: 2px solid #491ac9;
}
.styled-table tbody tr.active-row {
font-weight: bold;
color: #491ac9;
}
caption {
font-weight: bold;
font-size: 24px;
text-align: left;
color: #333;
}
</style>
</head>
<body>
<table class="styled-table">
<caption>Fresh/Brewed User Requests</caption>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</body>
</html>
which looks much nicer
We’re making headway, but we may want to expose just one more field - a note of when we will deliver the feature. I could see users being keen to know if we will actually do a writeup or respond.
I could show the state, but I then would need to dereference it from the states set for the project. It’s not elegant, but here is a first pass:
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re
app = Flask(__name__)
projectid = "9ca799e6-52c4-4a9e-8b40-461eef4f57e9"
workspacename = "tpk"
planeapikey = "plane_api_aabbccddeeffaabbccddeeffaabb"
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
stateresponse = requests.get(urlstates, headers=headers)
statedata = stateresponse.json()
stateresults = statedata['results']
table_data = []
for result in results:
description_html = re.sub(":: Requested by.*", "", result['description_html'])
stateName = ""
for state in stateresults:
if state["id"] == result['state']:
stateName = state["name"]
table_data.append([result['name'], stateName, description_html, result['target_date']])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
and of course, I need to expose that in the template HTML
<!DOCTYPE html>
<html>
<head>
<title>Table Data</title>
<style>
.styled-table {
border-collapse: collapse;
margin: 25px 0;
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}
.styled-table thead tr {
background-color: #491ac9;
color: #ffffff;
text-align: left;
}
.styled-table th,
.styled-table td {
padding: 12px 15px;
}
.styled-table tbody tr {
border-bottom: 1px solid #dddddd;
}
.styled-table tbody tr:nth-of-type(even) {
background-color: #dadada;
}
.styled-table tbody tr:last-of-type {
border-bottom: 2px solid #491ac9;
}
.styled-table tbody tr.active-row {
font-weight: bold;
color: #491ac9;
}
caption {
font-weight: bold;
font-size: 24px;
text-align: left;
color: #333;
}
</style>
</head>
<body>
<table class="styled-table">
<caption>Fresh/Brewed User Requests</caption>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Description</th>
<th>Target Date</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</body>
</html>
Not too bad:
Clearly, I’ll want to move this out of hardcoded variables and into Environment variables which we could call from Docker or Kubernetes.
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re
app = Flask(__name__)
projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
stateresponse = requests.get(urlstates, headers=headers)
statedata = stateresponse.json()
stateresults = statedata['results']
table_data = []
for result in results:
description_html = re.sub(":: Requested by.*", "", result['description_html'])
stateName = ""
for state in stateresults:
if state["id"] == result['state']:
stateName = state["name"]
table_data.append([result['name'], stateName, description_html, result['target_date']])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Which I’ll build
$ docker build -t pyplanelist:0.0.6 .
[+] Building 6.1s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.7-slim 1.4s
=> [internal] load build context 0.0s
=> => transferring context: 4.28kB 0.0s
=> [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> [3/4] ADD . /app 0.0s
=> [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt 4.4s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:8201031eaefe6e25aa121d46c58a8f2d0dae7dbd7a503820627d257503892dc8 0.0s
=> => naming to docker.io/library/pyplanelist:0.0.6
And this time, when I run it, I’ll pass in the environment variables
$ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -p 8999:80 --name pyplanetest pyplanelist:0.0.6
08056dfc7bddaa9b2b70aac44abeb916cfc14b0df621c5faec531b47f6e1a734
In fact, I think I’ll make one more change. So that others can use this, I’ll make the report header customizable as well.
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re
app = Flask(__name__)
projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")
tableTitle = os.environ.get("TABLETITLE")
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
stateresponse = requests.get(urlstates, headers=headers)
statedata = stateresponse.json()
stateresults = statedata['results']
table_data = []
for result in results:
description_html = re.sub(":: Requested by.*", "", result['description_html'])
stateName = ""
for state in stateresults:
if state["id"] == result['state']:
stateName = state["name"]
table_data.append([result['name'], stateName, description_html, result['target_date']])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data,title=tableTitle)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
with HTML template file as
<!DOCTYPE html>
<html>
<head>
<title>Table Data</title>
<style>
.styled-table {
border-collapse: collapse;
margin: 25px 0;
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}
.styled-table thead tr {
background-color: #491ac9;
color: #ffffff;
text-align: left;
}
.styled-table th,
.styled-table td {
padding: 12px 15px;
}
.styled-table tbody tr {
border-bottom: 1px solid #dddddd;
}
.styled-table tbody tr:nth-of-type(even) {
background-color: #dadada;
}
.styled-table tbody tr:last-of-type {
border-bottom: 2px solid #491ac9;
}
.styled-table tbody tr.active-row {
font-weight: bold;
color: #491ac9;
}
caption {
font-weight: bold;
font-size: 24px;
text-align: left;
color: #333;
}
</style>
</head>
<body>
<table class="styled-table">
<caption></caption>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Description</th>
<th>Target Date</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</body>
</html>
Now when I run it, I can pass in a title:
$ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -e TABLETITLE="Passed In Title" -p 8999:80 --name pyplanetest pyplanelist:0.0.6
which we can see reflected in the page:
Kubernetes
Next, I’ll want to tag and push to my CR
$ docker tag pyplanelist:0.0.6 harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
$ docker push harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
The push refers to repository [harbor.freshbrewed.science/freshbrewedprivate/pyplanereport]
9a35fea8b8dc: Pushed
e36c93b5a9f5: Pushed
90794590e378: Pushed
b8594deafbe5: Pushed
8a55150afecc: Pushed
ad34ffec41dd: Pushed
f19cb1e4112d: Pushed
d310e774110a: Pushed
0.0.1: digest: sha256:672ae9f8f51dadcad257bb2fd8ede8cff2cc74f6eb9289e3b746799f91c8a746 size: 1996
The first idea I had was to just use a YAML manifest like
apiVersion: v1
kind: Service
metadata:
name: pyplanereport
spec:
selector:
app: pyplanereport
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pyplanereport
spec:
replicas: 1
selector:
matchLabels:
app: pyplanereport
template:
metadata:
labels:
app: pyplanereport
spec:
containers:
- name: pyplanereport
image: harbor.freshbrewed.science/freshbrewedprivate/pyplanereport:0.0.1
ports:
- containerPort: 80
env:
- name: PRJID
valueFrom:
configMapKeyRef:
name: pyplanereport-config
key: PRJID
- name: WSNAME
valueFrom:
configMapKeyRef:
name: pyplanereport-config
key: WSNAME
- name: APIKEY
valueFrom:
configMapKeyRef:
name: pyplanereport-config
key: APIKEY
- name: TABLETITLE
valueFrom:
configMapKeyRef:
name: pyplanereport-config
key: TABLETITLE
imagePullSecrets:
- name: regcred
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pyplanereport-config
data:
PRJID: "123"
WSNAME: "myworkspace"
APIKEY: "myapikey"
TABLETITLE: "mytabletitle"
But then I built out a proper helm chart using helm create
to layout the boilerplate.
Installing:
$ helm install myplane ./pyplanereport
NAME: myplane
LAST DEPLOYED: Fri Dec 22 18:41:15 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
NOTES:
1. Get the application URL by running these commands:
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=pyplanereport,app.kubernetes.io/instance=myplane" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace default $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace default port-forward $POD_NAME 8080:$CONTAINER_PORT
And I can see the deployment is up:
$ kubectl get deployments myplane-pyplanereport
NAME READY UP-TO-DATE AVAILABLE AGE
myplane-pyplanereport 1/1 1 1 48s
And it works!
Note, the values file used:
planeso:
projectid: "9ca799e6-52c4-4a9e-8b40-461eef4f57e9" # e.g. 9ca799e6-52c4-4a9e-8b40-461eef4f57e9
workspacename: "tpk" # e.g. tpk
apikey: "plane_api_aabbccddeeffaabbccddeeffaabb" #e.g. plane_api_xxxxxxxxxx
tabletitle: "Project Issue Report"
I’m actually feeling like this is pretty close to being ready for release.
My gripe is just that there is a lot of garbage in the report. I’m thinking we should create the ability to ignore a state before wrap.
You can see how I added “ignoreState”
import requests
from flask import Flask, render_template
import os
from jinja2 import Environment, FileSystemLoader
import re
app = Flask(__name__)
projectid = os.environ.get("PRJID")
workspacename = os.environ.get("WSNAME")
planeapikey = os.environ.get("APIKEY")
tableTitle = os.environ.get("TABLETITLE")
ignoreState = os.environ.get("IGNORESTATE")
@app.route('/')
def index():
url = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/issues/'
headers = {
'X-API-Key': planeapikey,
'Content-Type': 'application/json'
}
response = requests.get(url, headers=headers)
data = response.json()
results = data['results']
urlstates = f'https://api.plane.so/api/v1/workspaces/{workspacename}/projects/{projectid}/states/'
stateresponse = requests.get(urlstates, headers=headers)
statedata = stateresponse.json()
stateresults = statedata['results']
table_data = []
for result in results:
description_html = re.sub(":: Requested by.*", "", result['description_html'])
stateName = ""
for state in stateresults:
if state["id"] == result['state']:
stateName = state["name"]
if ignoreState != stateName:
table_data.append([result['name'], stateName, description_html, result['target_date']])
env = Environment(loader=FileSystemLoader('/app'))
template = env.get_template('table.html')
return template.render(table_data=table_data,title=tableTitle)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Then I built and ran
$ docker build -t pyplanelist:0.0.7 .
[+] Building 6.4s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/python:3.7-slim 1.4s
=> [internal] load build context 0.0s
=> => transferring context: 23.96kB 0.0s
=> [1/4] FROM docker.io/library/python:3.7-slim@sha256:b53f496ca43e5af6994f8e316cf03af31050bf7944e0e4a308ad86c001cf028b 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> [3/4] ADD . /app 0.0s
=> [4/4] RUN pip install --trusted-host pypi.python.org -r requirements.txt 4.7s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:e4717a44ec24d91c013eb1a525181d0d03cfe9edd903d210a28ee81c86e70706 0.0s
=> => naming to docker.io/library/pyplanelist:0.0.7
$ docker run -d -e PRJID="9ca799e6-52c4-4a9e-8b40-461eef4f57e9" -e WSNAME="tpk" -e APIKEY="plane_api_aabbccddeeffaabbccddeeffaabb" -e TABLETITLE="Passed In Title" -e IGNORESTATE="Cancelled" -p 8999:80 --name pyplanetest pyplanelist:0.0.7
20903d6f949e0ebb150f0d5c54825350cc521af4194b039e75a0f6fa7bafcb4e
I could then see the “Cancelled” entry was skipped
Summary
In Part 2 we touched on the REST API and getting issues from SaaS Plane.so. In today’s post we used Python to build out a proper Issue Report with Flask and Jinja2 templating. I fixed up the styling of the HTML report and then updated it to run in Docker. With a Dockerized app we updated it to use Helm to deploy to Kubernetes. Lastly, I updated it to hide a specific state like “Cancelled”.
Next time, we’ll wrap this 4-part series by Open-sourcing it.