finish trouble tickets, notification infrastructure, challenge filtering
parent
6cef3d3671
commit
db19517b91
71
app.py
71
app.py
|
@ -1,7 +1,7 @@
|
|||
from flask import Flask, render_template, session, redirect, url_for, request, g, flash
|
||||
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 peewee import fn
|
||||
|
||||
|
@ -29,8 +29,10 @@ def scoreboard_variables():
|
|||
if "team_id" in session:
|
||||
var["logged_in"] = True
|
||||
var["team"] = g.team
|
||||
var["notifications"] = Notification.select().where(Notification.team == g.team)
|
||||
else:
|
||||
var["logged_in"] = False
|
||||
var["notifications"] = []
|
||||
|
||||
return var
|
||||
|
||||
|
@ -171,7 +173,8 @@ def dashboard():
|
|||
def challenges():
|
||||
chals = Challenge.select().order_by(Challenge.points)
|
||||
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"])
|
||||
@decorators.competition_running_required
|
||||
|
@ -184,6 +187,70 @@ def submit(challenge):
|
|||
flash(message)
|
||||
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
|
||||
|
||||
@app.before_request
|
||||
|
|
|
@ -6,3 +6,5 @@ FLAG_INCORRECT = (1003, "Incorrect flag.")
|
|||
|
||||
CAPTCHA_NOT_COMPLETED = (2001, "Please complete the CAPTCHA.")
|
||||
CAPTCHA_INVALID = (2002, "Invalid CAPTCHA response.")
|
||||
|
||||
NOTIFICATION_NOT_YOURS = (3001, "You cannot dismiss notifications that do not belong to you.")
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
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.admin
|
||||
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.route("/")
|
||||
|
@ -32,15 +35,54 @@ def admin_login():
|
|||
return render_template("admin/login.html")
|
||||
|
||||
@admin.route("/dashboard/")
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
teams = Team.select()
|
||||
solves = ChallengeSolve.select(ChallengeSolve, Challenge).join(Challenge)
|
||||
adjustments = ScoreAdjustment.select()
|
||||
scoredata = utils.scoreboard.get_all_scores(teams, solves, adjustments)
|
||||
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_required
|
||||
def admin_show_team(tid):
|
||||
team = Team.get(Team.id == tid)
|
||||
return render_template("admin/team.html", team=team)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from flask import Blueprint, jsonify, g, request
|
||||
from database import Challenge
|
||||
from database import Challenge, Notification
|
||||
from utils import decorators, flag
|
||||
from ctferror import *
|
||||
|
||||
api = Blueprint("api", "api", url_prefix="/api")
|
||||
@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)
|
||||
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))
|
||||
|
|
|
@ -15,11 +15,12 @@
|
|||
<body>
|
||||
{% if "admin" in session %}
|
||||
<div class="navbar-fixed">
|
||||
<nav class="blue darken-3">
|
||||
<nav class="red darken-3">
|
||||
<div class="container">
|
||||
<div class="nav-wrapper">
|
||||
<ul class="left">
|
||||
<li><a href="{{ url_for('admin.admin_dashboard') }}">Dashboard</a></li>
|
||||
<li><a href="{{ url_for('admin.admin_tickets') }}">Tickets</a></li>
|
||||
</ul>
|
||||
<a href="#" class="center brand-logo">{{ config.ctf_name }} Admin</a>
|
||||
<ul class="right">
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
{% extends "admin/base.html" %}
|
||||
{% 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>
|
||||
<thead>
|
||||
<tr><th>Team</th><th>Affiliation</th><th>Eligible</th><th>Last solve</th><th>Score</th></tr>
|
||||
|
|
|
@ -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> · {{ ticket.team.name }}</small>
|
||||
{% for comment in comments %}
|
||||
<p>{{ comment.comment }}</p>
|
||||
<small><abbr class="time" title="{{ comment.time }}">{{ comment.time }}</abbr> · {{ 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 %}
|
|
@ -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 %}
|
|
@ -24,6 +24,9 @@
|
|||
{% endif %}
|
||||
<li><a href="{{ url_for('scoreboard') }}">Scoreboard</a></li>
|
||||
{% endif %}
|
||||
{% if logged_in %}
|
||||
<li><a href="{{ url_for('team_tickets') }}">Tickets</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<a href="#" class="center brand-logo">{{ config.ctf_name }}</a>
|
||||
<ul class="right">
|
||||
|
@ -40,6 +43,15 @@
|
|||
</div>
|
||||
</nav>
|
||||
</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">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
@ -55,7 +67,14 @@
|
|||
<script src="{{ url_for('static', filename='vis.min.js') }}"></script>
|
||||
{% endif %}
|
||||
<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">
|
||||
{% for message in get_flashed_messages() %}
|
||||
Materialize.toast({{ message | tojson }}, 4000);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% block title %}Challenges{% endblock %}
|
||||
{% block head %}
|
||||
<script>
|
||||
var state = {{ solved.count() }};
|
||||
var state = !{{ solved.count() }};
|
||||
function openAll() {
|
||||
$(".collapsible-header").each(function(i, x){ $(x).hasClass("active") || $(x).click(); });
|
||||
}
|
||||
|
@ -14,6 +14,15 @@ function toggle() {
|
|||
else openAll();
|
||||
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>
|
||||
<style type="text/css">
|
||||
.collapsible-header {
|
||||
|
@ -24,10 +33,19 @@ function toggle() {
|
|||
{% block content %}
|
||||
<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="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">
|
||||
{% 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 %}">
|
||||
<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>
|
||||
|
@ -56,6 +74,11 @@ function toggle() {
|
|||
</ul>
|
||||
{% endblock %}
|
||||
{% block postscript %}
|
||||
<script>
|
||||
$(function() {
|
||||
$("select").material_select();
|
||||
});
|
||||
</script>
|
||||
{% if config.apisubmit %}
|
||||
<script>
|
||||
$("form").submit(function(e) {
|
||||
|
|
|
@ -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 %}
|
|
@ -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> · {{ g.team.name }}</small>
|
||||
{% for comment in comments %}
|
||||
<p>{{ comment.comment }}</p>
|
||||
<small><abbr class="time" title="{{ comment.time }}">{{ comment.time }}</abbr> · {{ 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 %}
|
|
@ -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 %}
|
|
@ -32,3 +32,11 @@ def competition_running_required(f):
|
|||
return f(*args, **kwargs)
|
||||
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
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
def make_link(text, target):
|
||||
return '<a href="{}">{}</a>'.format(target, text)
|
Loading…
Reference in New Issue