From 605b0643d3880ea821196d83a01d14c5bb4ad744 Mon Sep 17 00:00:00 2001 From: Yorhel Date: Sat, 17 Mar 2012 17:43:20 +0100 Subject: [PATCH] Moved css to separate file + Wrote minimal issue tracker for ncdc I designed the issue tracker to be a separate module for use in TUWF websites, and perhaps even as standalone. But for now it's just a small file in this repository. --- Issue.pm | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.cgi | 147 +++++++++++++++------------- style.css | 87 +++++++++++++++++ 3 files changed, 447 insertions(+), 69 deletions(-) create mode 100644 Issue.pm create mode 100644 style.css diff --git a/Issue.pm b/Issue.pm new file mode 100644 index 0000000..3242fa6 --- /dev/null +++ b/Issue.pm @@ -0,0 +1,282 @@ + +=head1 SQL Schema + + CREATE TABLE ${p}issues ( + issue SERIAL PRIMARY KEY, + latest integer NOT NULL + ); + + CREATE TABLE ${p}messages ( + id SERIAL PRIMARY KEY, + issue integer NOT NULL, + date timestamptz NOT NULL DEFAULT NOW(), + summary varchar(200) NOT NULL, + type varchar NOT NULL DEFAULT '', + status varchar NOT NULL DEFAULT '', + closed boolean NOT NULL DEFAULT false, + email varchar NOT NULL DEFAULT '', + message varchar NOT NULL DEFAULT '' + ); + +=cut + +# TODO: Atom feed? + +package TUWF::Issue; + +use TUWF ':html', 'html_escape'; + +sub new { + my $class = shift; + return bless { + prefix => 'issue_', + types => [qw|bug feature docs other|], + default_type => 'other', + statusses => [qw|new accepted duplicate confirmed fixed wontfix worksforme|], + default_status => 'new', + admins => ['code1', 'code2'], + @_ + }, $class; +} + + +sub dbListing { + my $self = shift; + my %o = ( + results => 100, + page => 1, + ); + $o{reverse} = 1 if !$o{sort}; + + my %where = ( + $o{id} ? ('i.issue = ?' => $o{id}) : (), + ); + + my $order = sprintf { + date => 'm.id %s', + }->{$o{sort}||'date'}, $o{reverse} ? 'DESC' : 'ASC'; + + my($r, $np) = $TUWF::OBJ->dbPage(\%o, q{ + SELECT i.issue, m.summary, to_char(m.date, 'YYYY-MM-DD') AS date, m.type, m.status, m.closed + FROM !sissues i + JOIN !smessages m ON m.id = i.latest + !W + ORDER BY !s}, $self->{prefix}, $self->{prefix}, \%where, $order + ); + return wantarray ? ($r, $np) : $r; +} + + +sub dbItem { + my($self, $id) = @_; + return $TUWF::OBJ->dbAll(q{ + SELECT m.issue, m.summary, to_char(m.date, 'YYYY-MM-DD HH24:MI:SS (tz)') AS date, m.type, m.status, m.closed, m.message + FROM !smessages m + WHERE m.issue = ? + ORDER BY m.id}, $self->{prefix}, $id + ); +} + + +sub dbEmails { + my($self, $id) = @_; + return [ map $_->{email}, @{$TUWF::OBJ->dbAll(q|SELECT DISTINCT m.email FROM !smessages m WHERE m.issue = ? AND m.email <> ''|, $self->{prefix}, $id)} ]; +} + + +sub dbSave { + my($self, $id, $closed, @a) = @_; + $id = $TUWF::OBJ->dbRow('INSERT INTO !sissues (latest) VALUES (0) RETURNING issue', $self->{prefix})->{issue} if !$id; + my $latest = $TUWF::OBJ->dbRow( + 'INSERT INTO !smessages (issue, closed, summary, email, type, status, message) VALUES (?, ?, !l) RETURNING id', + $self->{prefix}, $id, $closed?1:0, \@a + )->{id}; + $TUWF::OBJ->dbExec('UPDATE !sissues SET latest = ? WHERE issue = ?', $self->{prefix}, $latest, $id); + return $id; +} + + +# TODO: pagination / filtering +sub htmlListing { + my($s, $l, $lnk) = @_; + table class => 'issue_listing'; + thead; Tr; + td class => 'issue_col_id', 'Id'; + td class => 'issue_col_type', 'Type'; + td class => 'issue_col_status', 'Status'; + td class => 'issue_col_date', 'Updated'; + td class => 'issue_col_summary','Summary'; + end; end; + for(@$l) { + Tr $_->{closed} ? (class => 'issue_closed') : (); + td class => 'issue_col_id', $_->{issue}; + td class => 'issue_col_type', $_->{type}; + td class => 'issue_col_status', $_->{status}; + td class => 'issue_col_date', $_->{date}; + td class => 'issue_col_summary'; + a href => $lnk->($_->{issue}), $_->{summary}; + end; + end; + } + end; +} + + +sub htmlItem { + my($s, $d) = @_; + my $last = $d->[$#$d]; + dl class => 'issue_status'; + dt 'Id'; dd $last->{issue}; + dt 'Messages'; dd $#$d+1; + dt 'Type'; dd $last->{type}; + dt 'Status'; dd $last->{status}; + end; + div class => 'issue_item'; + my $num = -1; + for my $m (@$d) { + div class => 'issue_message'; + h1 !++$num ? 'Description' : "Reply $num"; + dl; + dt !$num ? 'Created' : 'Added'; dd $m->{date}; + for($num ? qw|summary type status| : ()) { + if($m->{$_} ne $d->[$num-1]{$_}) { + dt "\u$_"; + dd sprintf '"%s" to "%s"', $d->[$num-1]{$_}, $m->{$_}; + } + } + if($num && !$m->{closed} != !$d->[$num-1]{$_}) { + dt "Closed"; + dd sprintf '"%s" to "%s"', $d->[$num-1]{closed}?'yes':'no', $m->{closed}?'yes':'no'; + } + end; + # TODO: link formatting and some way to link to other issues + p; lit html_escape $m->{message}; end; + end; + } + end; +} + + +sub htmlForm { + my($s, $l, $url) = @_; + # TODO: anti-spam JS + form class => 'issue_frm', action => $url, method => 'post'; + fieldset; + input type => 'hidden', name => 'issue_id', value => $l ? $l->{issue} : 0; + legend $l ? 'Reply' : 'Report a new issue'; + ul; + li class => 'issue_frm_summary'; + label for => 'issue_summary', 'Summary'; + input type => 'text', name => 'issue_summary', id => 'issue_summary', size => 45, value => $l?$l->{summary}:''; + end; + li class => 'issue_frm_mail'; + label for => 'issue_email', 'Email'; + input type => 'text', name => 'issue_email', id => 'issue_email', size => 20; + lit ' '; + txt 'Optional, only used for notifications.'; + end; + if($l) { + li class => 'issue_frm_admin'; + label for => 'issue_type', 'Admin'; + Select name => 'issue_type'; + option value => $_, $_ eq $l->{type} ? (selected => 'selected') : (), $_ for @{$s->{types}}; + end; + Select name => 'issue_status'; + option value => $_, $_ eq $l->{status} ? (selected => 'selected') : (), $_ for @{$s->{statusses}}; + end; + Select name => 'issue_closed'; + option value => 0, !$l->{closed} ? (selected => 'selected') : (), 'Open'; + option value => 1, $l->{closed} ? (selected => 'selected') : (), 'Closed'; + end; + input type => 'password', name => 'issue_code', id => 'issue_code', size => 10, value => 'code'; + end; + } else { + li class => 'issue_frm_type'; + label for => 'issue_type', 'Type'; + Select name => 'issue_type'; + option value => $_, $_ eq $s->{default_type} ? (selected => 'selected') : (), $_ for @{$s->{types}}; + end; + end; + } + li class => 'issue_frm_message'; + textarea name => 'issue_message';end; br; + lit 'Please use a pastebin if you want to include large chunks of code or program output.'; + end; + li class => 'issue_frm_submit'; + input type => 'submit', value => 'Submit'; + end; + end 'ul'; + end; + end 'form'; +} + + +sub handleForm { + my($s, $url) = @_; + my $f = $TUWF::OBJ->formValidate( + { post => 'issue_id', min => 0 }, + { post => 'issue_summary', maxlength => 200, minlength => 2 }, + { post => 'issue_email', required => 0, regex => qr/^[^@<>]+@[^@.<>]+(?:\.[^@.<>]+)+$/ }, + { post => 'issue_code', required => 0, default => '' }, + { post => 'issue_message', maxlength => 256*1024, minlength => 1 }, + ); + return($f, undef) if $f->{_err}; + + my $l; + # Reply + if($f->{issue_id} > 0) { + $l = $s->dbListing(id => $f->{issue_id})->[0]; + push @{$f->{_err}}, ['issue_id', 'db_check', ''] and return($f, undef) if !$l; + + # Check admin things + if(grep $_ eq $f->{issue_code}, @{$s->{admins}}) { + my $fa = $TUWF::OBJ->formValidate( + { post => 'issue_type', enum => $s->{types} }, + { post => 'issue_status', enum => $s->{statusses} }, + { post => 'issue_closed', enum => [0,1] }, + ); + $f = { %$f, %$fa }; + return($f, $l) if $f->{_err}; + } else { + $f->{issue_type} = $l->{type}; + $f->{issue_status} = $l->{status}; + $f->{issue_closed} = $l->{closed}; + } + + # New issue + } else { + $f->{issue_status} = $s->{default_status}; + $f->{issue_closed} = 0; + my $fa = $TUWF::OBJ->formValidate({ post => 'issue_type', enum => $s->{types} }); + $f = { %$f, %$fa }; + return($f, $l) if $f->{_err}; + } + + # No errors? Save! + my $id = $s->dbSave(map $f->{"issue_$_"}, qw|id closed summary email type status message|); + + # For replies, send out notification emails + if($l) { + my $mails = $s->dbEmails($id); + my $u = $url->($id); + for(grep $_ ne $f->{issue_email}, @$mails) { + $TUWF::OBJ->mail( + "Hello!\n\n". + "A new reply has been posted to an issue you have previously shown\n". + "an interest in. You can view the reply at the following URL:\n". + " $u\n\n". + "If you do not wish to receive any more notifications for this (and\n". + "perhaps other) issues, please reply to this email stating your intent.", + Subject => "Reply to $f->{issue_summary}", + To => "$_", + ); + } + } + + $l = $s->dbListing(id => $id)->[0] if !$l; + + return($f, $l); +} + + +1; diff --git a/index.cgi b/index.cgi index e891a55..88a148e 100755 --- a/index.cgi +++ b/index.cgi @@ -12,6 +12,7 @@ BEGIN { ($ROOT = abs_path $0) =~ s{index\.cgi$}{}; } my @changes = ( + [ '2012-03-17', '/ncdc/issue', 'Wrote a small issue tracker for ncdc' ], [ '2012-03-14', '/ncdc', 'ncdc 1.9 released.' ], [ '2012-02-15', '/doc/commvis', 'Added an article on my new communication system.' ], [ '2012-02-13', '/ncdc', 'ncdc 1.8 released.' ], @@ -74,11 +75,16 @@ TUWF::register( qr{dump/grenamr} => sub { podpage(shift, 'dump-grenamr', 'dump', 'grenamr', 'GTK+ Mass File Renamer') }, qr{dump/nccolour} => sub { podpage(shift, 'dump-nccolour', 'dump', 'nccolour', 'Colours in NCurses') }, qr{feed\.atom} => \&atom, + qr{(ncdc)/issue} => \&issue_list, + qr{(ncdc)/issue/post} => \&issue_post, + qr{(ncdc)/issue/new} => \&issue_new, + qr{(ncdc)/issue/([1-9][0-9]*)} => \&issue_item, ); TUWF::set( logfile => '/www/err.log', error_404_handler => \¬found, + mail_from => 'Yorhel\'s issue tracker ', # this is a fairly static site, allow some aggressive caching pre_request_handler => sub { $_[0]->resHeader('Cache-Control', 's-max-age=86400, max-age=3600'); 1; }, ); @@ -190,6 +196,76 @@ sub notfound { + +# Issue handling + +sub _issue_init { + require "$ROOT/Issue.pm"; + my($s, $p) = @_; + $s->resHeader('Cache-Control', 'no-cache'); + $s->_load_module('TUWF::DB'); + $s->{_TUWF}{db_login} = [ undef, undef, undef ]; + $s->dbInit; + return TUWF::Issue->new(prefix => $p.'_', admins => [ $ENV{ISSUE_CODE} ]); +} + + +sub issue_list { + my($s, $p) = @_; + my $is = _issue_init(@_); + $s->htmlHeader(title => 'Ncdc Issue tracker', page => $p, sec => 'issue'); + br; a href => "/$p/issue/new", 'Report new issue'; br; br; + $is->htmlListing((scalar $is->dbListing()), sub { "/$p/issue/".shift }); + br; a href => "/$p/issue/new", 'Report new issue'; br; br; + $s->htmlFooter; +} + + +sub issue_new { + my($s, $p) = @_; + my $is = _issue_init(@_); + $s->htmlHeader(title => 'Ncdc: Report new issue', page => $p, sec => 'issue'); + br; a href => "/$p/issue", 'Back to the issue index'; br; br; + $is->htmlForm(undef, "/$p/issue/post"); + $s->htmlFooter; +} + + +sub issue_post { + my($s, $p) = @_; + return $s->resNotFound if $s->reqMethod() ne 'POST'; + my $is = _issue_init($s, $p); + my($f, $l) = $is->handleForm(sub { "http://dev.yorhel.nl/$p/issue/".shift }); + + if($f->{_err}) { + $s->htmlHeader(title => 'Error creating message', page => $p, sec => 'issue'); + p 'There was an error in the form. Please use the \'back\' button of your + browser to go back to the form (hopefully) without losing your message. + There was an error in the following fields: '.join(', ', map {(my$f=$_->[0])=~s/issue_// ;"\u$f"} @{$f->{_err}}).'.'; + return $s->htmlFooter; + } + + $s->resRedirect("/$p/issue/$l->{issue}", 'post'); +} + + +sub issue_item { + my($s, $p, $i) = @_; + my $is = _issue_init($s, $p); + my $item = $is->dbItem($i); + return $s->resNotFound if !@$item; + my $last = $item->[$#$item]; + $s->htmlHeader(title => 'Ncdc: '.$last->{summary}, page => $p, sec => 'issue'); + br; a href => "/$p/issue", 'Back to the issue index'; br; br; + $is->htmlItem($item); + $is->htmlForm($last, "/$p/issue/post") if !$last->{closed}; + br; a href => "/$p/issue", 'Back to the issue index'; br; br; + $s->htmlFooter; +} + + + + package TUWF::Object; use TUWF ':html'; @@ -266,9 +342,7 @@ sub htmlHeader { my %o = (page => '', sec => '', sec2 => '', @_ ); html; head; - style type => 'text/css'; - $s->printCSS; - end; + Link rel => 'stylesheet', href => '/style.css', type => 'text/css', media => 'all'; Link rel => 'alternate', type => 'application/atom+xml', href => '/feed.atom', title => 'Site updates'; title $o{title}; end; @@ -337,6 +411,7 @@ sub htmlMenu { $m->('/ncdc/man', 'Manual', $o{sec} eq 'man'); $m->('/ncdc/changes', 'Changelog', $o{sec} eq 'changes'); $m->('/ncdc/scr', 'Screenshots', $o{sec} eq 'scr'); + $m->('/ncdc/issue', 'Issue tracker', $o{sec} eq 'issue'); }); $m->('/tuwf', 'Tuwf', $o{page} eq 'tuwf', sub { $m->('/tuwf', 'Info', !$o{sec}); @@ -361,69 +436,3 @@ sub htmlMenu { end; } - -sub printCSS { - # font-face code from http://fonts.googleapis.com/css?family=Buenard:700,400 - lit <<' E;'; - @font-face { - font-family: 'Buenard'; - font-style: normal; - font-weight: bold; - src: local('Buenard Bold'), local('Buenard-Bold'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/8T0adwz_RAtKrxbccQmEFC3USBnSvpkopQaUR-2r7iU.ttf') format('truetype'); - } - @font-face { - font-family: 'Buenard'; - font-style: normal; - font-weight: 400; - src: local('Buenard'), local('Buenard-Regular'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/UUYHasP8umGDjV-yeZf27Q.ttf') format('truetype'); - } - html,body { background: #ccc; text-align: center; height: 100% } - * { margin: 0; padding: 0; font: 15px 'Buenard',serif; color: #222 } - #body { text-align: left; width: 800px; margin: 0 auto; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; min-height: 100% } - #uglyhack { height: 30px } - #main, #left { float: left; border-top: 0px dashed #aaa, margin-top: 50px } - #left { width: 130px; border-right: 1px dashed #aaa; padding: 20px 10px; margin-bottom: 30px } - #main { width: 609px; padding: 12px 20px 30px 20px } - #footer { clear: left; width: 150px; margin: 0 0 0 324px; border-top: 1px dashed #aaa; height: 20px; text-align: center } - #footer p { position: relative; top: -10px; padding: 0; background: #fff; display: inline; color: #aaa } - #left h1 { font-weight: bold; text-align: center; font-size: 15px } - #left li { margin: 20px 0 0 10px; list-style-type: none } - #left li a { text-decoration: none; display: block; width: 120px; border-bottom: 1px solid #fff } - #left li a:hover { border-bottom: 1px dashed #aaa } - #left li li { margin-top: 10px } - #left li li a { width: 110px } - #left li li li { margin-top: 2px } - #left li li li a { width: 100px } - #left .menusel { color: #03a } - #left .notes { margin-top: 50px; text-align: center } - #left .notes, #left .notes a { font-size: 12px; text-decoration: none } - #left .notes a:hover { text-decoration: underline } - img.right { float: right; margin: 0 0 5px 10px } - .indexgroup { margin: 30px 10px 0px 20px } - .indexgroup li { list-style-type: none; margin-left: 0px } - .indexgroup li li { margin-left: 20px } - .indexgroup + .dummyTopAnchor + p { margin-top: 20px } - a.external:after { content: url(/img/external.gif) } - b { font-weight: bold } - h1.title { margin-top: 0; font-size: 25px } - h1 { margin-top: 50px; } - h2 { margin-top: 25px; } - h3 { margin-top: 0; margin-left: 10px } - h1, h1 a { font-size: 19px; color: #000; margin-bottom: 5px; text-decoration: none } - h2, h2 a { font-size: 16px; color: #000; margin-bottom: 1px; text-decoration: none } - h3, h3 a { font-size: 15px; color: #000; margin-bottom: 1px; text-decoration: none } - li { margin-left: 35px; margin-right: 15px; text-align: justify } - p { margin: 3px 15px 13px 15px; text-align: justify } - p + ul, p + ol { margin-top: -10px } - pre { padding-left: 0 } - pre, code, pre b { font: 11px monospace; } - pre b { font-weight: bold } - pre { background: #f5f5f5; border: 1px dotted #aaa; margin: 5px 10px; display: block; padding: 5px 5px 5px 0; } - dd { margin-left: 15px } - dt a { color: #333 } - dt { margin-left: 10px } - i { font-style: normal } /* TODO */ - .sig { vertical-align: super } - .sig, .sig a { font-size: 12px; color: #333; text-decoration: none } - E; -} diff --git a/style.css b/style.css new file mode 100644 index 0000000..0d34403 --- /dev/null +++ b/style.css @@ -0,0 +1,87 @@ +/* font-face code from http://fonts.googleapis.com/css?family=Buenard:700,400 */ +@font-face { + font-family: 'Buenard'; + font-style: normal; + font-weight: bold; + src: local('Buenard Bold'), local('Buenard-Bold'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/8T0adwz_RAtKrxbccQmEFC3USBnSvpkopQaUR-2r7iU.ttf') format('truetype'); +} +@font-face { + font-family: 'Buenard'; + font-style: normal; + font-weight: 400; + src: local('Buenard'), local('Buenard-Regular'), url('http://themes.googleusercontent.com/static/fonts/buenard/v2/UUYHasP8umGDjV-yeZf27Q.ttf') format('truetype'); +} +html,body { background: #ccc; text-align: center; height: 100% } +* { margin: 0; padding: 0; font: 15px 'Buenard',serif; color: #222 } +#body { text-align: left; width: 800px; margin: 0 auto; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; min-height: 100% } +#uglyhack { height: 30px } +#main, #left { float: left; border-top: 0px dashed #aaa, margin-top: 50px } +#left { width: 130px; border-right: 1px dashed #aaa; padding: 20px 10px; margin-bottom: 30px } +#main { width: 609px; padding: 12px 20px 30px 20px } +#footer { clear: left; width: 150px; margin: 0 0 0 324px; border-top: 1px dashed #aaa; height: 20px; text-align: center } +#footer p { position: relative; top: -10px; padding: 0; background: #fff; display: inline; color: #aaa } +#left h1 { font-weight: bold; text-align: center; font-size: 15px } +#left li { margin: 20px 0 0 10px; list-style-type: none } +#left li a { text-decoration: none; display: block; width: 120px; border-bottom: 1px solid #fff } +#left li a:hover { border-bottom: 1px dashed #aaa } +#left li li { margin-top: 10px } +#left li li a { width: 110px } +#left li li li { margin-top: 2px } +#left li li li a { width: 100px } +#left .menusel { color: #03a } +#left .notes { margin-top: 50px; text-align: center } +#left .notes, #left .notes a { font-size: 12px; text-decoration: none } +#left .notes a:hover { text-decoration: underline } +img.right { float: right; margin: 0 0 5px 10px } +.indexgroup { margin: 30px 10px 0px 20px } +.indexgroup li { list-style-type: none; margin-left: 0px } +.indexgroup li li { margin-left: 20px } +.indexgroup + .dummyTopAnchor + p { margin-top: 20px } +a.external:after { content: url(/img/external.gif) } +b { font-weight: bold } +h1.title { margin-top: 0; font-size: 25px } +h1 { margin-top: 50px; } +h2 { margin-top: 25px; } +h3 { margin-top: 0; margin-left: 10px } +h1, h1 a { font-size: 19px; color: #000; margin-bottom: 5px; text-decoration: none } +h2, h2 a { font-size: 16px; color: #000; margin-bottom: 1px; text-decoration: none } +h3, h3 a { font-size: 15px; color: #000; margin-bottom: 1px; text-decoration: none } +li { margin-left: 35px; margin-right: 15px; text-align: justify } +p { margin: 3px 15px 13px 15px; text-align: justify } +p + ul, p + ol { margin-top: -10px } +pre { padding-left: 0 } +pre, code, pre b { font: 11px monospace; } +pre b { font-weight: bold } +pre { background: #f5f5f5; border: 1px dotted #aaa; margin: 5px 10px; display: block; padding: 5px 5px 5px 0; } +dd { margin-left: 15px } +dt a { color: #333 } +dt { margin-left: 10px } +i { font-style: normal } /* TODO */ +.sig { vertical-align: super } +.sig, .sig a { font-size: 12px; color: #333; text-decoration: none } +textarea, input, select { background: #fcfcfc; color: #000; border: 1px solid #999 } +textarea:focus, input:focus { background: #fff } + +table { border-collapse: collapse } +table td { padding: 0 2px } +table thead td { font-weight: bold } +.issue_listing tbody tr:nth-child(odd) { background-color: #f4f4f4 } +.issue_listing { width: 95% } +.issue_col_id, .issue_col_type, .issue_col_status, .issue_col_date { white-space: nowrap } +.issue_closed td { text-decoration: line-through } +.issue_status { display: block; height: 20px } +.issue_status dt { float: left; font-weight: bold } +.issue_status dd { float: left; } +.issue_item h1 { margin-top: 30px } +.issue_item dt { clear: left; float: left; font-weight: bold; width: 60px } +.issue_item dd { float: left; padding-right: 20px } +.issue_item p { clear: left; padding-top: 5px } +.issue_frm fieldset { border: 0; margin-top: 40px } +.issue_frm legend { font-size: 19px; color: #000; } +.issue_frm li { list-style-type: none; margin-left: 10px; clear: left; padding-top: 5px } +.issue_frm label { display: block; width: 80px; float: left } +.issue_frm input, .issue_frm select { float: left } +.issue_frm textarea { width: 100%; height: 200px } +.issue_frm_submit input { float: right; width: 200px } + +.issue_status dt::after, .issue_item dt::after, .issue_frm label::after { content: ":" }