diff --git a/common-data/loc-mailing-choices.json b/common-data/loc-mailing-choices.json new file mode 100644 index 000000000..db0af87f0 --- /dev/null +++ b/common-data/loc-mailing-choices.json @@ -0,0 +1,4 @@ +[ + ["WE_WILL_MAIL", "Yes, have JustFix.nyc mail this letter on my behalf."], + ["USER_WILL_MAIL", "No thanks, I'll mail it myself."] +] diff --git a/loc/forms.py b/loc/forms.py index 74ab7e2d4..d9b11ef47 100644 --- a/loc/forms.py +++ b/loc/forms.py @@ -35,3 +35,9 @@ class LandlordDetailsForm(forms.ModelForm): class Meta: model = models.LandlordDetails fields = ('name', 'address') + + +class LetterRequestForm(forms.ModelForm): + class Meta: + model = models.LetterRequest + fields = ('mail_choice',) diff --git a/loc/migrations/0003_letterrequest.py b/loc/migrations/0003_letterrequest.py new file mode 100644 index 000000000..2a57d0e0f --- /dev/null +++ b/loc/migrations/0003_letterrequest.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1 on 2018-09-13 12:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('loc', '0002_landlorddetails'), + ] + + operations = [ + migrations.CreateModel( + name='LetterRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('mail_choice', models.TextField(choices=[('choices', [('WE_WILL_MAIL', 'Yes, have JustFix.nyc mail this letter on my behalf.'), ('USER_WILL_MAIL', "No thanks, I'll mail it myself.")])], help_text='How the letter of complaint will be mailed.', max_length=30)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='letter_request', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/loc/models.py b/loc/models.py index 642ef4eaf..3bc8fd53b 100644 --- a/loc/models.py +++ b/loc/models.py @@ -2,9 +2,13 @@ import datetime from django.db import models +from project.common_data import Choices from users.models import JustfixUser +LOC_MAILING_CHOICES = Choices.from_file('loc-mailing-choices.json') + + class AccessDateManager(models.Manager): def set_for_user(self, user: JustfixUser, dates: List[datetime.date]): self.filter(user=user).delete() @@ -42,3 +46,21 @@ class LandlordDetails(models.Model): address = models.CharField( max_length=1000, help_text="The full mailing address for the landlord.") + + +class LetterRequest(models.Model): + ''' + A completed letter of complaint request submitted by a user. + ''' + + created_at = models.DateTimeField(auto_now_add=True) + + updated_at = models.DateTimeField(auto_now=True) + + user = models.OneToOneField( + JustfixUser, on_delete=models.CASCADE, related_name='letter_request') + + mail_choice = models.TextField( + max_length=30, + choices=LOC_MAILING_CHOICES, + help_text="How the letter of complaint will be mailed.") diff --git a/loc/schema.py b/loc/schema.py index 32e362579..74c3aa196 100644 --- a/loc/schema.py +++ b/loc/schema.py @@ -7,47 +7,112 @@ from . import forms, models -class AccessDates(DjangoFormMutation): - class Meta: - form_class = forms.AccessDatesForm +class SessionMutation(DjangoFormMutation): + ''' + A base class that can be used for any form mutation + that returns the current user's session. + ''' - login_required = True + class Meta: + abstract = True session = graphene.Field('project.schema.SessionInfo') @classmethod - def perform_mutate(cls, form: forms.AccessDatesForm, info: ResolveInfo): - request = info.context - models.AccessDate.objects.set_for_user(request.user, form.get_cleaned_dates()) - return AccessDates(session=import_string('project.schema.SessionInfo')) - + def mutation_success(cls, **kwargs): + ''' + This can be returned by any perform_mutate() method + to return a success condition along with the session. + ''' + + return cls( + errors=[], + session=import_string('project.schema.SessionInfo'), + **kwargs + ) + + +class OneToOneUserModelFormMutation(SessionMutation): + ''' + A base class that can be used to make any + ModelForm that represents a one-to-one relationship + with the user into a GraphQL mutation. + ''' -class LandlordDetails(DjangoFormMutation): class Meta: - form_class = forms.LandlordDetailsForm + abstract = True login_required = True - session = graphene.Field('project.schema.SessionInfo') - @classmethod def get_form_kwargs(cls, root, info: ResolveInfo, **input): + ''' + Either create a new instance of our model, or get the + existing one, and pass it on to the ModelForm. + ''' + user = info.context.user - if hasattr(user, 'landlord_details'): - details = user.landlord_details - else: - details = models.LandlordDetails(user=user) - return {"data": input, "instance": details} + model = cls._meta.form_class._meta.model + try: + instance = model.objects.get(user=user) + except model.DoesNotExist: + instance = model(user=user) + return {"data": input, "instance": instance} @classmethod - def perform_mutate(cls, form: forms.LandlordDetailsForm, info: ResolveInfo): + def perform_mutate(cls, form: forms.LetterRequestForm, info: ResolveInfo): + ''' + Save the ModelForm, which will have already been populated with + an instance of our model. + ''' + form.save() - return LandlordDetails(session=import_string('project.schema.SessionInfo')) + return cls.mutation_success() + + @classmethod + def resolve(cls, parent, info: ResolveInfo): + ''' + This can be used as a GraphQL resolver to get the + related model instance for the current user. + ''' + + user = info.context.user + if not user.is_authenticated: + return None + model = cls._meta.form_class._meta.model + try: + return model.objects.get(user=user) + except model.DoesNotExist: + return None + + +class AccessDates(SessionMutation): + class Meta: + form_class = forms.AccessDatesForm + + login_required = True + + @classmethod + def perform_mutate(cls, form: forms.AccessDatesForm, info: ResolveInfo): + request = info.context + models.AccessDate.objects.set_for_user(request.user, form.get_cleaned_dates()) + return cls.mutation_success() + + +class LandlordDetails(OneToOneUserModelFormMutation): + class Meta: + form_class = forms.LandlordDetailsForm + + +class LetterRequest(OneToOneUserModelFormMutation): + class Meta: + form_class = forms.LetterRequestForm class LocMutations: access_dates = AccessDates.Field(required=True) landlord_details = LandlordDetails.Field(required=True) + letter_request = LetterRequest.Field(required=True) class LandlordDetailsType(DjangoObjectType): @@ -56,18 +121,19 @@ class Meta: only_fields = ('name', 'address') +class LetterRequestType(DjangoObjectType): + class Meta: + model = models.LetterRequest + only_fields = ('mail_choice', 'updated_at') + + class LocSessionInfo: access_dates = graphene.List(graphene.NonNull(graphene.types.String), required=True) - landlord_details = graphene.Field(LandlordDetailsType) + landlord_details = graphene.Field(LandlordDetailsType, resolver=LandlordDetails.resolve) + letter_request = graphene.Field(LetterRequestType, resolver=LetterRequest.resolve) def resolve_access_dates(self, info: ResolveInfo): user = info.context.user if not user.is_authenticated: return [] return models.AccessDate.objects.get_for_user(user) - - def resolve_landlord_details(self, info: ResolveInfo): - user = info.context.user - if not user.is_authenticated or not hasattr(user, 'landlord_details'): - return None - return user.landlord_details diff --git a/loc/tests/test_schema.py b/loc/tests/test_schema.py index 5fb066feb..1bb9786e6 100644 --- a/loc/tests/test_schema.py +++ b/loc/tests/test_schema.py @@ -15,6 +15,11 @@ } +DEFAULT_LETTER_REQUEST_INPUT = { + 'mailChoice': 'WE_WILL_MAIL' +} + + def execute_ad_mutation(graphql_client, **input): input = {**DEFAULT_ACCESS_DATES_INPUT, **input} return graphql_client.execute( @@ -58,6 +63,29 @@ def execute_ld_mutation(graphql_client, **input): )['data']['output'] +def execute_lr_mutation(graphql_client, **input): + input = {**DEFAULT_LETTER_REQUEST_INPUT, **input} + return graphql_client.execute( + """ + mutation MyMutation($input: LetterRequestInput!) { + output: letterRequest(input: $input) { + errors { + field + messages + } + session { + letterRequest { + mailChoice, + updatedAt + } + } + } + } + """, + variables={'input': input} + )['data']['output'] + + @pytest.mark.django_db def test_access_dates_works(graphql_client): graphql_client.request.user = UserFactory.create() @@ -123,3 +151,36 @@ def test_landlord_details_is_null_when_user_has_not_yet_provided_it(graphql_clie graphql_client.request.user = UserFactory.create() result = graphql_client.execute('query { session { landlordDetails { name } } }') assert result['data']['session']['landlordDetails'] is None + + +@pytest.mark.django_db +def test_letter_request_works(graphql_client): + graphql_client.request.user = UserFactory.create() + + result = execute_lr_mutation(graphql_client) + assert result['errors'] == [] + assert result['session']['letterRequest']['mailChoice'] == 'WE_WILL_MAIL' + assert isinstance(result['session']['letterRequest']['updatedAt'], str) + + result = execute_lr_mutation(graphql_client, mailChoice='USER_WILL_MAIL') + assert result['errors'] == [] + assert result['session']['letterRequest']['mailChoice'] == 'USER_WILL_MAIL' + + +def test_letter_request_requires_auth(graphql_client): + result = execute_lr_mutation(graphql_client) + assert result['errors'] == [{'field': '__all__', 'messages': [ + 'You do not have permission to use this form!' + ]}] + + +def test_letter_request_is_null_when_unauthenticated(graphql_client): + result = graphql_client.execute('query { session { letterRequest { updatedAt } } }') + assert result['data']['session']['letterRequest'] is None + + +@pytest.mark.django_db +def test_letter_request_is_null_when_user_has_not_yet_requested_letter(graphql_client): + graphql_client.request.user = UserFactory.create() + result = graphql_client.execute('query { session { letterRequest { updatedAt } } }') + assert result['data']['session']['letterRequest'] is None diff --git a/schema.json b/schema.json index d94002e80..ba9974a2e 100644 --- a/schema.json +++ b/schema.json @@ -161,6 +161,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "letterRequest", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "LetterRequestType", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "phoneNumber", "description": "The phone number of the currently logged-in user, or null if not logged-in.", @@ -542,6 +554,82 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "LetterRequestType", + "description": null, + "fields": [ + { + "name": "updatedAt", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mailChoice", + "description": "How the letter of complaint will be mailed.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "LetterRequestMailChoice", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "DateTime", + "description": "The `DateTime` scalar type represents a DateTime\nvalue as specified by\n[iso8601](https://en.wikipedia.org/wiki/ISO_8601).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "LetterRequestMailChoice", + "description": "An enumeration.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "WE_WILL_MAIL", + "description": "Yes, have JustFix.nyc mail this letter on my behalf.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "USER_WILL_MAIL", + "description": "No thanks, I'll mail it myself.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Mutations", @@ -764,6 +852,37 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "letterRequest", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "LetterRequestInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LetterRequestPayload", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "logout", "description": null, @@ -2165,6 +2284,116 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "LetterRequestPayload", + "description": null, + "fields": [ + { + "name": "mailChoice", + "description": "How the letter of complaint will be mailed.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "A list of validation errors in the form, if any. If the form was valid, this list will be empty.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "StrictFormFieldErrorType", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "session", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "SessionInfo", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "LetterRequestInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "mailChoice", + "description": "How the letter of complaint will be mailed.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Logout",