Add new ticket system models and more api work
- nests ticket under correct product
- ticket creation and update take product context into account
diff --git a/pyproject.toml b/pyproject.toml
index c0671f9..fee9aad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@
django = "^3.2.2"
django-rest-framework = "^0.1.0"
drf-yasg = "^1.20.0"
+drf-nested-routers = "^0.93.3"
[tool.poetry.dev-dependencies]
selenium = "^3.141.0"
diff --git a/trackers/admin.py b/trackers/admin.py
index 2a4a378..8634528 100644
--- a/trackers/admin.py
+++ b/trackers/admin.py
@@ -19,7 +19,6 @@
from django.contrib import admin
# Register your models here.
-from trackers.models import Ticket, ChangeEvent
+from trackers.models import Product
-admin.site.register(Ticket)
-admin.site.register(ChangeEvent)
+admin.site.register(Product)
diff --git a/trackers/api/serializers.py b/trackers/api/serializers.py
index 92db9c6..a48abee 100644
--- a/trackers/api/serializers.py
+++ b/trackers/api/serializers.py
@@ -1,7 +1,7 @@
from django.contrib.auth.models import User, Group
+from django.shortcuts import get_object_or_404
from rest_framework import serializers
-from trackers import models
-from ..models import Product
+from ..models import Product, Ticket
class UserSerializer(serializers.HyperlinkedModelSerializer):
@@ -23,41 +23,18 @@
class TicketSerializer(serializers.ModelSerializer):
- api_url = serializers.SerializerMethodField()
- api_events_url = serializers.SerializerMethodField()
-
class Meta:
- model = models.Ticket
- fields = '__all__'
+ model = Ticket
+ fields = (
+ 'product_ticket_id',
+ 'summary',
+ 'description',
+ )
+ extra_kwargs = {'product_ticket_id': {'required': False}}
- def get_api_url(self, obj):
- return self.context['request'].build_absolute_uri(obj.api_url())
-
- def get_api_events_url(self, obj):
- return self.context['request'].build_absolute_uri(obj.api_events_url())
-
-
-class TicketFieldSerializer(serializers.ModelSerializer):
- api_url = serializers.SerializerMethodField()
-
- class Meta:
- model = models.TicketField
- fields = '__all__'
-
- def get_api_url(self, obj):
- return self.context['request'].build_absolute_uri(obj.api_url())
-
-
-class ChangeEventSerializer(serializers.ModelSerializer):
- api_url = serializers.SerializerMethodField()
- api_ticket_url = serializers.SerializerMethodField()
-
- class Meta:
- model = models.ChangeEvent
- fields = '__all__'
-
- def get_api_url(self, obj):
- return self.context['request'].build_absolute_uri(obj.api_url())
-
- def get_api_ticket_url(self, obj):
- return self.context['request'].build_absolute_uri(obj.api_ticket_url())
+ def create(self, validated_data):
+ if 'prefix' not in self.context['view'].kwargs.keys():
+ prefix = self.context['view'].kwargs['product_prefix']
+ product = get_object_or_404(Product.objects.all(), prefix=prefix)
+ validated_data['product'] = product
+ return super().create(validated_data)
diff --git a/trackers/api/urls.py b/trackers/api/urls.py
index c9ccf2f..1ca3741 100644
--- a/trackers/api/urls.py
+++ b/trackers/api/urls.py
@@ -17,22 +17,33 @@
from django.urls import path
from django.conf.urls import include
-from rest_framework import routers
+from rest_framework_nested import routers
from . import views
router = routers.DefaultRouter()
router.register('users', views.UserViewSet)
router.register('groups', views.GroupViewSet)
router.register('products', views.ProductViewSet)
-router.register('tickets', views.TicketViewSet)
-ticket_router = routers.DefaultRouter()
-ticket_router.register('ticketevents', views.ChangeEventViewSet)
+products_router = routers.NestedDefaultRouter(router, 'products', lookup='product')
+products_router.register('tickets', views.TicketViewSet, basename='product-tickets')
urlpatterns = [
path('', include(router.urls)),
- path('tickets/<uuid:id>/', include(ticket_router.urls)),
- path('swagger<str:format>', views.schema_view.without_ui(cache_timeout=0), name='schema-json'),
- path('swagger/', views.schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
- path('redoc/', views.schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
+ path('', include(products_router.urls)),
+ path(
+ 'swagger<str:format>',
+ views.schema_view.without_ui(cache_timeout=0),
+ name='schema-json',
+ ),
+ path(
+ 'swagger/',
+ views.schema_view.with_ui('swagger', cache_timeout=0),
+ name='schema-swagger-ui',
+ ),
+ path(
+ 'redoc/',
+ views.schema_view.with_ui('redoc', cache_timeout=0),
+ name='schema-redoc',
+ ),
]
diff --git a/trackers/api/views.py b/trackers/api/views.py
index 34f0d57..a1d439a 100644
--- a/trackers/api/views.py
+++ b/trackers/api/views.py
@@ -16,12 +16,13 @@
# under the License.
from django.contrib.auth.models import User, Group
+from django.shortcuts import get_object_or_404
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
-from rest_framework import permissions, viewsets
+from rest_framework import permissions, status, viewsets
+from rest_framework.response import Response
from . import serializers
-from ..models import Product
-from trackers import models
+from .. import models
schema_view = get_schema_view(
@@ -45,20 +46,16 @@
class ProductViewSet(viewsets.ModelViewSet):
- queryset = Product.objects.all()
+ queryset = models.Product.objects.all()
serializer_class = serializers.ProductSerializer
-
-
-class TicketFieldViewSet(viewsets.ModelViewSet):
- queryset = models.TicketField.objects.all()
- serializer_class = serializers.TicketFieldSerializer
+ lookup_field = 'prefix'
class TicketViewSet(viewsets.ModelViewSet):
queryset = models.Ticket.objects.all()
serializer_class = serializers.TicketSerializer
+ lookup_field = 'product_ticket_id'
-
-class ChangeEventViewSet(viewsets.ModelViewSet):
- queryset = models.ChangeEvent.objects.all()
- serializer_class = serializers.ChangeEventSerializer
+ def get_queryset(self, *args, **kwargs):
+ prefix = self.kwargs['product_prefix']
+ return models.Ticket.objects.filter(product=prefix)
diff --git a/trackers/models.py b/trackers/models.py
index f7fb321..6b48f0e 100644
--- a/trackers/models.py
+++ b/trackers/models.py
@@ -38,6 +38,7 @@
class ProductConfig(models.Model):
"""Possibly legacy table - keeping for now"""
+
product = models.ForeignKey(Product, on_delete=models.CASCADE)
section = models.TextField()
option = models.TextField()
@@ -50,6 +51,7 @@
class ProductResourceMap(models.Model):
"""Possibly legacy model - keeping for now"""
+
product_id = models.ForeignKey(Product, on_delete=models.CASCADE)
resource_type = models.TextField(blank=True, null=True)
resource_id = models.TextField(blank=True, null=True)
@@ -58,68 +60,143 @@
db_table = 'bloodhound_productresourcemap'
-class ModelCommon(models.Model):
- id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
- created = models.DateTimeField(auto_now_add=True, editable=False)
+class Component(models.Model):
+ name = models.TextField(primary_key=True)
+ owner = models.TextField(blank=True, null=True)
+ description = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
class Meta:
- abstract = True
+ db_table = 'component'
+ unique_together = (('name', 'product'),)
-class Ticket(ModelCommon):
+class Enum(models.Model):
+ type = models.TextField(primary_key=True)
+ name = models.TextField()
+ value = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
- def api_url(self):
- return reverse('ticket-detail', args=(self.id,))
-
- def api_events_url(self):
- return reverse('changeevent-list', args=(self.id,))
-
- def last_update(self):
- last_event = self.changeevent_set.order_by('created').last()
- return self.created if last_event is None else last_event.created
-
- def add_field_event(self, field, newvalue):
- current_lines = self.get_field_value(field).splitlines(keepends=True)
- replace_lines = newvalue.splitlines(keepends=True)
- result = '\n'.join(difflib.ndiff(current_lines, replace_lines))
-
- tfield, created = TicketField.objects.get_or_create(name=field)
- c = ChangeEvent(ticket=self, field=tfield, diff=result)
- c.save()
-
- def get_field_value(self, field):
- try:
- tfield = TicketField.objects.get(name=field)
- except TicketField.DoesNotExist as e:
- return ''
-
- event = self.changeevent_set.filter(field=tfield).order_by('created').last()
- return '' if event is None else event.value()
+ class Meta:
+ db_table = 'enum'
+ unique_together = (('type', 'name', 'product'),)
-class TicketField(ModelCommon):
- name = models.CharField(max_length=32)
+class Milestone(models.Model):
+ name = models.TextField(primary_key=True)
+ due = models.BigIntegerField(blank=True, null=True)
+ completed = models.BigIntegerField(blank=True, null=True)
+ description = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
- def api_url(self):
- return reverse('ticketfield-detail', args=(self.id,))
+ class Meta:
+ db_table = 'milestone'
+ unique_together = (('name', 'product'),)
-class ChangeEvent(ModelCommon):
- ticket = models.ForeignKey(Ticket, models.CASCADE, null=False)
- field = models.ForeignKey(TicketField, models.CASCADE)
- diff = models.TextField()
+class Version(models.Model):
+ name = models.TextField(primary_key=True)
+ time = models.BigIntegerField(blank=True, null=True)
+ description = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
- def value(self, which=2):
- return ''.join(difflib.restore(self.diff.splitlines(keepends=True), which)).strip()
+ class Meta:
+ db_table = 'version'
+ unique_together = (('name', 'product'),)
- old_value = functools.partialmethod(value, which=1)
- def __str__(self):
- return "Change to: {}; Field: {}; Diff: {}".format(
- self.ticket, self.field, self.diff)
+class Ticket(models.Model):
+ uid = models.AutoField(primary_key=True)
+ type = models.ForeignKey(
+ Enum,
+ blank=True,
+ null=True,
+ on_delete=models.PROTECT,
+ related_name='%(app_label)s_%(class)s_type_related',
+ )
+ time = models.BigIntegerField(blank=True, null=True)
+ changetime = models.BigIntegerField(blank=True, null=True)
+ component = models.ForeignKey(
+ Component, on_delete=models.PROTECT, blank=True, null=True
+ )
+ severity = models.TextField(blank=True, null=True)
+ priority = models.TextField(blank=True, null=True)
+ owner = models.TextField(blank=True, null=True)
+ reporter = models.TextField(blank=True, null=True)
+ cc = models.TextField(blank=True, null=True)
+ version = models.ForeignKey(
+ Version, on_delete=models.PROTECT, blank=True, null=True
+ )
+ milestone = models.ForeignKey(
+ Milestone, on_delete=models.PROTECT, blank=True, null=True
+ )
+ status = models.TextField(blank=True, null=True)
+ resolution = models.ForeignKey(
+ Enum,
+ on_delete=models.PROTECT,
+ related_name='%(app_label)s_%(class)s_resolution_related',
+ blank=True,
+ null=True,
+ )
+ summary = models.TextField()
+ description = models.TextField(blank=True, null=True)
+ keywords = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
+ product_ticket_id = models.IntegerField(db_column='id', editable=False)
- def api_url(self):
- return reverse('changeevent-detail', args=(self.ticket.id, self.id,))
+ class Meta:
+ db_table = 'ticket'
+ unique_together = (('product', 'product_ticket_id'),)
- def api_ticket_url(self):
- return reverse('ticket-detail', args=(self.ticket.id,))
+ def save(self, *args, **kwargs):
+ if self._state.adding:
+ # FIXME: deleting the latest tickets will allow reuse
+ # Consider:
+ # disallowing deletion
+ # switching to uuids
+ # recording last used on product model
+ product_tickets = Ticket.objects.filter(product=self.product)
+ if product_tickets.exists():
+ newest = product_tickets.latest('product_ticket_id')
+ new_id = 1 + newest.product_ticket_id
+ else:
+ new_id = 1
+ self.product_ticket_id = new_id
+ super().save(*args, **kwargs)
+
+
+class TicketChange(models.Model):
+ ticket = models.ForeignKey(Ticket, on_delete=models.PROTECT)
+ time = models.BigIntegerField()
+ author = models.TextField(blank=True, null=True)
+ field = models.TextField()
+ oldvalue = models.TextField(blank=True, null=True)
+ newvalue = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
+
+ class Meta:
+ db_table = 'ticket_change'
+ unique_together = (('ticket', 'time', 'field', 'product'),)
+
+
+class TicketCustom(models.Model):
+ ticket = models.ForeignKey(Ticket, on_delete=models.PROTECT)
+ name = models.TextField()
+ value = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
+
+ class Meta:
+ db_table = 'ticket_custom'
+ unique_together = (('ticket', 'name', 'product'),)
+
+
+class Report(models.Model):
+ author = models.TextField(blank=True, null=True)
+ title = models.TextField(blank=True, null=True)
+ query = models.TextField(blank=True, null=True)
+ description = models.TextField(blank=True, null=True)
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
+
+ class Meta:
+ db_table = 'report'
+ unique_together = (('id', 'product'),)
diff --git a/trackers/tests/test_models.py b/trackers/tests/test_models.py
index 13191fc..fef8144 100644
--- a/trackers/tests/test_models.py
+++ b/trackers/tests/test_models.py
@@ -16,11 +16,11 @@
# under the License.
from django.test import TestCase
-from ..models import Product
+from ..models import Product, Ticket
class ProductTest(TestCase):
- """Test modules for Product model"""
+ """Tests for Product model"""
def setUp(self):
Product.objects.create(
prefix='BHD',
@@ -39,3 +39,44 @@
self.assertEqual(bhd.name, "Bloodhound Legacy")
self.assertEqual(bh.name, "Bloodhound")
+
+
+class TicketTest(TestCase):
+ """Test for Ticket model"""
+ def setUp(self):
+ self.product = Product.objects.create(
+ prefix='BH',
+ name='Bloodhound',
+ description='Apache Bloodhound',
+ )
+
+ def test_ticket_create_sets_product_ticket_number(self):
+ ticket = Ticket.objects.create(
+ product=self.product,
+ )
+ self.assertIsNotNone(ticket.product_ticket_id)
+
+ def test_ticket_create_sets_unique_product_ticket_number(self):
+ ticket1 = Ticket.objects.create(
+ product=self.product,
+ )
+ ticket2 = Ticket.objects.create(
+ product=self.product,
+ )
+ self.assertNotEqual(ticket1.product_ticket_id, ticket2.product_ticket_id)
+
+ def test_ticket_create_uses_unique_product_ticket_number_when_tickets_deleted(self):
+ ticket1 = Ticket.objects.create(
+ product=self.product,
+ )
+ ticket2 = Ticket.objects.create(
+ product=self.product,
+ )
+ ticket1.delete()
+ ticket3 = Ticket.objects.create(
+ product=self.product,
+ )
+ self.assertIsNotNone(ticket1.product_ticket_id)
+ self.assertIsNotNone(ticket2.product_ticket_id)
+ self.assertIsNotNone(ticket3.product_ticket_id)
+ self.assertNotEqual(ticket2.product_ticket_id, ticket3.product_ticket_id)
diff --git a/trackers/tests/tests.py b/trackers/tests/tests.py
index ef6bcb4..23e10d7 100644
--- a/trackers/tests/tests.py
+++ b/trackers/tests/tests.py
@@ -33,61 +33,3 @@
self.assertTrue(response.content.startswith(b'<html>'))
self.assertIn(b'<title>Bloodhound Trackers</title>', response.content)
self.assertTrue(response.content.endswith(b'</html>'))
-
-
-from ..models import Ticket
-
-class TicketModelTest(TestCase):
- def test_last_update_on_create_returns_created_date(self):
- t = Ticket()
- t.save()
- self.assertEqual(t.created, t.last_update())
-
- def test_last_update_returns_last_change_date(self):
- # test may be safer with a fixture with an existing ticket to check
- t = Ticket()
- t.save()
- t.add_field_event('summary', "this is the summary")
- self.assertNotEqual(t.created, t.last_update())
-
- def test_ticket_creation(self):
- # Currently simple but may need updates for required fields
- pre_count = Ticket.objects.count()
- t = Ticket()
- t.save()
- self.assertEqual(pre_count + 1, Ticket.objects.count())
-
- def test_ticket_add_field_event(self):
- field = 'summary'
- field_value = "this is the summary"
-
- t = Ticket()
- t.save()
- t.add_field_event(field, field_value)
-
- self.assertEqual(t.get_field_value(field), field_value)
-
- def test_ticket_add_two_single_line_field_events_same_field(self):
- field = 'summary'
- first_field_value = "this is the summary"
- second_field_value = "this is the replacement summary"
-
- t = Ticket()
- t.save()
- t.add_field_event(field, first_field_value)
- t.add_field_event(field, second_field_value)
-
- self.assertEqual(t.get_field_value(field), second_field_value)
-
- def test_ticket_add_two_multiline_field_events_same_field(self):
- field = 'summary'
- first_field_value = "this is the summary\nwith multiple lines"
- second_field_value = "this is the replacement summary\nwith multiple lines"
-
- t = Ticket()
- t.save()
- t.add_field_event(field, first_field_value)
- t.add_field_event(field, second_field_value)
-
- self.assertEqual(t.get_field_value(field), second_field_value)
-