diff --git a/dear_petition/petition/api/serializers.py b/dear_petition/petition/api/serializers.py index f9b88ce1..c1135f69 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, @@ -139,6 +140,7 @@ class Meta: "zipcode", "user", "county", + "dob", ] class ClientSerializer(ContactSerializer): @@ -147,6 +149,24 @@ class ClientSerializer(ContactSerializer): state = serializers.CharField(required=True) zipcode = serializers.CharField(required=True) user = serializers.PrimaryKeyRelatedField(read_only=True) + dob = serializers.DateField(required=False) + + class Meta: + model = Client + fields = [ + "pk", + "name", + "category", + "address1", + "address2", + "formatted_address", + "city", + "state", + "zipcode", + "user", + "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/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..da79cd5a 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_queryset(self): + return pm.Client.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) class BatchViewSet(viewsets.ModelViewSet): 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/models.py b/dear_petition/petition/models.py index 0945748c..f627db5f 100644 --- a/dear_petition/petition/models.py +++ b/dear_petition/petition/models.py @@ -25,6 +25,7 @@ from dear_petition.users.models import User from dear_petition.users import constants as uc from dear_petition.common.models import PrintableModelMixin +from dear_petition.petition.utils import resolve_dob from . import constants as pc from .constants import ( @@ -267,6 +268,7 @@ def get_queryset(self): output_field=models.BooleanField(), )) ) + class Contact(PrintableModelMixin, models.Model): name = models.CharField(max_length=512) @@ -298,11 +300,21 @@ 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 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) class Batch(PrintableModelMixin, models.Model): @@ -321,7 +333,7 @@ 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"}, diff --git a/dear_petition/petition/types/adult_felonies.py b/dear_petition/petition/types/adult_felonies.py index 9fc7bc43..3632eb51 100644 --- a/dear_petition/petition/types/adult_felonies.py +++ b/dear_petition/petition/types/adult_felonies.py @@ -15,15 +15,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..6db53dd7 100644 --- a/dear_petition/petition/types/adult_misdemeanors.py +++ b/dear_petition/petition/types/adult_misdemeanors.py @@ -15,15 +15,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/underaged_convictions.py b/dear_petition/petition/types/underaged_convictions.py index fbfcb015..19b007d6 100644 --- a/dear_petition/petition/types/underaged_convictions.py +++ b/dear_petition/petition/types/underaged_convictions.py @@ -22,7 +22,7 @@ def get_offense_records(batch, jurisdiction=""): dob = resolve_dob(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/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/pages/GenerationPage/GenerationInput/PetitionerInput.jsx b/src/components/pages/GenerationPage/GenerationInput/PetitionerInput.jsx index ea828c23..53998ea2 100644 --- a/src/components/pages/GenerationPage/GenerationInput/PetitionerInput.jsx +++ b/src/components/pages/GenerationPage/GenerationInput/PetitionerInput.jsx @@ -9,7 +9,7 @@ import AutocompleteInput from '../../../elements/Input/AutocompleteInput'; import { useLazySearchClientsQuery, useUpdateBatchMutation, useUpdateContactMutation } from '../../../../service/api'; import Button, { ModalButton } from '../../../elements/Button'; import { useModalContext } from '../../../elements/Button/ModalButton'; -import { CreateContact } from '../../../../features/CreateContact'; +import { CreateClient } from '../../../../features/CreateClient'; const TextInput = styled(Input)` input { @@ -27,7 +27,7 @@ export const CreateClientModal = ({ onCreate }) => { const { closeModal } = useModalContext(); return (
- onCreate(submitData)} /> + onCreate(submitData)} />
); }; @@ -57,7 +57,7 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { 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]: [] })); @@ -202,6 +202,14 @@ export default function PetitionerInput({ petitioner, errors, onClearError }) { onClearError={onClearError} disabled={!isEditing} /> + setPetitionerData((prev) => ({ ...prev, name: e.target.value }))} + errors={isEditing && editErrors.name} + onClearError={onClearError} + disabled={!isEditing} + /> { + 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..d391f37d 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -45,6 +45,12 @@ 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 }), + }), deleteAgency: builder.mutation({ query: ({ id }) => ({ url: `contact/${id}/`, method: 'delete' }), invalidatesTags: [ @@ -164,6 +170,8 @@ export const { useLazySearchClientsQuery, useCreateContactMutation, useUpdateContactMutation, + useCreateClientMutation, + useUpdateClientMutation, useDeleteAgencyMutation, useImportAgenciesMutation, usePreviewImportAgenciesMutation,