This is an automated email from the ASF dual-hosted git repository. gjm pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/bloodhound-core.git
commit e3c1baadd8d9b6dd09bc0f3e355aaf70c5f4f49e Author: Gary Martin <g...@apache.org> AuthorDate: Sun May 16 23:44:36 2021 +0100 Add new ticket system models and more api work - nests ticket under correct product - ticket creation and update take product context into account --- pyproject.toml | 1 + trackers/admin.py | 5 +- trackers/api/serializers.py | 55 ++++---------- trackers/api/urls.py | 27 +++++-- trackers/api/views.py | 23 +++--- trackers/models.py | 169 ++++++++++++++++++++++++++++++------------ trackers/tests/test_models.py | 45 ++++++++++- trackers/tests/tests.py | 58 --------------- 8 files changed, 214 insertions(+), 169 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c0671f9..fee9aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ python = "^3.6" 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 ProductSerializer(serializers.ModelSerializer): class TicketSerializer(serializers.ModelSerializer): - api_url = serializers.SerializerMethodField() - api_events_url = serializers.SerializerMethodField() - class Meta: - model = models.Ticket - fields = '__all__' - - 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()) + model = Ticket + fields = ( + 'product_ticket_id', + 'summary', + 'description', + ) + extra_kwargs = {'product_ticket_id': {'required': False}} + + 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 GroupViewSet(viewsets.ModelViewSet): 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 Product(models.Model): 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 ProductConfig(models.Model): 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 @@ class ProductResourceMap(models.Model): 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 - - -class Ticket(ModelCommon): + db_table = 'component' + unique_together = (('name', 'product'),) - def api_url(self): - return reverse('ticket-detail', args=(self.id,)) - def api_events_url(self): - return reverse('changeevent-list', args=(self.id,)) +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 last_update(self): - last_event = self.changeevent_set.order_by('created').last() - return self.created if last_event is None else last_event.created + class Meta: + db_table = 'enum' + unique_together = (('type', 'name', 'product'),) - 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() +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 get_field_value(self, field): - try: - tfield = TicketField.objects.get(name=field) - except TicketField.DoesNotExist as e: - return '' + class Meta: + db_table = 'milestone' + unique_together = (('name', 'product'),) - event = self.changeevent_set.filter(field=tfield).order_by('created').last() - return '' if event is None else event.value() +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) -class TicketField(ModelCommon): - name = models.CharField(max_length=32) + class Meta: + db_table = 'version' + unique_together = (('name', 'product'),) + + +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('ticketfield-detail', args=(self.id,)) + class Meta: + db_table = 'ticket' + unique_together = (('product', 'product_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 ChangeEvent(ModelCommon): - ticket = models.ForeignKey(Ticket, models.CASCADE, null=False) - field = models.ForeignKey(TicketField, models.CASCADE) - diff = models.TextField() - def value(self, which=2): - return ''.join(difflib.restore(self.diff.splitlines(keepends=True), which)).strip() +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) - old_value = functools.partialmethod(value, which=1) + class Meta: + db_table = 'ticket_custom' + unique_together = (('ticket', 'name', 'product'),) - def __str__(self): - return "Change to: {}; Field: {}; Diff: {}".format( - self.ticket, self.field, self.diff) - def api_url(self): - return reverse('changeevent-detail', args=(self.ticket.id, self.id,)) +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) - def api_ticket_url(self): - return reverse('ticket-detail', args=(self.ticket.id,)) + 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 @@ class ProductTest(TestCase): 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 @@ class HomePageTest(TestCase): 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) -