Implement ticket editing in the UI
This commit is contained in:
parent
44d05940ed
commit
deb31fb00e
|
@ -0,0 +1,23 @@
|
||||||
|
"""Add superceeded_by_id column to ticket comment
|
||||||
|
|
||||||
|
Revision ID: c32f13924e46
|
||||||
|
Revises: 074182407bb2
|
||||||
|
Create Date: 2020-08-25 15:28:19.574915
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c32f13924e46'
|
||||||
|
down_revision = '074182407bb2'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("ticket_comment", sa.Column("superceeded_by_id",
|
||||||
|
sa.Integer, sa.ForeignKey("ticket_comment.id", ondelete="SET NULL")))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("ticket_comment", "superceeded_by_id")
|
|
@ -0,0 +1,11 @@
|
||||||
|
import gzip
|
||||||
|
import tarfile
|
||||||
|
from flask import Blueprint, abort, send_file
|
||||||
|
from srht.oauth import oauth
|
||||||
|
|
||||||
|
internal = Blueprint("api.internal", __name__)
|
||||||
|
|
||||||
|
@internal.route("/api/_internal/data-export")
|
||||||
|
@oauth(None, require_internal=True)
|
||||||
|
def data_export():
|
||||||
|
return send_file("/home/sircmpwn/sources/libressl-2.5.1.tar.gz")
|
|
@ -1,5 +1,5 @@
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from datetime import datetime
|
||||||
from flask import Blueprint, render_template, request, abort, redirect
|
from flask import Blueprint, render_template, request, abort, redirect
|
||||||
from srht.config import cfg
|
from srht.config import cfg
|
||||||
from srht.database import db
|
from srht.database import db
|
||||||
|
@ -12,10 +12,12 @@ from todosrht.tickets import add_comment, mark_seen, assign, unassign
|
||||||
from todosrht.tickets import get_participant_for_user
|
from todosrht.tickets import get_participant_for_user
|
||||||
from todosrht.trackers import get_recent_users
|
from todosrht.trackers import get_recent_users
|
||||||
from todosrht.types import Event, EventType, Label, TicketLabel
|
from todosrht.types import Event, EventType, Label, TicketLabel
|
||||||
from todosrht.types import TicketAccess, TicketResolution
|
from todosrht.types import TicketAccess, TicketResolution, ParticipantType
|
||||||
|
from todosrht.types import TicketComment, TicketAuthenticity
|
||||||
from todosrht.types import TicketSubscription, User, Participant
|
from todosrht.types import TicketSubscription, User, Participant
|
||||||
from todosrht.urls import ticket_url
|
from todosrht.urls import ticket_url
|
||||||
from todosrht.webhooks import TrackerWebhook, TicketWebhook
|
from todosrht.webhooks import TrackerWebhook, TicketWebhook
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
ticket = Blueprint("ticket", __name__)
|
ticket = Blueprint("ticket", __name__)
|
||||||
|
@ -67,6 +69,7 @@ def get_ticket_context(ticket, tracker, access):
|
||||||
.filter(Event.ticket_id == ticket.id)
|
.filter(Event.ticket_id == ticket.id)
|
||||||
.order_by(Event.created)),
|
.order_by(Event.created)),
|
||||||
"access": access,
|
"access": access,
|
||||||
|
"TicketAccess": TicketAccess,
|
||||||
"tracker_sub": tracker_sub,
|
"tracker_sub": tracker_sub,
|
||||||
"ticket_sub": ticket_sub,
|
"ticket_sub": ticket_sub,
|
||||||
"ticket_subscribe": ticket_subscribe,
|
"ticket_subscribe": ticket_subscribe,
|
||||||
|
@ -199,6 +202,88 @@ def ticket_comment_POST(owner, name, ticket_id):
|
||||||
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
|
TrackerWebhook.Subscription.tracker_id == ticket.tracker_id)
|
||||||
return redirect(ticket_url(ticket, event.comment))
|
return redirect(ticket_url(ticket, event.comment))
|
||||||
|
|
||||||
|
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit/<int:comment_id>")
|
||||||
|
@loginrequired
|
||||||
|
def ticket_comment_edit_GET(owner, name, ticket_id, comment_id):
|
||||||
|
tracker, traccess = get_tracker(owner, name)
|
||||||
|
if not tracker:
|
||||||
|
abort(404)
|
||||||
|
ticket, tiaccess = get_ticket(tracker, ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
comment = (TicketComment.query
|
||||||
|
.filter(TicketComment.id == comment_id)
|
||||||
|
.filter(TicketComment.ticket_id == ticket.id)).one_or_none()
|
||||||
|
if not comment:
|
||||||
|
abort(404)
|
||||||
|
if (comment.submitter.user_id != current_user.id
|
||||||
|
and TicketAccess.triage not in traccess):
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
ctx = get_ticket_context(ticket, tracker, tiaccess)
|
||||||
|
return render_template("edit-comment.html",
|
||||||
|
comment=comment, **ctx)
|
||||||
|
|
||||||
|
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit/<int:comment_id>", methods=["POST"])
|
||||||
|
@loginrequired
|
||||||
|
def ticket_comment_edit_POST(owner, name, ticket_id, comment_id):
|
||||||
|
tracker, traccess = get_tracker(owner, name)
|
||||||
|
if not tracker:
|
||||||
|
abort(404)
|
||||||
|
ticket, tiaccess = get_ticket(tracker, ticket_id)
|
||||||
|
if not ticket:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
comment = (TicketComment.query
|
||||||
|
.filter(TicketComment.id == comment_id)
|
||||||
|
.filter(TicketComment.ticket_id == ticket.id)).one_or_none()
|
||||||
|
if not comment:
|
||||||
|
abort(404)
|
||||||
|
if (comment.submitter.user_id != current_user.id
|
||||||
|
and TicketAccess.triage not in traccess):
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
valid = Validation(request)
|
||||||
|
text = valid.require("text", friendly_name="Comment text")
|
||||||
|
preview = valid.optional("preview")
|
||||||
|
valid.expect(not text or 3 <= len(text) <= 16384,
|
||||||
|
"Comment must be between 3 and 16384 characters.", field="text")
|
||||||
|
if not valid.ok:
|
||||||
|
ctx = get_ticket_context(ticket, tracker, tiaccess)
|
||||||
|
return render_template("edit-comment.html",
|
||||||
|
comment=comment, **ctx, **valid.kwargs)
|
||||||
|
if preview == "true":
|
||||||
|
ctx = get_ticket_context(ticket, tracker, tiaccess)
|
||||||
|
ctx.update({
|
||||||
|
"text": text,
|
||||||
|
"rendered_preview": render_markup(tracker, text),
|
||||||
|
})
|
||||||
|
return render_template("edit-comment.html", comment=comment, **ctx)
|
||||||
|
|
||||||
|
event = Event.query.filter(Event.comment_id == comment.id).one_or_none()
|
||||||
|
assert event is not None
|
||||||
|
|
||||||
|
new_comment = TicketComment()
|
||||||
|
new_comment._no_autoupdate = True
|
||||||
|
new_comment.submitter_id = comment.submitter_id
|
||||||
|
new_comment.created = comment.created
|
||||||
|
new_comment.updated = datetime.utcnow()
|
||||||
|
new_comment.ticket_id = ticket.id
|
||||||
|
if (comment.submitter.participant_type != ParticipantType.user
|
||||||
|
or comment.submitter.user_id != current_user.id):
|
||||||
|
new_comment.authenticity = TicketAuthenticity.tampered
|
||||||
|
else:
|
||||||
|
new_comment.authenticity = comment.authenticity
|
||||||
|
new_comment.text = text
|
||||||
|
db.session.add(new_comment)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
comment.superceeded_by_id = new_comment.id
|
||||||
|
event.comment_id = new_comment.id
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(ticket_url(ticket))
|
||||||
|
|
||||||
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit")
|
@ticket.route("/<owner>/<name>/<int:ticket_id>/edit")
|
||||||
@loginrequired
|
@loginrequired
|
||||||
def ticket_edit_GET(owner, name, ticket_id):
|
def ticket_edit_GET(owner, name, ticket_id):
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import json
|
||||||
|
from collections import OrderedDict
|
||||||
|
from srht.config import get_origin
|
||||||
|
from srht.crypto import sign_payload
|
||||||
|
from srht.flask import date_handler
|
||||||
|
from todosrht.types import Event, EventType, Ticket, ParticipantType
|
||||||
|
|
||||||
|
def tracker_export(tracker):
|
||||||
|
"""
|
||||||
|
Exports a tracker as a JSON string.
|
||||||
|
"""
|
||||||
|
dump = list()
|
||||||
|
tickets = Ticket.query.filter(Ticket.tracker_id == tracker.id).all()
|
||||||
|
for ticket in tickets:
|
||||||
|
td = ticket.to_dict()
|
||||||
|
td["upstream"] = get_origin("todo.sr.ht", external=True)
|
||||||
|
if ticket.submitter.participant_type == ParticipantType.user:
|
||||||
|
sigdata = OrderedDict({
|
||||||
|
"description": ticket.description,
|
||||||
|
"ref": ticket.ref(),
|
||||||
|
"submitter": ticket.submitter.user.canonical_name,
|
||||||
|
"title": ticket.title,
|
||||||
|
"upstream": get_origin("todo.sr.ht", external=True),
|
||||||
|
})
|
||||||
|
sigdata = json.dumps(sigdata)
|
||||||
|
signature = sign_payload(sigdata)
|
||||||
|
td.update(signature)
|
||||||
|
|
||||||
|
events = Event.query.filter(Event.ticket_id == ticket.id).all()
|
||||||
|
if any(events):
|
||||||
|
td["events"] = list()
|
||||||
|
for event in events:
|
||||||
|
ev = event.to_dict()
|
||||||
|
ev["upstream"] = get_origin("todo.sr.ht", external=True)
|
||||||
|
if (EventType.comment in event.event_type
|
||||||
|
and event.participant.participant_type == ParticipantType.user):
|
||||||
|
sigdata = OrderedDict({
|
||||||
|
"comment": event.comment.text,
|
||||||
|
"id": event.id,
|
||||||
|
"ticket": event.ticket.ref(),
|
||||||
|
"user": event.participant.user.canonical_name,
|
||||||
|
"upstream": get_origin("todo.sr.ht", external=True),
|
||||||
|
})
|
||||||
|
sigdata = json.dumps(sigdata)
|
||||||
|
signature = sign_payload(sigdata)
|
||||||
|
ev.update(signature)
|
||||||
|
td["events"].append(ev)
|
||||||
|
dump.append(td)
|
||||||
|
|
||||||
|
dump = json.dumps({
|
||||||
|
"owner": tracker.owner.to_dict(),
|
||||||
|
"name": tracker.name,
|
||||||
|
"labels": [l.to_dict() for l in tracker.labels],
|
||||||
|
"tickets": dump,
|
||||||
|
}, default=date_handler)
|
||||||
|
return dump
|
|
@ -0,0 +1,210 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block title %}
|
||||||
|
<title>
|
||||||
|
{{ ticket.ref() }}: {{ ticket.title }}
|
||||||
|
—
|
||||||
|
{{ cfg("sr.ht", "site-name") }} todo
|
||||||
|
</title>
|
||||||
|
{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="ticket-title">
|
||||||
|
<div>
|
||||||
|
<a href="{{ tracker.owner|user_url }}"
|
||||||
|
>{{ tracker.owner }}</a>/<a href="{{ tracker|tracker_url }}"
|
||||||
|
>{{ tracker.name }}</a>#{{ ticket.scoped_id }}<span
|
||||||
|
class="d-none d-md-inline">:</span>
|
||||||
|
</div>
|
||||||
|
<div id="title-field">
|
||||||
|
{{ticket.title}}
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-tabbed">
|
||||||
|
{% if not tracker_sub %}
|
||||||
|
<form method="POST" action="{{url_for("ticket." +
|
||||||
|
("disable_notifications" if ticket_sub else "enable_notifications"),
|
||||||
|
owner=tracker.owner.canonical_name,
|
||||||
|
name=tracker.name,
|
||||||
|
ticket_id=ticket.scoped_id)}}"
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
{{csrf_token()}}
|
||||||
|
{% else %}
|
||||||
|
<div class="container">
|
||||||
|
{% endif %}
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ ticket|ticket_url }}"
|
||||||
|
class="nav-link active">view</a>
|
||||||
|
</li>
|
||||||
|
{% if TicketAccess.edit in access %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ ticket|ticket_edit_url }}"
|
||||||
|
class="nav-link">edit</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="flex-grow-1 d-none d-md-block"></li>
|
||||||
|
<li class="nav-item">
|
||||||
|
{% if current_user %}
|
||||||
|
<button
|
||||||
|
class="nav-link active"
|
||||||
|
{% if tracker_sub %}
|
||||||
|
title="you are subscribed to all activity on this tracker"
|
||||||
|
disabled
|
||||||
|
{% else %}
|
||||||
|
type="submit"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{{icon("envelope-o")}}
|
||||||
|
{% if ticket_sub or tracker_sub %}
|
||||||
|
Disable notifications
|
||||||
|
{% else %}
|
||||||
|
Enable notifications
|
||||||
|
{% endif %}
|
||||||
|
{{icon("caret-right")}}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link active" href="{{ ticket_subscribe }}">
|
||||||
|
{{icon("envelope-o")}}
|
||||||
|
Subscribe
|
||||||
|
{{icon("caret-right")}}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% if not tracker_sub %}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
{% if ticket.description %}
|
||||||
|
<div id="description-field">
|
||||||
|
{{ ticket|render_ticket_description }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-md-3">Status</dt>
|
||||||
|
<dd class="col-md-9">
|
||||||
|
<strong id="status-field" class="text-success">
|
||||||
|
{{ ticket.status.name.upper() }}
|
||||||
|
{% if ticket.status == TicketStatus.resolved %}
|
||||||
|
{{ ticket.resolution.name.upper() }}
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
</dd>
|
||||||
|
<dt class="col-md-3">Submitter</dt>
|
||||||
|
<dd class="col-md-9">
|
||||||
|
<a
|
||||||
|
id="submitter-field"
|
||||||
|
href="{{ ticket.submitter|participant_url }}"
|
||||||
|
>{{ ticket.submitter }}</a>
|
||||||
|
{% if ticket.authenticity.name == "unauthenticated" %}
|
||||||
|
<span
|
||||||
|
class="text-danger"
|
||||||
|
title="This ticket was imported from an external source and its authenticity cannot be guaranteed."
|
||||||
|
>(unverified)</span>
|
||||||
|
{% elif ticket.authenticity.name == "tampered" %}
|
||||||
|
<span
|
||||||
|
class="text-danger"
|
||||||
|
title="This ticket has been edited by a third-party - its contents are not genuine."
|
||||||
|
>(edited)</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-md-3">Assigned to</dt>
|
||||||
|
<dd id="assignee-field" class="col-md-9">
|
||||||
|
{% for assignee in ticket.assigned_users %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<a href="{{ assignee|user_url }}">{{ assignee }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not ticket.assigned_users %}
|
||||||
|
No-one
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-md-3">Submitted</dt>
|
||||||
|
<dd id="submitted-field" class="col-md-9">
|
||||||
|
{{ ticket.created | date }}</dd>
|
||||||
|
<dt class="col-md-3">Updated</dt>
|
||||||
|
<dd id="updated-field" class="col-md-9">
|
||||||
|
{{ ticket.updated | date }}</dd>
|
||||||
|
<dt class="col-md-3">Labels</dt>
|
||||||
|
<dd id="labels-field" class="col-md-9">
|
||||||
|
{% for label in ticket.labels %}
|
||||||
|
{{ label|label_badge }}
|
||||||
|
{% else %}
|
||||||
|
No labels applied.
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 event-list ticket-events">
|
||||||
|
<h3>Edit comment</h3>
|
||||||
|
<div class="event">
|
||||||
|
<h4>
|
||||||
|
<a
|
||||||
|
href="{{ comment.submitter|participant_url }}"
|
||||||
|
>{{ comment.submitter }}</a>
|
||||||
|
{% if comment.authenticity.name == "unauthenticated" %}
|
||||||
|
<span
|
||||||
|
class="text-danger"
|
||||||
|
title="This comment was imported from an external source and its authenticity cannot be guaranteed."
|
||||||
|
>(unverified)</span>
|
||||||
|
{% elif comment.authenticity.name == "tampered" %}
|
||||||
|
<span
|
||||||
|
class="text-danger"
|
||||||
|
title="This comment has been edited by a third-party."
|
||||||
|
>(edited)</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="pull-right">
|
||||||
|
<small>
|
||||||
|
{{ comment.created | date }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<form style="margin-top: 1rem" method="POST">
|
||||||
|
{{csrf_token()}}
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea
|
||||||
|
class="form-control {{valid.cls('text')}}"
|
||||||
|
name="text"
|
||||||
|
rows="5"
|
||||||
|
>{{text or comment.text}}</textarea>
|
||||||
|
{{valid.summary("text")}}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="comment-submit"
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>Submit {{icon("caret-right")}}</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="preview"
|
||||||
|
value="true"
|
||||||
|
class="btn btn-default"
|
||||||
|
>Preview {{icon("caret-right")}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rendered_preview %}
|
||||||
|
<div class="event preview">
|
||||||
|
<span class="preview-tag">Comment preview</span>
|
||||||
|
<a href="{{ current_user|user_url }}">{{ current_user }}</a>
|
||||||
|
<blockquote>
|
||||||
|
{{ rendered_preview }}
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -270,12 +270,12 @@
|
||||||
{% if event.comment.authenticity.name == "unauthenticated" %}
|
{% if event.comment.authenticity.name == "unauthenticated" %}
|
||||||
<span
|
<span
|
||||||
class="text-danger"
|
class="text-danger"
|
||||||
title="This ticket was imported from an external source and its authenticity cannot be guaranteed."
|
title="This comment was imported from an external source and its authenticity cannot be guaranteed."
|
||||||
>(unverified)</span>
|
>(unverified)</span>
|
||||||
{% elif event.comment.authenticity.name == "tampered" %}
|
{% elif event.comment.authenticity.name == "tampered" %}
|
||||||
<span
|
<span
|
||||||
class="text-danger"
|
class="text-danger"
|
||||||
title="This ticket has been edited by a third-party - its contents are not genuine."
|
title="This comment has been edited by a third-party."
|
||||||
>(edited)</span>
|
>(edited)</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -348,6 +348,18 @@
|
||||||
<span class="pull-right">
|
<span class="pull-right">
|
||||||
<small>
|
<small>
|
||||||
<a href="#event-{{event.id}}">{{ event.created | date }}</a>
|
<a href="#event-{{event.id}}">{{ event.created | date }}</a>
|
||||||
|
{%- if EventType.comment in event.event_type and
|
||||||
|
event.comment.superceedes -%}
|
||||||
|
<span title="This comment has been edited">*</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if EventType.comment in event.event_type
|
||||||
|
and (TicketAccess.triage in access
|
||||||
|
or event.comment.submitter.user == current_user) %}
|
||||||
|
· <a href="{{url_for("ticket.ticket_comment_edit_GET",
|
||||||
|
owner=tracker.owner.canonical_name, name=tracker.name,
|
||||||
|
ticket_id=ticket.scoped_id,
|
||||||
|
comment_id=event.comment.id)}}">edit</a>
|
||||||
|
{% endif %}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
|
@ -33,6 +33,12 @@ class TicketComment(Base):
|
||||||
signature is present, or tampered if the signature does not validate.
|
signature is present, or tampered if the signature does not validate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
superceeded_by_id = sa.Column(sa.Integer,
|
||||||
|
sa.ForeignKey("ticket_comment.id", ondelete="SET NULL"))
|
||||||
|
superceeded_by = sa.orm.relationship("TicketComment",
|
||||||
|
backref=sa.orm.backref("superceedes"),
|
||||||
|
remote_side=[id])
|
||||||
|
|
||||||
def to_dict(self, short=False):
|
def to_dict(self, short=False):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
|
|
Loading…
Reference in New Issue