parent
558774de80
commit
6f68733ca1
@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Copyright (C) 2020 Simon Quigley <tsimonq2@lubuntu.me>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import requests_cache
|
||||||
|
import time
|
||||||
|
from modules.utilities import *
|
||||||
|
from os import getenv, makedirs, path
|
||||||
|
from pydiscourse import DiscourseClient
|
||||||
|
|
||||||
|
requests_cache.install_cache("discourse", backend="sqlite", expire_after=300)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscourseModule:
|
||||||
|
"""Discourse module for the Metrics program"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.name = "Discourse"
|
||||||
|
|
||||||
|
def _auth_discourse_server(self):
|
||||||
|
"""Authenticate to Discourse
|
||||||
|
|
||||||
|
This uses the API_SITE, API_USER, and API_KEY env vars.
|
||||||
|
"""
|
||||||
|
# Load the config, so we can store secrets outside of env vars
|
||||||
|
config = load_config()
|
||||||
|
in_conf = "discourse" in config
|
||||||
|
|
||||||
|
# Load the needed secrets either from the config file if it exists
|
||||||
|
# or the env var if it's defined (which takes precedence)
|
||||||
|
site = getenv("DISCOURSE_API_SITE") or (in_conf and config["discourse"]["site"])
|
||||||
|
user = getenv("DISCOURSE_API_USER") or (in_conf and config["discourse"]["user"])
|
||||||
|
key = getenv("DISCOURSE_API_KEY") or (in_conf and config["discourse"]["key"])
|
||||||
|
for envvar in [site, user, key]:
|
||||||
|
if not envvar:
|
||||||
|
raise ValueError("DISCOURSE_API_SITE, DISCOURSE_API_USER, and",
|
||||||
|
"DISCOURSE_API_KEY must be defined")
|
||||||
|
# Authenticate to the server
|
||||||
|
server = DiscourseClient(site, api_username=user, api_key=key)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
def _get_data(self):
|
||||||
|
"""Get the data from Discourse
|
||||||
|
|
||||||
|
This function returns six distinct values as one list:
|
||||||
|
|
||||||
|
[open_support, total_support, percent_support, open_all, total_all,
|
||||||
|
percent_all]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Authenticate to the server
|
||||||
|
server = self._auth_discourse_server()
|
||||||
|
|
||||||
|
# Initialize the data
|
||||||
|
data = [0, 0, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
for category in server.categories():
|
||||||
|
# We are limited to 30 topics per page, so we have to loop until
|
||||||
|
# there are no more topics
|
||||||
|
page = 0
|
||||||
|
on_page = 1
|
||||||
|
c_id = category["id"]
|
||||||
|
|
||||||
|
while on_page > 0:
|
||||||
|
# Get a list of all the topics to then iterate on
|
||||||
|
page_data = server.category_topics(category_id=c_id, page=page)
|
||||||
|
topics = page_data["topic_list"]["topics"]
|
||||||
|
on_page = len(topics)
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
print("Working on " + category["name"])
|
||||||
|
for topic in topics:
|
||||||
|
# Increment total_all
|
||||||
|
data[4] += 1
|
||||||
|
|
||||||
|
# If it's open, increment open_all
|
||||||
|
if not topic["closed"]:
|
||||||
|
data[3] += 1
|
||||||
|
|
||||||
|
# If the topic is in Support, repeat
|
||||||
|
if category["name"] == "Support":
|
||||||
|
data[1] += 1
|
||||||
|
if not topic["closed"]:
|
||||||
|
data[0] += 1
|
||||||
|
|
||||||
|
# Calculate the percentages
|
||||||
|
data[2] = ((data[0] / data[1]) * 100)
|
||||||
|
data[5] = ((data[3] / data[4]) * 100)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def sqlite_setup(self):
|
||||||
|
"""Initially set up the table for usage in SQLite
|
||||||
|
|
||||||
|
This returns a str which will then be executed in our SQLite db
|
||||||
|
|
||||||
|
Here is the "discourse" table layout:
|
||||||
|
- date is the primary key, and it is the Unix timestamp as an int
|
||||||
|
- open_support is the number of open topics in the Support category
|
||||||
|
- total_support is the number of total topics in the Support category
|
||||||
|
- percent_support is ((open_support / total_support) * 100)
|
||||||
|
- open_all is the number of open topics on the Discourse instance
|
||||||
|
- total_all is the number of total topics on the Discourse instance
|
||||||
|
- percent_all is ((open_all / total_all) * 100)
|
||||||
|
"""
|
||||||
|
|
||||||
|
command = "CREATE TABLE IF NOT EXISTS discourse (date INTEGER PRIMARY"
|
||||||
|
command += " KEY, open_support INTEGER, total_support INTEGER, "
|
||||||
|
command += "percent_support INTEGER, open_all INTEGER, total_all "
|
||||||
|
command += "INTEGER, percent_all INTEGER);"
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
def sqlite_add(self):
|
||||||
|
"""Add data to the SQLite db
|
||||||
|
|
||||||
|
This retrieves the current data from the Jenkins server, and returns a
|
||||||
|
str which will then be executed in our SQLite db
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Match the variable names with the column names in the db
|
||||||
|
data = tuple(self._get_data())
|
||||||
|
date = "strftime('%s', 'now')"
|
||||||
|
|
||||||
|
# Craft the str
|
||||||
|
print(data)
|
||||||
|
command = "INSERT INTO discourse VALUES ("
|
||||||
|
command += "{}, {}, {}, {}, {}, {}, {});".format(date, *data)
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
def sqlite_time_range(self, days):
|
||||||
|
"""Get the rows which have been inserted given days
|
||||||
|
|
||||||
|
e.g. if days is 180, it gets all of the values which have been
|
||||||
|
inserted in the past 180 days.
|
||||||
|
|
||||||
|
Note: this just returns the command to be ran, it doesn't actually run
|
||||||
|
"""
|
||||||
|
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
timedelta = datetime.timedelta(days=days)
|
||||||
|
unix_time = int(time.mktime((now - timedelta).timetuple()))
|
||||||
|
|
||||||
|
command = "SELECT * FROM discourse WHERE date > %s;" % unix_time
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
def render_template(self, days, data):
|
||||||
|
"""Render a template with days in the filename, given the data
|
||||||
|
|
||||||
|
The above function sqlite_time_range() is ran on the database with
|
||||||
|
some predetermined date ranges. This function actually interprets that
|
||||||
|
data, uses Jinja2 to magically render a template, and voila.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Initialize a (softly) ephemeral dict to store data
|
||||||
|
discourse = dict()
|
||||||
|
keys = ["date", "open_support", "total_support", "percent_support",
|
||||||
|
"open_all", "total_all", "percent_all"]
|
||||||
|
|
||||||
|
# Use a lambda to map the data into a dict with the keys
|
||||||
|
for row in data:
|
||||||
|
print(row)
|
||||||
|
_data = {keys[x] : row[x] for x in range(len(row))}
|
||||||
|
|
||||||
|
# Add our ephemeral dict to the master dict, and create the key if
|
||||||
|
# it doesn't already exist
|
||||||
|
for key in _data.keys():
|
||||||
|
if key in discourse:
|
||||||
|
discourse[key].append(_data[key])
|
||||||
|
else:
|
||||||
|
discourse[key] = [_data[key]]
|
||||||
|
|
||||||
|
data = discourse
|
||||||
|
|
||||||
|
# Get human-readable averages and throw it in a dict
|
||||||
|
average = {}
|
||||||
|
for datatype in keys:
|
||||||
|
try:
|
||||||
|
num = sum(data[datatype]) / len(data[datatype])
|
||||||
|
num = format(num, ".1f")
|
||||||
|
except ZeroDivisionError:
|
||||||
|
num = 0
|
||||||
|
average[datatype] = num
|
||||||
|
|
||||||
|
# Assign data to the dict Jinja2 is actually going to use
|
||||||
|
discourse = {keys[x] : zip(data["date"], data[keys[x]]) for x in range(len(keys))}
|
||||||
|
|
||||||
|
# Make the output dir if it doesn't already exist
|
||||||
|
if not path.exists("output"):
|
||||||
|
makedirs("output")
|
||||||
|
|
||||||
|
src = path.join("templates", "discourse.html")
|
||||||
|
dest = path.join("output", "discourse_%sdays.html" % days)
|
||||||
|
jinja2_template(src, dest, discourse=discourse, average=average,
|
||||||
|
days=days)
|
||||||
|
|
||||||
|
# Return the averages for use in the summary
|
||||||
|
r_data = []
|
||||||
|
del keys[0]
|
||||||
|
for key in keys:
|
||||||
|
r_data.append(average[key])
|
||||||
|
|
||||||
|
return tuple(r_data)
|
@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- HTML5 compliance -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<!-- Import Chart.js 2.9.3 -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.js"></script>
|
||||||
|
|
||||||
|
<!-- Import Bootstrap 4.3.1 -->
|
||||||
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
|
||||||
|
|
||||||
|
<!-- Favicon and page title -->
|
||||||
|
<link rel="icon" type="image/png" href="/assets/favicon.png"/>
|
||||||
|
<title>Discourse data for the past {{ days }} day{{ "s" if days > 1 }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col" style="text-align: center;">
|
||||||
|
<h1>Discourse data for the past {{ days }} day{{ "s" if days > 1 }}</h1>
|
||||||
|
<canvas id="discoursechart"></canvas>
|
||||||
|
<h2>Average number of open support topics: {{ average.open_support }}</h2>
|
||||||
|
<h2>Average number of total support topics: {{ average.total_support }}</h2>
|
||||||
|
<h2>Average percent of (open / total) support topics: {{ average.percent_support }}%</h2>
|
||||||
|
<h2>Average number of open topics: {{ average.open_all }}</h2>
|
||||||
|
<h2>Average number of total topics: {{ average.total_all }}</h2>
|
||||||
|
<h2>Average percent of (open / total) topics: {{ average.percent_all }}%</h2>
|
||||||
|
<h2><a href="./">Back to summary page</a></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- This is the actual chart functionality -->
|
||||||
|
<script>
|
||||||
|
var ctx = document.getElementById("discoursechart").getContext("2d");
|
||||||
|
|
||||||
|
var myChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Open Support Topics',
|
||||||
|
backgroundColor: "#FF0000",
|
||||||
|
borderColor: "#FF0000",
|
||||||
|
data: [
|
||||||
|
{% for timestamp, num in discourse.open_support %}
|
||||||
|
{
|
||||||
|
t: new Date({{ timestamp }} * 1000),
|
||||||
|
y: {{ num }}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Topics',
|
||||||
|
backgroundColor: "#FFFF00",
|
||||||
|
borderColor: "#FFFF00",
|
||||||
|
data: [
|
||||||
|
{% for timestamp, num in discourse.open_all %}
|
||||||
|
{
|
||||||
|
t: new Date({{ timestamp }} * 1000),
|
||||||
|
y: {{ num }}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Support Topics',
|
||||||
|
backgroundColor: "#32CD32",
|
||||||
|
borderColor: "#32CD32",
|
||||||
|
data: [
|
||||||
|
{% for timestamp, num in discourse.total_support %}
|
||||||
|
{
|
||||||
|
t: new Date({{ timestamp }} * 1000),
|
||||||
|
y: {{ num }}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Topics',
|
||||||
|
backgroundColor: "#003A72",
|
||||||
|
//borderColor: "#003A72",
|
||||||
|
data: [
|
||||||
|
{% for timestamp, num in discourse.total_all %}
|
||||||
|
{
|
||||||
|
t: new Date({{ timestamp }} * 1000),
|
||||||
|
y: {{ num }}
|
||||||
|
},
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
xAxes: [{
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in new issue