2020-01-21 06:35:58 +00:00
|
|
|
import json
|
|
|
|
|
2020-10-20 03:54:13 +00:00
|
|
|
from itertools import chain
|
|
|
|
|
2020-01-21 06:35:58 +00:00
|
|
|
from django import forms
|
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
2022-05-14 17:57:27 +00:00
|
|
|
from django.core.exceptions import (
|
|
|
|
ImproperlyConfigured,
|
|
|
|
PermissionDenied,
|
|
|
|
ValidationError,
|
|
|
|
)
|
|
|
|
from django.http import (
|
|
|
|
Http404,
|
|
|
|
HttpResponse,
|
|
|
|
HttpResponseBadRequest,
|
|
|
|
HttpResponseRedirect,
|
|
|
|
JsonResponse,
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
from django.shortcuts import get_object_or_404
|
|
|
|
from django.template.defaultfilters import truncatechars
|
|
|
|
from django.template.loader import get_template
|
|
|
|
from django.urls import reverse, reverse_lazy
|
|
|
|
from django.utils.functional import cached_property
|
|
|
|
from django.utils.html import escape, format_html, linebreaks
|
|
|
|
from django.utils.safestring import mark_safe
|
|
|
|
from django.utils.translation import gettext as _, gettext_lazy
|
|
|
|
from django.views import View
|
|
|
|
from django.views.generic import ListView
|
|
|
|
from django.views.generic.detail import SingleObjectMixin
|
|
|
|
|
|
|
|
from judge import event_poster as event
|
2020-10-20 03:54:13 +00:00
|
|
|
from judge.models import Problem, Profile, Ticket, TicketMessage, Notification
|
2020-01-21 06:35:58 +00:00
|
|
|
from judge.utils.diggpaginator import DiggPaginator
|
|
|
|
from judge.utils.tickets import filter_visible_tickets, own_ticket_filter
|
|
|
|
from judge.utils.views import SingleObjectFormView, TitleMixin, paginate_query_context
|
|
|
|
from judge.views.problem import ProblemMixin
|
|
|
|
from judge.widgets import HeavyPreviewPageDownWidget
|
2023-10-14 01:16:21 +00:00
|
|
|
from judge.models.notification import make_notification
|
2020-01-21 06:35:58 +00:00
|
|
|
|
2022-05-14 17:57:27 +00:00
|
|
|
ticket_widget = (
|
|
|
|
forms.Textarea()
|
|
|
|
if HeavyPreviewPageDownWidget is None
|
|
|
|
else HeavyPreviewPageDownWidget(
|
|
|
|
preview=reverse_lazy("ticket_preview"),
|
|
|
|
preview_timeout=1000,
|
|
|
|
hide_preview_button=True,
|
|
|
|
)
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
|
2020-10-20 03:54:13 +00:00
|
|
|
def add_ticket_notifications(users, author, link, ticket):
|
2022-05-14 17:57:27 +00:00
|
|
|
html = f'<a href="{link}">{ticket.linked_item}</a>'
|
2020-10-20 17:43:34 +00:00
|
|
|
users = set(users)
|
|
|
|
if author in users:
|
|
|
|
users.remove(author)
|
2023-10-10 22:38:48 +00:00
|
|
|
make_notification(users, "Ticket", html, author)
|
2020-10-20 03:54:13 +00:00
|
|
|
|
|
|
|
|
2020-01-21 06:35:58 +00:00
|
|
|
class TicketForm(forms.Form):
|
2022-05-14 17:57:27 +00:00
|
|
|
title = forms.CharField(max_length=100, label=gettext_lazy("Ticket title"))
|
2020-01-21 06:35:58 +00:00
|
|
|
body = forms.CharField(widget=ticket_widget)
|
|
|
|
|
|
|
|
def __init__(self, request, *args, **kwargs):
|
|
|
|
self.request = request
|
|
|
|
super(TicketForm, self).__init__(*args, **kwargs)
|
2022-05-14 17:57:27 +00:00
|
|
|
self.fields["title"].widget.attrs.update({"placeholder": _("Ticket title")})
|
|
|
|
self.fields["body"].widget.attrs.update({"placeholder": _("Issue description")})
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def clean(self):
|
|
|
|
if self.request is not None and self.request.user.is_authenticated:
|
|
|
|
profile = self.request.profile
|
|
|
|
if profile.mute:
|
2022-05-14 17:57:27 +00:00
|
|
|
raise ValidationError(_("Your part is silent, little toad."))
|
2020-01-21 06:35:58 +00:00
|
|
|
return super(TicketForm, self).clean()
|
|
|
|
|
|
|
|
|
|
|
|
class NewTicketView(LoginRequiredMixin, SingleObjectFormView):
|
|
|
|
form_class = TicketForm
|
2022-05-14 17:57:27 +00:00
|
|
|
template_name = "ticket/new.html"
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_assignees(self):
|
|
|
|
return []
|
|
|
|
|
|
|
|
def get_form_kwargs(self):
|
|
|
|
kwargs = super(NewTicketView, self).get_form_kwargs()
|
2022-05-14 17:57:27 +00:00
|
|
|
kwargs["request"] = self.request
|
2020-01-21 06:35:58 +00:00
|
|
|
return kwargs
|
|
|
|
|
|
|
|
def form_valid(self, form):
|
2022-05-14 17:57:27 +00:00
|
|
|
ticket = Ticket(user=self.request.profile, title=form.cleaned_data["title"])
|
2020-01-21 06:35:58 +00:00
|
|
|
ticket.linked_item = self.object
|
|
|
|
ticket.save()
|
2022-05-14 17:57:27 +00:00
|
|
|
message = TicketMessage(
|
|
|
|
ticket=ticket, user=ticket.user, body=form.cleaned_data["body"]
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
message.save()
|
|
|
|
ticket.assignees.set(self.get_assignees())
|
2020-10-20 03:54:13 +00:00
|
|
|
|
2022-05-14 17:57:27 +00:00
|
|
|
link = reverse("ticket", args=[ticket.id])
|
2020-10-20 03:54:13 +00:00
|
|
|
|
|
|
|
add_ticket_notifications(ticket.assignees.all(), ticket.user, link, ticket)
|
|
|
|
|
2020-01-21 06:35:58 +00:00
|
|
|
if event.real:
|
2022-05-14 17:57:27 +00:00
|
|
|
event.post(
|
|
|
|
"tickets",
|
|
|
|
{
|
|
|
|
"type": "new-ticket",
|
|
|
|
"id": ticket.id,
|
|
|
|
"message": message.id,
|
|
|
|
"user": ticket.user_id,
|
|
|
|
"assignees": list(ticket.assignees.values_list("id", flat=True)),
|
|
|
|
},
|
|
|
|
)
|
2020-10-20 03:54:13 +00:00
|
|
|
return HttpResponseRedirect(link)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
|
|
|
|
class NewProblemTicketView(ProblemMixin, TitleMixin, NewTicketView):
|
2022-05-14 17:57:27 +00:00
|
|
|
template_name = "ticket/new_problem.html"
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_assignees(self):
|
|
|
|
return self.object.authors.all()
|
|
|
|
|
|
|
|
def get_title(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return _("New ticket for %s") % self.object.name
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_content_title(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return mark_safe(
|
|
|
|
escape(_("New ticket for %s"))
|
|
|
|
% format_html(
|
|
|
|
'<a href="{0}">{1}</a>',
|
|
|
|
reverse("problem_detail", args=[self.object.code]),
|
|
|
|
self.object.translated_name(self.request.LANGUAGE_CODE),
|
|
|
|
)
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def form_valid(self, form):
|
|
|
|
if not self.object.is_accessible_by(self.request.user):
|
|
|
|
raise Http404()
|
|
|
|
return super().form_valid(form)
|
|
|
|
|
|
|
|
|
|
|
|
class TicketCommentForm(forms.Form):
|
|
|
|
body = forms.CharField(widget=ticket_widget)
|
|
|
|
|
|
|
|
|
|
|
|
class TicketMixin(object):
|
|
|
|
model = Ticket
|
|
|
|
|
|
|
|
def get_object(self, queryset=None):
|
|
|
|
ticket = super(TicketMixin, self).get_object(queryset)
|
|
|
|
profile_id = self.request.profile.id
|
2022-05-14 17:57:27 +00:00
|
|
|
if self.request.user.has_perm("judge.change_ticket"):
|
2020-01-21 06:35:58 +00:00
|
|
|
return ticket
|
|
|
|
if ticket.user_id == profile_id:
|
|
|
|
return ticket
|
|
|
|
if ticket.assignees.filter(id=profile_id).exists():
|
|
|
|
return ticket
|
|
|
|
linked = ticket.linked_item
|
|
|
|
if isinstance(linked, Problem) and linked.is_editable_by(self.request.user):
|
|
|
|
return ticket
|
|
|
|
raise PermissionDenied()
|
|
|
|
|
|
|
|
|
|
|
|
class TicketView(TitleMixin, LoginRequiredMixin, TicketMixin, SingleObjectFormView):
|
|
|
|
form_class = TicketCommentForm
|
2022-05-14 17:57:27 +00:00
|
|
|
template_name = "ticket/ticket.html"
|
|
|
|
context_object_name = "ticket"
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def form_valid(self, form):
|
2022-05-14 17:57:27 +00:00
|
|
|
message = TicketMessage(
|
|
|
|
user=self.request.profile,
|
|
|
|
body=form.cleaned_data["body"],
|
|
|
|
ticket=self.object,
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
message.save()
|
2020-10-20 03:54:13 +00:00
|
|
|
|
2022-05-14 17:57:27 +00:00
|
|
|
link = "%s#message-%d" % (reverse("ticket", args=[self.object.id]), message.id)
|
2020-10-20 03:54:13 +00:00
|
|
|
|
|
|
|
notify_list = list(chain(self.object.assignees.all(), [self.object.user]))
|
|
|
|
add_ticket_notifications(notify_list, message.user, link, self.object)
|
|
|
|
|
2020-01-21 06:35:58 +00:00
|
|
|
if event.real:
|
2022-05-14 17:57:27 +00:00
|
|
|
event.post(
|
|
|
|
"tickets",
|
|
|
|
{
|
|
|
|
"type": "ticket-message",
|
|
|
|
"id": self.object.id,
|
|
|
|
"message": message.id,
|
|
|
|
"user": self.object.user_id,
|
|
|
|
"assignees": list(
|
|
|
|
self.object.assignees.values_list("id", flat=True)
|
|
|
|
),
|
|
|
|
},
|
|
|
|
)
|
|
|
|
event.post(
|
|
|
|
"ticket-%d" % self.object.id,
|
|
|
|
{
|
|
|
|
"type": "ticket-message",
|
|
|
|
"message": message.id,
|
|
|
|
},
|
|
|
|
)
|
2020-10-20 03:54:13 +00:00
|
|
|
return HttpResponseRedirect(link)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_title(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return _("%(title)s - Ticket %(id)d") % {
|
|
|
|
"title": self.object.title,
|
|
|
|
"id": self.object.id,
|
|
|
|
}
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_context_data(self, **kwargs):
|
|
|
|
context = super(TicketView, self).get_context_data(**kwargs)
|
2022-05-14 17:57:27 +00:00
|
|
|
context["ticket_messages"] = self.object.messages.select_related("user__user")
|
|
|
|
context["assignees"] = self.object.assignees.select_related("user")
|
|
|
|
context["last_msg"] = event.last()
|
2020-01-21 06:35:58 +00:00
|
|
|
return context
|
|
|
|
|
|
|
|
|
|
|
|
class TicketStatusChangeView(LoginRequiredMixin, TicketMixin, SingleObjectMixin, View):
|
|
|
|
open = None
|
|
|
|
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
|
|
if self.open is None:
|
2022-05-14 17:57:27 +00:00
|
|
|
raise ImproperlyConfigured("Need to define open")
|
2020-01-21 06:35:58 +00:00
|
|
|
ticket = self.get_object()
|
|
|
|
if ticket.is_open != self.open:
|
|
|
|
ticket.is_open = self.open
|
|
|
|
ticket.save()
|
|
|
|
if event.real:
|
2022-05-14 17:57:27 +00:00
|
|
|
event.post(
|
|
|
|
"tickets",
|
|
|
|
{
|
|
|
|
"type": "ticket-status",
|
|
|
|
"id": ticket.id,
|
|
|
|
"open": self.open,
|
|
|
|
"user": ticket.user_id,
|
|
|
|
"assignees": list(
|
|
|
|
ticket.assignees.values_list("id", flat=True)
|
|
|
|
),
|
|
|
|
"title": ticket.title,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
event.post(
|
|
|
|
"ticket-%d" % ticket.id,
|
|
|
|
{
|
|
|
|
"type": "ticket-status",
|
|
|
|
"open": self.open,
|
|
|
|
},
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
|
|
|
|
|
|
|
class TicketNotesForm(forms.Form):
|
|
|
|
notes = forms.CharField(widget=forms.Textarea(), required=False)
|
|
|
|
|
|
|
|
|
|
|
|
class TicketNotesEditView(LoginRequiredMixin, TicketMixin, SingleObjectFormView):
|
2022-05-14 17:57:27 +00:00
|
|
|
template_name = "ticket/edit-notes.html"
|
2020-01-21 06:35:58 +00:00
|
|
|
form_class = TicketNotesForm
|
2022-05-14 17:57:27 +00:00
|
|
|
context_object_name = "ticket"
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_initial(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return {"notes": self.get_object().notes}
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def form_valid(self, form):
|
|
|
|
ticket = self.get_object()
|
2022-05-14 17:57:27 +00:00
|
|
|
ticket.notes = notes = form.cleaned_data["notes"]
|
2020-01-21 06:35:58 +00:00
|
|
|
ticket.save()
|
|
|
|
if notes:
|
|
|
|
return HttpResponse(linebreaks(notes, autoescape=True))
|
|
|
|
else:
|
|
|
|
return HttpResponse()
|
|
|
|
|
|
|
|
def form_invalid(self, form):
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
|
|
|
|
|
|
|
|
class TicketList(LoginRequiredMixin, ListView):
|
|
|
|
model = Ticket
|
2022-05-14 17:57:27 +00:00
|
|
|
template_name = "ticket/list.html"
|
|
|
|
context_object_name = "tickets"
|
2020-01-21 06:35:58 +00:00
|
|
|
paginate_by = 50
|
|
|
|
paginator_class = DiggPaginator
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def user(self):
|
|
|
|
return self.request.user
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def profile(self):
|
|
|
|
return self.user.profile
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def can_edit_all(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return self.request.user.has_perm("judge.change_ticket")
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def filter_users(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return self.request.GET.getlist("user")
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def filter_assignees(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return self.request.GET.getlist("assignee")
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def GET_with_session(self, key):
|
|
|
|
if not self.request.GET:
|
|
|
|
return self.request.session.get(key, False)
|
2022-05-14 17:57:27 +00:00
|
|
|
return self.request.GET.get(key, None) == "1"
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def _get_queryset(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
return (
|
|
|
|
Ticket.objects.select_related("user__user")
|
|
|
|
.prefetch_related("assignees__user")
|
|
|
|
.order_by("-id")
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
def get_queryset(self):
|
|
|
|
queryset = self._get_queryset()
|
2022-05-14 17:57:27 +00:00
|
|
|
if self.GET_with_session("own"):
|
2020-01-21 06:35:58 +00:00
|
|
|
queryset = queryset.filter(own_ticket_filter(self.profile.id))
|
|
|
|
elif not self.can_edit_all:
|
|
|
|
queryset = filter_visible_tickets(queryset, self.user, self.profile)
|
|
|
|
if self.filter_assignees:
|
2022-05-14 17:57:27 +00:00
|
|
|
queryset = queryset.filter(
|
|
|
|
assignees__user__username__in=self.filter_assignees
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
if self.filter_users:
|
|
|
|
queryset = queryset.filter(user__user__username__in=self.filter_users)
|
|
|
|
return queryset.distinct()
|
|
|
|
|
|
|
|
def get_context_data(self, **kwargs):
|
|
|
|
context = super(TicketList, self).get_context_data(**kwargs)
|
|
|
|
|
2022-05-14 17:57:27 +00:00
|
|
|
page = context["page_obj"]
|
|
|
|
context["title"] = _("Tickets - Page %(number)d of %(total)d") % {
|
|
|
|
"number": page.number,
|
|
|
|
"total": page.paginator.num_pages,
|
2020-01-21 06:35:58 +00:00
|
|
|
}
|
2022-05-14 17:57:27 +00:00
|
|
|
context["can_edit_all"] = self.can_edit_all
|
|
|
|
context["filter_status"] = {
|
|
|
|
"own": self.GET_with_session("own"),
|
|
|
|
"user": self.filter_users,
|
|
|
|
"assignee": self.filter_assignees,
|
|
|
|
"user_id": json.dumps(
|
|
|
|
list(
|
|
|
|
Profile.objects.filter(
|
|
|
|
user__username__in=self.filter_users
|
|
|
|
).values_list("id", flat=True)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
"assignee_id": json.dumps(
|
|
|
|
list(
|
|
|
|
Profile.objects.filter(
|
|
|
|
user__username__in=self.filter_assignees
|
|
|
|
).values_list("id", flat=True)
|
|
|
|
)
|
|
|
|
),
|
|
|
|
"own_id": self.profile.id if self.GET_with_session("own") else "null",
|
2020-01-21 06:35:58 +00:00
|
|
|
}
|
2022-05-14 17:57:27 +00:00
|
|
|
context["last_msg"] = event.last()
|
2020-01-21 06:35:58 +00:00
|
|
|
context.update(paginate_query_context(self.request))
|
|
|
|
return context
|
|
|
|
|
|
|
|
def post(self, request, *args, **kwargs):
|
2022-05-14 17:57:27 +00:00
|
|
|
to_update = ("own",)
|
2020-01-21 06:35:58 +00:00
|
|
|
for key in to_update:
|
|
|
|
if key in request.GET:
|
2022-05-14 17:57:27 +00:00
|
|
|
val = request.GET.get(key) == "1"
|
2020-01-21 06:35:58 +00:00
|
|
|
request.session[key] = val
|
|
|
|
else:
|
|
|
|
request.session.pop(key, None)
|
|
|
|
return HttpResponseRedirect(request.get_full_path())
|
|
|
|
|
|
|
|
|
|
|
|
class ProblemTicketListView(TicketList):
|
|
|
|
def _get_queryset(self):
|
2022-05-14 17:57:27 +00:00
|
|
|
problem = get_object_or_404(Problem, code=self.kwargs.get("problem"))
|
2020-01-21 06:35:58 +00:00
|
|
|
if problem.is_editable_by(self.request.user):
|
2022-05-14 17:57:27 +00:00
|
|
|
return problem.tickets.order_by("-id")
|
2020-01-21 06:35:58 +00:00
|
|
|
elif problem.is_accessible_by(self.request.user):
|
2022-05-14 17:57:27 +00:00
|
|
|
return problem.tickets.filter(own_ticket_filter(self.profile.id)).order_by(
|
|
|
|
"-id"
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
raise Http404()
|
|
|
|
|
|
|
|
|
|
|
|
class TicketListDataAjax(TicketMixin, SingleObjectMixin, View):
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
|
|
try:
|
2022-05-14 17:57:27 +00:00
|
|
|
self.kwargs["pk"] = request.GET["id"]
|
2020-01-21 06:35:58 +00:00
|
|
|
except KeyError:
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
ticket = self.get_object()
|
|
|
|
message = ticket.messages.first()
|
2022-05-14 17:57:27 +00:00
|
|
|
return JsonResponse(
|
|
|
|
{
|
|
|
|
"row": get_template("ticket/row.html").render(
|
|
|
|
{"ticket": ticket}, request
|
|
|
|
),
|
|
|
|
"notification": {
|
|
|
|
"title": _("New Ticket: %s") % ticket.title,
|
|
|
|
"body": "%s\n%s"
|
|
|
|
% (
|
|
|
|
_("#%(id)d, assigned to: %(users)s")
|
|
|
|
% {
|
|
|
|
"id": ticket.id,
|
|
|
|
"users": (
|
|
|
|
_(", ").join(
|
|
|
|
ticket.assignees.values_list(
|
|
|
|
"user__username", flat=True
|
|
|
|
)
|
|
|
|
)
|
|
|
|
or _("no one")
|
|
|
|
),
|
|
|
|
},
|
|
|
|
truncatechars(message.body, 200),
|
|
|
|
),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
)
|
2020-01-21 06:35:58 +00:00
|
|
|
|
|
|
|
|
|
|
|
class TicketMessageDataAjax(TicketMixin, SingleObjectMixin, View):
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
|
|
try:
|
2022-05-14 17:57:27 +00:00
|
|
|
message_id = request.GET["message"]
|
2020-01-21 06:35:58 +00:00
|
|
|
except KeyError:
|
|
|
|
return HttpResponseBadRequest()
|
|
|
|
ticket = self.get_object()
|
|
|
|
try:
|
|
|
|
message = ticket.messages.get(id=message_id)
|
|
|
|
except TicketMessage.DoesNotExist:
|
|
|
|
return HttpResponseBadRequest()
|
2022-05-14 17:57:27 +00:00
|
|
|
return JsonResponse(
|
|
|
|
{
|
|
|
|
"message": get_template("ticket/message.html").render(
|
|
|
|
{"message": message}, request
|
|
|
|
),
|
|
|
|
"notification": {
|
|
|
|
"title": _("New Ticket Message For: %s") % ticket.title,
|
|
|
|
"body": truncatechars(message.body, 200),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
)
|