diff --git a/dear_petition/petition/api/serializers.py b/dear_petition/petition/api/serializers.py index f9b88ce1..e6208a15 100644 --- a/dear_petition/petition/api/serializers.py +++ b/dear_petition/petition/api/serializers.py @@ -7,6 +7,7 @@ from dear_petition.petition.models import ( CIPRSRecord, Contact, + Client, Batch, Offense, OffenseRecord, @@ -147,6 +148,25 @@ class ClientSerializer(ContactSerializer): state = serializers.CharField(required=True) zipcode = serializers.CharField(required=True) user = serializers.PrimaryKeyRelatedField(read_only=True) + batches = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + + class Meta: + model = Client + fields = [ + "pk", + "name", + "category", + "address1", + "address2", + "formatted_address", + "city", + "state", + "zipcode", + "user", + "batches", + "county", + "dob", + ] class GeneratePetitionSerializer(serializers.Serializer): @@ -288,7 +308,7 @@ class BatchSerializer(serializers.ModelSerializer): petitions = PetitionSerializer(many=True, read_only=True) generate_letter_errors = ValidationField(method_name='get_generate_errors_data', serializer=GenerateDocumentSerializer) generate_summary_errors = ValidationField(method_name='get_generate_errors_data', serializer=GenerateDocumentSerializer) - + def get_generate_errors_data(self, obj): return {'batch': obj.pk} @@ -316,9 +336,10 @@ class BatchDetailSerializer(serializers.ModelSerializer): ) attorney = ContactSerializer(read_only=True) client_id = serializers.PrimaryKeyRelatedField( - source='client', queryset=Contact.objects.filter(category='client'), write_only=True, required=False + source='client', queryset=Client.objects.all(), write_only=True, required=False ) client = ClientSerializer(read_only=True) + client_errors = serializers.SerializerMethodField() generate_letter_errors = ValidationField(method_name='get_generate_errors_data', serializer=GenerateDocumentSerializer) generate_summary_errors = ValidationField(method_name='get_generate_errors_data', serializer=GenerateDocumentSerializer) @@ -341,6 +362,7 @@ class Meta: "client_id", "generate_letter_errors", "generate_summary_errors", + "client_errors" ] read_only_fields = ["user", "pk", "date_uploaded", "records", "petitions"] @@ -350,6 +372,14 @@ def get_petitions(self, instance): batch=instance.pk, ).order_by("county", "jurisdiction") return ParentPetitionSerializer(parent_petitions, many=True).data + + def get_client_errors(self, instance): + errors = [] + if not instance.client: + return errors + if not instance.client.dob: + errors.append("Date of birth missing. The petition generator will try its best to identify a date of birth from the records at time of petition creation.") + return errors class MyInboxSerializer(serializers.ModelSerializer): diff --git a/dear_petition/petition/api/tests/test_batch.py b/dear_petition/petition/api/tests/test_batch.py index 9505fc7b..cc87ac71 100644 --- a/dear_petition/petition/api/tests/test_batch.py +++ b/dear_petition/petition/api/tests/test_batch.py @@ -1,10 +1,14 @@ import pytest +import datetime from django.urls import reverse +from django.utils import timezone from django.utils.datastructures import MultiValueDict from rest_framework import status +from dear_petition.petition.tests.factories import BatchFactory, CIPRSRecordFactory, OffenseFactory, ClientFactory, OffenseRecordFactory + pytestmark = pytest.mark.django_db @@ -29,3 +33,42 @@ def test_batch_post_multiple_files(api_client, fake_pdf, fake_pdf2, mock_import) assert response.status_code == status.HTTP_201_CREATED assert mock_import.assert_called_once assert "id" in response.data + +def test_adjust_for_new_client_dob(): + """Test that when a DOB is added or updated on the client, the batch is updated accordingly.""" + + batch = BatchFactory() + record = CIPRSRecordFactory(batch=batch, offense_date=datetime.date(2000,1,1), dob=None) + offense = OffenseFactory(ciprs_record=record, disposition_method="OTHER") # Conviction charge + offense_record = OffenseRecordFactory( + action="CONVICTED", offense=offense + ) + + client = ClientFactory(dob=timezone.now().date()) # Create a youngster + batch.client = client + batch.save() + batch.adjust_for_new_client_dob() + + assert offense_record in batch.underaged_conviction_records() + + batch.client.dob = datetime.date(1800,1,1) # Update the youngster to be an elder + batch.client.save() + batch.refresh_from_db() # adjust_for_new_client_dob should get automatically called in Client save + assert offense_record not in batch.underaged_conviction_records() + + client = ClientFactory(dob=datetime.date(1800,1,1)) # Create an elder + batch.client = client + batch.save() + batch.adjust_for_new_client_dob() + assert offense_record not in batch.underaged_conviction_records() + + batch.client.dob = timezone.now().date() # Update the elder to be a youngster + batch.client.save() + batch.refresh_from_db() # adjust_for_new_client_dob should get automatically called in Client save + assert offense_record in batch.underaged_conviction_records() + + # try un-setting client to test default behavior when no DOB known + batch.client = None + batch.save() + batch.adjust_for_new_client_dob() + assert offense_record not in batch.underaged_conviction_records() diff --git a/dear_petition/petition/api/urls.py b/dear_petition/petition/api/urls.py index 9325339c..bea5d537 100644 --- a/dear_petition/petition/api/urls.py +++ b/dear_petition/petition/api/urls.py @@ -9,6 +9,7 @@ router.register(r"offense", viewsets.OffenseViewSet) router.register(r"offenserecord", viewsets.OffenseRecordViewSet) router.register(r"contact", viewsets.ContactViewSet) +router.register(r"client", viewsets.ClientViewSet) router.register(r"batch", viewsets.BatchViewSet) router.register(r"petitions", viewsets.PetitionViewSet) router.register(r"generatedpetition", viewsets.GeneratedPetitionViewSet) diff --git a/dear_petition/petition/api/viewsets.py b/dear_petition/petition/api/viewsets.py index 1d7613b1..0fb1b922 100644 --- a/dear_petition/petition/api/viewsets.py +++ b/dear_petition/petition/api/viewsets.py @@ -237,6 +237,15 @@ def import_agencies(self, request): resources.AgencyResource().import_data(dataset, raise_errors=True) return Response({}) +class ClientViewSet(ContactViewSet): + queryset = pm.Client.objects.all() + serializer_class = serializers.ClientSerializer + + def get_serializer_class(self): + return serializers.ClientSerializer + + def get_queryset(self): + return pm.Client.objects.filter(user=self.request.user) class BatchViewSet(viewsets.ModelViewSet): @@ -354,6 +363,27 @@ def combine_batches(self, request): new_batch = combine_batches(batch_ids, label, user_id) return Response(self.get_serializer(new_batch).data) + + @action( + detail=True, + methods=[ + "post", + ], + ) + def assign_client_to_batch(self, request, pk): + client_id = request.data['client_id'] + + try: + client = pm.Client.objects.get(pk=client_id) + except pm.Client.DoesNotExist: + return Response( + "Unknown client.", status=status.HTTP_400_BAD_REQUEST + ) + batch = self.get_object() + batch.client = client + batch.save() + batch.adjust_for_new_client_dob() + return Response({"batch_id": batch.pk}) class MyInboxView(generics.ListAPIView): diff --git a/dear_petition/petition/conftest.py b/dear_petition/petition/conftest.py index 39952449..785090eb 100644 --- a/dear_petition/petition/conftest.py +++ b/dear_petition/petition/conftest.py @@ -1,6 +1,7 @@ import string from datetime import datetime +from django.utils import timezone import pytest from dear_petition.petition.constants import CHARGED, CONVICTED, FEMALE @@ -164,6 +165,7 @@ def client(): city='Durham', state='NC', zipcode='27701', + dob = timezone.now().date() # I wasn't born yesterday ) diff --git a/dear_petition/petition/etl/load.py b/dear_petition/petition/etl/load.py index 93aece58..c1234ac0 100644 --- a/dear_petition/petition/etl/load.py +++ b/dear_petition/petition/etl/load.py @@ -102,7 +102,7 @@ def create_petitions_from_records(batch, form_type): ) -def link_offense_records(petition, filter_active=True): +def link_offense_records(petition): """Divide offense records across petition and any needed attachment forms.""" offense_records = petition.get_all_offense_records() diff --git a/dear_petition/petition/etl/tests/test_transform.py b/dear_petition/petition/etl/tests/test_transform.py index 143c1792..bc40df75 100644 --- a/dear_petition/petition/etl/tests/test_transform.py +++ b/dear_petition/petition/etl/tests/test_transform.py @@ -1,4 +1,5 @@ import pytest +from django.utils import timezone from dear_petition.petition import constants from dear_petition.petition import models as pm @@ -9,6 +10,7 @@ OffenseRecordFactory, PetitionFactory, CIPRSRecordFactory, + ClientFactory, ) from dear_petition.petition.etl.load import link_offense_records, create_documents from dear_petition.petition.etl.transform import recalculate_petitions, combine_batches @@ -17,13 +19,12 @@ def test_recalculate_petitions(petition): - petition = PetitionFactory(form_type=constants.UNDERAGED_CONVICTIONS) batch = petition.batch record = CIPRSRecordFactory( batch=batch, jurisdiction=constants.DISTRICT_COURT, county="DURHAM" ) offense = OffenseFactory( - disposition_method="PROBATION OTHER", + disposition_method="Dismissed by Court", ciprs_record=record, jurisdiction=constants.DISTRICT_COURT, ) @@ -48,35 +49,64 @@ def test_recalculate_petitions(petition): assert not petition.has_attachments() + + def test_combine_batches(batch, batch_file, fake_pdf): record = CIPRSRecordFactory( batch=batch, jurisdiction=constants.DISTRICT_COURT, county="DURHAM" ) - offense = OffenseFactory( - disposition_method="PROBATION OTHER", - ciprs_record=record, - jurisdiction=constants.DISTRICT_COURT, - ) - offense_record = OffenseRecordFactory(offense=offense, action="CHARGED") + record.refresh_record_from_data() second_batch = BatchFactory() + + second_record_data = { + "General": {"County": "DURHAM", "File No": "00GR000001"}, + "Case Information": { + "Case Status": "DISPOSED", + "Offense Date": "2018-01-01T20:00:00", + }, + "Defendant": { + "Date of Birth/Estimated Age": "1990-01-01", + "Name": "DOE,JON,BOJACK", + "Race": "WHITE", + "Sex": "MALE", + }, + "District Court Offense Information": [ + { + "Records": [ + { + "Action": "CHARGED", + "Description": "SPEEDING(80 mph in a 65 mph zone)", + "Severity": "TRAFFIC", + "Law": "20-141(J1)", + "Code": "4450", + }, + ], + "Disposed On": "2018-02-01", + "Disposition Method": "DISPOSED BY JUDGE", + }, + { + "Records": [ + { + "Action": "CHARGED", + "Description": "SPEEDING(80 mph in a 65 mph zone)", + "Severity": "TRAFFIC", + "Law": "20-141(J1)", + "Code": "4450", + }, + ], + "Disposed On": "2018-02-01", + "Disposition Method": "DISPOSED BY JUDGE", + } + ], + "Superior Court Offense Information": [], + } second_batch_file = BatchFileFactory(batch=second_batch, file=fake_pdf) second_record = CIPRSRecordFactory( - batch=second_batch, batch_file=second_batch_file, jurisdiction=constants.DISTRICT_COURT, county="DURHAM" - ) - second_offense = OffenseFactory( - disposition_method="PROBATION OTHER", - ciprs_record=second_record, - jurisdiction=constants.DISTRICT_COURT, - ) - OffenseRecordFactory(offense=second_offense, action="CHARGED") - third_offense = OffenseFactory( - disposition_method="PROBATION OTHER", - ciprs_record=second_record, - jurisdiction=constants.SUPERIOR_COURT, + batch=second_batch, data = second_record_data, batch_file=second_batch_file, jurisdiction=constants.DISTRICT_COURT, county="DURHAM" ) - OffenseRecordFactory(offense=third_offense, action="CHARGED") + second_record.refresh_record_from_data() assert batch.records.count() == 1 assert pm.Offense.objects.filter(ciprs_record__batch__id=batch.id).count() == 1 @@ -88,7 +118,8 @@ def test_combine_batches(batch, batch_file, fake_pdf): new_batch = combine_batches([batch.id, second_batch.id], label=new_label, user_id=user_id) assert new_batch.records.count() == 2 - assert pm.Offense.objects.filter(ciprs_record__batch__id=new_batch.id).count() == 2 + assert pm.Offense.objects.filter(ciprs_record__batch__id=new_batch.id).count() == 3 + assert pm.OffenseRecord.objects.filter(offense__ciprs_record__batch__id=new_batch.id).count() == 4 assert new_batch.files.count() == 2 assert new_batch.label == new_label assert new_batch.user_id == user_id diff --git a/dear_petition/petition/migrations/0061_client_alter_generatedpetition_form_type_and_more.py b/dear_petition/petition/migrations/0061_client_alter_generatedpetition_form_type_and_more.py new file mode 100644 index 00000000..47a4bb80 --- /dev/null +++ b/dear_petition/petition/migrations/0061_client_alter_generatedpetition_form_type_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.9 on 2024-05-26 17:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("petition", "0060_alter_contact_user"), + ] + + operations = [ + migrations.CreateModel( + name="Client", + fields=[ + ( + "contact_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="petition.contact", + ), + ), + ("dob", models.DateField(blank=True, null=True)), + ], + bases=("petition.contact",), + ), + migrations.AlterField( + model_name="generatedpetition", + name="form_type", + field=models.CharField( + choices=[ + ("AOC-CR-281", "AOC-CR-281"), + ("AOC-CR-285", "AOC-CR-285"), + ("AOC-CR-287", "AOC-CR-287"), + ("AOC-CR-288", "AOC-CR-288"), + ("AOC-CR-293", "AOC-CR-293"), + ("AOC-CR-297", "AOC-CR-297"), + ("AOC-CR-298", "AOC-CR-298"), + ("Addendum 3B", "Addendum 3B"), + ], + max_length=255, + ), + ), + migrations.AlterField( + model_name="petition", + name="agencies", + field=models.ManyToManyField(related_name="+", to="petition.contact"), + ), + migrations.AlterField( + model_name="petition", + name="form_type", + field=models.CharField( + choices=[ + ("AOC-CR-281", "AOC-CR-281"), + ("AOC-CR-285", "AOC-CR-285"), + ("AOC-CR-287", "AOC-CR-287"), + ("AOC-CR-288", "AOC-CR-288"), + ("AOC-CR-293", "AOC-CR-293"), + ("AOC-CR-297", "AOC-CR-297"), + ("AOC-CR-298", "AOC-CR-298"), + ("Addendum 3B", "Addendum 3B"), + ], + max_length=255, + ), + ), + migrations.AlterField( + model_name="petitiondocument", + name="agencies", + field=models.ManyToManyField(related_name="+", to="petition.contact"), + ), + migrations.AlterField( + model_name="petitiondocument", + name="form_type", + field=models.CharField( + choices=[ + ("AOC-CR-281", "AOC-CR-281"), + ("AOC-CR-285", "AOC-CR-285"), + ("AOC-CR-287", "AOC-CR-287"), + ("AOC-CR-288", "AOC-CR-288"), + ("AOC-CR-293", "AOC-CR-293"), + ("AOC-CR-297", "AOC-CR-297"), + ("AOC-CR-298", "AOC-CR-298"), + ("Addendum 3B", "Addendum 3B"), + ], + max_length=255, + ), + ), + migrations.AlterField( + model_name="batch", + name="client", + field=models.ForeignKey( + limit_choices_to={"category": "client"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="batches", + to="petition.client", + ), + ), + ] diff --git a/dear_petition/petition/migrations/0062_alter_batch_client.py b/dear_petition/petition/migrations/0062_alter_batch_client.py new file mode 100644 index 00000000..78480e96 --- /dev/null +++ b/dear_petition/petition/migrations/0062_alter_batch_client.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.9 on 2024-06-01 20:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("petition", "0061_client_alter_generatedpetition_form_type_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="batch", + name="client", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="batches", + to="petition.client", + ), + ), + ] diff --git a/dear_petition/petition/models.py b/dear_petition/petition/models.py index 0945748c..dc0f701a 100644 --- a/dear_petition/petition/models.py +++ b/dear_petition/petition/models.py @@ -267,6 +267,7 @@ def get_queryset(self): output_field=models.BooleanField(), )) ) + class Contact(PrintableModelMixin, models.Model): name = models.CharField(max_length=512) @@ -298,11 +299,34 @@ def __str__(self): @classmethod def get_sherriff_office_by_county(cls, county: str): - print(county) qs = cls.agencies_with_sherriff_office.filter(county__iexact=county, is_sheriff_office=True) if qs.count() > 1: logger.error('Multiple agencies with sherriff department name detected. Picking first one...') return qs.first() if qs.exists() else None + + +class Client(Contact): + dob = models.DateField(null=True, blank=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Save the original DOB so we can compare it during save to see if it has changed + self.history = {"dob": self.dob} + + def save(self, *args, **kwargs): + # This is for backwards compatibility with existing data that pre-exists the multi-table inheritance paradigm + # TODO: Fully convert to multi-table inheritance paradigm + + if self._state.adding: + self.category = "client" + + super().save(*args, **kwargs) + + if self.dob != self.history["dob"]: + # The DOB has changed. Need to recalculate underaged conviction forms + for batch in self.batches.all(): + batch.adjust_for_new_client_dob() + class Batch(PrintableModelMixin, models.Model): @@ -321,10 +345,9 @@ class Batch(PrintableModelMixin, models.Model): on_delete=models.SET_NULL, ) client = models.ForeignKey( - Contact, + Client, related_name="batches", null=True, - limit_choices_to={"category": "client"}, on_delete=models.SET_NULL, ) @@ -378,6 +401,14 @@ def adult_felony_records(self, jurisdiction=""): def adult_misdemeanor_records(self, jurisdiction=""): return self.petition_offense_records(pc.ADULT_MISDEMEANORS, jurisdiction) + + def adjust_for_new_client_dob(self): + """ + Called when a new date of birth is added to a batch's client to adjust the petitions accordingly. + """ + from dear_petition.petition.etl.load import create_petitions_from_records + Petition.objects.filter(batch=self, form_type=pc.UNDERAGED_CONVICTIONS).delete() + create_petitions_from_records(self, pc.UNDERAGED_CONVICTIONS) @property def race(self): diff --git a/dear_petition/petition/tests/factories.py b/dear_petition/petition/tests/factories.py index 6cd8e671..38a307cd 100644 --- a/dear_petition/petition/tests/factories.py +++ b/dear_petition/petition/tests/factories.py @@ -3,7 +3,7 @@ import factory from dear_petition.petition.models import (Batch, BatchFile, CIPRSRecord, - Contact, GeneratedPetition, Offense, + Contact, Client, GeneratedPetition, Offense, OffenseRecord, Petition, PetitionDocument, PetitionOffenseRecord) @@ -16,9 +16,29 @@ FEMALE, MALE, NOT_AVAILABLE, SUPERIOR_COURT, UNKNOWN) +class ContactFactory(factory.django.DjangoModelFactory): + class Meta: + model = Contact + + +class ClientFactory(factory.django.DjangoModelFactory): + category = "client" + + class Meta: + model = Client + + +class AttorneyFactory(factory.django.DjangoModelFactory): + category = "attorney" + + class Meta: + model = Contact + + class BatchFactory(factory.django.DjangoModelFactory): label = factory.Faker("name") user = factory.SubFactory(UserFactory) + client = factory.SubFactory(ClientFactory) class Meta: model = Batch @@ -183,25 +203,6 @@ class Meta: model = PetitionDocument -class ContactFactory(factory.django.DjangoModelFactory): - class Meta: - model = Contact - - -class ClientFactory(factory.django.DjangoModelFactory): - category = "client" - - class Meta: - model = Contact - - -class AttorneyFactory(factory.django.DjangoModelFactory): - category = "attorney" - - class Meta: - model = Contact - - class GeneratedPetitionFactory(factory.django.DjangoModelFactory): form_type = DISMISSED number_of_charges = 1 diff --git a/dear_petition/petition/types/adult_felonies.py b/dear_petition/petition/types/adult_felonies.py index 9fc7bc43..d65eea34 100644 --- a/dear_petition/petition/types/adult_felonies.py +++ b/dear_petition/petition/types/adult_felonies.py @@ -6,7 +6,6 @@ from dear_petition.petition.models import OffenseRecord from dear_petition.petition import constants as pc -from dear_petition.petition.utils import resolve_dob logger = logging.getLogger(__name__) @@ -15,15 +14,12 @@ def get_offense_records(batch, jurisdiction=""): qs = OffenseRecord.objects.filter(offense__ciprs_record__batch=batch) if jurisdiction: qs = qs.filter(offense__jurisdiction=jurisdiction) - dob = resolve_dob(qs) - if not dob: - return qs # We can't determine this petition type without the date of birth - query = build_query(dob) + query = build_query() qs = qs.filter(query) return qs.select_related("offense__ciprs_record__batch") -def build_query(dob): +def build_query(): action = Q(action=pc.CONVICTED) severity = Q(severity__iexact=pc.SEVERITIES.FELONY) today = timezone.now().date() diff --git a/dear_petition/petition/types/adult_misdemeanors.py b/dear_petition/petition/types/adult_misdemeanors.py index 577bded9..b82adaaa 100644 --- a/dear_petition/petition/types/adult_misdemeanors.py +++ b/dear_petition/petition/types/adult_misdemeanors.py @@ -6,7 +6,6 @@ from dear_petition.petition.models import OffenseRecord from dear_petition.petition import constants as pc -from dear_petition.petition.utils import resolve_dob logger = logging.getLogger(__name__) @@ -15,15 +14,12 @@ def get_offense_records(batch, jurisdiction=""): qs = OffenseRecord.objects.filter(offense__ciprs_record__batch=batch) if jurisdiction: qs = qs.filter(offense__jurisdiction=jurisdiction) - dob = resolve_dob(qs) - if not dob: - return qs # We can't determine this petition type without the date of birth - query = build_query(dob) + query = build_query() qs = qs.filter(query) return qs.select_related("offense__ciprs_record__batch") -def build_query(dob): +def build_query(): action = Q(action=pc.CONVICTED) severity = Q(severity__iexact=pc.SEVERITIES.MISDEMEANOR) today = timezone.now().date() diff --git a/dear_petition/petition/types/tests/test_underaged_convictions.py b/dear_petition/petition/types/tests/test_underaged_convictions.py index 599ef40e..015a2fa0 100644 --- a/dear_petition/petition/types/tests/test_underaged_convictions.py +++ b/dear_petition/petition/types/tests/test_underaged_convictions.py @@ -14,6 +14,16 @@ pytestmark = pytest.mark.django_db +def test_no_dob_conviction_not_included(batch, record1, non_dismissed_offense): + # This tests the scenario where the record has no DOB (aka Portal), nor has the user added a DOB for the client. + record1.dob = None + record1.save() + offense_record = OffenseRecordFactory( + action="CONVICTED", offense=non_dismissed_offense + ) + assert offense_record not in batch.underaged_conviction_records() + + def test_underaged_conviction_included(batch, record1, non_dismissed_offense): record1.dob = datetime(2000, 1, 2) record1.offense_date = datetime(2018, 1, 1) @@ -32,3 +42,41 @@ def test_overaged_conviction_not_included(batch, record1, non_dismissed_offense) action="CONVICTED", offense=non_dismissed_offense ) assert offense_record not in batch.underaged_conviction_records() + + +def test_underaged_conviction_using_client_dob_included(batch, record1, non_dismissed_offense): + # This tests the scenario where the record has no DOB (aka Portal), but the user has included a DOB for the client + + record1.dob = None + record1.offense_date = datetime(2018, 1, 1) + record1.save() + + offense_record = OffenseRecordFactory( + action="CONVICTED", offense=non_dismissed_offense + ) + + batch.client.dob = datetime(2000, 1, 2) + batch.client.save() + + assert offense_record in batch.underaged_conviction_records() + + +def test_saving_client_dob_recalculates_underaged_convictions(batch, record1, non_dismissed_offense): + record1.dob = None + record1.offense_date = datetime(2018, 1, 1) + record1.save() + + offense_record = OffenseRecordFactory( + action="CONVICTED", offense=non_dismissed_offense + ) + + batch.client.dob = datetime(2000, 1, 2) + batch.client.save() + + assert offense_record in batch.underaged_conviction_records() + + batch.client.dob = datetime(2000, 1, 1) + batch.client.save() + + assert offense_record not in batch.underaged_conviction_records() + diff --git a/dear_petition/petition/types/underaged_convictions.py b/dear_petition/petition/types/underaged_convictions.py index fbfcb015..6914d7ac 100644 --- a/dear_petition/petition/types/underaged_convictions.py +++ b/dear_petition/petition/types/underaged_convictions.py @@ -9,7 +9,7 @@ from dear_petition.petition.types.not_guilty import ( build_query as build_not_guilty_query, ) -from dear_petition.petition.utils import resolve_dob +from dear_petition.petition.utils import resolve_dob_from_offense_records logger = logging.getLogger(__name__) @@ -20,9 +20,12 @@ def get_offense_records(batch, jurisdiction=""): if not qs.exists(): return qs - dob = resolve_dob(qs) + if batch.client and batch.client.dob: + dob = batch.client.dob + else: + dob = resolve_dob_from_offense_records(qs) if not dob: - return qs # We can't determine this petition type without the date of birth + return OffenseRecord.objects.none() # We can't determine this petition type without the date of birth if jurisdiction: qs = qs.filter(offense__ciprs_record__jurisdiction=jurisdiction) diff --git a/dear_petition/petition/utils.py b/dear_petition/petition/utils.py index 41d7fd0d..8dec4625 100644 --- a/dear_petition/petition/utils.py +++ b/dear_petition/petition/utils.py @@ -200,7 +200,7 @@ def get_ordered_offense_records(petition_document): return qs -def resolve_dob(qs): +def resolve_dob_from_offense_records(qs): """ It is possible that different CIPRS records could have different dates of birth. In this case, use the earliest date of birth as it is the most conservative. """ @@ -219,4 +219,4 @@ def resolve_dob(qs): f"This batch has multiple birthdates. Using the earliest birthdate {earliest_dob}" ) - return earliest_dob + return earliest_dob \ No newline at end of file diff --git a/src/components/elements/Input/FormDateInput.jsx b/src/components/elements/Input/FormDateInput.jsx new file mode 100644 index 00000000..318bec6b --- /dev/null +++ b/src/components/elements/Input/FormDateInput.jsx @@ -0,0 +1,34 @@ +import { useController } from 'react-hook-form'; +import { AnimatePresence } from 'framer-motion'; +import { InputWrapper, InputStyled, ActualInputStyled, InputErrors } from './Input.styled'; + +const FormDateInput = ({ className, label, errors, inputProps, ...restProps }) => { + const { field, fieldState } = useController(inputProps); + const { error: inputError } = fieldState; + const error = inputError ? ( +

Invalid date

+ ) : ( + // eslint-disable-next-line react/no-array-index-key + errors?.map((errMsg, i) =>

{errMsg}

) + ); + return ( + + {label} + + {error && ( + + + {error} + + + )} + + ); +}; + +export default FormDateInput; diff --git a/src/components/elements/Input/Input.jsx b/src/components/elements/Input/Input.jsx index 1b86d085..9888766c 100644 --- a/src/components/elements/Input/Input.jsx +++ b/src/components/elements/Input/Input.jsx @@ -2,12 +2,12 @@ import React from 'react'; import { InputWrapper, InputStyled, ActualInputStyled, InputErrors } from './Input.styled'; import { AnimatePresence } from 'framer-motion'; -function Input({ className, innerClassName, label, errors, register, name, ...inputProps }, ref) { +function Input({ className, innerClassName, label, errors, register, name, type, ...inputProps }, ref) { const registerProps = register && name ? { ...register(name) } : {}; return ( {label} - + {errors && ( { const { closeModal } = useModalContext(); return (
- onCreate(submitData)} /> + onCreate(submitData)} />
); }; @@ -51,13 +55,13 @@ const getPetitionerData = (petitioner) => { export default function PetitionerInput({ petitioner, errors, onClearError }) { const { batchId } = useParams(); const [triggerSuggestionsFetch] = useLazySearchClientsQuery(); - const [triggerBatchUpdate] = useUpdateBatchMutation(); - const [triggerContactUpdate] = useUpdateContactMutation(); + const [triggerAssignClientToBatch] = useAssignClientToBatchMutation(); + const [triggerClientUpdate] = useUpdateClientMutation(); const [isEditing, setIsEditing] = useState(false); const [editErrors, setEditErrors] = useState({}); const [petitionerData, setPetitionerData] = useState(getPetitionerData(petitioner)); - const { name, ...address } = petitionerData; + const { name, dob, ...address } = petitionerData; const addError = (key, error) => setEditErrors((prev) => ({ ...prev, [key]: [error] })); const clearError = (key) => setEditErrors((prev) => ({ ...prev, [key]: [] })); @@ -84,8 +88,8 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { onClearError('client'); clearError('client'); try { - await triggerBatchUpdate({ - id: batchId, + await triggerAssignClientToBatch({ + batchId: batchId, data: { client_id: clientData.pk }, }).unwrap(); setPetitionerData(getPetitionerData(clientData)); @@ -116,7 +120,7 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { onClick={async () => { clearAllErrors(); try { - await triggerContactUpdate({ + await triggerClientUpdate({ id: petitioner.pk, data: { ...petitionerData, category: 'client' }, }).unwrap(); @@ -162,8 +166,8 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { onClearError('client'); clearError('client'); try { - await triggerBatchUpdate({ - id: batchId, + await triggerAssignClientToBatch({ + batchId: batchId, data: { client_id: pk }, }).unwrap(); @@ -202,6 +206,15 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { onClearError={onClearError} disabled={!isEditing} /> + setPetitionerData((prev) => ({ ...prev, dob: e.target.value }))} + errors={isEditing && editErrors.dob} + onClearError={onClearError} + disabled={!isEditing} + type="date" + /> { + const [triggerCreate] = useCreateClientMutation(); + const { control, handleSubmit, reset } = useForm({ + defaultValues: { + name: '', + address: '', + city: '', + zipcode: '', + state: { label: 'NC', value: 'NC' }, + dob: null, + }, + reValidateMode: 'onSubmit', + }); + const onSubmit = async (formData) => { + const submitData = {}; + Object.keys(formData).forEach((field) => { + if (field === 'address') { + const [address1, address2] = formData.address.split('\n'); + submitData.address1 = address1.trim(); + submitData.address2 = address2 ? address2.trim() : ''; + } else if (field === 'state') { + submitData.state = formData.state.value; + } else { + submitData[field] = formData[field]; + } + }); + const data = await triggerCreate({ data: { ...submitData, category } }).unwrap(); + reset(); + onSubmitSuccess?.(data); + }; + const onSubmitAndClose = async (data) => { + await onSubmit(data); + onClose(); + }; + return ( +
+

{'Add New Client'}

+
+ + + !!value?.trim() && !!value.split('\n')[0] }, + }} + /> + + ({ value: s[0], label: s[0] }))} + /> + !Number.isNaN(+value), + }, + }} + /> + +
+ {submitAndKeepOpenTitle && ( + + )} + + +
+
+ ); +}; + +export default CreateClient; diff --git a/src/service/api.js b/src/service/api.js index 4b26129c..314e62bd 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -45,6 +45,24 @@ export const api = createApi({ ] : [], }), + createClient: builder.mutation({ + query: ({ data }) => ({ url: `client/`, method: 'post', data }), + }), + updateClient: builder.mutation({ + query: ({ id, data }) => ({ url: `client/${id}/`, method: 'put', data }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + const tags = [ + { type: 'ContactList', id: result.category }, + { type: 'ContactFilterOptions', id: result.category }, + ]; + console.log(result); + result?.batches?.forEach((batchId) => tags.push({ type: 'Batch', id: batchId })); + return tags; + }, + }), deleteAgency: builder.mutation({ query: ({ id }) => ({ url: `contact/${id}/`, method: 'delete' }), invalidatesTags: [ @@ -61,7 +79,7 @@ export const api = createApi({ }), searchClients: builder.query({ query: ({ search }) => ({ - url: `contact/?category=client&search=${search}`, + url: `client/?search=${search}`, method: 'get', }), }), @@ -153,6 +171,24 @@ export const api = createApi({ }), invalidatesTags: (_result, _err, { petitionId }) => [{ type: 'Petition', id: petitionId }], }), + assignClientToBatch: builder.mutation({ + query: ({ batchId, data }) => ({ + url: `/batch/${batchId}/assign_client_to_batch/`, + method: 'post', + data: data, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + const tags = [ + { type: 'ContactList', id: result.category }, + { type: 'ContactFilterOptions', id: result.category }, + { type: 'Batch', id: result.batch_id }, + ]; + return tags; + }, + }), }), }); @@ -164,6 +200,8 @@ export const { useLazySearchClientsQuery, useCreateContactMutation, useUpdateContactMutation, + useCreateClientMutation, + useUpdateClientMutation, useDeleteAgencyMutation, useImportAgenciesMutation, usePreviewImportAgenciesMutation, @@ -183,4 +221,5 @@ export const { useCreateUserMutation, useModifyUserMutation, useUsersQuery, + useAssignClientToBatchMutation, } = api;