Implement initial ticket detail page

This commit is contained in:
Drew DeVault 2017-09-13 22:09:32 -04:00
parent 2c6b094d49
commit 09d66b26d3
11 changed files with 233 additions and 14 deletions

View File

@ -1 +1,11 @@
@import "base";
.ellipsis {
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
select {
padding: 0.1rem;
}

View File

@ -8,7 +8,7 @@ from srht.config import cfg, cfgi, load_config
load_config("todo")
from srht.database import DbSession
db = DbSession(cfg("sr.ht", "connection-string"))
from todosrht.types import User
from todosrht.types import User, TicketAccess, TicketStatus, TicketResolution
db.init()
from srht.flask import SrhtFlask
@ -43,13 +43,19 @@ app.register_blueprint(tracker)
meta_sr_ht = cfg("network", "meta")
meta_client_id = cfg("meta.sr.ht", "oauth-client-id")
def tracker_name(tracker):
def tracker_name(tracker, full=False):
split = tracker.name.split("/")
user = "~" + tracker.owner.username
if full:
return Markup(
"/".join([
"<a href='/{}/{}'>{}</a>".format(user, "/".join(split[:i + 1]), p)
for i, p in enumerate(split)
]))
name = split[-1]
if len(name) == 0:
return name
parts = split[:-1]
user = "~" + tracker.owner.username
return Markup(
"/".join([
"<a href='/{}/{}'>{}</a>".format(user, "/".join(parts[:i + 1]), p)
@ -57,11 +63,29 @@ def tracker_name(tracker):
]) + "/" + name
)
def render_status(ticket, access):
if TicketAccess.edit in access:
return Markup(
"<select name='status'>" +
"".join([
"<option value='{0}' {1}>{0}</option>".format(s.name,
"selected" if ticket.status == s else "")
for s in TicketStatus
]) +
"</select>"
)
else:
return "<span>{}</span>".format(ticket.status.name)
@app.context_processor
def inject():
return {
"oauth_url": oauth_url(request.full_path),
"current_user": User.query.filter(User.id == current_user.id).first() \
if current_user else None,
"format_tracker_name": tracker_name
"format_tracker_name": tracker_name,
"render_status": render_status,
"TicketAccess": TicketAccess,
"TicketStatus": TicketStatus,
"TicketResolution": TicketResolution
}

View File

@ -4,7 +4,8 @@ from flask import Blueprint, render_template, request, url_for, abort, redirect
from flask import session
from flask_login import current_user
from todosrht.decorators import loginrequired
from todosrht.types import Tracker, User, Ticket, TicketStatus
from todosrht.types import Tracker, User, Ticket, TicketStatus, TicketAccess
from todosrht.types import TicketComment
from srht.validation import Validation
from srht.database import db
@ -131,20 +132,20 @@ def tracker_configure_GET(owner, name):
@tracker.route("/<owner>/<path:name>/submit", methods=["POST"])
@loginrequired
def tracker_submit_GET(owner, name):
def tracker_submit_POST(owner, name):
tracker = get_tracker(owner, name)
if not tracker:
abort(404)
valid = Validation(request)
title = valid.require("title", friendly_name="Title")
desc = valid.require("description", friendly_name="Description")
desc = valid.optional("description")
another = valid.optional("another")
valid.expect(not title or 3 <= len(title) <= 2048,
"Title must be between 3 and 2048 characters.",
field="title")
valid.expect(not desc or len(desc) < 2048,
valid.expect(not desc or len(desc) < 16384,
"Description must be no more than 16384 characters.",
field="description")
@ -171,6 +172,67 @@ def tracker_submit_GET(owner, name):
name=name,
ticket_id=ticket.id))
def get_access(tracker, ticket):
# TODO: flesh out
if current_user and current_user.id == tracker.owner_id:
return TicketAccess.all
elif current_user and current_user.id == ticket.submitter_id:
return ticket.submitter_perms or tracker.default_submitter_perms
elif current_user:
return ticket.user_perms or tracker.default_user_perms
return ticket.anonymous_perms or tracker.default_anonymous_perms
@tracker.route("/<owner>/<path:name>/<int:ticket_id>")
def ticket_GET(owner, name, ticket_id):
pass
tracker = get_tracker(owner, name)
if not tracker:
abort(404)
ticket = Ticket.query.get(ticket_id)
if not ticket:
abort(404)
access = get_access(tracker, ticket)
if not TicketAccess.browse in access:
abort(404)
return render_template("ticket.html",
tracker=tracker,
ticket=ticket,
access=access)
@tracker.route("/<owner>/<path:name>/<int:ticket_id>/comment", methods=["POST"])
@loginrequired
def ticket_comment_POST(owner, name, ticket_id):
tracker = get_tracker(owner, name)
if not tracker:
abort(404)
ticket = Ticket.query.get(ticket_id)
if not ticket:
abort(404)
access = get_access(tracker, ticket)
if not TicketAccess.browse in access:
abort(404)
valid = Validation(request)
text = valid.require("comment", friendly_name="Comment")
valid.expect(not text or 3 < len(text) < 16384,
"Comment must be between 3 and 16384 characters.")
if not valid.ok:
return render_template("ticket.html",
tracker=tracker,
ticket=ticket,
access=access,
**valid.kwargs)
comment = TicketComment()
comment.text = text
# TODO: anonymous comments (when configured appropriately)
comment.submitter_id = current_user.id
comment.ticket_id = ticket.id
db.session.add(comment)
db.session.commit()
return redirect(url_for(".ticket_GET",
owner="~" + tracker.owner.username,
name=tracker.name,
ticket_id=ticket.id) + "#comment-" + str(comment.id))

View File

@ -0,0 +1,99 @@
{% extends "layout.html" %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-md-12">
<h2>
{{ format_tracker_name(tracker, full=True) }}/#{{ticket.id}}:
{{ticket.title}}
</h2>
</div>
</div>
<div class="row">
<form class="col-md-6">
{% if ticket.description %}
<h3>Description</h3>
{{ ticket.description | md }}
{% endif %}
{% if TicketAccess.edit in access %}
<a href="#" class="btn btn-default pull-right">Edit</a>
{% endif %}
<h3>
Details
</h3>
<dl class="row">
<dt class="col-md-3">Status</dt>
<dd class="col-md-9">
<span class="text-danger">{{ ticket.status.name.upper() }}</span>
</dd>
<dt class="col-md-3">Submitter</dt>
<dd class="col-md-9"><a href="#">~{{ ticket.submitter.username }}</a></dd>
<dt class="col-md-3">Submitted</dt>
<dd class="col-md-9">{{ ticket.created | date }}</dd>
<dt class="col-md-3">Updated</dt>
<dd class="col-md-9">{{ ticket.updated | date }}</dd>
<dt class="col-md-3">User Agent</dt>
<dd class="col-md-9 ellipsis" title="{{ ticket.user_agent }}">
{{ ticket.user_agent }}
</dd>
</dl>
</form>
<div class="col-md-6">
{% for comment in ticket.comments %}
<h4>
<a href="#">~{{ comment.submitter.username }}</a>
<span class="pull-right">
<small><a href="#">edit</a></small>
<small><a href="#">delete</a></small>
<small>{{ comment.created | date }}</small>
</span>
</h4>
{{ comment.text | md }}
{% endfor %}
{% if TicketAccess.comment in access %}
<h3 style="margin-top: 1rem">Add comment</h3>
<form method="POST" action="{{
url_for(".ticket_comment_POST",
owner="~" + tracker.owner.username,
name=tracker.name,
ticket_id=ticket.id
)
}}">
<div class="form-group {{ valid.cls("comment") }}">
<textarea
class="form-control"
id="comment"
name="comment"
placeholder="Markdown supported"
maxlength="16384"
rows="5">{{ comment or "" }}</textarea>
{{valid.summary("comment")}}
</div>
<button
type="submit"
class="btn btn-default"
>Comment</button>
{% if TicketAccess.edit in access %}
<button
type="submit"
class="btn btn-default"
name="resolve"
>Resolve</button>
<select name="resolution">
{% for r in TicketResolution %}
{% if r.name != "unresolved" %}
<option value="{{ r.name }}">{{ r.name }}</option>
{% endif %}
{% endfor %}
</select>
{% endif %}
</form>
{% else %}
{% if not ticket.comments %}
<p>It's a bit quiet in here.</p>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -11,7 +11,7 @@
{{ tracker.description | md }}
<h3 style="margin-top: 1rem">Submit ticket</h3>
<form method="POST" action="{{
url_for(".tracker_submit_GET",
url_for(".tracker_submit_POST",
owner="~" + tracker.owner.username,
name=tracker.name
)
@ -76,6 +76,7 @@
href="#">closed tickets</a>
</li>
</ul>
{% if len(tickets) %}
<table class="table table-striped">
<thead>
<tr>
@ -90,18 +91,21 @@
<tr>
<td><a href="{{url_for(".ticket_GET",
owner="~" + tracker.owner.username,
name=name,
name=tracker.name,
ticket_id=ticket.id)}}">#{{ticket.id}}</a></td>
<td>{{ ticket.title }}</td>
<td>{{ ticket.updated | date }}</td>
<td><a href="{{url_for(".ticket_GET",
owner="~" + tracker.owner.username,
name=name,
name=tracker.name,
ticket_id=ticket.id)}}">{{ ticket.submitter.username }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">No tickets found for this search criteria.</div>
{% endif %}
{% if total_pages > 1 %}
<div class="row">
<div class="col-md-4">

View File

@ -5,3 +5,4 @@ from .ticketstatus import TicketStatus, TicketResolution
from .tracker import Tracker
from .ticket import Ticket
from .ticketsubscription import TicketSubscription
from .ticketcomment import TicketComment

View File

@ -20,7 +20,7 @@ class Ticket(Base):
submitter = sa.orm.relationship("User", backref=sa.orm.backref("submitted"))
title = sa.Column(sa.Unicode(2048), nullable=False)
description = sa.Column(sa.Unicode(16384), nullable=False)
description = sa.Column(sa.Unicode(16384))
user_agent = sa.Column(sa.Unicode(2048))
status = sa.Column(FlagType(TicketStatus),

View File

@ -7,3 +7,4 @@ class TicketAccess(IntFlag):
comment = 4
edit = 8
triage = 16
all = browse | submit | comment | edit | triage

View File

@ -0,0 +1,17 @@
import sqlalchemy as sa
from srht.database import Base
from todosrht.types import FlagType, TicketAccess, TicketStatus, TicketResolution
class TicketComment(Base):
__tablename__ = 'ticket_comment'
id = sa.Column(sa.Integer, primary_key=True)
created = sa.Column(sa.DateTime, nullable=False)
updated = sa.Column(sa.DateTime, nullable=False)
submitter_id = sa.Column(sa.Integer, sa.ForeignKey("user.id"), nullable=False)
submitter = sa.orm.relationship("User")
ticket_id = sa.Column(sa.Integer, sa.ForeignKey("ticket.id"), nullable=False)
ticket = sa.orm.relationship("Ticket", backref=sa.orm.backref("comments"))
text = sa.Column(sa.Unicode(16384))

View File

@ -6,7 +6,6 @@ class TicketStatus(IntFlag):
in_progress = 2
pending = 4
resolved = 8
shipped = 16
class TicketResolution(IntFlag):
unresolved = 0

View File

@ -18,6 +18,8 @@ class Tracker(Base):
description = sa.Column(sa.Unicode(8192))
"""Markdown"""
min_desc_length = sa.Column(sa.Integer, nullable=False, default=0)
enable_ticket_status = sa.Column(FlagType(TicketStatus),
nullable=False,
default=TicketStatus.resolved)