finish trouble tickets, notification infrastructure, challenge filtering

master
Fox Wilson 2015-11-30 23:05:17 -05:00
parent 6cef3d3671
commit db19517b91
15 changed files with 315 additions and 10 deletions

71
app.py
View File

@ -1,7 +1,7 @@
from flask import Flask, render_template, session, redirect, url_for, request, g, flash from flask import Flask, render_template, session, redirect, url_for, request, g, flash
app = Flask(__name__) app = Flask(__name__)
from database import Team, TeamAccess, Challenge, ChallengeSolve, ChallengeFailure, ScoreAdjustment, db from database import Team, TeamAccess, Challenge, ChallengeSolve, ChallengeFailure, ScoreAdjustment, TroubleTicket, TicketComment, Notification, db
from datetime import datetime from datetime import datetime
from peewee import fn from peewee import fn
@ -29,8 +29,10 @@ def scoreboard_variables():
if "team_id" in session: if "team_id" in session:
var["logged_in"] = True var["logged_in"] = True
var["team"] = g.team var["team"] = g.team
var["notifications"] = Notification.select().where(Notification.team == g.team)
else: else:
var["logged_in"] = False var["logged_in"] = False
var["notifications"] = []
return var return var
@ -171,7 +173,8 @@ def dashboard():
def challenges(): def challenges():
chals = Challenge.select().order_by(Challenge.points) chals = Challenge.select().order_by(Challenge.points)
solved = Challenge.select().join(ChallengeSolve).where(ChallengeSolve.team == g.team) solved = Challenge.select().join(ChallengeSolve).where(ChallengeSolve.team == g.team)
return render_template("challenges.html", challenges=chals, solved=solved) categories = sorted(list({chal.category for chal in chals}))
return render_template("challenges.html", challenges=chals, solved=solved, categories=categories)
@app.route('/submit/<int:challenge>/', methods=["POST"]) @app.route('/submit/<int:challenge>/', methods=["POST"])
@decorators.competition_running_required @decorators.competition_running_required
@ -184,6 +187,70 @@ def submit(challenge):
flash(message) flash(message)
return redirect(url_for('challenges')) return redirect(url_for('challenges'))
# Trouble tickets
@app.route('/tickets/')
@decorators.login_required
def team_tickets():
return render_template("tickets.html", tickets=list(g.team.tickets))
@app.route('/tickets/new/', methods=["GET", "POST"])
@decorators.login_required
def open_ticket():
if request.method == "GET":
return render_template("open_ticket.html")
elif request.method == "POST":
summary = request.form["summary"]
description = request.form["description"]
opened_at = datetime.now()
ticket = TroubleTicket.create(team=g.team, summary=summary, description=description, opened_at=opened_at)
flash("Ticket #{} opened.".format(ticket.id))
return redirect(url_for("team_ticket_detail", ticket=ticket.id))
@app.route('/tickets/<int:ticket>/')
@decorators.login_required
def team_ticket_detail(ticket):
try:
ticket = TroubleTicket.get(TroubleTicket.id == ticket)
except TroubleTicket.DoesNotExist:
flash("Couldn't find ticket #{}.".format(ticket))
return redirect(url_for("team_tickets"))
if ticket.team != g.team:
flash("That's not your ticket.")
return redirect(url_for("team_tickets"))
comments = TicketComment.select().where(TicketComment.ticket == ticket)
return render_template("ticket_detail.html", ticket=ticket, comments=comments)
@app.route('/tickets/<int:ticket>/comment/', methods=["POST"])
def team_ticket_comment(ticket):
try:
ticket = TroubleTicket.get(TroubleTicket.id == ticket)
except TroubleTicket.DoesNotExist:
flash("Couldn't find ticket #{}.".format(ticket))
return redirect(url_for("team_tickets"))
if ticket.team != g.team:
flash("That's not your ticket.")
return redirect(url_for("team_tickets"))
if request.form["comment"]:
TicketComment.create(ticket=ticket, comment_by=g.team.name, comment=request.form["comment"], time=datetime.now())
flash("Comment added.")
if ticket.active and "resolved" in request.form:
ticket.active = False
ticket.save()
flash("Ticket closed.")
elif not ticket.active and "resolved" not in request.form:
ticket.active = True
ticket.save()
flash("Ticket re-opened.")
return redirect(url_for("team_ticket_detail", ticket=ticket.id))
# Manage Peewee database sessions and Redis # Manage Peewee database sessions and Redis
@app.before_request @app.before_request

View File

@ -6,3 +6,5 @@ FLAG_INCORRECT = (1003, "Incorrect flag.")
CAPTCHA_NOT_COMPLETED = (2001, "Please complete the CAPTCHA.") CAPTCHA_NOT_COMPLETED = (2001, "Please complete the CAPTCHA.")
CAPTCHA_INVALID = (2002, "Invalid CAPTCHA response.") CAPTCHA_INVALID = (2002, "Invalid CAPTCHA response.")
NOTIFICATION_NOT_YOURS = (3001, "You cannot dismiss notifications that do not belong to you.")

View File

@ -1,8 +1,11 @@
from flask import Blueprint, render_template, request, session, redirect, url_for, flash from flask import Blueprint, render_template, request, session, redirect, url_for, flash
from database import AdminUser, Team, Challenge, ChallengeSolve, ChallengeFailure, ScoreAdjustment from database import AdminUser, Team, Challenge, ChallengeSolve, ChallengeFailure, ScoreAdjustment, TroubleTicket, TicketComment, Notification
import utils import utils
import utils.admin import utils.admin
import utils.scoreboard import utils.scoreboard
from utils.decorators import admin_required
from utils.notification import make_link
from datetime import datetime
admin = Blueprint("admin", "admin", url_prefix="/admin") admin = Blueprint("admin", "admin", url_prefix="/admin")
@admin.route("/") @admin.route("/")
@ -32,15 +35,54 @@ def admin_login():
return render_template("admin/login.html") return render_template("admin/login.html")
@admin.route("/dashboard/") @admin.route("/dashboard/")
@admin_required
def admin_dashboard(): def admin_dashboard():
teams = Team.select() teams = Team.select()
solves = ChallengeSolve.select(ChallengeSolve, Challenge).join(Challenge) solves = ChallengeSolve.select(ChallengeSolve, Challenge).join(Challenge)
adjustments = ScoreAdjustment.select() adjustments = ScoreAdjustment.select()
scoredata = utils.scoreboard.get_all_scores(teams, solves, adjustments) scoredata = utils.scoreboard.get_all_scores(teams, solves, adjustments)
lastsolvedata = utils.scoreboard.get_last_solves(teams, solves) lastsolvedata = utils.scoreboard.get_last_solves(teams, solves)
return render_template("admin/dashboard.html", teams=teams, scoredata=scoredata, lastsolvedata=lastsolvedata) tickets = list(TroubleTicket.select().where(TroubleTicket.active == True))
return render_template("admin/dashboard.html", teams=teams, scoredata=scoredata, lastsolvedata=lastsolvedata, tickets=tickets)
@admin.route("/tickets/")
@admin_required
def admin_tickets():
tickets = list(TroubleTicket.select(TroubleTicket, Team).join(Team))
return render_template("admin/tickets.html", tickets=tickets)
@admin.route("/tickets/<int:ticket>/")
@admin_required
def admin_ticket_detail(ticket):
ticket = TroubleTicket.get(TroubleTicket.id == ticket)
comments = list(TicketComment.select().where(TicketComment.ticket == ticket))
return render_template("admin/ticket_detail.html", ticket=ticket, comments=comments)
@admin.route("/tickets/<int:ticket>/comment/", methods=["POST"])
@admin_required
def admin_ticket_comment(ticket):
ticket = TroubleTicket.get(TroubleTicket.id == ticket)
if request.form["comment"]:
TicketComment.create(ticket=ticket, comment_by=session["admin"], comment=request.form["comment"], time=datetime.now())
Notification.create(team=ticket.team, notification="A response has been added for {}.".format(make_link("ticket #{}".format(ticket.id), url_for("team_ticket_detail", ticket=ticket.id))))
flash("Comment added.")
if ticket.active and "resolved" in request.form:
ticket.active = False
ticket.save()
Notification.create(team=ticket.team, notification="{} has been marked resolved.".format(make_link("Ticket #{}".format(ticket.id), url_for("team_ticket_detail", ticket=ticket.id))))
flash("Ticket closed.")
elif not ticket.active and "resolved" not in request.form:
ticket.active = True
ticket.save()
Notification.create(team=ticket.team, notification="{} has been reopened.".format(make_link("Ticket #{}".format(ticket.id), url_for("team_ticket_detail", ticket=ticket.id))))
flash("Ticket reopened.")
return redirect(url_for(".admin_ticket_detail", ticket=ticket.id))
@admin.route("/team/<int:tid>/") @admin.route("/team/<int:tid>/")
@admin_required
def admin_show_team(tid): def admin_show_team(tid):
team = Team.get(Team.id == tid) team = Team.get(Team.id == tid)
return render_template("admin/team.html", team=team) return render_template("admin/team.html", team=team)

View File

@ -1,6 +1,7 @@
from flask import Blueprint, jsonify, g, request from flask import Blueprint, jsonify, g, request
from database import Challenge from database import Challenge, Notification
from utils import decorators, flag from utils import decorators, flag
from ctferror import *
api = Blueprint("api", "api", url_prefix="/api") api = Blueprint("api", "api", url_prefix="/api")
@api.route("/submit/<int:challenge>.json", methods=["POST"]) @api.route("/submit/<int:challenge>.json", methods=["POST"])
@ -12,3 +13,14 @@ def submit_api(challenge):
code, message = flag.submit_flag(g.team, chal, flagval) code, message = flag.submit_flag(g.team, chal, flagval)
return jsonify(dict(code=code, message=message)) return jsonify(dict(code=code, message=message))
@api.route("/dismiss/<int:nid>.json", methods=["POST"])
@decorators.login_required
def dismiss_notification(nid):
n = Notification.get(Notification.id == nid)
if g.team != n.team:
code, message = NOTIFICATION_NOT_YOURS
Notification.delete().where(Notification.id == nid).execute()
code, message = SUCCESS
return jsonify(dict(code=code, message=message))

View File

@ -15,11 +15,12 @@
<body> <body>
{% if "admin" in session %} {% if "admin" in session %}
<div class="navbar-fixed"> <div class="navbar-fixed">
<nav class="blue darken-3"> <nav class="red darken-3">
<div class="container"> <div class="container">
<div class="nav-wrapper"> <div class="nav-wrapper">
<ul class="left"> <ul class="left">
<li><a href="{{ url_for('admin.admin_dashboard') }}">Dashboard</a></li> <li><a href="{{ url_for('admin.admin_dashboard') }}">Dashboard</a></li>
<li><a href="{{ url_for('admin.admin_tickets') }}">Tickets</a></li>
</ul> </ul>
<a href="#" class="center brand-logo">{{ config.ctf_name }} Admin</a> <a href="#" class="center brand-logo">{{ config.ctf_name }} Admin</a>
<ul class="right"> <ul class="right">

View File

@ -1,5 +1,13 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block content %} {% block content %}
{% if tickets %}
<div class="card red darken-1">
<div class="card-content white-text">
<span class="card-title">Unresolved issues</span>
<p>There are unresolved trouble tickets.</p>
</div>
</div>
{% endif %}
<table> <table>
<thead> <thead>
<tr><th>Team</th><th>Affiliation</th><th>Eligible</th><th>Last solve</th><th>Score</th></tr> <tr><th>Team</th><th>Affiliation</th><th>Eligible</th><th>Last solve</th><th>Score</th></tr>

View File

@ -0,0 +1,22 @@
{% extends "admin/base.html" %}
{% block title %}Ticket #{{ ticket.id }}{% endblock %}
{% block content %}
<h2>Ticket #{{ ticket.id }}: {{ ticket.summary }}</h2>
<p>{{ ticket.description }}</p>
<small><abbr class="time" title="{{ ticket.opened_at }}">{{ ticket.opened_at }}</abbr> &middot; {{ ticket.team.name }}</small>
{% for comment in comments %}
<p>{{ comment.comment }}</p>
<small><abbr class="time" title="{{ comment.time }}">{{ comment.time }}</abbr> &middot; {{ comment.comment_by }}</small>
{% endfor %}
<br />
<form action="{{ url_for('admin.admin_ticket_comment', ticket=ticket.id) }}" method="POST">
<div class="input-field">
<textarea id="comment" name="comment" class="materialize-textarea"></textarea>
<label for="comment">Comment</label>
</div>
<input id="resolved" name="resolved" type="checkbox" {% if not ticket.active %}checked="checked"{% endif %} />
<label for="resolved">Mark resolved</label><br /><br />
<input name="_csrf_token" type="hidden" value="{{ csrf_token() }}" />
<button class="btn waves-effect waves-light" type="submit">Update ticket</button>
</form>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "admin/base.html" %}
{% block title %}Trouble Tickets{% endblock %}
{% block content %}
<h2>Trouble tickets</h2>
{% if tickets %}
The following tickets are open:
<div class="collection">
{% for ticket in tickets %}
{% if ticket.active %}
<a class="collection-item" href="{{ url_for('.admin_ticket_detail', ticket=ticket.id) }}">#{{ ticket.id }} {{ ticket.summary }} ({{ ticket.team.name }})</a>
{% endif %}
{% endfor %}
</div>
The following tickets are closed:
<div class="collection">
{% for ticket in tickets %}
{% if not ticket.active %}
<a class="collection-item" href="{{ url_for('.admin_ticket_detail', ticket=ticket.id) }}">#{{ ticket.id }} {{ ticket.summary }} ({{ ticket.team.name }})</a>
{% endif %}
{% endfor %}
</div>
{% else %}
Yay, no tickets!
{% endif %}
{% endblock %}

View File

@ -24,6 +24,9 @@
{% endif %} {% endif %}
<li><a href="{{ url_for('scoreboard') }}">Scoreboard</a></li> <li><a href="{{ url_for('scoreboard') }}">Scoreboard</a></li>
{% endif %} {% endif %}
{% if logged_in %}
<li><a href="{{ url_for('team_tickets') }}">Tickets</a></li>
{% endif %}
</ul> </ul>
<a href="#" class="center brand-logo">{{ config.ctf_name }}</a> <a href="#" class="center brand-logo">{{ config.ctf_name }}</a>
<ul class="right"> <ul class="right">
@ -40,6 +43,15 @@
</div> </div>
</nav> </nav>
</div> </div>
<div class="container">
{% for notification in notifications %}
<div class="card yellow darken-2" id="notification{{ notification.id }}" onclick="dismissNotification({{ notification.id }});" style="cursor: hand;">
<div class="card-content">
{{ notification.notification | safe }} (Click to dismiss)
</div>
</div>
{% endfor %}
</div>
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
@ -55,7 +67,14 @@
<script src="{{ url_for('static', filename='vis.min.js') }}"></script> <script src="{{ url_for('static', filename='vis.min.js') }}"></script>
{% endif %} {% endif %}
<script src="{{ url_for('static', filename='api.js') }}"></script> <script src="{{ url_for('static', filename='api.js') }}"></script>
<script>$("abbr.time").timeago();</script> <script>
$("abbr.time").timeago();
function dismissNotification(id) {
api.makeCall("/dismiss/" + id + ".json", {}, function() {
$("#notification" + id).slideUp();
});
}
</script>
<script id="toasts" type="text/javascript"> <script id="toasts" type="text/javascript">
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
Materialize.toast({{ message | tojson }}, 4000); Materialize.toast({{ message | tojson }}, 4000);

View File

@ -2,7 +2,7 @@
{% block title %}Challenges{% endblock %} {% block title %}Challenges{% endblock %}
{% block head %} {% block head %}
<script> <script>
var state = {{ solved.count() }}; var state = !{{ solved.count() }};
function openAll() { function openAll() {
$(".collapsible-header").each(function(i, x){ $(x).hasClass("active") || $(x).click(); }); $(".collapsible-header").each(function(i, x){ $(x).hasClass("active") || $(x).click(); });
} }
@ -14,6 +14,15 @@ function toggle() {
else openAll(); else openAll();
state = !state; state = !state;
} }
function filterCategories(t) {
var v = t.options[t.selectedIndex].value;
if(v == "*")
$(".challenge").show();
else {
$(".challenge[data-category=" + v + "]").show();
$(".challenge[data-category!=" + v + "]").hide();
}
}
</script> </script>
<style type="text/css"> <style type="text/css">
.collapsible-header { .collapsible-header {
@ -24,10 +33,19 @@ function toggle() {
{% block content %} {% block content %}
<p>You are scoring on behalf of {{ team.name }}. If this is incorrect, you should <p>You are scoring on behalf of {{ team.name }}. If this is incorrect, you should
<a href="{{ url_for('logout') }}">logout</a> and login with the correct team key.</p> <a href="{{ url_for('logout') }}">logout</a> and login with the correct team key.</p>
<a href="javascript:toggle()">Toggle collapsed state</a>
<select onchange="filterCategories(this);">
<option value="*">Show all</option>
{% for category in categories %}
<option>{{ category }}</option>
{% endfor %}
</select>
<span class="left"><a href="javascript:toggle()">Toggle collapsed state</a></span>
<br />
<ul class="collapsible" data-collapsible="expandable"> <ul class="collapsible" data-collapsible="expandable">
{% for challenge in challenges %} {% for challenge in challenges %}
<li> <li class="challenge" data-category="{{ challenge.category }}">
<div id="header{{ challenge.id }}" class="collapsible-header{% if challenge not in solved %} active{% endif %}"> <div id="header{{ challenge.id }}" class="collapsible-header{% if challenge not in solved %} active{% endif %}">
<strong style="font-size: 110%;">{{ challenge.name }}</strong> <strong style="font-size: 110%;">{{ challenge.name }}</strong>
<span class="left" style="margin-right: -5px;"><i id="check{{ challenge.id }}" style="display:{{ "block" if challenge in solved else "none" }}" class="material-icons">check</i></span> <span class="left" style="margin-right: -5px;"><i id="check{{ challenge.id }}" style="display:{{ "block" if challenge in solved else "none" }}" class="material-icons">check</i></span>
@ -56,6 +74,11 @@ function toggle() {
</ul> </ul>
{% endblock %} {% endblock %}
{% block postscript %} {% block postscript %}
<script>
$(function() {
$("select").material_select();
});
</script>
{% if config.apisubmit %} {% if config.apisubmit %}
<script> <script>
$("form").submit(function(e) { $("form").submit(function(e) {

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Open Ticket{% endblock %}
{% block content %}
<h2>Open a Trouble Ticket</h2>
<p>If you're having an issue with the platform or your account, you should open
a trouble ticket. You should also submit a ticket if a problem is broken. Please
don't submit a ticket if you're stuck on a problem, and need a hint &endash; if
that is the case, please contact the author of the problem on IRC.</p>
<p>The issue summary should be a one-sentence description of the problem you
are having. You should elaborate on the issue in the description field.</p>
<form method="POST">
<div class="input-field">
<input required id="summary" name="summary" type="text" />
<label for="summary">Issue summary</label>
</div>
<div class="input-field">
<textarea required id="description" name="description" class="materialize-textarea"></textarea>
<label for="description">Issue description</label>
</div>
<input name="_csrf_token" type="hidden" value="{{ csrf_token() }}" />
<button class="btn waves-effect waves-light" type="submit">Open ticket</button>
</form>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Ticket #{{ ticket.id }}{% endblock %}
{% block content %}
<h2>Ticket #{{ ticket.id }}: {{ ticket.summary }}</h2>
<p>{{ ticket.description }}</p>
<small><abbr class="time" title="{{ ticket.opened_at }}">{{ ticket.opened_at }}</abbr> &middot; {{ g.team.name }}</small>
{% for comment in comments %}
<p>{{ comment.comment }}</p>
<small><abbr class="time" title="{{ comment.time }}">{{ comment.time }}</abbr> &middot; {{ comment.comment_by }}</small>
{% endfor %}
<br />
<form action="{{ url_for('team_ticket_comment', ticket=ticket.id) }}" method="POST">
<div class="input-field">
<textarea id="comment" name="comment" class="materialize-textarea"></textarea>
<label for="comment">Comment</label>
</div>
<input id="resolved" name="resolved" type="checkbox" {% if not ticket.active %}checked="checked"{% endif %} />
<label for="resolved">Mark resolved</label><br /><br />
<input name="_csrf_token" type="hidden" value="{{ csrf_token() }}" />
<button class="btn waves-effect waves-light" type="submit">Update ticket</button>
</form>
{% endblock %}

26
templates/tickets.html Normal file
View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Trouble Tickets{% endblock %}
{% block content %}
<h2>Trouble tickets</h2>
{% if tickets %}
You have the following open tickets. If you're having an issue, you can <a href="{{ url_for('open_ticket') }}">open a new ticket</a>.
<div class="collection">
{% for ticket in tickets %}
{% if ticket.active %}
<a class="collection-item" href="{{ url_for('team_ticket_detail', ticket=ticket.id) }}">#{{ ticket.id }} {{ ticket.summary }}</a>
{% endif %}
{% endfor %}
</div>
You have the following closed tickets:
<div class="collection">
{% for ticket in tickets %}
{% if not ticket.active %}
<a class="collection-item" href="{{ url_for('team_ticket_detail', ticket=ticket.id) }}">#{{ ticket.id }} {{ ticket.summary }}</a>
{% endif %}
{% endfor %}
</div>
{% else %}
You have no open tickets right now. You can <a href="{{ url_for('open_ticket') }}">open one</a> if you're having an issue.
{% endif %}
{% endblock %}

View File

@ -32,3 +32,11 @@ def competition_running_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if "admin" not in session:
flash("You must be an admin to access that page.")
return redirect(url_for("admin.admin_login"))
return f(*args, **kwargs)
return decorated

2
utils/notification.py Normal file
View File

@ -0,0 +1,2 @@
def make_link(text, target):
return '<a href="{}">{}</a>'.format(target, text)