Skip to content

Commit

Permalink
Add rich text handling to service notes & update timeline when servic…
Browse files Browse the repository at this point in the history
…es added or edited

This change includes a number of underlying changes, the most notable of which
are updating all frontend javascript packages and added support for mantine web
components. We use the mantine rich text editor for service notes. This
required a few changes to our webpack configuration.

Other changes are:

- Changed the UI/UX for case closures so that users are notified immediately if
  there are unfinished ongoing services & prevents closing the case rather than
  as a validation error when they attempt to close the case.

- Fixed some parameter name mismatch typing errors.
  • Loading branch information
luca-vari committed Oct 30, 2024
1 parent a83ff9c commit 8e95c7c
Show file tree
Hide file tree
Showing 27 changed files with 9,901 additions and 3,000 deletions.
6 changes: 3 additions & 3 deletions app/case/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ def get_fields(self, *args, **kwargs):
)
return fields

def validate(self, data):
if data.get("stage") == CaseStage.CLOSED and self.instance:
def validate(self, attrs):
if attrs.get("stage") == CaseStage.CLOSED and self.instance:
query = Q(
issue_id=self.instance.id,
category=ServiceCategory.ONGOING,
Expand All @@ -108,7 +108,7 @@ def validate(self, data):
"Cannot close case with unfinished ongoing services"
)

return data
return attrs

def validate_paralegal_id(self, paralegal: User):
return paralegal.id if paralegal else None
Expand Down
16 changes: 8 additions & 8 deletions app/case/serializers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ def to_internal_value(self, data):

return super(ServiceSerializer, self).to_internal_value(data)

def validate(self, data):
def validate(self, attrs):
# Type must belong to a set of choices depending on the category value.
category = data.get("category")
type = data.get("type")
category = attrs.get("category")
type = attrs.get("type")
if category and type:
error_message = f"When category is {category}, type must be one of: "
if category == ServiceCategory.ONGOING and type not in OngoingServiceType:
Expand All @@ -49,27 +49,27 @@ def validate(self, data):
)

# Count is required for discrete services.
count = data.get("count")
count = attrs.get("count")
if category == ServiceCategory.DISCRETE and not count:
raise serializers.ValidationError(
{"count": self.fields["count"].error_messages["required"]}
)

# Finish must be after start date.
started_at = data.get("started_at")
finished_at = data.get("finished_at")
started_at = attrs.get("started_at")
finished_at = attrs.get("finished_at")
if started_at and finished_at and finished_at < started_at:
raise serializers.ValidationError(
{"finished_at": "Finish date must be after the start date"}
)

issue_id = data.get("issue_id")
issue_id = attrs.get("issue_id")
if issue_id and Issue.objects.filter(id=issue_id, is_open=False).exists():
raise serializers.ValidationError(
"Cannot add a service to a case that is closed"
)

return data
return attrs


class ServiceSearchSerializer(serializers.ModelSerializer):
Expand Down
2 changes: 1 addition & 1 deletion app/case/templates/case/_base.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load static %}
<html lang="en">
<html lang="en" data-mantine-color-scheme="light">
<head>
<title>
{% block title %}{% endblock %} | Anika Clerk
Expand Down
40 changes: 40 additions & 0 deletions app/case/templates/case/service_event.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<div>
<span>{{ intro }}</span>
<table class="ui definition table small very compact">
<tbody>
<tr>
<td class="three wide">Type</td>
<td>{{ service.get_type_display }}</td>
</tr>
{% if service.category == categories.discrete %}
<tr>
<td class="three wide">Date</td>
<td>{{ service.started_at|date:'d/m/Y' }}</td>
</tr>
{% if service.count %}
<tr>
<td class="three wide">Count</td>
<td>{{ service.count }}</td>
</tr>
{% endif %}
{% else %}
<tr>
<td class="three wide">Start date</td>
<td>{{ service.started_at|date:'d/m/Y' }}</td>
</tr>
{% if service.finished_at %}
<tr>
<td class="three wide">Finish date</td>
<td>{{ service.finished_at|date:'d/m/Y' }}</td>
</tr>
{% endif %}
{% endif %}
{% if service.notes %}
<tr>
<td class="three wide">Notes</td>
<td>{{ service.notes|safe }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_case_service_create_api(superuser_client: APIClient, superuser: User):
assert response.status_code == 201, response.json()
response_data = ServiceSerializer(response.json()).data

# Compare the response and request date with each other.
# Compare response and request data.
assert response_data["id"] is not None
for field_name in filter(lambda f: f != "id", ServiceSerializer.Meta.fields):
assert request_data[field_name] == response_data[field_name]
Expand Down
18 changes: 11 additions & 7 deletions app/case/views/case.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from core.events.service import (
on_service_create,
on_service_update,
)
from core.models import Issue, IssueNote
from core.models.issue import CaseOutcome, CaseStage, CaseTopic
from core.models.service import DiscreteServiceType, OngoingServiceType, ServiceCategory
Expand Down Expand Up @@ -305,14 +309,12 @@ def get_documents_view(self, request, pk):
)
def service_list(self, request, pk):
"""
List & create case services.
List or create case services.
"""
issue = self.get_object()

if request.method == "GET":
queryset = issue.service_set.all()
queryset = queryset.order_by("-started_at")

queryset = issue.service_set.order_by("-started_at", "-modified_at")
serializer = ServiceSearchSerializer(
data=self.request.query_params, partial=True
)
Expand All @@ -328,7 +330,8 @@ def service_list(self, request, pk):
data = {**request.data, "issue_id": issue.pk}
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
serializer.save()
service = serializer.save()
on_service_create(service=service, user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)

@action(
Expand All @@ -340,7 +343,7 @@ def service_list(self, request, pk):
)
def service_detail(self, request, pk, service_pk):
"""
Get & update a particular case service.
Get, update or delete a particular case service.
"""
issue = self.get_object()
service = get_object_or_404(issue.service_set, pk=service_pk)
Expand All @@ -351,7 +354,8 @@ def service_detail(self, request, pk, service_pk):
elif request.method == "PATCH":
serializer = self.get_serializer(service, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
service = serializer.save()
on_service_update(service=service, user=request.user)
return Response(serializer.data)
else:
serializer = self.get_serializer(service)
Expand Down
24 changes: 22 additions & 2 deletions app/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
IssueEvent,
IssueNote,
Service,
ServiceEvent,
Person,
Submission,
Tenancy,
Expand Down Expand Up @@ -153,11 +154,30 @@ def agent_link(self, agent):

@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
ordering = ("-created_at",)
list_display = (
"id",
"category",
"type",
"started_at",
"finished_at",
)
)
ordering = ("-created_at",)


@admin.register(ServiceEvent)
class ServiceEventAdmin(admin.ModelAdmin):
ordering = ("-created_at",)
list_display = (
"id",
"event_type",
"service_link",
"user_link",
)

@admin_link("service", "Service")
def service_link(self, service):
return service.id if service else None

@admin_link("user", "User")
def user_link(self, user):
return user.get_full_name()
11 changes: 11 additions & 0 deletions app/core/events/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from accounts.models import User
from core.models import Service
from core.models.service_event import ServiceEvent, EventType


def on_service_create(service: Service, user: User):
ServiceEvent.objects.create(event_type=EventType.CREATE, service=service, user=user)


def on_service_update(service: Service, user: User):
ServiceEvent.objects.create(event_type=EventType.UPDATE, service=service, user=user)
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
# Generated by Django 5.1.1 on 2024-09-24 06:12
# Generated by Django 5.1.1 on 2024-10-24 23:49

import django.core.serializers.json
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0071_alter_issue_outcome_notes"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
Expand Down Expand Up @@ -73,4 +76,56 @@ class Migration(migrations.Migration):
"abstract": False,
},
),
migrations.CreateModel(
name="ServiceEvent",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"modified_at",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"event_type",
models.CharField(
choices=[
("CREATE", "Service created"),
("UPDATE", "Service updated"),
],
max_length=32,
),
),
(
"service_at_event",
models.JSONField(
encoder=django.core.serializers.json.DjangoJSONEncoder
),
),
(
"service",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="core.service"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
1 change: 1 addition & 0 deletions app/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .issue_note import IssueNote
from .person import Person
from .service import Service
from .service_event import ServiceEvent
from .submission import Submission
from .tenancy import Tenancy
from .timestamped import TimestampedModel
Expand Down
70 changes: 70 additions & 0 deletions app/core/models/service_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from accounts.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.template.loader import render_to_string
from rest_framework import serializers

from .issue_note import IssueNote
from .service import Service, ServiceCategory
from .timestamped import TimestampedModel


class _ServiceSerializer(serializers.ModelSerializer):
class Meta:
model = Service
fields = "__all__"


class EventType(models.TextChoices):
CREATE = "CREATE", "Service created"
UPDATE = "UPDATE", "Service updated"


class ServiceEvent(TimestampedModel):
"""
An event that occurs on a service.
"""

event_type = models.CharField(max_length=32, choices=EventType)
service = models.ForeignKey(Service, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="+")
issue_notes = GenericRelation(IssueNote)

# We need to save the details of the service at the point in time the event
# occurred so we use the appropriate values when we generate the text for
# this event (see get_text method)
service_at_event = models.JSONField(encoder=DjangoJSONEncoder)

def save(self, *args, **kwargs):
self.service_at_event = _ServiceSerializer(self.service).data
return super().save(*args, **kwargs)

def get_text(self) -> str:
name = self.user.get_full_name()
match self.event_type:
case EventType.CREATE:
verb = "added"
case EventType.UPDATE:
verb = "updated"
case _:
raise Exception(f"Unhandled event type: {self.event_type}")

serializer = _ServiceSerializer(data=self.service_at_event)
serializer.is_valid(raise_exception=True)
service = Service(**serializer.validated_data)

category_lower = service.category.lower()
indefinite_article = (
"an" if category_lower[0] in ("a", "e", "i", "o", "u") else "a"
)

context = {
"intro": f"{name} {verb} {indefinite_article} {category_lower} service:",
"service": service,
"categories": {
"discrete": ServiceCategory.DISCRETE,
"ongoing": ServiceCategory.ONGOING,
},
}
return render_to_string("case/service_event.html", context)
Loading

0 comments on commit 8e95c7c

Please sign in to comment.