From f4f6a70de214b49867b63d929c638be29e8bab73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yoann=20Pi=C3=A9tri?= Date: Mon, 10 May 2021 20:56:34 +0200 Subject: [PATCH] feat: :sparkles: add first part of ticket api --- tickets/api/__init__.py | 14 ++++++ tickets/api/serializers.py | 55 ++++++++++++++++++++++ tickets/api/urls.py | 31 +++++++++++++ tickets/api/views.py | 51 +++++++++++++++++++++ tickets/migrations/0003_ticket_uuid.py | 19 ++++++++ tickets/models.py | 46 +++++++++++++------ tickets/templates/tickets/aff_ticket.html | 1 + tickets/urls.py | 27 ++++++++--- tickets/views.py | 56 ++++++++++++++--------- 9 files changed, 258 insertions(+), 42 deletions(-) create mode 100644 tickets/api/__init__.py create mode 100644 tickets/api/serializers.py create mode 100644 tickets/api/urls.py create mode 100644 tickets/api/views.py create mode 100644 tickets/migrations/0003_ticket_uuid.py diff --git a/tickets/api/__init__.py b/tickets/api/__init__.py new file mode 100644 index 00000000..95754e37 --- /dev/null +++ b/tickets/api/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2021 nanoy +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/tickets/api/serializers.py b/tickets/api/serializers.py new file mode 100644 index 00000000..56f94d4a --- /dev/null +++ b/tickets/api/serializers.py @@ -0,0 +1,55 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2018 Maël Kervella +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from rest_framework import serializers + +from tickets.models import Ticket, CommentTicket +from api.serializers import NamespacedHMSerializer + + +class TicketSerializer(NamespacedHMSerializer): + """Serialize `tickets.models.Ticket` objects.""" + + class Meta: + model = Ticket + fields = ("id", "title", "description", "email", "uuid") + + +class CommentTicketSerializer(NamespacedHMSerializer): + uuid = serializers.UUIDField() + + class Meta: + model = CommentTicket + fields = ("comment", "uuid", "parent_ticket", "created_at", "created_by") + read_only_fields = ("parent_ticket", "created_at", "created_by") + extra_kwargs = { + "uuid": {"write_only": True}, + } + + def create(self, validated_data): + validated_data = { + "comment": validated_data["comment"], + "parent_ticket": Ticket.objects.get(uuid=validated_data["uuid"]), + "created_by": validated_data["created_by"], + } + comment = CommentTicket(**validated_data) + comment.save() + return comment diff --git a/tickets/api/urls.py b/tickets/api/urls.py new file mode 100644 index 00000000..f991799b --- /dev/null +++ b/tickets/api/urls.py @@ -0,0 +1,31 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2018 Maël Kervella +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from . import views + +urls_viewset = [ + (r"tickets/tickets", views.TicketsViewSet, None), + (r"tickets/comments", views.CommentTicketViewSet, None), +] + +# urls_view = [ +# (r"ticket/tickets", ), +# ] diff --git a/tickets/api/views.py b/tickets/api/views.py new file mode 100644 index 00000000..32e17ae7 --- /dev/null +++ b/tickets/api/views.py @@ -0,0 +1,51 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2018 Maël Kervella +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from rest_framework import viewsets + +from tickets.models import Ticket, CommentTicket +from api.permissions import ACLPermission + +from . import serializers + + +class TicketsViewSet(viewsets.ModelViewSet): + + permission_classes = (ACLPermission,) + perms_map = { + "GET": [Ticket.can_view_all], + "POST": [], + } + serializer_class = serializers.TicketSerializer + queryset = Ticket.objects.all() + + +class CommentTicketViewSet(viewsets.ModelViewSet): + permission_classes = (ACLPermission,) + perms_map = { + "GET": [Ticket.can_view_all], + "POST": [], + } + serializer_class = serializers.CommentTicketSerializer + queryset = CommentTicket.objects.all() + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) diff --git a/tickets/migrations/0003_ticket_uuid.py b/tickets/migrations/0003_ticket_uuid.py new file mode 100644 index 00000000..854190b0 --- /dev/null +++ b/tickets/migrations/0003_ticket_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.18 on 2021-05-09 17:29 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0002_auto_20210214_1046'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/tickets/models.py b/tickets/models.py index 3bae040a..d8785292 100644 --- a/tickets/models.py +++ b/tickets/models.py @@ -25,6 +25,9 @@ Ticket model from __future__ import absolute_import +import uuid + +from django.core.mail import EmailMessage from django.db import models from django.utils.translation import ugettext_lazy as _ from django.template import loader @@ -85,8 +88,9 @@ class Ticket(AclMixin, models.Model): ) solved = models.BooleanField(default=False) language = models.CharField( - max_length=16, help_text=_("Language of the ticket."), default="en" + max_length=16, help_text=_("Language of the ticket."), default="en" ) + uuid = models.UUIDField(default=uuid.uuid4, editable=False) request = None class Meta: @@ -96,7 +100,9 @@ class Ticket(AclMixin, models.Model): def __str__(self): if self.user: - return _("Ticket from {name}. Date: {date}.").format(name=self.user.get_full_name(),date=self.date) + return _("Ticket from {name}. Date: {date}.").format( + name=self.user.get_full_name(), date=self.date + ) else: return _("Anonymous ticket. Date: %s.") % (self.date) @@ -106,12 +112,12 @@ class Ticket(AclMixin, models.Model): if self.user: return self.user.get_full_name() else: - return _("Anonymous user") + return _("Anonymous user") @cached_property def get_mail(self): """Get the email address of the user who opened the ticket.""" - return self.email or self.user.get_mail + return self.email or self.user.get_mail def publish_mail(self): """Send an email for a newly opened ticket to the address set in the @@ -134,10 +140,10 @@ class Ticket(AclMixin, models.Model): GeneralOption.get_cached_value("email_from"), [to_addr], reply_to=[self.get_mail], + headers={"re2o-uuid": self.uuid}, ) send_mail_object(mail_to_send, self.request) - def can_view(self, user_request, *_args, **_kwargs): """Check that the user has the right to view the ticket or that it is the author.""" @@ -189,9 +195,7 @@ class CommentTicket(AclMixin, models.Model): blank=False, null=False, ) - parent_ticket = models.ForeignKey( - "Ticket", on_delete=models.CASCADE - ) + parent_ticket = models.ForeignKey("Ticket", on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) created_by = models.ForeignKey( "users.User", @@ -207,7 +211,16 @@ class CommentTicket(AclMixin, models.Model): @cached_property def comment_id(self): - return CommentTicket.objects.filter(parent_ticket=self.parent_ticket, pk__lt=self.pk).count() + 1 + return ( + CommentTicket.objects.filter( + parent_ticket=self.parent_ticket, pk__lt=self.pk + ).count() + + 1 + ) + + @property + def uuid(self): + return self.parent_ticket.uuid def can_view(self, user_request, *_args, **_kwargs): """Check that the user has the right to view the ticket comment @@ -218,7 +231,9 @@ class CommentTicket(AclMixin, models.Model): ): return ( False, - _("You don't have the right to view other tickets comments than yours."), + _( + "You don't have the right to view other tickets comments than yours." + ), ("tickets.view_commentticket",), ) else: @@ -227,13 +242,15 @@ class CommentTicket(AclMixin, models.Model): def can_edit(self, user_request, *_args, **_kwargs): """Check that the user has the right to edit the ticket comment or that it is the author.""" - if ( - not user_request.has_perm("tickets.change_commentticket") - and (self.parent_ticket.user != user_request or self.parent_ticket.user != self.created_by) + if not user_request.has_perm("tickets.change_commentticket") and ( + self.parent_ticket.user != user_request + or self.parent_ticket.user != self.created_by ): return ( False, - _("You don't have the right to edit other tickets comments than yours."), + _( + "You don't have the right to edit other tickets comments than yours." + ), ("tickets.change_commentticket",), ) else: @@ -273,6 +290,7 @@ class CommentTicket(AclMixin, models.Model): GeneralOption.get_cached_value("email_from"), [to_addr, self.parent_ticket.get_mail], reply_to=[to_addr, self.parent_ticket.get_mail], + headers={"re2o-uuid": self.uuid}, ) send_mail_object(mail_to_send, self.request) diff --git a/tickets/templates/tickets/aff_ticket.html b/tickets/templates/tickets/aff_ticket.html index c5ad6cda..4f54499e 100644 --- a/tickets/templates/tickets/aff_ticket.html +++ b/tickets/templates/tickets/aff_ticket.html @@ -39,6 +39,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% else %} {% trans "Not solved" %} {% endif %} +{{ ticket.uuid }}
diff --git a/tickets/urls.py b/tickets/urls.py index 1fcab4c5..87659394 100644 --- a/tickets/urls.py +++ b/tickets/urls.py @@ -29,17 +29,30 @@ from . import views from .preferences.views import edit_options urlpatterns = [ - url(r"^$", views.aff_tickets, name="aff-tickets"), - url(r"^(?P[0-9]+)$", views.aff_ticket, name="aff-ticket"), - url(r"^change_ticket_status/(?P[0-9]+)$", views.change_ticket_status, name="change-ticket-status"), - url(r"^edit_ticket/(?P[0-9]+)$", views.edit_ticket, name="edit-ticket"), - url( + path("", views.aff_tickets, name="aff-tickets"), + path("", views.aff_ticket, name="aff-ticket"), + path("by_uuid/", views.aff_ticket_uuid, name="aff-ticket-uuid"), + path( + "change_ticket_status/", + views.change_ticket_status, + name="change-ticket-status", + ), + path("edit_ticket/", views.edit_ticket, name="edit-ticket"), + re_path( r"^edit_options/(?P
TicketOption)$", edit_options, name="edit-options", ), url(r"^new_ticket/$", views.new_ticket, name="new-ticket"), url(r"^add_comment/(?P[0-9]+)$", views.add_comment, name="add-comment"), - url(r"^edit_comment/(?P[0-9]+)$", views.edit_comment, name="edit-comment"), - url(r"^del_comment/(?P[0-9]+)$", views.del_comment, name="del-comment"), + url( + r"^edit_comment/(?P[0-9]+)$", + views.edit_comment, + name="edit-comment", + ), + url( + r"^del_comment/(?P[0-9]+)$", + views.del_comment, + name="del-comment", + ), ] diff --git a/tickets/views.py b/tickets/views.py index f0736265..27cd8cff 100644 --- a/tickets/views.py +++ b/tickets/views.py @@ -22,7 +22,8 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.shortcuts import render, redirect +from django.forms import modelformset_factory +from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.views.decorators.cache import cache_page from django.utils.translation import ugettext as _ @@ -32,13 +33,7 @@ from re2o.views import form from re2o.base import re2o_paginator -from re2o.acl import ( - can_view, - can_view_all, - can_edit, - can_create, - can_delete -) +from re2o.acl import can_view, can_view_all, can_edit, can_create, can_delete from preferences.models import GeneralOption @@ -62,10 +57,12 @@ def new_ticket(request): return redirect(reverse("index")) else: return redirect( - reverse("users:profil", kwargs={"userid": str(request.user.id)}) + reverse("users:profil", kwargs={"userid": str(request.user.id)}) ) return form( - {"ticketform": ticketform, 'action_name': ("Create a ticket")}, "tickets/edit.html", request + {"ticketform": ticketform, "action_name": ("Create a ticket")}, + "tickets/edit.html", + request, ) @@ -81,15 +78,24 @@ def aff_ticket(request, ticket, ticketid): ) +def aff_ticket_uuid(request, ticketuuid): + """View used to display a single ticket.""" + ticket = get_object_or_404(Ticket, uuid=ticketuuid) + comments = CommentTicket.objects.filter(parent_ticket=ticket) + return render( + request, + "tickets/aff_ticket.html", + {"ticket": ticket, "comments": comments}, + ) + + @login_required @can_edit(Ticket) def change_ticket_status(request, ticket, ticketid): """View used to change a ticket's status.""" ticket.solved = not ticket.solved ticket.save() - return redirect( - reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)}) - ) + return redirect(reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)})) @login_required @@ -101,15 +107,15 @@ def edit_ticket(request, ticket, ticketid): ticketform.save() messages.success( request, - _( - "Ticket has been updated successfully" - ), + _("Ticket has been updated successfully"), ) return redirect( reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)}) ) return form( - {"ticketform": ticketform, 'action_name': ("Edit this ticket")}, "tickets/edit.html", request + {"ticketform": ticketform, "action_name": ("Edit this ticket")}, + "tickets/edit.html", + request, ) @@ -128,7 +134,9 @@ def add_comment(request, ticket, ticketid): reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)}) ) return form( - {"ticketform": commentticket, "action_name": _("Add a comment")}, "tickets/edit.html", request + {"ticketform": commentticket, "action_name": _("Add a comment")}, + "tickets/edit.html", + request, ) @@ -136,7 +144,9 @@ def add_comment(request, ticket, ticketid): @can_edit(CommentTicket) def edit_comment(request, commentticket_instance, **_kwargs): """View used to edit a comment of a ticket.""" - commentticket = CommentTicketForm(request.POST or None, instance=commentticket_instance) + commentticket = CommentTicketForm( + request.POST or None, instance=commentticket_instance + ) if commentticket.is_valid(): ticketid = commentticket_instance.parent_ticket.id if commentticket.changed_data: @@ -146,7 +156,9 @@ def edit_comment(request, commentticket_instance, **_kwargs): reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)}) ) return form( - {"ticketform": commentticket, "action_name": _("Edit")}, "tickets/edit.html", request, + {"ticketform": commentticket, "action_name": _("Edit")}, + "tickets/edit.html", + request, ) @@ -162,7 +174,9 @@ def del_comment(request, commentticket, **_kwargs): reverse("tickets:aff-ticket", kwargs={"ticketid": str(ticketid)}) ) return form( - {"objet": commentticket, "objet_name": _("Ticket Comment")}, "tickets/delete.html", request + {"objet": commentticket, "objet_name": _("Ticket Comment")}, + "tickets/delete.html", + request, )