summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Pérez-Cerezo <gabriel@gpcf.eu>2018-07-06 17:34:38 +0200
committerGabriel Pérez-Cerezo <gabriel@gpcf.eu>2018-07-06 17:34:38 +0200
commitc35389d77608fa78affe4eee331970897707bc97 (patch)
treec5edaedc030c5b5025e78aafd1ab22d0bba8b04a
downloadhemiptera-c35389d77608fa78affe4eee331970897707bc97.tar.gz
hemiptera-c35389d77608fa78affe4eee331970897707bc97.tar.bz2
hemiptera-c35389d77608fa78affe4eee331970897707bc97.zip
first commit
-rw-r--r--export/static/excl.pngbin0 -> 449 bytes
-rw-r--r--export/styles.css132
-rwxr-xr-xhemiptera364
-rw-r--r--hemiptera_avatar_gen.py52
-rw-r--r--hemiptera_common.py25
-rw-r--r--hemiptera_html.py117
6 files changed, 690 insertions, 0 deletions
diff --git a/export/static/excl.png b/export/static/excl.png
new file mode 100644
index 0000000..7be223f
--- /dev/null
+++ b/export/static/excl.png
Binary files differ
diff --git a/export/styles.css b/export/styles.css
new file mode 100644
index 0000000..51f797e
--- /dev/null
+++ b/export/styles.css
@@ -0,0 +1,132 @@
+.container {
+ border: 1px solid #aaa;
+ margin: 1em;
+ margin-top:0;
+ margin-bottom:-1px;
+ padding: 1em;
+ /* border-radius: 0.5em;*/
+ background-color: #eee;
+ max-width: 35em;
+ padding-bottom: 0;
+}
+
+header {
+ border: 1px solid #aaa;
+ padding: 1em;
+ padding-top: 0.1em;
+ background-color: #eee;
+ margin: 0;
+ margin-bottom: 1em;
+ max-width: 40em;
+}
+
+.avatar {
+ height: 3em;
+ float: left;
+ margin-right: 0.7em;
+}
+
+
+.subject {
+ color: #0a0;
+}
+
+body {
+ font-family: sans-serif;
+ margin: 0;
+}
+
+.container:first-of-type {
+ border-radius: 0.5em 0.5em 0 0 ;
+}
+
+.container:last-of-type {
+ border-radius: 0 0 0.5em 0.5em;
+}
+
+table tr:nth-child(odd) {
+ background: #eed;
+}
+
+.date, thead td {
+ border-bottom: 1px solid #aaa;
+ background-color: #dde;
+ padding: 0.5em;
+ margin: -1em;
+ margin-bottom: 0em;
+ height: 3em;
+}
+
+thead td {
+ padding: 0.5em;
+}
+td {
+ padding: 0.3em;
+ overflow: ellipsis;
+}
+
+tr.closed {
+ background: #dee;
+}
+table tr.closed:nth-child(odd) {
+ background: #cdd;
+}
+table {
+ border: 1px solid #aaa;
+ padding: 0;
+}
+
+img {
+ image-rendering: -moz-crisp-edges;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: -o-crisp-edges;
+ image-rendering: -webkit-optimize-contrast;
+ -ms-interpolation-mode: nearest-neighbor;
+}
+
+.status-header {
+ background-color: #ddd;
+}
+
+.op {
+ background-color: #ded;
+}
+
+.dev {
+ background-color: #edd;
+}
+
+.opdev {
+ background-color: #ede;
+}
+
+.container:first-of-type .date {
+ border-radius: 0.5em 0.5em 0 0;
+}
+
+.reply {
+ font-family: monospace;
+ white-space: pre-wrap;
+ counter-reset: line;
+}
+.reply p {
+ counter-increment: line;
+ margin: 0.2em;
+ margin-left: 3em;
+ background-color: #ddd;
+}
+.reply p::before {
+ content: "" counter(line) "";
+/* font-size: 0.7em;*/
+ display: inline-block;
+ width: 2em;
+ margin-right: 1em;
+ text-align: right;
+ margin-left: -3em;
+ color: #a00;
+}
+
+table {
+ margin: 1em
+}
+
diff --git a/hemiptera b/hemiptera
new file mode 100755
index 0000000..7dc0bcf
--- /dev/null
+++ b/hemiptera
@@ -0,0 +1,364 @@
+#!/usr/bin/python3
+
+## Hemiptera:
+## Simple E-Mail-based Bugtracker
+
+
+
+
+
+#
+# DIRECTORY STRUCTURE
+
+# basedir
+# ├── bugcount <- File keeping track of the count of bugs
+# ├── bugs ← For direct access to bugs for fast reply indexing
+# │ ├── 388 ⇒ basedir/projects/foo-bar/388
+# │ └── 498 ⇒ basedir/projects/bar-baz/498
+# ├── inbox ← Emails arrive here
+# ├── lock ⇒ hemiptera:$PID ← Filesystem lock
+# ├── outbox
+# │ ├── 1529069051.7963576.bugreply
+# │ └── 1529069051.7971687.bugreply
+# ├── projects ← Directory containing bugs, sorted by directory
+# │ ├── foo-bar
+# │ │ ├── 388 ← Directory containing messages for bug 388
+# │ │ │ ├── 1528380502.4440382
+# │ │ │ ├── 1529069051.7901955
+# │ │ │ └── subscribers ← File containing subscribers of bug 388
+# │ │ └── devs.txt ← File containing addresses of developers of foo-bar
+# │ └── bar-baz
+# │ ├── [...]
+# └── unconfirmed
+# ├── 0f61caf5ff24b5871835801c008341e7347928ba
+# ├── 8751c708556bb583c19b6ed87a891c3f25b99e2b
+#
+import email
+from email import policy
+from email.parser import BytesParser, Parser
+from email.utils import parseaddr
+import email.utils
+import sys
+import os
+import time
+from hemiptera_common import *
+import glob
+LOCKPATH =opj(basedir, "lock")
+
+class Bug :
+ def __init__(self, path) :
+ self.path = path
+ replies = glob.glob(os.path.join(self.path, "[0-9]*"))
+ def get_subs(self) :
+ if os.path.exists(opj(self.path, "subscribers")) :
+ with open(os.path.join(self.path, "subscribers"), "r+" ) as f :
+ return [line.strip() for line in f]
+ else :
+ return []
+ def set_subs(self, subs) :
+ with open(opj(self.path, "subscribers", "w" )) as f :
+ f.write("\n".join(subs))
+ f.close()
+ def get_message(self, i) :
+ # get i-th reply to the bug
+ f = open(replies[i], "rb")
+ m = BytesParser(policy=policy.default).parse(f)
+ f.close()
+ return m
+ def get_op(self) :
+ m = self.get_message(0)
+ return m["From"]
+ def get_subject(self) :
+ m = self.get_message(0)
+ return m["Subject"]
+
+
+def check_lock() :
+ if os.path.islink(LOCKPATH) :
+ sys.stderr.write("ERROR: Directory locked by "+os.readlink(LOCKPATH)+". Aborting\n")
+ sys.exit(255)
+def get_lock() :
+ """ Get a filesystem lock, as emacs does it (symlink to PID)"""
+ check_lock()
+ os.symlink("hemiptera:"+str(os.getpid()), LOCKPATH)
+
+def remove_lock() :
+ os.remove(LOCKPATH)
+
+def get_subs(bug) :
+ """ Get e-mail addresses of people who subscribed to bug """
+ if os.path.exists(os.path.join(bug, "subscribers")) :
+ with open(os.path.join(bug, "subscribers"), "r+" ) as f :
+ return [line.strip() for line in f]
+ else :
+ return None
+
+def write_subs(bug, subs) :
+ with open(os.path.join(bug, "subscribers"), "w") as f :
+ f.write("\n".join(subs))
+ f.close()
+
+def get_op(bug) :
+ r = glob.glob(os.path.join(bug, "[0-9]*"))
+ f = open(r[0], "rb")
+ m = BytesParser(policy=policy.default).parse(f)
+ f.close()
+ return m["From"]
+
+def write_message(path, m) :
+ """ Write message m to path """
+ f = open(path, "wb")
+ f.write(m.as_bytes())
+ f.close()
+
+def get_message_data(m) :
+ """ returns subject and content of message """
+ content = m.get_body("plain").get_content()
+ subject = m["Subject"]
+ return subject,content
+
+def get_from_addr(m) :
+ """ returns the from address of a message """
+ return parseaddr(m["From"])[1]
+def get_to_addr(m) :
+ """ returns the to address of a message """
+ return parseaddr(m["To"])[1]
+
+
+def generate_confirmation_id() :
+ """ Generates a confirmation ID for text """
+ return os.urandom(8).hex()
+
+
+def create_censored_message(msg, TrustedDate=False) :
+ """This transforms a mail message received by the script into a
+message which was stripped of any names and other personal information.
+Saved stuff:
+ - Email address (No name)
+ - Date
+ - Subject
+ - Body
+ - references and In-Reply-To (if they exist)
+In a further step, when generating HTML, e-mail addresses will also get stripped.
+
+The TrustedDate Parameter indicates whether the date from the message
+ is to be trusted. Otherwise, it is replaced with the date at which
+ this function is run.
+
+ """
+ m = email.message.EmailMessage()
+ m.set_payload(msg.get_body("plain").get_content(), charset="utf-8")
+ m["From"] = get_from_addr(msg)
+ m["To"] = get_to_addr(msg)
+ m["Subject"] = msg["Subject"]
+ if not "Date" in msg or not TrustedDate :
+ del m["Date"]
+ m["Date"] = email.utils.formatdate()
+ else :
+ m["Date"] = msg["Date"]
+ if "In-Reply-To" in msg :
+ m["In-Reply-To"] = msg["In-Reply-To"]
+ if "References" in msg:
+ m["References"] = msg["References"]
+ m["Message-ID"] = email.utils.make_msgid(domain=DOMAIN)
+ return m
+
+
+def gen_message(body,subject, to, fr, headers=None) :
+ r = email.message.EmailMessage()
+ r["To"] = to
+ r["From"] = fr
+ r["Subject"] = subject
+ r.set_payload(body, charset="utf-8")
+ os.makedirs(outbox, exist_ok=True)
+ fname = opj(outbox, str(time.time())+".bugreply")
+ write_message(fname, r)
+
+
+def reply(body, subject, to, bugid) :
+ """ This function generates a reply message"""
+ fr = str(bugid) + "@" + DOMAIN
+ gen_message(body,subject,to,fr)
+
+
+def get_project(bug) :
+ """Get project name for bug path pointing into global bug directory"""
+ dest = os.readlink(bug)
+ return dest.split(os.sep)[-2]
+
+def create_unconfirmed_bug(msg) :
+ ## send back confirmation message
+ get_lock()
+ fromaddr = get_from_addr(msg)
+ confid = generate_confirmation_id()
+ print("ID: "+confid)
+ bugpath = os.path.join(basedir, "unconfirmed" , confid)
+ fr = "confirm-bug@" + DOMAIN
+ subject, _ = get_message_data(msg)
+ gen_message(confid,subject,fromaddr,fr)
+ os.makedirs(bugpath)
+ m = create_censored_message(msg)
+ write_message(os.path.join(bugpath, "message"), m)
+ remove_lock()
+ return
+
+def create_bug(prname, msg) :
+ get_lock()
+ bak = open(opj(basedir, "bugcount.bak"), "w")
+ try :
+ bg = open("bugcount", "r+")
+ bugcount = int(bg.read())
+ bak.write(str(bugcount))
+ bak.close()
+ bg.close()
+ bg = open("bugcount", "w")
+ except FileNotFoundError :
+ bg = open("bugcount", "w")
+ bg.write("0")
+ bugcount = 0
+ bg.close()
+ bg = open("bugcount", "w")
+ bugcount += 1
+ bg.write(str(bugcount))
+ bugpath = os.path.join(basedir, "projects" , prname,str(bugcount))
+ os.makedirs(bugpath)
+ subs = get_devs(prname)
+ fromaddr = get_from_addr(msg)
+ if fromaddr not in subs :
+ subs.append(fromaddr)
+
+ os.symlink( bugpath, os.path.join(bugdir, str(bugcount)), target_is_directory=True)
+
+ m = create_censored_message(msg)
+ subject,content = get_message_data(m)
+ write_message(os.path.join(bugpath, str(time.time())), m)
+ write_subs(bugpath, subs)
+
+ print(
+
+ """
+You have sent a bug report to %s.
+
+It has been processed under the id %d
+
+
+ """ % (prname, bugcount))
+ for sub in subs :
+ ### Send out an e-mail to each subscriber
+ reply(content, subject, sub, str(bugcount))
+ remove_lock()
+
+
+def reply_to_bug(bugname, msg) :
+ get_lock()
+ bug = os.path.join(bugdir, bugname)
+ fromaddr = get_from_addr(msg)
+ cm = create_censored_message(msg)
+ subject,content = get_message_data(cm)
+ write_message(os.path.join(bug, str(time.time())), cm)
+ print("""
+Your reply to bug ID %s has been processed
+ """%bugname)
+ subs = get_subs(bug)
+ if not fromaddr in subs :
+ subs.append(fromaddr)
+ write_subs(bug, subs)
+ for sub in subs :
+ reply(content, subject, sub, bugname)
+ remove_lock()
+
+def process_command(bugname, msg) :
+ bug = os.path.join(bugdir, bugname)
+ fromaddr = get_from_addr(msg)
+ cm = create_censored_message(msg)
+ subject,content = get_message_data(cm)
+ if "%close" in content :
+ f = open(opj(bug, "closed"), "w")
+ f.write(cm["Date"])
+ f.close()
+ subs = get_subs(bug)
+ for sub in subs :
+ reply("This bug was closed", "Bug #%s closed" % bugname, sub, bugname)
+
+
+def import_bugs() :
+ """ Imports bugs from Inbox """
+ os.makedirs(inbox, exist_ok=True)
+ for i in os.listdir(inbox) :
+ g = open(opj(inbox, i), "rb")
+ m = BytesParser(policy=policy.default).parse(g)
+ process_message(m)
+ os.remove(opj(inbox, i))
+
+def process_message(m) :
+ toaddr = get_to_addr(m)
+ os.makedirs(bugdir, exist_ok=True)
+ dest = toaddr.split("@")[0]
+ if dest in prlist :
+ create_unconfirmed_bug(m)
+ elif dest in os.listdir(bugdir) :
+ reply_to_bug(dest, m)
+ elif dest.split("-")[0] in os.listdir(bugdir) and dest.split("-")[1] == "command" :
+ ### Process command
+ print("Command spotted")
+ process_command(dest.split("-")[0],m)
+ elif dest.startswith("confirm-bug") :
+ unconf_dir = opj(basedir, "unconfirmed")
+ subject, content = get_message_data(m)
+ for i in os.listdir(unconf_dir) :
+ if i in content :
+ f = open(opj(unconf_dir, i, "message"), "rb")
+ m = BytesParser(policy=policy.default).parse(f)
+ f.close()
+ p = parseaddr(m["To"])[1].split("@")[0]
+ create_bug(p,m)
+ import shutil
+ shutil.rmtree(opj(unconf_dir, i))
+ exit(0)
+ print("Invalid activation id")
+ else :
+ print("Invalid message")
+
+
+
+
+import_bugs()
+
+# m = BytesParser(policy=policy.default).parse(g)
+
+# fromaddr = get_from_addr(m)
+# toaddr = get_to_addr(m)
+# subject,content = get_message_data(m)
+# bugdir = os.path.join(basedir, "bugs")
+# os.makedirs(bugdir, exist_ok=True)
+
+# prname = toaddr.split("@")[0]
+# print(prname)
+# if prname in prlist :
+# create_unconfirmed_bug(m)
+# elif prname in os.listdir(bugdir) :
+# reply_to_bug(prname, m)
+# elif prname.split("-")[0] in os.listdir(bugdir) and prname.split("-")[1] == "command" :
+# ### Process command
+# print("Command spotted")
+# process_command(prname.split("-")[0],m)
+# elif prname.startswith("confirm-bug") :
+# unconf_dir = opj(basedir, "unconfirmed")
+# for i in os.listdir(unconf_dir) :
+# if i in content :
+# f = open(opj(unconf_dir, i, "message"), "rb")
+# m = BytesParser(policy=policy.default).parse(f)
+# f.close()
+# p = parseaddr(m["To"])[1].split("@")[0]
+# create_bug(p,m)
+# import shutil
+# shutil.rmtree(opj(unconf_dir, i))
+# exit(0)
+# print("Invalid activation id")
+# else :
+# print("Invalid message")
+
+
+
+
+
diff --git a/hemiptera_avatar_gen.py b/hemiptera_avatar_gen.py
new file mode 100644
index 0000000..db380a9
--- /dev/null
+++ b/hemiptera_avatar_gen.py
@@ -0,0 +1,52 @@
+import hashlib
+import random
+from PIL import Image, ImageDraw
+from hemiptera_common import *
+import os
+
+SERVER_SALT="foobar"
+
+D = 7
+pixel_width=1
+def hash_generator(h) :
+ index = 0
+ while True :
+ yield int(h[index],16)
+ index +=1
+ if index == len(h) : ## Keep images interesting if size is significantly larger than hash input
+ index = 0
+ h = hashlib.sha1(h.encode()).hexdigest()
+
+
+def gen_color(h) :
+ return tuple(tuple(next(h)*16 for i in range(3)) for i in range(2))
+
+def gen_image(h) :
+ h = hash_generator(h)
+ c1, c2 = gen_color(h)
+ im = Image.new("RGBA", (D,D))
+ draw = ImageDraw.Draw(im)
+ index = 0
+ for i in range(D//2+1) :
+ for j in range(D) :
+ k = next(h)
+ if k % 2 or k//2 % 2 :
+ if k % 2 :
+ color = c1
+ else :
+ color = c2
+ draw.point([(i,j), (D-1-i,j)], fill=color)
+ return im
+
+
+def generate_avatar(email) :
+ if email is None :
+ email = ""
+ h = hashlib.sha1(str(email+SERVER_SALT).encode()).hexdigest()
+ imgpath = opj(basedir, "export", "static")
+ img = gen_image(h)
+ os.makedirs(imgpath, exist_ok=True)
+ img.save(opj(imgpath, h+".png"))
+ return "/static/" + h+".png"
+
+
diff --git a/hemiptera_common.py b/hemiptera_common.py
new file mode 100644
index 0000000..6204f62
--- /dev/null
+++ b/hemiptera_common.py
@@ -0,0 +1,25 @@
+import email
+from email import policy
+from email.parser import BytesParser
+from email.utils import parseaddr
+import sys
+import os
+import time
+opj = os.path.join
+
+DOMAIN = "bugs.bananach.space"
+prlist = ["advtrains", "moreblocks", "foo"]
+basedir = ""
+basedir = os.path.abspath("")
+outbox = opj(basedir, "outbox")
+inbox = os.path.join(basedir, "inbox")
+bugdir = os.path.join(basedir, "bugs")
+
+def get_devs(project) :
+ """ Get list of developer addresses for project """
+ devfile = os.path.join(basedir,"projects",project,"devs.txt")
+ devs = []
+ if os.path.exists(devfile):
+ f = open(devfile, "r")
+ devs = [ line.strip() for line in f ]
+ return devs
diff --git a/hemiptera_html.py b/hemiptera_html.py
new file mode 100644
index 0000000..6596c13
--- /dev/null
+++ b/hemiptera_html.py
@@ -0,0 +1,117 @@
+#!/usr/bin/python
+
+## HTML exporter for hemiptera
+from hemiptera_common import *
+import glob
+
+from email.utils import parsedate_tz, mktime_tz, formatdate, parsedate_to_datetime
+from jinja2 import Environment, FileSystemLoader
+templatedir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
+file_loader = FileSystemLoader(templatedir)
+
+env = Environment(loader=file_loader)
+template = env.get_template("main_page.html")
+buglist = env.get_template("buglist.html")
+bugt = env.get_template("bug.html")
+from hemiptera_avatar_gen import generate_avatar
+export = opj(basedir, "export")
+
+## TODO: Choose template system, create nice template
+class Project() :
+ pass
+class Bug() :
+ pass
+### https://stackoverflow.com/questions/410221/natural-relative-days-in-python
+import datetime
+def prettydate(d):
+ if not d.tzname() :
+ d = d.replace(tzinfo=datetime.timezone.utc)
+ diff = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) - d
+ s = diff.seconds
+ if diff.days > 7 or diff.days < 0:
+ return d.strftime('%Y-%m-%d')
+ elif diff.days == 1:
+ return '1 day ago'
+ elif diff.days > 1:
+ return '{} days ago'.format(diff.days)
+ elif s <= 1:
+ return 'just now'
+ elif s < 60:
+ return '{} seconds ago'.format(s)
+ elif s < 120:
+ return '1 minute ago'
+ elif s < 3600:
+ return '{} minutes ago'.format(s//60)
+ elif s < 7200:
+ return '1 hour ago'
+ else:
+ return '{} hours ago'.format(s//3600)
+
+
+
+def create_project_list() :
+ projects = []
+ for i in os.listdir(opj(basedir, "projects")) :
+ k = Project()
+ k.name = i
+ k.count = len(os.listdir(opj(basedir, "projects", i))) -1
+ projects.append(k)
+ bugs =[]
+ os.makedirs(opj(export, i), exist_ok=True)
+ devs = get_devs(i)
+ for j in glob.glob(opj(basedir, "projects", i, "[0-9]*")) :
+ b = Bug()
+ b.closed = os.path.exists(opj(j, "closed"))
+ if b.closed :
+ b.closeddate = open(opj(j,"closed"), "r").read()
+
+ b.id = os.path.basename(j)
+ r = glob.glob(opj(j, "[0-9]*"))
+ r.sort()
+ b.replies = len(r)
+ f = open(r[0], "rb")
+ m = BytesParser(policy=policy.default).parse(f)
+ b.subject = m["Subject"]
+ if not b.subject :
+ b.subject = "No subject"
+ bugs.append(b)
+ replies = []
+ for k in r :
+ f = open(k, "rb")
+ m = BytesParser(policy=policy.default).parse(f)
+ m["Avatar"] = generate_avatar(m["From"])
+ replies.append(m)
+ b.created = replies[0]["Date"]
+ if b.created :
+ b.nicecreated = prettydate(parsedate_to_datetime(b.created))
+ else :
+ b.nicecreated = "unknown"
+ b.last_reply = replies[-1]["Date"]
+ if b.last_reply :
+ b.nicereply = prettydate(parsedate_to_datetime(b.last_reply))
+ else :
+ b.nicereply = "unknown"
+ f = open(opj(export, i, str(b.id)+".html"), "w")
+ f.write(bugt.render(DOMAIN=DOMAIN, replies=replies, prname=i, bug=b, devs=devs))
+ bugs.sort(key=lambda b: int(b.id), reverse=True)
+ bugs.sort(key=lambda b: int(b.closed))
+ f = open(opj(export, i, "index.html"), "w")
+ f.write(buglist.render(DOMAIN=DOMAIN, bugs=bugs, prname=i))
+ f.close()
+ f = open(opj(export, "index.html"), "w")
+ f.write(template.render(DOMAIN=DOMAIN, projects=projects))
+ ## Create the project list
+
+
+def create_buglist(project) :
+ ## Create bug list for project
+ pass
+
+def show_bug(bug_nr):
+ ## Display all the replies to bug with number bug_nr
+ pass
+
+
+
+if __name__ == "__main__" :
+ create_project_list()