diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 8343c3694040ebf61dae516df709ac2c49b359cf..77a92b8a16b2f443a484d2983477f9ff8ef38e0a 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -18,7 +18,7 @@ User = get_user_model() def link_to(target, request): - if target: + if target and hasattr(target, 'get_absolute_url'): return request.scheme + '://' + request.get_host() + target.get_absolute_url() @@ -55,7 +55,14 @@ neat_related = { MESSAGES.DELETE_REVIEW: 'review', MESSAGES.EDIT_REVIEW: 'review', MESSAGES.CREATED_PROJECT: 'submission', + MESSAGES.PROJECT_TRANSITION: 'old_stage', MESSAGES.UPDATE_PROJECT_LEAD: 'old_lead', + MESSAGES.APPROVE_CONTRACT: 'contract', + MESSAGES.UPLOAD_CONTRACT: 'contract', + MESSAGES.REQUEST_PAYMENT: 'payment_request', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request', + MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request', + MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request', } @@ -214,11 +221,16 @@ class ActivityAdapter(AdapterBase): MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed', MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}', MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}', - MESSAGES.CREATED_PROJECT: '{user} has created Project', - MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}', - MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval', - MESSAGES.APPROVE_PROJECT: '{user} has approved', - MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"', + MESSAGES.CREATED_PROJECT: 'Created', + MESSAGES.PROJECT_TRANSITION: 'Progressed from {old_stage} to {source.status_display}', + MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead}', + MESSAGES.SEND_FOR_APPROVAL: 'Requested approval', + MESSAGES.APPROVE_PROJECT: 'Approved', + MESSAGES.REQUEST_PROJECT_CHANGE: 'Requested changes for acceptance: "{comment}"', + MESSAGES.UPLOAD_CONTRACT: 'Uploaded a {contract.state} contract', + MESSAGES.APPROVE_CONTRACT: 'Approved contract', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}', + MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted', } def recipients(self, message_type, **kwargs): @@ -336,9 +348,10 @@ class ActivityAdapter(AdapterBase): except KeyError: pass - # TODO resolve how related objects work with submission/project - has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url']) - if has_correct_fields and isinstance(related, models.Model): + has_correct_fields = all(hasattr(related, attr) for attr in ['get_absolute_url']) + isnt_source = source != related + is_model = isinstance(related, models.Model) + if has_correct_fields and isnt_source and is_model: related_object = related else: related_object = None @@ -385,6 +398,12 @@ class SlackAdapter(AdapterBase): MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.', MESSAGES.APPROVE_PROJECT: '{user} has approved project <{link}|{source.title}>.', MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes for project acceptance on <{link}|{source.title}>.', + MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for <{link}|{source.title}>.', + MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for <{link}|{source.title}>.', + MESSAGES.REQUEST_PAYMENT: '{user} has requested payment for <{link}|{source.title}>.', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has changed the status of <{link_related}|payment request> on <{link}|{source.title}> to {payment_request.status_display}.', + MESSAGES.DELETE_PAYMENT_REQUEST: '{user} has deleted payment request from <{link}|{source.title}>.', + MESSAGES.UPDATE_PAYMENT_REQUEST: '{user} has updated payment request for <{link}|{source.title}>.', } def __init__(self): @@ -402,13 +421,16 @@ class SlackAdapter(AdapterBase): source = kwargs['source'] sources = kwargs['sources'] request = kwargs['request'] + related = kwargs['related'] link = link_to(source, request) + link_related = link_to(related, request) links = { source.id: link_to(source, request) for source in sources } return { 'link': link, + 'link_related': link_related, 'links': links, } @@ -606,12 +628,16 @@ class EmailAdapter(AdapterBase): MESSAGES.READY_FOR_REVIEW: 'messages/email/ready_to_review.html', MESSAGES.PARTNERS_UPDATED: 'partners_updated_applicant', MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner', + MESSAGES.UPLOAD_CONTRACT: 'messages/email/contract_uploaded.html', + MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html', + MESSAGES.UPDATE_PAYMENT_REQUEST: 'handle_update_payment_request', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated', } def get_subject(self, message_type, source): if source: if is_ready_for_review(message_type): - subject = 'Application ready to review: {submission.title}'.format(submission=source) + subject = 'Application ready to review: {source.title}'.format(source=source) else: try: subject = source.page.specific.subject or 'Your application to {org_long_name}: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source) @@ -647,6 +673,22 @@ class EmailAdapter(AdapterBase): old_phase = transitions[submission.id] return self.handle_transition(old_phase=old_phase, source=submission, **kwargs) + def handle_update_payment_request(self, user, **kwargs): + if user.is_applicant: + return + + return self.render_message( + 'messages/email/payment_request_updated.html', + **kwargs, + ) + + def handle_payment_status_updated(self, related, **kwargs): + return self.render_message( + 'messages/email/payment_request_status_updated.html', + has_changes_requested=related.has_changes_requested, + **kwargs, + ) + def batch_determination(self, determinations, sources, **kwargs): submissions = sources kwargs.pop('source') @@ -678,6 +720,16 @@ class EmailAdapter(AdapterBase): partners = kwargs['added'] return [partner.email for partner in partners] + if message_type == MESSAGES.SENT_TO_COMPLIANCE: + from opentech.apply.projects.models import ProjectSettings + project_settings = ProjectSettings.objects.first() + + if project_settings is None: + # TODO: what to do when this isn't configured?? + return [] + + return [project_settings.compliance_email] + return [source.user.email] def batch_recipients(self, message_type, sources, **kwargs): @@ -720,11 +772,16 @@ class EmailAdapter(AdapterBase): return render_to_string(template, kwargs) def send_message(self, message, source, subject, recipient, logs, **kwargs): + try: + from_email = source.page.specific.from_address + except AttributeError: # we're dealing with a project + from_email = source.submission.page.specific.from_address + try: send_mail( subject, message, - source.page.specific.from_address, + from_email, [recipient], logs=logs ) @@ -740,8 +797,8 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.BATCH_TRANSITION: 'batch_transition', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations', - MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document "{title}"', - MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document "{title}"', + MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document', + MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document', } def batch_reviewers_updated(self, added, sources, **kwargs): diff --git a/opentech/apply/activity/migrations/0041_add_upload_contract.py b/opentech/apply/activity/migrations/0041_add_upload_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff116ee9196e49d598ad9ad4c0971e8da0952ef --- /dev/null +++ b/opentech/apply/activity/migrations/0041_add_upload_contract.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-13 09:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0040_merge_activity_update_and_generic_relations'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0042_add_approve_contract.py b/opentech/apply/activity/migrations/0042_add_approve_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..2eb0e19a20fa5ca4f24c581c04a303b635a26c19 --- /dev/null +++ b/opentech/apply/activity/migrations/0042_add_approve_contract.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-13 09:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0041_add_upload_contract'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0043_add_request_payment.py b/opentech/apply/activity/migrations/0043_add_request_payment.py new file mode 100644 index 0000000000000000000000000000000000000000..3544f9796c621640a424567e952b57e599c9c504 --- /dev/null +++ b/opentech/apply/activity/migrations/0043_add_request_payment.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-15 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0042_add_approve_contract'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py b/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py new file mode 100644 index 0000000000000000000000000000000000000000..22ebb62bf29a2f42465fdb3fe1889e82c37c806b --- /dev/null +++ b/opentech/apply/activity/migrations/0044_add_update_payment_request_status.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-21 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0043_add_request_payment'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0045_add_delete_payment_request.py b/opentech/apply/activity/migrations/0045_add_delete_payment_request.py new file mode 100644 index 0000000000000000000000000000000000000000..394919e34a3e6545257acfd4ffeee74a1283fdcd --- /dev/null +++ b/opentech/apply/activity/migrations/0045_add_delete_payment_request.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-22 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0044_add_update_payment_request_status'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py b/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py new file mode 100644 index 0000000000000000000000000000000000000000..3b9491d91dc38ab303436cbaf9f47c09b088501d --- /dev/null +++ b/opentech/apply/activity/migrations/0046_add_sent_to_compliance.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-20 14:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0045_add_delete_payment_request'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0047_add_update_payment_request.py b/opentech/apply/activity/migrations/0047_add_update_payment_request.py new file mode 100644 index 0000000000000000000000000000000000000000..07e51482ea0d51f436c6d755b81dcf5e63449310 --- /dev/null +++ b/opentech/apply/activity/migrations/0047_add_update_payment_request.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-23 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0046_add_sent_to_compliance'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0048_add_project_transition.py b/opentech/apply/activity/migrations/0048_add_project_transition.py new file mode 100644 index 0000000000000000000000000000000000000000..b576b1ed898cd36f307d48ee54be5225451e53da --- /dev/null +++ b/opentech/apply/activity/migrations/0048_add_project_transition.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-09-05 05:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0047_add_update_payment_request'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 1dacb5fe6c8b5480174f6815d95aba5dbb01e46e..7d2f9bb588d82ad1b32fe80a6a9602944adc9362 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -31,9 +31,17 @@ class MESSAGES(Enum): EDIT_REVIEW = 'Edit Review' SEND_FOR_APPROVAL = 'Send for Approval' APPROVE_PROJECT = 'Project was Approved' + PROJECT_TRANSITION = 'Project was Transitioned' REQUEST_PROJECT_CHANGE = 'Project change requested' UPLOAD_DOCUMENT = 'Document was Uploaded to Project' REMOVE_DOCUMENT = 'Document was Removed from Project' + UPLOAD_CONTRACT = 'Contract was Uploaded to Project' + APPROVE_CONTRACT = 'Contract was Approved' + REQUEST_PAYMENT = 'Payment was requested for Project' + UPDATE_PAYMENT_REQUEST_STATUS = 'Updated Payment Request Status' + DELETE_PAYMENT_REQUEST = 'Delete Payment Request' + SENT_TO_COMPLIANCE = 'Project was sent to Compliance' + UPDATE_PAYMENT_REQUEST = 'Updated Payment Request' @classmethod def choices(cls): diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html index bdc55edf7ae7fa60bed5332b66916ae77fc3995e..5dce52086caa022b6ce8326272a9cac600468630 100644 --- a/opentech/apply/activity/templates/activity/include/listing_base.html +++ b/opentech/apply/activity/templates/activity/include/listing_base.html @@ -1,4 +1,4 @@ -{% load activity_tags bleach_tags markdown_tags submission_tags %} +{% load activity_tags bleach_tags markdown_tags submission_tags apply_tags %} <div class="feed__item feed__item--{{ activity.type }}"> <div class="feed__pre-content"> <p class="feed__label feed__label--{{ activity.type }}">{{ activity.type|capfirst }}</p> @@ -49,7 +49,7 @@ {% with url=activity.related_object.get_absolute_url %} {% if url %} <a href="{{ url }}" class="feed__related-item"> - {{ activity.related_object }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + {{ activity.related_object|model_verbose_name }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> </a> {% endif %} {% endwith %} diff --git a/opentech/apply/activity/templates/messages/email/contract_uploaded.html b/opentech/apply/activity/templates/messages/email/contract_uploaded.html new file mode 100644 index 0000000000000000000000000000000000000000..5a5aeed5b6716484eb28dad517425425bfd30499 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/contract_uploaded.html @@ -0,0 +1,15 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} +A new contract has been added to your Project: + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} + +{% if contract.is_signed %} +This contract has already been signed and there is no action for you to take. +{% else %} +Please review the contract and sign it before reuploading it to your Project page for the {{ ORG_SHORT_NAME }} Team to approve. +{% endif %} + +{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html new file mode 100644 index 0000000000000000000000000000000000000000..c00b187bf3c39013a3f70dd7c0b24660491bfd34 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html @@ -0,0 +1,15 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} +An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. +It is now {{ payment_request.get_status_display }}. + +{% if has_changes_requested %} +The staff member left this comment: + +{{ payment_request.comment }} +{% endif %} + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/payment_request_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_updated.html new file mode 100644 index 0000000000000000000000000000000000000000..368b14344ad427b9d478a2c02ed7821d938101a3 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/payment_request_updated.html @@ -0,0 +1,9 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} +An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. +It is now {{ payment_request.get_status_display }}. + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/sent_to_compliance.html b/opentech/apply/activity/templates/messages/email/sent_to_compliance.html new file mode 100644 index 0000000000000000000000000000000000000000..23b65a2bcd294704d23a002d5b35781f00fe5b11 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/sent_to_compliance.html @@ -0,0 +1,12 @@ +{% extends "messages/email/base.html" %} + +{% block salutation %}{% endblock %} + +{% block content %} +A Project is awaiting your review. + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:detail' pk=source.pk %} + +Please contact {{ source.lead }} - {{ source.lead.email }} if you have any questions. +{% endblock %} diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py index 9a1aa5e4e087067dbcccd49b74b4042f78827bc0..649e48387139fa0025722a3e690ebac4d6eb18bc 100644 --- a/opentech/apply/activity/templatetags/activity_tags.py +++ b/opentech/apply/activity/templatetags/activity_tags.py @@ -3,6 +3,7 @@ import json from django import template from opentech.apply.determinations.models import Determination +from opentech.apply.projects.models import Contract from opentech.apply.review.models import Review from ..models import TEAM, ALL, REVIEWER @@ -25,7 +26,7 @@ def user_can_see_related(activity, user): if user.is_apply_staff: return True - if isinstance(activity.related_object, Determination): + if isinstance(activity.related_object, (Determination, Contract)): return True return False diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index 6b6262de277e6f06d1b85181568393affaabe9d9..08cb17eebf476543c8960b23ff24331df5a48dff 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -16,8 +16,16 @@ from opentech.apply.funds.tests.factories import ( AssignedWithRoleReviewersFactory, ) from opentech.apply.review.tests.factories import ReviewFactory -from opentech.apply.users.tests.factories import ReviewerFactory, UserFactory -from opentech.apply.projects.tests.factories import ProjectFactory +from opentech.apply.users.tests.factories import ( + ApplicantFactory, + ReviewerFactory, + StaffFactory, + UserFactory +) +from opentech.apply.projects.tests.factories import ( + ProjectFactory, + PaymentRequestFactory +) from ..models import Activity, Event, Message, TEAM, ALL from ..messaging import ( @@ -378,7 +386,7 @@ class TestSlackAdapter(AdapterMixin, TestCase): @responses.activate def test_round_slack_channel(self): responses.add(responses.POST, self.target_url, status=200, body='OK') - submission = ApplicationSubmissionFactory(page__slack_channel='dummy') + submission = ApplicationSubmissionFactory(round__parent__slack_channel='dummy') adapter = SlackAdapter() message = 'my message' adapter.send_message(message, '', source=submission) @@ -599,7 +607,7 @@ class TestAdaptersForProject(AdapterMixin, TestCase): ) self.assertEqual(Activity.objects.count(), 1) activity = Activity.objects.first() - self.assertEqual(None, activity.related_object) + self.assertEqual(project.submission, activity.related_object) @override_settings( SLACK_DESTINATION_URL=target_url, @@ -642,3 +650,68 @@ class TestAdaptersForProject(AdapterMixin, TestCase): data = json.loads(responses.calls[0].request.body) self.assertIn(str(user), data['message']) self.assertIn(str(project), data['message']) + + @override_settings( + SLACK_DESTINATION_URL=target_url, + SLACK_DESTINATION_ROOM=target_room, + ) + @responses.activate + def test_slack_applicant_update_payment_request(self): + responses.add(responses.POST, self.target_url, status=200, body='OK') + + project = self.source_factory() + payment_request = PaymentRequestFactory(project=project) + applicant = ApplicantFactory() + + self.adapter_process( + MESSAGES.UPDATE_PAYMENT_REQUEST, + adapter=self.slack(), + user=applicant, + source=project, + related=payment_request, + ) + + self.assertEqual(len(responses.calls), 1) + + data = json.loads(responses.calls[0].request.body) + self.assertIn(str(applicant), data['message']) + self.assertIn(str(project), data['message']) + + @override_settings( + SLACK_DESTINATION_URL=target_url, + SLACK_DESTINATION_ROOM=target_room, + ) + @responses.activate + def test_slack_staff_update_payment_request(self): + responses.add(responses.POST, self.target_url, status=200, body='OK') + + project = self.source_factory() + payment_request = PaymentRequestFactory(project=project) + staff = StaffFactory() + + self.adapter_process( + MESSAGES.UPDATE_PAYMENT_REQUEST, + adapter=self.slack(), + user=staff, + source=project, + related=payment_request, + ) + + self.assertEqual(len(responses.calls), 1) + + @override_settings(SEND_MESSAGES=True) + def test_email_staff_update_payment_request(self): + project = self.source_factory() + payment_request = PaymentRequestFactory(project=project) + staff = StaffFactory() + + self.adapter_process( + MESSAGES.UPDATE_PAYMENT_REQUEST, + adapter=EmailAdapter(), + user=staff, + source=project, + related=payment_request, + ) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [project.user.email]) diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index c0edaf6b7abe782220d0d241b14e1e2eeddfb64d..9b7c4bb433a89fabc4f2680b5f6e4dd15b871c6f 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -43,12 +43,12 @@ class ActivityContextMixin: 'actions': Activity.actions.filter(**query).select_related( 'user', ).prefetch_related( - 'related_object__submission', + 'related_object', ).visible_to(self.request.user), 'comments': Activity.comments.filter(**query).select_related( 'user', ).prefetch_related( - 'related_object__submission', + 'related_object', ).visible_to(self.request.user), } return super().get_context_data(**extra, **kwargs) @@ -77,7 +77,8 @@ class CommentFormView(DelegatedViewMixin, CreateView): def get_success_url(self): return self.object.source.get_absolute_url() + '#communications' - @classmethod - def contribute_form(cls, instance, user): + def get_form_kwargs(self): # We dont want to pass the submission as the instance - return super().contribute_form(instance=None, user=user) + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + return kwargs diff --git a/opentech/apply/categories/blocks.py b/opentech/apply/categories/blocks.py index b8bf9a67c1180cd53a62490fae49aa46930fe6e9..e2f2d0f917aa5ee1e2e4a59a0ea792d99fdf07a9 100644 --- a/opentech/apply/categories/blocks.py +++ b/opentech/apply/categories/blocks.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.functional import cached_property +from django.utils.functional import cached_property, SimpleLazyObject from django.utils.translation import ugettext_lazy as _ from wagtail.core.blocks import BooleanBlock, CharBlock, ChooserBlock, TextBlock @@ -20,6 +20,10 @@ class ModelChooserBlock(ChooserBlock): def target_model(self): return resolve_model_string(self._target_model) + def to_python(self, value): + super_method = super().to_python + return SimpleLazyObject(lambda: super_method(value)) + class CategoryQuestionBlock(OptionalFormFieldBlock): class Meta: diff --git a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html index 1c2441ac97883e465874eb42e7981680b7377671..2a74eb03d365d939d637e85d972b074e1461d8e9 100644 --- a/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/applicant_dashboard.html @@ -2,14 +2,14 @@ {% load render_table from django_tables2 %} {% load static wagtailcore_tags workflow_tags statusbar_tags %} -{% block title %}Submission Dashboard{% endblock %} +{% block title %}Dashboard{% endblock %} {% block content %} <div class="admin-bar"> <div class="admin-bar__inner wrapper--applicant-dashboard"> <div> <h3 class="heading heading--no-margin">Dashboard</h3> - <h5>An overview of active and past submissions</h5> + <h5>An overview of active and past submissions and projects</h5> </div> <div class="wrapper wrapper--cta-box"> <h4 class="heading heading--no-margin">Submit a new application</h4> @@ -18,6 +18,7 @@ </div> </div> </div> + <div class="wrapper wrapper--large wrapper--inner-space-medium"> <h3>Your active submissions</h3> {% for submission in my_active_submissions %} @@ -44,12 +45,27 @@ {% endfor %} </div> -{% if table.data %} - <div class="wrapper wrapper--large wrapper--inner-space-medium"> - <h3>Submission history</h3> - {% render_table table %} - </div> +{% if tables.0.data %} +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + <h3>Your active projects</h3> + {% render_table tables.0 %} +</div> {% endif %} + +{% if tables.1.data %} +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + <h3>Submission history</h3> + {% render_table tables.1 %} +</div> +{% endif %} + +{% if tables.2.data %} +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + <h3>Project history</h3> + {% render_table tables.2 %} +</div> +{% endif %} + {% endblock %} {% block extra_js %} diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html index f4c6282fb88d132ddd9545605d1ad19249b180ec..6c6f84d4e84b696b45218bd7aaa12a2ef50bfe6c 100644 --- a/opentech/apply/dashboard/templates/dashboard/dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html @@ -3,7 +3,7 @@ {% load static %} {% block extra_css %} - {{ filter.form.media.css }} + {{ my_reviewed.filterset.form.media.css }} {% endblock %} {% block title %}Dashboard{% endblock %} @@ -23,54 +23,88 @@ <div class="wrapper wrapper--large wrapper--inner-space-medium"> - <!-- Example Stat Block markup - <div class="wrapper wrapper--bottom-space"> - <div class="stat-block"> - <a href="#" class="stat-block__item"> - <p class="stat-block__number">3</p> - <p class="stat-block__text">Submissions waiting for your review</p> - <div class="stat-block__view">View</div> - </a> - <a href="#" class="stat-block__item"> - <p class="stat-block__number">10</p> - <p class="stat-block__text">Live projects under your management</p> - <div class="stat-block__view">View</div> - </a> - <a href="#" class="stat-block__item"> - <p class="stat-block__number">4</p> - <p class="stat-block__text">Requests for payment requiring your attention</p> - <div class="stat-block__view">View</div> - </a> - </div> + <div class="wrapper wrapper--bottom-space"> + <div class="stat-block"> + <a href="#submissions-awaiting-review" class="stat-block__item"> + <p class="stat-block__number">{{ awaiting_reviews.count }}</p> + <p class="stat-block__text">Submissions waiting for your review</p> + <div class="stat-block__view">View</div> + </a> + <a href="#active-projects" class="stat-block__item"> + <p class="stat-block__number">{{ projects.count }}</p> + <p class="stat-block__text">Live projects under your management</p> + <div class="stat-block__view">View</div> + </a> + {% if not projects_to_approve.count is None%} + <a href="#projects-awaiting-approval" class="stat-block__item"> + <p class="stat-block__number">{{ projects_to_approve.count }}</p> + <p class="stat-block__text">Projects awaiting approval</p> + <div class="stat-block__view">View</div> + </a> + {% endif %} + <a href="#active-payment-requests" class="stat-block__item"> + <p class="stat-block__number">{{ active_payment_requests.count }}</p> + <p class="stat-block__text">Requests for payment requiring your attention</p> + <div class="stat-block__view">View</div> + </a> </div> - --> + </div> + + <div id="submissions-awaiting-review" class="wrapper wrapper--bottom-space"> + {% include "dashboard/includes/waiting-for-review.html" with in_review_count=awating_reviews.count my_review=awaiting_reviews.table display_more=awaiting_reviews.display_more active_statuses_filter=awaiting_reviews.active_statuses_filter %} + </div> + + {% if rounds.closed or rounds.open %} + {% include "funds/includes/round-block.html" with closed_rounds=rounds.closed open_rounds=rounds.open title="Your rounds and labs" %} + {% endif %} + + {% if projects.table.data %} + <div id="active-projects" class="wrapper wrapper--bottom-space"> + {% include "funds/includes/table_filter_and_search.html" with filter=projects.filterset filter_action=projects.url search_term=search_term search_action=projects.url search_placeholder="projects" use_search=True use_batch_actions=False heading="Your projects" %} + {% render_table projects.table %} + + {% if projects.display_more %} + <div class="all-submissions-table__more"> + <a href="{{ projects.url }}?lead={{ request.user.pk }}">Show all</a> + </div> + {% endif %} - <div class="wrapper wrapper--bottom-space"> - {% include "dashboard/includes/waiting-for-review.html" with in_review_count=in_review_count my_review=my_review display_more=display_more active_statuses_filter=active_statuses_filter %} </div> + {% endif %} - {% if closed_rounds or open_rounds %} - {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %} + {% if projects_to_approve.count %} + <div id="projects-awaiting-approval" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">Projects awaiting approval</h4> + {% render_table projects_to_approve.table %} + </div> {% endif %} - {% if my_reviewed.data %} + {% if active_payment_requests.count %} + <div id="active-payment-requests" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">Active requests for payment</h4> + {% render_table active_payment_requests.table %} + </div> + {% endif %} + + {% if my_reviewed.table.data %} <div class="wrapper wrapper--bottom-space"> - {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True use_batch_actions=False heading="Your previous reviews" %} - {% render_table my_reviewed %} + {% include "funds/includes/table_filter_and_search.html" with filter=my_reviewed.filterset filter_action=my_reviewed.url search_term=search_term search_action=my_reviewed.url use_search=True use_batch_actions=False heading="Your previous reviews" %} + {% render_table my_reviewed.table %} - {% if display_more_reviewed %} + {% if my_reviewed.display_more %} <div class="all-submissions-table__more"> - <a href="{% url 'apply:submissions:list' %}?reviewers={{ request.user.pk }}">Show all</a> + <a href="{{ my_reviewed.url }}?reviewers={{ request.user.pk }}">Show all</a> </div> {% endif %} </div> {% endif %} + </div> {% endblock %} {% block extra_js %} - {{ filter.form.media.js }} + {{ my_reviewed.filterset.form.media.js }} <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script> <script src="{% static 'js/apply/submission-filters.js' %}"></script> <script src="{% static 'js/apply/submission-tooltips.js' %}"></script> diff --git a/opentech/apply/dashboard/tests/test_views.py b/opentech/apply/dashboard/tests/test_views.py index eb3892c58c22ce5d71845e20ee5147eb889a16c7..05b5b15ffe2d98f48a5731942213b02a0a96aa72 100644 --- a/opentech/apply/dashboard/tests/test_views.py +++ b/opentech/apply/dashboard/tests/test_views.py @@ -3,8 +3,26 @@ from opentech.apply.funds.tests.factories import ( ApplicationRevisionFactory, InvitedToProposalFactory, ) +from opentech.apply.projects.models import ( + CHANGES_REQUESTED, + COMMITTED, + DECLINED, + PAID, + SUBMITTED, + UNDER_REVIEW, +) +from opentech.apply.projects.tests.factories import ( + PaymentRequestFactory, + ProjectFactory +) from opentech.apply.review.tests.factories import ReviewFactory, ReviewOpinionFactory -from opentech.apply.users.tests.factories import ApplicantFactory, ReviewerFactory, StaffFactory +from opentech.apply.users.groups import APPROVER_GROUP_NAME +from opentech.apply.users.tests.factories import ( + ApplicantFactory, + GroupFactory, + ReviewerFactory, + StaffFactory +) from opentech.apply.utils.testing.tests import BaseViewTestCase @@ -68,7 +86,7 @@ class TestStaffDashboard(BaseViewTestCase): response = self.get_page() self.assertContains(response, 'Waiting for your review') self.assertContains(response, submission.title) - self.assertEquals(response.context['in_review_count'], 1) + self.assertEquals(response.context['awaiting_reviews']['count'], 1) def test_waiting_for_review_after_agreement_is_empty(self): staff = StaffFactory() @@ -78,7 +96,59 @@ class TestStaffDashboard(BaseViewTestCase): response = self.get_page() self.assertContains(response, 'Waiting for your review') self.assertContains(response, "Nice! You're all caught up.") - self.assertEquals(response.context['in_review_count'], 0) + self.assertEquals(response.context['awaiting_reviews']['count'], 0) + + def test_active_payment_requests_with_no_project(self): + response = self.get_page() + self.assertNotContains(response, "Active requests for payment") + + def test_doesnt_show_active_payment_requests_with_none(self): + ProjectFactory(lead=self.user) + + response = self.get_page() + self.assertNotContains(response, "Active requests for payment") + + def test_doest_show_active_payment_requests_when_paid_or_declined(self): + project = ProjectFactory(lead=self.user) + PaymentRequestFactory(project=project, status=PAID) + PaymentRequestFactory(project=project, status=DECLINED) + + response = self.get_page() + self.assertNotContains(response, "Active requests for payment") + + def test_active_payment_requests_with_payment_requests_in_correct_state(self): + project = ProjectFactory(lead=self.user) + PaymentRequestFactory(project=project, status=SUBMITTED) + PaymentRequestFactory(project=project, status=CHANGES_REQUESTED) + PaymentRequestFactory(project=project, status=UNDER_REVIEW) + + response = self.get_page() + self.assertContains(response, "Active requests for payment") + + def test_doesnt_show_active_payment_requests_when_not_mine(self): + project = ProjectFactory() + PaymentRequestFactory(project=project, status=SUBMITTED) + PaymentRequestFactory(project=project, status=CHANGES_REQUESTED) + PaymentRequestFactory(project=project, status=UNDER_REVIEW) + + response = self.get_page() + self.assertNotContains(response, "Active requests for payment") + + def test_non_project_approver_cannot_see_projects_awaiting_review_stats_or_table(self): + ProjectFactory(is_locked=True, status=COMMITTED) + + response = self.get_page() + self.assertNotContains(response, "Projects awaiting approval") + + def test_project_approver_can_see_projects_awaiting_review_stats_or_table(self): + ProjectFactory(is_locked=True, status=COMMITTED) + + user = StaffFactory() + user.groups.add(GroupFactory(name=APPROVER_GROUP_NAME)) + self.client.force_login(user) + + response = self.get_page() + self.assertContains(response, "Projects awaiting approval") class TestReviewerDashboard(BaseViewTestCase): diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py index 0056650ffb2f0e73f79aa623383f572b17d396ee..148bddc9bff5fde9602e26662ecb1e5ee1a1ba56 100644 --- a/opentech/apply/dashboard/views.py +++ b/opentech/apply/dashboard/views.py @@ -1,8 +1,8 @@ from django.http import HttpResponseRedirect from django.shortcuts import render +from django.urls import reverse, reverse_lazy from django.views.generic import TemplateView -from django.urls import reverse_lazy -from django_tables2.views import SingleTableView +from django_tables2.views import MultiTableMixin from opentech.apply.funds.models import ApplicationSubmission, RoundsAndLabs from opentech.apply.funds.tables import ( @@ -12,79 +12,112 @@ from opentech.apply.funds.tables import ( SubmissionsTable, SummarySubmissionsTable, SummarySubmissionsTableWithRole, - review_filter_for_user, + review_filter_for_user +) +from opentech.apply.projects.filters import ProjectListFilter +from opentech.apply.projects.models import ( + PaymentRequest, + Project +) +from opentech.apply.projects.tables import ( + PaymentRequestsDashboardTable, + ProjectsDashboardTable ) from opentech.apply.utils.views import ViewDispatcher class AdminDashboardView(TemplateView): + template_name = 'dashboard/dashboard.html' - def get(self, request, *args, **kwargs): - # redirect to submissions list when we use the filter to search for something - if len(request.GET): - query_str = '?' - for key, value in request.GET.items(): - query_str += key + '=' + value + '&' - return HttpResponseRedirect(reverse_lazy('funds:submissions:list') + query_str) + def get_context_data(self, **kwargs): + submissions = ApplicationSubmission.objects.all().for_table(self.request.user) + + extra_context = { + 'active_payment_requests': self.get_my_active_payment_requests(self.request.user), + 'awaiting_reviews': self.get_my_awaiting_reviews(self.request.user, submissions), + 'my_reviewed': self.get_my_reviewed(self.request, submissions), + 'projects': self.get_my_projects(self.request), + 'projects_to_approve': self.get_my_projects_to_approve(self.request.user), + 'rounds': self.get_rounds(self.request.user) + } + current_context = super().get_context_data(**kwargs) + return {**current_context, **extra_context} - qs = ApplicationSubmission.objects.all().for_table(self.request.user) + def get_my_active_payment_requests(self, user): + payment_requests = PaymentRequest.objects.filter( + project__lead=user, + ).in_progress() - base_query = RoundsAndLabs.objects.with_progress().active().order_by('-end_date') - base_query = base_query.by_lead(request.user) - open_rounds = base_query.open()[:6] - open_query = '?round_state=open' - closed_rounds = base_query.closed()[:6] - closed_query = '?round_state=closed' - rounds_title = 'Your rounds and labs' + return { + 'count': payment_requests.count(), + 'table': PaymentRequestsDashboardTable(payment_requests), + } - # Staff reviewer's current to-review submissions - my_review_qs, my_review, display_more = self.get_my_reviews(request.user, qs) + def get_my_projects(self, request): + projects = Project.objects.filter(lead=request.user).for_table() - # Staff reviewer's reviewed submissions for 'Previous reviews' block - filterset, my_reviewed_qs, my_reviewed, display_more_reviewed = self.get_my_reviewed(request, qs) + filterset = ProjectListFilter(data=request.GET or None, request=request, queryset=projects) - # Filter for all active statuses. - active_statuses_filter = ''.join(f'&status={status}' for status in review_filter_for_user(request.user)) + limit = 10 - context = { - 'open_rounds': open_rounds, - 'open_query': open_query, - 'closed_rounds': closed_rounds, - 'closed_query': closed_query, - 'rounds_title': rounds_title, - 'my_review': my_review, - 'in_review_count': my_review_qs.count(), - 'display_more': display_more, - 'my_reviewed': my_reviewed, - 'display_more_reviewed': display_more_reviewed, - 'filter': filterset, - 'active_statuses_filter': active_statuses_filter, + return { + 'count': projects.count(), + 'filterset': filterset, + 'table': ProjectsDashboardTable(projects[:limit]), + 'display_more': projects.count() > limit, + 'url': reverse('apply:projects:all'), } - return render(request, 'dashboard/dashboard.html', context) + def get_my_projects_to_approve(self, user): + if not user.is_approver: + return { + 'count': None, + 'table': None, + } - def get_my_reviews(self, user, qs): - my_review_qs = qs.in_review_for(user).order_by('-submit_time') - my_review_table = SummarySubmissionsTableWithRole(my_review_qs[:5], prefix='my-review-') - display_more = (my_review_qs.count() > 5) + to_approve = Project.objects.in_approval().for_table() - return my_review_qs, my_review_table, display_more + return { + 'count': to_approve.count(), + 'table': ProjectsDashboardTable(data=to_approve), + } - def get_my_reviewed(self, request, qs): - # Replicating django_filters.views.FilterView - my_reviewed_qs = qs.reviewed_by(request.user).order_by('-submit_time') - kwargs = { - 'data': self.request.GET or None, - 'request': self.request, - 'queryset': my_reviewed_qs, + def get_my_awaiting_reviews(self, user, qs): + """Staff reviewer's current to-review submissions.""" + qs = qs.in_review_for(user).order_by('-submit_time') + count = qs.count() + + limit = 5 + return { + 'active_statuses_filter': ''.join(f'&status={status}' for status in review_filter_for_user(user)), + 'count': count, + 'display_more': count > limit, + 'table': SummarySubmissionsTableWithRole(qs[:limit], prefix='my-review-'), } - filterset = SubmissionFilterAndSearch(**kwargs) - my_reviewed_qs = filterset.qs - my_reviewed_table = SummarySubmissionsTable(my_reviewed_qs[:5], prefix='my-reviewed-') - display_more_reviewed = (my_reviewed_qs.count() > 5) + def get_my_reviewed(self, request, qs): + """Staff reviewer's reviewed submissions for 'Previous reviews' block""" + qs = qs.reviewed_by(request.user).order_by('-submit_time') - return filterset, my_reviewed_qs, my_reviewed_table, display_more_reviewed + filterset = SubmissionFilterAndSearch(data=request.GET or None, request=request, queryset=qs) + + limit = 5 + return { + 'filterset': filterset, + 'table': SummarySubmissionsTable(qs[:limit], prefix='my-reviewed-'), + 'display_more': qs.count() > limit, + 'url': reverse('funds:submissions:list'), + } + + def get_rounds(self, user): + qs = (RoundsAndLabs.objects.with_progress() + .active() + .order_by('-end_date') + .by_lead(user)) + return { + 'closed': qs.closed()[:6], + 'open': qs.open()[:6], + } class ReviewerDashboardView(TemplateView): @@ -279,29 +312,46 @@ class CommunityDashboardView(TemplateView): return context -class ApplicantDashboardView(SingleTableView): +class ApplicantDashboardView(MultiTableMixin, TemplateView): + tables = [ + ProjectsDashboardTable, + SubmissionsTable, + ProjectsDashboardTable, + ] template_name = 'dashboard/applicant_dashboard.html' - model = ApplicationSubmission - table_class = SubmissionsTable - - def get_queryset(self): - return self.model.objects.filter( - user=self.request.user - ).inactive().current().for_table(self.request.user) def get_context_data(self, **kwargs): - my_active_submissions = self.model.objects.filter( - user=self.request.user - ).active().current().select_related('draft_revision') + active_submissions = list(self.get_active_submissions(self.request.user)) - my_active_submissions = [ - submission.from_draft() for submission in my_active_submissions - ] + context = super().get_context_data(**kwargs) + context['my_active_submissions'] = active_submissions + return context - return super().get_context_data( - my_active_submissions=my_active_submissions, - **kwargs, - ) + def get_active_project_data(self, user): + return Project.objects.filter(user=user).in_progress().for_table() + + def get_active_submissions(self, user): + active_subs = ApplicationSubmission.objects.filter( + user=user, + ) .active().current().select_related('draft_revision') + + for submission in active_subs: + yield submission.from_draft() + + def get_historical_project_data(self, user): + return Project.objects.filter(user=user).complete().for_table() + + def get_historical_submission_data(self, user): + return ApplicationSubmission.objects.filter( + user=user, + ).inactive().current().for_table(user) + + def get_tables_data(self): + return [ + self.get_active_project_data(self.request.user), + self.get_historical_submission_data(self.request.user), + self.get_historical_project_data(self.request.user), + ] class DashboardView(ViewDispatcher): diff --git a/opentech/apply/determinations/tests/test_views.py b/opentech/apply/determinations/tests/test_views.py index 03ead29bd2675e75e9b32f1a4e4f0b60509c6aa3..6632d606b611b70b80c83b63d62bf2be500e1842 100644 --- a/opentech/apply/determinations/tests/test_views.py +++ b/opentech/apply/determinations/tests/test_views.py @@ -270,6 +270,48 @@ class DeterminationFormTestCase(BaseViewTestCase): self.assertIsNone(submission_next) self.assertFalse(hasattr(submission_original, 'project')) + @override_settings(PROJECTS_AUTO_CREATE=False) + def test_disabling_project_auto_creation_stops_projects_being_created(self): + submission = ApplicationSubmissionFactory( + status='post_review_discussion', + workflow_stages=1, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': ACCEPTED, + 'message': 'You are invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + self.assertIsNone(submission_next) + self.assertFalse(hasattr(submission_original, 'project')) + + @override_settings(PROJECTS_ENABLED=False, PROJECTS_AUTO_CREATE=True) + def test_disabling_projects_ignores_auto_creation_setting(self): + submission = ApplicationSubmissionFactory( + status='post_review_discussion', + workflow_stages=1, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': ACCEPTED, + 'message': 'You are invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + self.assertIsNone(submission_next) + self.assertFalse(hasattr(submission_original, 'project')) + class BatchDeterminationTestCase(BaseViewTestCase): user_factory = StaffFactory diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py index 260e6eafa576650c947bf175e7f426cc37894507..426096a1fe9393ab5088ca155ca8410992285ed5 100644 --- a/opentech/apply/determinations/views.py +++ b/opentech/apply/determinations/views.py @@ -1,5 +1,6 @@ from urllib import parse +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied @@ -277,7 +278,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): proposal_form=proposal_form, ) - if self.submission.accepted_for_funding: + if self.submission.accepted_for_funding and settings.PROJECTS_AUTO_CREATE: project = Project.create_from_submission(self.submission) if project: messenger( diff --git a/opentech/apply/funds/files.py b/opentech/apply/funds/files.py index 6f834375decea544af46540a69cd42b1370fae84..5c05b703476bb54f6b361c7d49d40b8d0754ede5 100644 --- a/opentech/apply/funds/files.py +++ b/opentech/apply/funds/files.py @@ -12,7 +12,7 @@ def generate_submission_file_path(submission_id, field_id, file_name): return os.path.join(path, file_name) -class SubmissionStreamFileField(StreamFieldFile): +class SubmissionStreamFieldFile(StreamFieldFile): def generate_filename(self): return generate_submission_file_path(self.instance.pk, self.field.id, self.name) diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 84ab400ad95ebb6e153eac5099cb0441b983ac4b..df7ce6d8a9e0446ae2f674598dc59d17d65d6e06 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -139,10 +139,6 @@ class BatchUpdateSubmissionLeadForm(forms.Form): def save(self): new_lead = self.cleaned_data['lead'] - import logging - logger = logging.getLogger('opentech') - logger.debug(new_lead) - logger.debug(new_lead.id) submissions = self.cleaned_data['submissions'] for submission in submissions: diff --git a/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py b/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py new file mode 100644 index 0000000000000000000000000000000000000000..d82e486bc9d22bd22f115278bce93a557d450300 --- /dev/null +++ b/opentech/apply/funds/migrations/0068_link_funds_and_labs_to_paf.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2019-08-11 06:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0018_projectapprovalform'), + ('funds', '0067_data_migration_for_one_form_per_stage'), + ] + + operations = [ + migrations.AddField( + model_name='applicationbase', + name='approval_form', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funds', to='application_projects.ProjectApprovalForm'), + ), + migrations.AddField( + model_name='labbase', + name='approval_form', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='labs', to='application_projects.ProjectApprovalForm'), + ), + ] diff --git a/opentech/apply/funds/migrations/0069_merge_20190905_0403.py b/opentech/apply/funds/migrations/0069_merge_20190905_0403.py new file mode 100644 index 0000000000000000000000000000000000000000..8e20eaf23c46e25add89713b94b50332d7032b00 --- /dev/null +++ b/opentech/apply/funds/migrations/0069_merge_20190905_0403.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.11 on 2019-09-05 03:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0068_link_funds_and_labs_to_paf'), + ('funds', '0068_add_help_link_field'), + ] + + operations = [ + ] diff --git a/opentech/apply/funds/models/applications.py b/opentech/apply/funds/models/applications.py index d124036f471d785e28a8dd88f6bda14c4a9bb20a..6568029ca3665ca02c31108daf68b37b14e5b78c 100644 --- a/opentech/apply/funds/models/applications.py +++ b/opentech/apply/funds/models/applications.py @@ -64,6 +64,14 @@ class ApplicationBase(EmailForm, WorkflowStreamForm): # type: ignore blank=True, ) + approval_form = models.ForeignKey( + 'application_projects.ProjectApprovalForm', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='funds', + ) + slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications.')) objects = PageManager.from_queryset(ApplicationBaseManager)() @@ -99,6 +107,7 @@ class ApplicationBase(EmailForm, WorkflowStreamForm): # type: ignore return self.open_round.serve(request) content_panels = WorkflowStreamForm.content_panels + [ + FieldPanel('approval_form'), FieldPanel('reviewers'), FieldPanel('slack_channel'), ] @@ -382,12 +391,21 @@ class LabBase(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig blank=True, ) + approval_form = models.ForeignKey( + 'application_projects.ProjectApprovalForm', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='labs', + ) + slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications.')) parent_page_types = ['apply_home.ApplyHomePage'] subpage_types = [] # type: ignore content_panels = WorkflowStreamForm.content_panels + [ + FieldPanel('approval_form'), FieldPanel('lead'), FieldPanel('reviewers'), FieldPanel('slack_channel'), diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py index 03c943107ea9354dad440de83d68a9b5ac8a96cb..ddf20ac4b841e8d189a5050ed16090123c51daf3 100644 --- a/opentech/apply/funds/models/mixins.py +++ b/opentech/apply/funds/models/mixins.py @@ -9,7 +9,7 @@ from opentech.apply.utils.blocks import SingleIncludeMixin from opentech.apply.stream_forms.blocks import UploadableMediaBlock from opentech.apply.utils.storage import PrivateStorage -from ..files import SubmissionStreamFileField +from ..files import SubmissionStreamFieldFile __all__ = ['AccessFormData'] @@ -25,7 +25,7 @@ class AccessFormData: - form_data > jsonfield containing the submitted data - form_fields > streamfield containing the original form fields """ - stream_file_class = SubmissionStreamFileField + stream_file_class = SubmissionStreamFieldFile storage_class = PrivateStorage @property @@ -36,7 +36,7 @@ class AccessFormData: for field_name, field_id in self.named_blocks.items(): if field_id not in data: try: - response = data[field_name] + response = data.pop(field_name) except KeyError: # There was no value supplied for the named field pass @@ -67,6 +67,25 @@ class AccessFormData: else: return cls.stream_file(instance, field, file) + def process_file_data(self, data): + for field in self.form_fields: + if isinstance(field.block, UploadableMediaBlock): + file = self.process_file(self, field, data.get(field.id, [])) + try: + file.save() + except AttributeError: + for f in file: + f.save() + self.form_data[field.id] = file + + def extract_files(self): + files = {} + for field in self.form_fields: + if isinstance(field.block, UploadableMediaBlock): + files[field.id] = self.data(field.id) or [] + self.form_data.pop(field.id, None) + return files + @classmethod def from_db(cls, db, field_names, values): instance = super().from_db(db, field_names, values) @@ -235,5 +254,8 @@ class AccessFormData: # Returns a safe string of the rendered answers return mark_safe(''.join(self.render_answers())) + def output_text_answers(self): + return mark_safe(''.join(self.render_text_blocks_answers())) + def output_first_group_text_answers(self): return mark_safe(''.join(self.render_first_group_text_answers())) diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py index 20e91b8dd24ab43a2b5ee8dce6d74c2b8d1b25c7..a6df3d877f198d8d3b0281429afd3a173aad5ef7 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -36,7 +36,6 @@ from opentech.apply.categories.models import MetaCategory from opentech.apply.determinations.models import Determination from opentech.apply.review.models import ReviewOpinion from opentech.apply.review.options import MAYBE, AGREE, DISAGREE -from opentech.apply.stream_forms.blocks import UploadableMediaBlock from opentech.apply.stream_forms.files import StreamFieldDataEncoder from opentech.apply.stream_forms.models import BaseStreamForm @@ -579,25 +578,6 @@ class ApplicationSubmission( if response: self.form_data[field_name] = response - def extract_files(self): - files = {} - for field in self.form_fields: - if isinstance(field.block, UploadableMediaBlock): - files[field.id] = self.data(field.id) or [] - self.form_data.pop(field.id, None) - return files - - def process_file_data(self, data): - for field in self.form_fields: - if isinstance(field.block, UploadableMediaBlock): - file = self.process_file(self, field, data.get(field.id, [])) - try: - file.save() - except AttributeError: - for f in file: - f.save() - self.form_data[field.id] = file - def save(self, *args, update_fields=list(), skip_custom=False, **kwargs): if update_fields and 'form_data' not in update_fields: # We don't want to use this approach if the user is sending data @@ -745,7 +725,7 @@ class ApplicationSubmission( return self.render_answer(name) def _get_REQUIRED_value(self, name): - return self.form_data[name] + return self.data(name) @receiver(post_transition, sender=ApplicationSubmission) @@ -783,7 +763,7 @@ def log_status_update(sender, **kwargs): ) -class ApplicationRevision(AccessFormData, models.Model): +class ApplicationRevision(BaseStreamForm, AccessFormData, models.Model): submission = models.ForeignKey(ApplicationSubmission, related_name='revisions', on_delete=models.CASCADE) form_data = JSONField(encoder=StreamFieldDataEncoder) timestamp = models.DateTimeField(auto_now=True) diff --git a/opentech/apply/funds/paginators.py b/opentech/apply/funds/paginators.py new file mode 100644 index 0000000000000000000000000000000000000000..bc8fd3e05c0510ca3e71026700c9df8eb557040c --- /dev/null +++ b/opentech/apply/funds/paginators.py @@ -0,0 +1,113 @@ +from django.core.paginator import EmptyPage, Page, PageNotAnInteger, Paginator +from django.utils.translation import gettext as _ + +# https://django-tables2.readthedocs.io/en/latest/pages/api-reference.html#django_tables2.paginators.LazyPaginator + +# REMOVE IN django_tables2 2.0 + + +class LazyPaginator(Paginator): + """ + Implement lazy pagination, preventing any count() queries. + + By default, for any valid page, the total number of pages for the paginator will be + + - `current + 1` if the number of records fetched for the current page offset is + bigger than the number of records per page. + - `current` if the number of records fetched is less than the number of records per page. + + The number of additional records fetched can be adjusted using `look_ahead`, which + defaults to 1 page. If you like to provide a little more extra information on how much + pages follow the current page, you can use a higher value. + + .. note:: + + The number of records fetched for each page is `per_page * look_ahead + 1`, so increasing + the value for `look_ahead` makes the view a bit more expensive. + + So:: + + paginator = LazyPaginator(range(10000), 10) + + >>> paginator.page(1).object_list + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + >>> paginator.num_pages + 2 + >>> paginator.page(10).object_list + [91, 92, 93, 94, 95, 96, 97, 98, 99, 100] + >>> paginator.num_pages + 11 + >>> paginator.page(1000).object_list + [9991, 9992, 9993, 9994, 9995, 9996, 9997, 9998, 9999] + >>> paginator.num_pages + 1000 + + Usage with `~.SingleTableView`:: + + class UserListView(SingleTableView): + table_class = UserTable + table_data = User.objects.all() + pagination_class = LazyPaginator + + Or with `~.RequestConfig`:: + + RequestConfig(paginate={"paginator_class": LazyPaginator}).configure(table) + + .. versionadded :: 2.0.0 + """ + + look_ahead = 1 + + def __init__(self, object_list, per_page, look_ahead=None, **kwargs): + self._num_pages = None + if look_ahead is not None: + self.look_ahead = look_ahead + + super().__init__(object_list, per_page, **kwargs) + + def validate_number(self, number): + """Validate the given 1-based page number.""" + try: + if isinstance(number, float) and not number.is_integer(): + raise ValueError + number = int(number) + except (TypeError, ValueError): + raise PageNotAnInteger(_("That page number is not an integer")) + if number < 1: + raise EmptyPage(_("That page number is less than 1")) + return number + + def page(self, number): + number = self.validate_number(number) + bottom = (number - 1) * self.per_page + top = bottom + self.per_page + # Retrieve more objects to check if there is a next page. + look_ahead_items = (self.look_ahead - 1) * self.per_page + 1 + objects = list(self.object_list[bottom: top + self.orphans + look_ahead_items]) + objects_count = len(objects) + if objects_count > (self.per_page + self.orphans): + # If another page is found, increase the total number of pages. + self._num_pages = number + (objects_count // self.per_page) + # In any case, return only objects for this page. + objects = objects[: self.per_page] + elif (number != 1) and (objects_count <= self.orphans): + raise EmptyPage(_("That page contains no results")) + else: + # This is the last page. + self._num_pages = number + return Page(objects, number, self) + + def _get_count(self): + return 0 + + count = property(_get_count) + + def _get_num_pages(self): + return self._num_pages + + num_pages = property(_get_num_pages) + + def _get_page_range(self): + raise NotImplementedError + + page_range = property(_get_page_range) diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py index f5d5a4c1033fe9ae4ef8e7cf10ccb0cac3efcae1..90821692c36308900cd77695b5ccfbe873dc05b0 100644 --- a/opentech/apply/funds/tables.py +++ b/opentech/apply/funds/tables.py @@ -55,7 +55,7 @@ def render_title(record): class SubmissionsTable(tables.Table): """Base table for listing submissions, do not include admin data to this table""" - title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-tooltip': lambda record: record.title, 'class': 'js-title'}}) + title = tables.LinkColumn('funds:submissions:detail', text=render_title, args=[A('pk')], orderable=True, attrs={'td': {'data-title-tooltip': lambda record: record.title, 'class': 'js-title'}}) submit_time = tables.DateColumn(verbose_name="Submitted") phase = tables.Column(verbose_name="Status", order_by=('status',), attrs={'td': {'data-actions': render_actions, 'class': 'js-actions'}}) stage = tables.Column(verbose_name="Type", order_by=('status',)) diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html index 4bea7c49f804685720f6b8de0ccd94ec03193d06..e8e3b36fb373cc59d855baa6e7e02a49f9199ac5 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -70,5 +70,4 @@ <script src="{% static 'js/apply/toggle-related.js' %}"></script> <script src="{% static 'js/apply/edit-comment.js' %}"></script> <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script> - <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script> {% endblock %} diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..f0999f261d0a79375803958472e8751276b24a0f --- /dev/null +++ b/opentech/apply/funds/templates/funds/applicationsubmission_simplified_detail.html @@ -0,0 +1,36 @@ +{% extends "base-apply.html" %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block body_class %}{% endblock %} + +{% block content %} +<div class="simplified"> + <div class="simplified__admin-bar"> + <div class="simplified__admin-bar-inner"> + <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h1 class="simplified__heading">{{ object.title }}</h1> + <h5 class="simplified__subheading"> + <span>{{ object.stage }}</span> + <span>{{ object.page }}</span> + <span>{{ object.round }}</span> + <span>Lead: {{ object.lead }}</span> + </h5> + </div> + </div> + + <div class="simplified__wrapper"> + <header class="simplified__meta"> + <span class="simplified__meta-item">Submitted: <strong>{{ object.submit_time.date }} by {{ object.user.get_full_name }}</strong></span> + <span class="simplified__meta-item">Last edited: <strong>{{ object.live_revision.timestamp.date }} by {{ object.live_revision.author }}</strong></span> + </header> + + <h3>Proposal Information</h3> + <div class="simplified__rich-text"> + {{ object.output_text_answers }} + </div> + </div> +</div> +{% endblock %} diff --git a/opentech/apply/funds/templates/funds/includes/actions.html b/opentech/apply/funds/templates/funds/includes/actions.html index cfd7665631b18b7448da129f75ce1fb059be85ec..0dcfc3d8ab1b46e8b71842c04a74545f8aa037e5 100644 --- a/opentech/apply/funds/templates/funds/includes/actions.html +++ b/opentech/apply/funds/templates/funds/includes/actions.html @@ -2,12 +2,14 @@ {% if PROJECTS_ENABLED %} -<a data-fancybox - data-src="#create-project" - class="button button--bottom-space button--primary button--full-width {% if object.accepted_for_funding and not object.project %}is-not-disabled{% else %}is-disabled{% endif %}" - href="#"> - Create Project -</a> +{% if object.accepted_for_funding and not object.project %} + <a data-fancybox + data-src="#create-project" + class="button button--bottom-space button--primary button--full-width" + href="#"> + Create Project + </a> +{% endif %} {% endif %} <a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a> diff --git a/opentech/apply/funds/templates/funds/includes/create_project_form.html b/opentech/apply/funds/templates/funds/includes/create_project_form.html index f7b10c87b7925af311aea81c128017fce5b1dddd..5a0b52dc90eb678107bc5996a48bd1a7e83887d8 100644 --- a/opentech/apply/funds/templates/funds/includes/create_project_form.html +++ b/opentech/apply/funds/templates/funds/includes/create_project_form.html @@ -1,4 +1,6 @@ <div class="modal" id="create-project"> <h4 class="modal__header-bar">Create Project</h4> - {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Create'%} + <p>This will create a new project and notify the Applicant.</p> + <p>This cannot be undone.</p> + {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Confirm'%} </div> diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html index 4ed3da58223d879cda16744876e5288eb8cad3bf..18292b3d89d3c371b58917a9063f0baf8a63eae0 100644 --- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html +++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html @@ -1,4 +1,3 @@ -{% load util_tags %} <form class="form {{ extra_classes }}" method="post" @@ -10,7 +9,10 @@ {{ form.media }} - {% for field in form %} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% for field in form.visible_fields %} {% if field.field %} {% include "forms/includes/field.html" %} {% else %} diff --git a/opentech/apply/funds/templates/funds/includes/funding_block.html b/opentech/apply/funds/templates/funds/includes/funding_block.html deleted file mode 100644 index c507b379fe44078dfa14260ebca6ef8fcbeae13c..0000000000000000000000000000000000000000 --- a/opentech/apply/funds/templates/funds/includes/funding_block.html +++ /dev/null @@ -1,16 +0,0 @@ -<ul class="funding-block"> - <li class="funding-block__item"> - <p class="funding-block__title">Fund total</p> - <p class="funding-block__standout">$50,000</p> - </li> - <li class="funding-block__item"> - <p class="funding-block__title">Total paid</p> - <p class="funding-block__standout">$2,000</p> - <p class="funding-block__meta">(4%)</p> - </li> - <li class="funding-block__item"> - <p class="funding-block__title">Awaiting payment</p> - <p class="funding-block__standout">$10,000</p> - <p class="funding-block__meta">(20%)</p> - </li> -</ul> diff --git a/opentech/apply/funds/templates/funds/includes/payment_requests.html b/opentech/apply/funds/templates/funds/includes/payment_requests.html deleted file mode 100644 index d344d17f9539db00ce09052968fdeba6e3239905..0000000000000000000000000000000000000000 --- a/opentech/apply/funds/templates/funds/includes/payment_requests.html +++ /dev/null @@ -1,79 +0,0 @@ -<div class="payment-block"> - <div class="payment-block__header"> - <p class="payment-block__title">Payment Requests</p> - <button class="payment-block__button button button--primary">Add Request</button> - </div> - - <table class="payment-block__table"> - <thead> - <tr> - <th class="payment-block__table-amount">Amount</th> - <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-docs">Documents</th> - <th class="payment-block__table-update">Status</th> - </tr> - </thead> - <tbody> - <tr> - <td><span class="payment-block__mobile-label">Amount: </span>$10,000</td> - <td><span class="payment-block__mobile-label">Status: </span>Approved 1111-11-11</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> - <td> - <span class="payment-block__mobile-label">Status:</span> - <span class="payment-block__status">Approved</span> - <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a> - </td> - </tr> - <tr> - <td><span class="payment-block__mobile-label">Amount: </span>$1,000</td> - <td><span class="payment-block__mobile-label">Status: </span>Approved 2222-11-11</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> - <td> - <span class="payment-block__mobile-label">Status:</span> - <span class="payment-block__status">Paid</span> - <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a> - </td> - </tr> - </tbody> - </table> - <p class="payment-block__rejected"><a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a></p> - - <table class="payment-block__table is-hidden js-payment-block-rejected-table"> - <thead> - <tr> - <th class="payment-block__table-amount">Amount</th> - <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-docs">Documents</th> - <th class="payment-block__table-update">Status</th> - </tr> - </thead> - <tbody> - <tr> - <td><span class="payment-block__mobile-label">Amount: </span>$14,000</td> - <td><span class="payment-block__mobile-label">Status: </span>Rejected 1111-11-11</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> - <td> - <span class="payment-block__mobile-label">Status:</span> - <span class="payment-block__status">Rejected</span> - </td> - </tr> - <tr> - <td><span class="payment-block__mobile-label">Amount: </span>$105,000</td> - <td><span class="payment-block__mobile-label">Status: </span>Rejected 2222-11-11</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> - <td> - <span class="payment-block__mobile-label">Status:</span> - <span class="payment-block__status">Rejected</span> - </td> - </tr> - </tbody> - </table> -</div> - -<div class="modal" id="change-payment-status"> - <h4 class="modal__header-bar">Change payment status</h4> - <div class="wrapper--outer-space-medium"> - <p>Current status: <b>Approved</b></p> - {# {% include 'funds/includes/delegated_form_base.html' with form=some_form value='Update'%} #} - </div> -</div> diff --git a/opentech/apply/funds/templates/funds/includes/status-block.html b/opentech/apply/funds/templates/funds/includes/status-block.html index 782e3d4999225db60eca1efa5a64a33b6493de2e..0cd33e9d7b4bcbdce2865063c53bcc20efb44863 100644 --- a/opentech/apply/funds/templates/funds/includes/status-block.html +++ b/opentech/apply/funds/templates/funds/includes/status-block.html @@ -5,8 +5,8 @@ {% for status, data in status_counts.items %} <li class="status-block__item"> <h5 class="status-block__title">{{ data.name }}</h5> - <p class="status-block__info">{{ data.count }} Applications</p> - <a class="status-block__link" href="{% url "funds:submissions:status" status=status %}">View</a> + <p class="status-block__info">{{ data.count }} {{ type }}</p> + <a class="status-block__link" href="{{ data.url }}">View</a> </li> {% endfor %} </ul> diff --git a/opentech/apply/funds/templates/funds/includes/status_bar.html b/opentech/apply/funds/templates/funds/includes/status_bar.html index eb5235a2ec9213c99817cb3f12941e53e70e6e78..3cdde65fe9d9748399b1cf90218d5eeb3fe6c233 100644 --- a/opentech/apply/funds/templates/funds/includes/status_bar.html +++ b/opentech/apply/funds/templates/funds/includes/status_bar.html @@ -2,18 +2,15 @@ <div class="status-bar {{ class }}"> {% for phase in phases %} {% ifchanged phase.step %} - <div class="status-bar__item - {% if phase.step == current_phase.step %} - status-bar__item--is-current - {% elif current_phase.step > phase.step %} - status-bar__item--is-complete - {% endif %}"> - <span class="status-bar__tooltip" - {% status_display current_phase phase public as display_text %} - data-title="{{ display_text }}" aria-label="{{ display_text }}" - ></span> - <svg class="status-bar__icon"><use xlink:href="#tick-alt"></use></svg> - </div> + {% status_display current_phase phase public as display_text %} + {% if current_phase.step == phase.step %} + {% include "funds/includes/status_bar_item.html" with is_current=True is_complete=False label=display_text %} + {% elif current_phase.step > phase.step %} + {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=True label=display_text %} + {% else %} + {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=False label=display_text %} + {% endif %} + {% endifchanged %} {% endfor %} </div> diff --git a/opentech/apply/funds/templates/funds/includes/status_bar_item.html b/opentech/apply/funds/templates/funds/includes/status_bar_item.html new file mode 100644 index 0000000000000000000000000000000000000000..051d097a3153f945cb40bf3adba0e94c7f6ca71c --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/status_bar_item.html @@ -0,0 +1,12 @@ +<div class="status-bar__item +{% if is_current %} + status-bar__item--is-current +{% elif is_complete %} + status-bar__item--is-complete +{% endif %}"> + <span class="status-bar__tooltip" + data-title="{{ label }}" aria-label="{{ label }}"></span> + <svg class="status-bar__icon"> + <use xlink:href="#tick-alt"></use> + </svg> +</div> diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html index 6f681f328a0e65087a31d6a1f0d73d25e1635907..de0201ac2a9efe9ea276af1d57886261656c0462 100644 --- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -1,4 +1,4 @@ -<div class="wrapper wrapper--table-actions"> +<div class="wrapper wrapper--table-actions js-table-actions"> <div class="actions-bar"> {# Left #} <div class="actions-bar__inner actions-bar__inner--left"> @@ -40,11 +40,11 @@ <button class="button button--filters js-toggle-filters">Filters</button> {% if use_search|default:False %} - <form method="get" role="search" class="form form--search-desktop js-search-form"> + <form action="{{ search_action }}" method="get" role="search" class="form form--search-desktop"> <button class="button button--search" type="submit" aria-label="Search"> <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg> </button> - <input class="input input--search input--secondary js-search-input" type="text" placeholder="Search submissions" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input"> + <input class="input input--search input--secondary" type="text" placeholder="Search {{ search_placeholder|default:"submissions" }}" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input"> </form> {% endif %} </div> diff --git a/opentech/apply/funds/templates/funds/submissions_overview.html b/opentech/apply/funds/templates/funds/submissions_overview.html index 1c17a481f86da17d3eef4afe6e85ecf2c2192022..e712be96f56205853bec7ca2e7a957e0ba7af060 100644 --- a/opentech/apply/funds/templates/funds/submissions_overview.html +++ b/opentech/apply/funds/templates/funds/submissions_overview.html @@ -17,7 +17,7 @@ <div class="wrapper wrapper--large wrapper--inner-space-medium"> - {% include "funds/includes/status-block.html" %} + {% include "funds/includes/status-block.html" with type="Applications" %} {% if closed_rounds or open_rounds %} {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title%} diff --git a/opentech/apply/funds/templates/funds/tables/table.html b/opentech/apply/funds/templates/funds/tables/table.html index db8cf106f06157ff258857e4a0c7ff67861cdb2b..e7048089c54b94069c7531f3d4b46c35972dde83 100644 --- a/opentech/apply/funds/templates/funds/tables/table.html +++ b/opentech/apply/funds/templates/funds/tables/table.html @@ -1,5 +1,5 @@ {% extends 'django_tables2/table.html' %} -{% load django_tables2 table_tags review_tags wagtailimages_tags %} +{% load django_tables2 table_tags review_tags wagtailimages_tags i18n %} {% block table.tbody.row %} <tr {{ row.attrs.as_html }}> @@ -118,3 +118,31 @@ {% block table.tbody.empty_text %} <tr class="all-submissions-table__empty"><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr> {% endblock table.tbody.empty_text %} + +{% block pagination %} + {% if table.page and table.paginator.num_pages > 1 %} + <ul class="pagination"> + {% if table.page.has_previous %} + <li class="previous"> + <a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"> + {% trans 'previous' %} + </a> + </li> + {% endif %} + {% if table.page.has_previous or table.page.has_next %} + <li class="cardinality"> + <p> + Page {{ table.page.number }} + </p> + </li> + {% endif %} + {% if table.page.has_next %} + <li class="next"> + <a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"> + {% trans 'next' %} + </a> + </li> + {% endif %} + </ul> + {% endif %} +{% endblock pagination %} diff --git a/opentech/apply/funds/tests/factories/blocks.py b/opentech/apply/funds/tests/factories/blocks.py index 7d05c2fdc3e63f85e7e8c59187d7fc444038169a..643e5beb4012485b14ec5cb05059209103cbd2c9 100644 --- a/opentech/apply/funds/tests/factories/blocks.py +++ b/opentech/apply/funds/tests/factories/blocks.py @@ -4,13 +4,8 @@ import factory from opentech.apply.funds import blocks from opentech.apply.stream_forms.testing.factories import ( - CharFieldBlockFactory, - FileFieldBlockFactory, + BLOCK_FACTORY_DEFINITION, FormFieldBlockFactory, - ImageFieldBlockFactory, - MultiFileFieldBlockFactory, - NumberFieldBlockFactory, - RadioFieldBlockFactory, ParagraphBlockFactory, StreamFieldUUIDFactory, ) @@ -64,7 +59,9 @@ class AddressFieldBlockFactory(FormFieldBlockFactory): model = blocks.AddressFieldBlock @classmethod - def make_answer(cls, params=dict()): + def make_answer(cls, params): + if not params: + params = {} return json.dumps({ 'country': 'GB', 'thoroughfare': factory.Faker('street_name').generate(params), @@ -76,31 +73,38 @@ class AddressFieldBlockFactory(FormFieldBlockFactory): @classmethod def make_form_answer(cls, params=dict()): - return { - 'country': 'GB', - 'thoroughfare': factory.Faker('street_name').generate(params), - 'premise': factory.Faker('building_number').generate(params), - 'locality': { - 'localityname': factory.Faker('city').generate(params), - 'administrativearea': factory.Faker('city').generate(params), - 'postal_code': 'SW1 4AQ', + try: + address = json.loads(params) + except TypeError: + if not params: + params = {} + return { + 'country': 'GB', + 'thoroughfare': factory.Faker('street_name').generate(params), + 'premise': factory.Faker('building_number').generate(params), + 'locality': { + 'localityname': factory.Faker('city').generate(params), + 'administrativearea': factory.Faker('city').generate(params), + 'postal_code': 'SW1 4AQ', + } } + + address['locality'] = { + 'localityname': address.pop('localityname'), + 'administrativearea': address.pop('administrativearea'), + 'postalcode': address.pop('postalcode'), } + return address CustomFormFieldsFactory = StreamFieldUUIDFactory({ + **BLOCK_FACTORY_DEFINITION, 'duration': DurationBlockFactory, 'title': TitleBlockFactory, 'value': ValueFieldBlockFactory, 'email': EmailBlockFactory, 'address': AddressFieldBlockFactory, 'full_name': FullNameBlockFactory, - 'char': CharFieldBlockFactory, - 'number': NumberFieldBlockFactory, - 'radios': RadioFieldBlockFactory, - 'rich_text': RichTextFieldBlockFactory, - 'image': ImageFieldBlockFactory, - 'file': FileFieldBlockFactory, - 'multi_file': MultiFileFieldBlockFactory, 'text_markup': ParagraphBlockFactory, + 'rich_text': RichTextFieldBlockFactory, }) diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py index 6b348db8f04cd1b3c1b190a4e2966c25ea3eb794..20fd24800615cf1fa837906d852bf1b63e402823 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -75,6 +75,7 @@ class AbstractApplicationFactory(wagtail_factories.PageFactory): # Will need to update how the stages are identified as Fund Page changes workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages)) + approval_form = factory.SubFactory('opentech.apply.projects.tests.factories.ProjectApprovalFormFactory') @factory.post_generation def parent(self, create, extracted_parent, **parent_kwargs): @@ -237,7 +238,7 @@ class ApplicationSubmissionFactory(factory.DjangoModelFactory): ApplicationFormDataFactory, form_fields=factory.SelfAttribute('..form_fields'), ) - page = factory.SubFactory(FundTypeFactory) + page = factory.SelfAttribute('.round.fund') workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages)) round = factory.SubFactory( RoundFactory, diff --git a/opentech/apply/funds/tests/test_admin_form.py b/opentech/apply/funds/tests/test_admin_form.py index 244ece50b13c2f5b9269b9f22a79e18ffa26856e..d256c41ed86a18fbb39c651919238acc7604f028 100644 --- a/opentech/apply/funds/tests/test_admin_form.py +++ b/opentech/apply/funds/tests/test_admin_form.py @@ -49,6 +49,7 @@ def form_data(num_appl_forms=0, num_review_forms=0, delete=0, stages=1, same_for fund_data['workflow_name'] = workflow_for_stages(stages) form_data.update(fund_data) + form_data.update(approval_form='') return form_data diff --git a/opentech/apply/funds/tests/test_admin_views.py b/opentech/apply/funds/tests/test_admin_views.py index 185a0e47bbbc7f50c850a5911535f0127b7fb520..bb983563f5689d0604080aa9ac82b70d1afddfd7 100644 --- a/opentech/apply/funds/tests/test_admin_views.py +++ b/opentech/apply/funds/tests/test_admin_views.py @@ -22,11 +22,21 @@ class TestFundCreationView(TestCase): url = reverse('wagtailadmin_pages:add', args=('funds', 'fundtype', self.home.id)) data = form_data( - appl_forms, review_forms, same_forms=same_forms, stages=stages, form_stage_info=form_stage_info) + appl_forms, + review_forms, + same_forms=same_forms, + stages=stages, + form_stage_info=form_stage_info, + ) data['action-publish'] = True response = self.client.post(url, data=data, secure=True, follow=True) - self.assertContains(response, 'success') + try: + # If the form is present there was an error - report it + form = response.context['form'] + self.assertTrue(form.is_valid(), form.errors.as_text()) + except KeyError: + self.assertContains(response, 'success') self.home.refresh_from_db() fund = self.home.get_children().first() diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py index a09447647fddc3965fcf11aa067f931a308a69f7..3e7f9e7ba7b4c3314cd49464448aeba4e7f8783a 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -489,10 +489,19 @@ class TestSubmissionRenderMethods(TestCase): def test_named_blocks_dont_break_if_no_response(self): submission = ApplicationSubmissionFactory() + # the user didn't respond del submission.form_data['value'] + + # value doesnt sneak into raw_data self.assertTrue('value' not in submission.raw_data) - self.assertTrue('duration' in submission.raw_data) + + # value field_id gone + field_id = submission.get_definitive_id('value') + self.assertTrue(field_id not in submission.raw_data) + + # value attr is None + self.assertIsNone(submission.value) def test_file_private_url_included(self): submission = ApplicationSubmissionFactory() diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py index b5a7b0235a4270049372f36b0ff003a134e214a1..383b6db67063c9743822070713ef9fc2c6f0403d 100644 --- a/opentech/apply/funds/tests/test_views.py +++ b/opentech/apply/funds/tests/test_views.py @@ -1,13 +1,16 @@ from datetime import timedelta -import json from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from django.utils.text import slugify from opentech.apply.activity.models import Activity, TEAM from opentech.apply.projects.models import Project +from opentech.apply.projects.tests.factories import ProjectFactory from opentech.apply.determinations.tests.factories import DeterminationFactory from opentech.apply.funds.tests.factories import ( ApplicationSubmissionFactory, @@ -22,7 +25,6 @@ from opentech.apply.funds.tests.factories import ( SealedSubmissionFactory, ) from opentech.apply.review.tests.factories import ReviewFactory -from opentech.apply.stream_forms.testing.factories import flatten_for_form from opentech.apply.users.tests.factories import ( ReviewerFactory, StaffFactory, @@ -33,6 +35,8 @@ from opentech.apply.utils.testing import make_request from opentech.apply.utils.testing.tests import BaseViewTestCase from ..models import ApplicationRevision, ApplicationSubmission +from ..views import SubmissionDetailSimplifiedView +from .factories import CustomFormFieldsFactory def prepare_form_data(submission, **kwargs): @@ -43,22 +47,7 @@ def prepare_form_data(submission, **kwargs): field_id = submission.field(field).id data[field_id] = value - address_field = submission.named_blocks['address'] - address = data.pop(address_field) - data.update(**prepare_address(address, address_field)) - - return data - - -def prepare_address(address, field): - address = json.loads(address) - address['locality'] = { - 'localityname': address.pop('localityname'), - 'administrativearea': address.pop('administrativearea'), - 'postalcode': address.pop('postalcode'), - } - address = flatten_for_form(address, field, number=True) - return address + return CustomFormFieldsFactory.form_response(submission.form_fields, data) class BaseSubmissionViewTestCase(BaseViewTestCase): @@ -453,6 +442,7 @@ class TestRevisionsView(BaseSubmissionViewTestCase): submission = self.refresh(submission) + self.maxDiff = None self.assertEqual(submission.status, 'proposal_discussion') self.assertEqual(submission.revisions.count(), 2) self.assertDictEqual(submission.revisions.last().form_data, old_data) @@ -470,6 +460,7 @@ class TestRevisionsView(BaseSubmissionViewTestCase): submission = self.refresh(submission) + self.maxDiff = None self.assertEqual(submission.status, 'draft_proposal') self.assertEqual(submission.revisions.count(), 2) self.assertDictEqual(submission.draft_revision.form_data, submission.from_draft().form_data) @@ -491,9 +482,9 @@ class TestRevisionsView(BaseSubmissionViewTestCase): submission = self.refresh(submission) self.maxDiff = None - self.assertEqual(submission.revisions.count(), 2) self.assertDictEqual(submission.draft_revision.form_data, submission.from_draft().form_data) self.assertDictEqual(submission.live_revision.form_data, submission.form_data) + self.assertEqual(submission.revisions.count(), 2) self.assertEqual(submission.title, newer_title) @@ -662,6 +653,37 @@ class TestSuperUserSubmissionView(BaseSubmissionViewTestCase): self.assertEqual(activity.visibility, TEAM) +class TestSubmissionDetailSimplifiedView(TestCase): + def test_staff_only(self): + factory = RequestFactory() + submission = ApplicationSubmissionFactory() + ProjectFactory(submission=submission) + + request = factory.get(f'/submission/{submission.pk}') + request.user = StaffFactory() + + response = SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk) + self.assertEqual(response.status_code, 200) + + request.user = ApplicantFactory() + with self.assertRaises(PermissionDenied): + SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk) + + def test_project_required(self): + factory = RequestFactory() + submission = ApplicationSubmissionFactory() + + request = factory.get(f'/submission/{submission.pk}') + request.user = StaffFactory() + + with self.assertRaises(Http404): + SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk) + + ProjectFactory(submission=submission) + response = SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk) + self.assertEqual(response.status_code, 200) + + class BaseSubmissionFileViewTestCase(BaseViewTestCase): url_name = 'funds:submissions:{}' base_view_name = 'serve_private_media' diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 4f035de13e61112260e7853140ee7eeaf848a5f3..706fddede0349dcf01515894b85a659f4e23e46e 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -15,6 +15,7 @@ from .views import ( SubmissionSealedView, SubmissionDeleteView, SubmissionPrivateMediaView, + SubmissionDetailSimplifiedView ) from .api_views import ( CommentEdit, @@ -43,6 +44,7 @@ submission_urls = ([ path('', SubmissionDetailView.as_view(), name="detail"), path('edit/', SubmissionEditView.as_view(), name="edit"), path('sealed/', SubmissionSealedView.as_view(), name="sealed"), + path('simplified/', SubmissionDetailSimplifiedView.as_view(), name="simplified"), path('delete/', SubmissionDeleteView.as_view(), name="delete"), path( 'documents/<uuid:field_id>/<str:file_name>', diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index d118e93365c94c00f457d729b32e1f0f03fe003f..af01e3a39e0e0ad6b51f1d5d86778c65865d9e6c 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -53,6 +53,7 @@ from .models import ( RoundBase, LabBase ) +from .paginators import LazyPaginator from .permissions import is_user_has_access_to_view_submission from .tables import ( AdminSubmissionsTable, @@ -70,6 +71,7 @@ class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): table_class = AdminSubmissionsTable filterset_class = SubmissionFilterAndSearch filter_action = '' + table_pagination = {'klass': LazyPaginator} excluded_fields = [] @@ -256,6 +258,7 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable) status: { 'name': data['name'], 'count': sum(status_counts.get(status, 0) for status in data['statuses']), + 'url': reverse_lazy("funds:submissions:status", kwargs={'status': status}) } for status, data in PHASES_MAPPING.items() } @@ -931,3 +934,17 @@ class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView): def test_func(self): return is_user_has_access_to_view_submission(self.request.user, self.submission) + + +@method_decorator(staff_required, name='dispatch') +class SubmissionDetailSimplifiedView(DetailView): + model = ApplicationSubmission + template_name_suffix = '_simplified_detail' + + def get_object(self, queryset=None): + obj = super().get_object(queryset) + + if not hasattr(obj, 'project'): + raise Http404 + + return obj diff --git a/opentech/apply/projects/admin.py b/opentech/apply/projects/admin.py index 711563188a21bdd91c2e29ea110956626a556f9e..eac072de393a4d4ba1efd818674951985d011bb1 100644 --- a/opentech/apply/projects/admin.py +++ b/opentech/apply/projects/admin.py @@ -1,6 +1,7 @@ +from django.utils.html import mark_safe from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup -from .models import DocumentCategory +from .models import DocumentCategory, ProjectApprovalForm class DocumentCategoryAdmin(ModelAdmin): @@ -9,9 +10,24 @@ class DocumentCategoryAdmin(ModelAdmin): list_display = ('name', 'recommended_minimum',) +class ProjectApprovalFormAdmin(ModelAdmin): + model = ProjectApprovalForm + menu_icon = 'form' + list_display = ('name', 'used_by',) + + def used_by(self, obj): + rows = list() + for field in ('funds', 'labs',): + related = ', '.join(getattr(obj, f'{field}').values_list('title', flat=True)) + if related: + rows.append(related) + return mark_safe('<br>'.join(rows)) + + class ManageAdminGoup(ModelAdminGroup): menu_label = 'Manage' menu_icon = 'folder-open-inverse' items = ( DocumentCategoryAdmin, + ProjectApprovalFormAdmin, ) diff --git a/opentech/apply/projects/files.py b/opentech/apply/projects/files.py new file mode 100644 index 0000000000000000000000000000000000000000..2195d989e212cfc57b3eb0d79a03a7966558a443 --- /dev/null +++ b/opentech/apply/projects/files.py @@ -0,0 +1,28 @@ +def flatten(iterable): + """Flatten the given iterable into an iterable of non-list items""" + for item in iterable: + if isinstance(item, list): + yield from flatten(item) + else: + yield item + + +def get_files(project): + """ + Get files from the given Project's Submission. + + A Submission can have fields which contain multiple files, these are + returned from Submission.data as a list meaning the given fields can be in + the form of: + + [obj1, [obj2, obj3]] + + This function will flatten this providing a single level iterable: + + [obj1, obj2, obj3] + + """ + file_field_names = project.submission.file_field_ids + file_fields = (project.submission.data(field) for field in file_field_names) + + return list(flatten(file_fields)) diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..260cc0ec0cc8909f4af22a1f6db53b8ad4556cb2 --- /dev/null +++ b/opentech/apply/projects/filters.py @@ -0,0 +1,43 @@ +import django_filters as filters +from django import forms +from django.contrib.auth import get_user_model + +from opentech.apply.funds.tables import ( + Select2ModelMultipleChoiceFilter, + Select2MultipleChoiceFilter, + get_used_funds +) + +from .models import ( + PROJECT_STATUS_CHOICES, + REQUEST_STATUS_CHOICES, + PaymentRequest, + Project +) + +User = get_user_model() + + +def get_project_leads(request): + return User.objects.filter(lead_projects__isnull=False).distinct() + + +class PaymentRequestListFilter(filters.FilterSet): + fund = Select2ModelMultipleChoiceFilter(label='Funds', queryset=get_used_funds, field_name='project__submission__page') + status = Select2MultipleChoiceFilter(label='Status', choices=REQUEST_STATUS_CHOICES) + lead = Select2ModelMultipleChoiceFilter(label='Lead', queryset=get_project_leads, field_name='project__lead') + + class Meta: + fields = ['lead', 'fund', 'status'] + model = PaymentRequest + + +class ProjectListFilter(filters.FilterSet): + fund = Select2ModelMultipleChoiceFilter(label='Funds', queryset=get_used_funds) + lead = Select2ModelMultipleChoiceFilter(label='Lead', queryset=get_project_leads) + status = Select2MultipleChoiceFilter(label='Status', choices=PROJECT_STATUS_CHOICES) + query = filters.CharFilter(field_name='title', lookup_expr="icontains", widget=forms.HiddenInput) + + class Meta: + fields = ['status', 'lead', 'fund'] + model = Project diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index 13d19826fdf490fba6a6f3b2ca7b60e92158837f..5dc566f88d8a51ea809e0e9e25289dc10699f2b1 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -1,11 +1,102 @@ +import functools + from django import forms +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.db import transaction from django.db.models import Q from addressfield.fields import AddressField from opentech.apply.funds.models import ApplicationSubmission +from opentech.apply.stream_forms.fields import MultiFileField from opentech.apply.users.groups import STAFF_GROUP_NAME -from .models import COMMITTED, Approval, PacketFile, Project +from .models import ( + CHANGES_REQUESTED, + COMMITTED, + DECLINED, + PAID, + REQUEST_STATUS_CHOICES, + SUBMITTED, + UNDER_REVIEW, + Approval, + Contract, + PacketFile, + PaymentReceipt, + PaymentRequest, + Project +) + + +User = get_user_model() + + +def filter_choices(available, choices): + return [(k, v) for k, v in available if k in choices] + + +filter_request_choices = functools.partial(filter_choices, REQUEST_STATUS_CHOICES) + + +class ApproveContractForm(forms.Form): + id = forms.IntegerField(widget=forms.HiddenInput()) + + def __init__(self, instance, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance = instance + if instance: + self.fields['id'].initial = instance.id + + def clean_id(self): + if self.has_changed(): + raise forms.ValidationError('Something changed before your approval please re-review') + + def clean(self): + if not self.instance: + raise forms.ValidationError('The contract you were trying to approve has already been approved') + + if not self.instance.is_signed: + raise forms.ValidationError('You can only approve a signed contract') + + super().clean() + + def save(self, *args, **kwargs): + self.instance.save() + return self.instance + + +class ChangePaymentRequestStatusForm(forms.ModelForm): + name_prefix = 'change_payment_request_status_form' + + class Meta: + fields = ['status', 'comment', 'paid_value'] + model = PaymentRequest + + def __init__(self, instance, *args, **kwargs): + super().__init__(instance=instance, *args, **kwargs) + + self.initial['paid_value'] = self.instance.requested_value + + status_field = self.fields['status'] + + possible_status_transitions_lut = { + CHANGES_REQUESTED: filter_request_choices([DECLINED]), + SUBMITTED: filter_request_choices([CHANGES_REQUESTED, UNDER_REVIEW, DECLINED]), + UNDER_REVIEW: filter_request_choices([PAID]), + } + status_field.choices = possible_status_transitions_lut.get(instance.status, []) + + if instance.status != UNDER_REVIEW: + del self.fields['paid_value'] + + def clean(self): + cleaned_data = super().clean() + status = cleaned_data['status'] + paid_value = cleaned_data.get('paid_value') + + if paid_value and status != PAID: + self.add_error('paid_value', 'You can only set a value when moving to the Paid status.') + return cleaned_data class CreateProjectForm(forms.Form): @@ -26,15 +117,24 @@ class CreateProjectForm(forms.Form): class CreateApprovalForm(forms.ModelForm): + by = forms.ModelChoiceField( + queryset=User.objects.approvers(), + widget=forms.HiddenInput(), + ) + class Meta: model = Approval - fields = ['by'] - widgets = {'by': forms.HiddenInput()} + fields = ('by',) def __init__(self, user=None, *args, **kwargs): - initial = kwargs.pop('initial', {}) - initial.update(by=user) - super().__init__(*args, initial=initial, **kwargs) + self.user = user + super().__init__(*args, **kwargs) + + def clean_by(self): + by = self.cleaned_data['by'] + if by != self.user: + raise forms.ValidationError('Cannot approve for a different user') + return by class ProjectEditForm(forms.ModelForm): @@ -63,7 +163,20 @@ class ProjectEditForm(forms.ModelForm): class ProjectApprovalForm(ProjectEditForm): + def __init__(self, *args, extra_fields=None, **kwargs): + super().__init__(*args, **kwargs) + if extra_fields: + self.fields = { + **self.fields, + **extra_fields, + } + def save(self, *args, **kwargs): + self.instance.form_data = { + field: self.cleaned_data[field] + for field in self.instance.question_field_ids + if field in self.cleaned_data + } self.instance.user_has_updated_details = True return super().save(*args, **kwargs) @@ -86,6 +199,113 @@ class RemoveDocumentForm(forms.ModelForm): super().__init__(*args, **kwargs) +class PaymentRequestBaseForm(forms.ModelForm): + class Meta: + fields = ['requested_value', 'invoice', 'date_from', 'date_to'] + model = PaymentRequest + widgets = { + 'date_from': forms.DateInput, + 'date_to': forms.DateInput, + } + labels = { + 'requested_value': 'Requested Value ($)' + } + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['requested_value'].widget.attrs['min'] = 0 + + def clean(self): + cleaned_data = super().clean() + date_from = cleaned_data['date_from'] + date_to = cleaned_data['date_to'] + + if date_from > date_to: + self.add_error('date_from', 'Date From must be before Date To') + + return cleaned_data + + +class CreatePaymentRequestForm(PaymentRequestBaseForm): + receipts = MultiFileField() + + def save(self, commit=True): + request = super().save(commit=commit) + + PaymentReceipt.objects.bulk_create( + PaymentReceipt(payment_request=request, file=receipt) + for receipt in self.cleaned_data['receipts'] + ) + + return request + + +class EditPaymentRequestForm(PaymentRequestBaseForm): + receipt_list = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}), + queryset=PaymentReceipt.objects.all(), + required=False, + label='Receipts' + ) + receipts = MultiFileField(label='', required=False) + + def __init__(self, user=None, instance=None, *args, **kwargs): + super().__init__(*args, instance=instance, **kwargs) + + self.fields['receipt_list'].queryset = instance.receipts.all() + + self.fields['requested_value'].label = 'Value' + + @transaction.atomic + def save(self, commit=True): + request = super().save(commit=commit) + + removed_receipts = self.cleaned_data['receipt_list'] + + removed_receipts.delete() + + to_add = self.cleaned_data['receipts'] + if to_add: + PaymentReceipt.objects.bulk_create( + PaymentReceipt(payment_request=request, file=receipt) + for receipt in to_add + ) + return request + + +class SelectDocumentForm(forms.ModelForm): + document = forms.ChoiceField( + label="Document", + widget=forms.Select(attrs={'id': 'from_submission'}) + ) + + class Meta: + model = PacketFile + fields = ['category', 'document'] + + def __init__(self, existing_files, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.files = existing_files + + choices = [(f.url, f.filename) for f in self.files] + + self.fields['document'].choices = choices + + def clean_document(self): + file_url = self.cleaned_data['document'] + for file in self.files: + if file.url == file_url: + new_file = ContentFile(file.read()) + new_file.name = file.filename + return new_file + raise forms.ValidationError("File not found on submission") + + @transaction.atomic() + def save(self, *args, **kwargs): + return super().save(*args, **kwargs) + + class SetPendingForm(forms.ModelForm): class Meta: fields = ['id'] @@ -109,11 +329,26 @@ class SetPendingForm(forms.ModelForm): return super().save(*args, **kwargs) +class UploadContractForm(forms.ModelForm): + class Meta: + fields = ['file'] + model = Contract + + +class StaffUploadContractForm(forms.ModelForm): + class Meta: + fields = ['file', 'is_signed'] + model = Contract + + class UploadDocumentForm(forms.ModelForm): class Meta: fields = ['title', 'category', 'document'] model = PacketFile widgets = {'title': forms.TextInput()} + labels = { + "title": "File Name", + } def __init__(self, user=None, instance=None, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/opentech/apply/projects/migrations/0013_add_contract.py b/opentech/apply/projects/migrations/0013_add_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..4b92228e45f0e0a9321999bd593cde89521dcb68 --- /dev/null +++ b/opentech/apply/projects/migrations/0013_add_contract.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.13 on 2019-08-12 15:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0012_adjust_storage_class'), + ] + + operations = [ + migrations.CreateModel( + name='Contract', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=opentech.apply.projects.models.contract_path)), + ('is_signed', models.BooleanField('Signed?', default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('approver', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='application_projects.Project')), + ], + ), + ] diff --git a/opentech/apply/projects/migrations/0014_add_payment_related_models.py b/opentech/apply/projects/migrations/0014_add_payment_related_models.py new file mode 100644 index 0000000000000000000000000000000000000000..022f6504499d728a204f096babd084921bbe69b3 --- /dev/null +++ b/opentech/apply/projects/migrations/0014_add_payment_related_models.py @@ -0,0 +1,59 @@ +# Generated by Django 2.0.13 on 2019-08-14 14:09 + +from decimal import Decimal +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0013_add_contract'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentApproval', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_approvals', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='PaymentReceipt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.receipt_path)), + ], + ), + migrations.CreateModel( + name='PaymentRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invoice', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.invoice_path)), + ('value', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])), + ('requested_at', models.DateTimeField(auto_now_add=True)), + ('date_from', models.DateTimeField()), + ('date_to', models.DateTimeField()), + ('comment', models.TextField()), + ('status', models.TextField(choices=[('submitted', 'Submitted'), ('under_review', 'Under Review'), ('paid', 'Paid'), ('declined', 'Declined')], default='submitted')), + ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_requests', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_requests', to='application_projects.Project')), + ], + ), + migrations.AddField( + model_name='paymentreceipt', + name='payment_request', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receipts', to='application_projects.PaymentRequest'), + ), + migrations.AddField( + model_name='paymentapproval', + name='request', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='application_projects.PaymentRequest'), + ), + ] diff --git a/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py b/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py new file mode 100644 index 0000000000000000000000000000000000000000..3d11bdabdbe040c017477e5c7663d203d7121a0b --- /dev/null +++ b/opentech/apply/projects/migrations/0015_add_payment_request_changes_requested.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-22 08:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0014_add_payment_related_models'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentrequest', + name='status', + field=models.TextField(choices=[('submitted', 'Submitted'), ('changes_requested', 'Changes Requested'), ('under_review', 'Under Review'), ('paid', 'Paid'), ('declined', 'Declined')], default='submitted'), + ), + ] diff --git a/opentech/apply/projects/migrations/0016_add_project_settings.py b/opentech/apply/projects/migrations/0016_add_project_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..fcd5b8ae0ec1429ba00c827c432a079e768053e8 --- /dev/null +++ b/opentech/apply/projects/migrations/0016_add_project_settings.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.13 on 2019-08-28 08:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0040_page_draft_title'), + ('application_projects', '0015_add_payment_request_changes_requested'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('compliance_email', models.TextField(verbose_name='Compliance Email')), + ('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Site')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py b/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py new file mode 100644 index 0000000000000000000000000000000000000000..157528c41484389ea61408fc77bbda4a7697f1ae --- /dev/null +++ b/opentech/apply/projects/migrations/0017_add_sent_to_compliance_at.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-20 13:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0016_add_project_settings'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='sent_to_compliance_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/opentech/apply/projects/migrations/0018_projectapprovalform.py b/opentech/apply/projects/migrations/0018_projectapprovalform.py new file mode 100644 index 0000000000000000000000000000000000000000..6a2f11648bb5dc70e8e5ecc33fddd4336751f7e9 --- /dev/null +++ b/opentech/apply/projects/migrations/0018_projectapprovalform.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.13 on 2019-08-11 06:01 + +from django.db import migrations, models +import opentech.apply.stream_forms.blocks +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0017_add_sent_to_compliance_at'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectApprovalForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('form_fields', wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))])), + ], + ), + ] diff --git a/opentech/apply/projects/migrations/0019_add_form_to_projects.py b/opentech/apply/projects/migrations/0019_add_form_to_projects.py new file mode 100644 index 0000000000000000000000000000000000000000..77a352e018a704a981947717906b8071398fe7a1 --- /dev/null +++ b/opentech/apply/projects/migrations/0019_add_form_to_projects.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.13 on 2019-08-12 00:55 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations +import opentech.apply.stream_forms.blocks +import opentech.apply.stream_forms.files +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0018_projectapprovalform'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='form_data', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, encoder=opentech.apply.stream_forms.files.StreamFieldDataEncoder), + ), + migrations.AddField( + model_name='project', + name='form_fields', + field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))], null=True), + ), + ] diff --git a/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py b/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py new file mode 100644 index 0000000000000000000000000000000000000000..d3006c6ad30c780785475428b3c7d64c4f1b86ec --- /dev/null +++ b/opentech/apply/projects/migrations/0020_rename_value_to_requested_value.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-26 11:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0019_add_form_to_projects'), + ] + + operations = [ + migrations.RenameField( + model_name='paymentrequest', + old_name='value', + new_name='requested_value', + ), + ] diff --git a/opentech/apply/projects/migrations/0021_add_paid_value.py b/opentech/apply/projects/migrations/0021_add_paid_value.py new file mode 100644 index 0000000000000000000000000000000000000000..a24f1cb74f29d7f8838b793bea2d9fcc02474b49 --- /dev/null +++ b/opentech/apply/projects/migrations/0021_add_paid_value.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.13 on 2019-08-26 12:04 + +from decimal import Decimal +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0020_rename_value_to_requested_value'), + ] + + operations = [ + migrations.AddField( + model_name='paymentrequest', + name='paid_value', + field=models.DecimalField(decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))]), + ), + ] diff --git a/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py b/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..be7ca59859a06812a23d3f250267d7d8d629bd69 --- /dev/null +++ b/opentech/apply/projects/migrations/0022_update_field_definitions_for_forms.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.11 on 2019-09-05 08:44 + +from django.db import migrations +import opentech.apply.stream_forms.blocks +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0021_add_paid_value'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='form_fields', + field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))], null=True), + ), + migrations.AlterField( + model_name='projectapprovalform', + name='form_fields', + field=wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text/header')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False))], group='Fields')), ('number', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('radios', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('checkboxes', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateBlock(required=False))], group='Fields')), ('time', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TimeBlock(required=False))], group='Fields')), ('datetime', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.DateTimeBlock(required=False))], group='Fields')), ('image', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('multi_file', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False))], group='Fields')), ('group_toggle', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(default=True, label='Required')), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', opentech.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))]), + ), + ] diff --git a/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..b7cbcf235b2f53d99e5a93a7ab31c7c46ef8ce44 --- /dev/null +++ b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.11 on 2019-09-05 08:48 + +import django.core.files.storage +from django.db import migrations, models +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0022_update_field_definitions_for_forms'), + ] + + operations = [ + migrations.AlterField( + model_name='contract', + name='file', + field=models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.contract_path), + ), + ] diff --git a/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py new file mode 100644 index 0000000000000000000000000000000000000000..a694b4d9607c65c74d41dce65b1d7fe60f8288bd --- /dev/null +++ b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-09-05 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0023_ensure_contracts_uses_private_storage'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentrequest', + name='comment', + field=models.TextField(blank=True), + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 4b3f1b98f6011ec827ed8e4632f7495a43134df7..7e2739bd15dbb0f3ac8783c77f3f7780c7278cab 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -2,22 +2,57 @@ import collections import decimal import json import logging +import os + -from addressfield.fields import ADDRESS_FIELDS_ORDER from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models +from django.db.models import F, Max, Sum, Value as V +from django.db.models.functions import Coalesce +from django.db.models.signals import post_delete +from django.dispatch.dispatcher import receiver from django.urls import reverse +from django.utils import timezone from django.utils.translation import ugettext as _ +from wagtail.contrib.settings.models import BaseSetting, register_setting +from wagtail.admin.edit_handlers import ( + FieldPanel, + StreamFieldPanel, +) +from wagtail.core.fields import StreamField + +from opentech.apply.funds.models.mixins import AccessFormData +from opentech.apply.stream_forms.blocks import FormFieldsBlock +from opentech.apply.stream_forms.files import StreamFieldDataEncoder +from opentech.apply.stream_forms.models import BaseStreamForm +from addressfield.fields import ADDRESS_FIELDS_ORDER +from opentech.apply.activity.messaging import MESSAGES, messenger from opentech.apply.utils.storage import PrivateStorage - logger = logging.getLogger(__name__) +def contract_path(instance, filename): + return f'projects/{instance.project_id}/contracts/{filename}' + + +def document_path(instance, filename): + return f'projects/{instance.project_id}/supporting_documents/{filename}' + + +def invoice_path(instance, filename): + return f'projects/{instance.project_id}/payment_invoices/{filename}' + + +def receipt_path(instance, filename): + return f'projects/{instance.payment_request.project_id}/payment_receipts/{filename}' + + class Approval(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals") by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals") @@ -31,8 +66,31 @@ class Approval(models.Model): return f'Approval of "{self.project.title}" by {self.by}' -def document_path(instance, filename): - return f'projects/{instance.project_id}/supporting_documents/{filename}' +class ContractQuerySet(models.QuerySet): + def approved(self): + return self.filter(is_signed=True, approver__isnull=False) + + +class Contract(models.Model): + approver = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='contracts') + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="contracts") + + file = models.FileField(upload_to=contract_path, storage=PrivateStorage()) + + is_signed = models.BooleanField("Signed?", default=False) + created_at = models.DateTimeField(auto_now_add=True) + + objects = ContractQuerySet.as_manager() + + @property + def state(self): + return 'Signed' if self.is_signed else 'Unsigned' + + def __str__(self): + return f'Contract for {self.project} ({self.state})' + + def get_absolute_url(self): + return reverse('apply:projects:contract', args=[self.project.pk, self.pk]) class PacketFile(models.Model): @@ -53,23 +111,194 @@ class PacketFile(models.Model): instance of PacketFile in the supporting documents template. The standard Delegated View flow makes it difficult to create these forms in the view or template. - """ + """ from .forms import RemoveDocumentForm return RemoveDocumentForm(instance=self) +@receiver(post_delete, sender=PacketFile) +def delete_packetfile_file(sender, instance, **kwargs): + # Remove the file and don't save the base model + instance.document.delete(False) + + +class PaymentApproval(models.Model): + request = models.ForeignKey('PaymentRequest', on_delete=models.CASCADE, related_name="approvals") + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_approvals") + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'Approval for {self.request} by {self.by}' + + +class PaymentReceipt(models.Model): + payment_request = models.ForeignKey("PaymentRequest", on_delete=models.CASCADE, related_name="receipts") + + file = models.FileField(upload_to=receipt_path, storage=PrivateStorage()) + + def __str__(self): + return os.path.basename(self.file.name) + + +SUBMITTED = 'submitted' +CHANGES_REQUESTED = 'changes_requested' +UNDER_REVIEW = 'under_review' +PAID = 'paid' +DECLINED = 'declined' +REQUEST_STATUS_CHOICES = [ + (SUBMITTED, 'Submitted'), + (CHANGES_REQUESTED, 'Changes Requested'), + (UNDER_REVIEW, 'Under Review'), + (PAID, 'Paid'), + (DECLINED, 'Declined'), +] + + +class PaymentRequestQueryset(models.QuerySet): + def in_progress(self): + return self.exclude(status__in=[DECLINED, PAID]) + + def rejected(self): + return self.filter(status=DECLINED) + + def not_rejected(self): + return self.exclude(status=DECLINED) + + def total_value(self, field): + return self.aggregate(total=Coalesce(Sum(field), V(0)))['total'] + + def paid_value(self): + return self.filter(status=PAID).total_value('paid_value') + + def unpaid_value(self): + return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value('requested_value') + + +class PaymentRequest(models.Model): + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="payment_requests") + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_requests") + + requested_value = models.DecimalField( + default=0, + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(decimal.Decimal('0.01'))], + ) + paid_value = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(decimal.Decimal('0.01'))], + null=True + ) + + invoice = models.FileField(upload_to=invoice_path, storage=PrivateStorage()) + requested_at = models.DateTimeField(auto_now_add=True) + date_from = models.DateTimeField() + date_to = models.DateTimeField() + comment = models.TextField(blank=True) + status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED) + + objects = PaymentRequestQueryset.as_manager() + + def __str__(self): + return f'Payment requested for {self.project}' + + @property + def has_changes_requested(self): + return self.status == CHANGES_REQUESTED + + @property + def status_display(self): + return self.get_status_display() + + def can_user_delete(self, user): + if user.is_applicant: + if self.status in (SUBMITTED, CHANGES_REQUESTED): + return True + + if user.is_apply_staff: + if self.status in {SUBMITTED}: + return True + + return False + + def can_user_edit(self, user): + if user.is_applicant: + if self.status in {SUBMITTED, CHANGES_REQUESTED}: + return True + + if user.is_apply_staff: + if self.status in {SUBMITTED}: + return True + + return False + + def can_user_change_status(self, user): + if not user.is_apply_staff: + return False # Users can't change status + + if self.status in {PAID, DECLINED}: + return False + + return True + + @property + def value(self): + return self.paid_value or self.requested_value + + def get_absolute_url(self): + return reverse('apply:projects:payments:detail', args=[self.pk]) + + COMMITTED = 'committed' CONTRACTING = 'contracting' +IN_PROGRESS = 'in_progress' +CLOSING = 'closing' +COMPLETE = 'complete' PROJECT_STATUS_CHOICES = [ (COMMITTED, 'Committed'), (CONTRACTING, 'Contracting'), - ('in_progress', 'In Progress'), - ('closing', 'Closing'), - ('complete', 'Complete'), + (IN_PROGRESS, 'In Progress'), + (CLOSING, 'Closing'), + (COMPLETE, 'Complete'), ] -class Project(models.Model): +class ProjectQuerySet(models.QuerySet): + def in_progress(self): + return self.exclude(status=COMPLETE) + + def complete(self): + return self.filter(status=COMPLETE) + + def in_approval(self): + return self.filter( + is_locked=True, + status=COMMITTED, + approvals__isnull=True, + ) + + def by_end_date(self, desc=False): + order = getattr(F('proposed_end'), 'desc' if desc else 'asc')(nulls_last=True) + + return self.order_by(order) + + def with_amount_paid(self): + return self.annotate( + amount_paid=Coalesce(Sum('payment_requests__paid_value'), V(0)), + ) + + def with_last_payment(self): + return self.annotate( + last_payment_request=Max('payment_requests__requested_at'), + ) + + def for_table(self): + return self.with_amount_paid().with_last_payment() + + +class Project(BaseStreamForm, AccessFormData, models.Model): lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects') submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects') @@ -91,6 +320,9 @@ class Project(models.Model): status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED) + form_data = JSONField(encoder=StreamFieldDataEncoder, default=dict) + form_fields = StreamField(FormFieldsBlock(), null=True) + # tracks read/write state of the Project is_locked = models.BooleanField(default=False) @@ -105,9 +337,17 @@ class Project(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) + sent_to_compliance_at = models.DateTimeField(null=True) + + objects = ProjectQuerySet.as_manager() + def __str__(self): return self.title + @property + def status_display(self): + return self.get_status_display() + def get_address_display(self): address = json.loads(self.contact_address) return ', '.join( @@ -144,6 +384,12 @@ class Project(models.Model): value=submission.form_data.get('value', 0), ) + def paid_value(self): + return self.payment_requests.paid_value() + + def unpaid_value(self): + return self.payment_requests.unpaid_value() + def clean(self): if self.proposed_start is None: return @@ -154,6 +400,19 @@ class Project(models.Model): if self.proposed_start > self.proposed_end: raise ValidationError(_('Proposed End Date must be after Proposed Start Date')) + def save(self, *args, **kwargs): + creating = not self.pk + + if creating: + files = self.extract_files() + else: + self.process_file_data(self.form_data) + + super().save(*args, **kwargs) + + if creating: + self.process_file_data(files) + def editable_by(self, user): if self.editable: return True @@ -163,6 +422,13 @@ class Project(models.Model): @property def editable(self): + if self.status not in (CONTRACTING, COMMITTED): + return True + + # Someone has approved the project - consider it locked while with contracting + if self.approvals.exists(): + return False + # Someone must lead the project to make changes return self.lead and not self.is_locked @@ -175,6 +441,12 @@ class Project(models.Model): def can_make_approval(self): return self.is_locked and self.status == COMMITTED + def can_request_funding(self): + """ + Should we show this Project's funding block? + """ + return self.status in (CLOSING, IN_PROGRESS) + @property def can_send_for_approval(self): """ @@ -187,6 +459,10 @@ class Project(models.Model): correct_state = self.status == COMMITTED and not self.is_locked return correct_state and self.user_has_updated_details + @property + def requires_approval(self): + return not self.approvals.exists() + def get_missing_document_categories(self): """ Get the number of documents required to meet each DocumentCategorys minimum @@ -205,6 +481,28 @@ class Project(models.Model): 'difference': difference, } + @property + def is_in_progress(self): + return self.status == IN_PROGRESS + + def send_to_compliance(self, request): + """Notify Compliance about this Project.""" + + messenger( + MESSAGES.SENT_TO_COMPLIANCE, + request=request, + user=request.user, + source=self, + ) + + self.sent_to_compliance_at = timezone.now() + self.save(update_fields=['sent_to_compliance_at']) + + +@register_setting +class ProjectSettings(BaseSetting): + compliance_email = models.TextField("Compliance Email") + class DocumentCategory(models.Model): name = models.CharField(max_length=254) @@ -216,3 +514,16 @@ class DocumentCategory(models.Model): class Meta: ordering = ('name',) verbose_name_plural = 'Document Categories' + + +class ProjectApprovalForm(BaseStreamForm, models.Model): + name = models.CharField(max_length=255) + form_fields = StreamField(FormFieldsBlock()) + + panels = [ + FieldPanel('name'), + StreamFieldPanel('form_fields'), + ] + + def __str__(self): + return self.name diff --git a/opentech/apply/projects/tables.py b/opentech/apply/projects/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..1aaa9d67b08e956983f158be9d74fb0923e47e8a --- /dev/null +++ b/opentech/apply/projects/tables.py @@ -0,0 +1,121 @@ +import textwrap + +import django_tables2 as tables +from django.db.models import F, Sum +from django.contrib.humanize.templatetags.humanize import intcomma + +from .models import PaymentRequest, Project + + +class BasePaymentRequestsTable(tables.Table): + project = tables.LinkColumn( + 'funds:projects:payments:detail', + text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."), + args=[tables.utils.A('pk')], + ) + status = tables.Column() + requested_at = tables.DateColumn(verbose_name='Submitted') + + def render_value(self, value): + return f'${value}' + + +class PaymentRequestsDashboardTable(BasePaymentRequestsTable): + date_from = tables.DateColumn(verbose_name='Period Start') + date_to = tables.DateColumn(verbose_name='Period End') + + class Meta: + fields = [ + 'project', + 'status', + 'requested_at', + 'date_from', + 'date_to', + 'value', + ] + model = PaymentRequest + orderable = False + order_by = ['-requested_at'] + + +class PaymentRequestsListTable(BasePaymentRequestsTable): + fund = tables.Column(verbose_name="Fund", accessor='project.submission.page') + lead = tables.Column(verbose_name="Lead", accessor='project.lead') + + class Meta: + fields = [ + 'project', + 'fund', + 'lead', + 'status', + 'requested_at', + 'value', + ] + model = PaymentRequest + orderable = True + order_by = ['-requested_at'] + + def order_value(self, qs, is_descending): + direction = '-' if is_descending else '' + + qs = qs.order_by(f'{direction}paid_value', f'{direction}requested_value') + + return qs, True + + +class BaseProjectsTable(tables.Table): + title = tables.LinkColumn( + 'funds:projects:detail', + text=lambda r: textwrap.shorten(r.title, width=30, placeholder="..."), + args=[tables.utils.A('pk')], + ) + status = tables.Column(verbose_name='Status', accessor='get_status_display', order_by=('status',)) + fund = tables.Column(verbose_name='Fund', accessor='submission.page') + last_payment_request = tables.DateColumn() + end_date = tables.DateColumn(verbose_name='End Date', accessor='proposed_end') + fund_allocation = tables.Column(verbose_name='Fund Allocation', accessor='value') + + def render_fund_allocation(self, record): + return f'${intcomma(record.amount_paid)} / ${intcomma(record.value)}' + + +class ProjectsDashboardTable(BaseProjectsTable): + class Meta: + fields = [ + 'title', + 'status', + 'fund', + 'last_payment_request', + 'end_date', + 'fund_allocation', + ] + model = Project + orderable = False + + +class ProjectsListTable(BaseProjectsTable): + class Meta: + fields = [ + 'title', + 'status', + 'lead', + 'fund', + 'last_payment_request', + 'end_date', + 'fund_allocation', + ] + model = Project + orderable = True + order_by = ('-end_date',) + + def order_fund_allocation(self, qs, is_descending): + direction = '-' if is_descending else '' + + qs = qs.annotate( + paid_ratio=Sum('payment_requests__paid_value') / F('value'), + ).order_by(f'{direction}paid_ratio', f'{direction}value') + + return qs, True + + def order_end_date(self, qs, desc): + return qs.by_end_date(desc), True diff --git a/opentech/apply/projects/templates/application_projects/includes/funding_block.html b/opentech/apply/projects/templates/application_projects/includes/funding_block.html new file mode 100644 index 0000000000000000000000000000000000000000..c5a1459fe73eb43e5eb1fc3abe8d452cf20c740d --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/funding_block.html @@ -0,0 +1,17 @@ +{% load humanize payment_request_tools %} +<ul class="funding-block"> + <li class="funding-block__item"> + <p class="funding-block__title">Fund total</p> + <p class="funding-block__standout">${{ object.value|intcomma }}</p> + </li> + <li class="funding-block__item"> + <p class="funding-block__title">Total paid</p> + <p class="funding-block__standout">${{ object.paid_value|intcomma }}</p> + <p class="funding-block__meta">({% percentage object.paid_value object.value %}%)</p> + </li> + <li class="funding-block__item"> + <p class="funding-block__title">Awaiting payment</p> + <p class="funding-block__standout">${{ object.unpaid_value|intcomma}}</p> + <p class="funding-block__meta">({% percentage object.unpaid_value object.value %}%)</p> + </li> +</ul> diff --git a/opentech/apply/funds/templates/funds/includes/invoice_block.html b/opentech/apply/projects/templates/application_projects/includes/invoice_block.html similarity index 100% rename from opentech/apply/funds/templates/funds/includes/invoice_block.html rename to opentech/apply/projects/templates/application_projects/includes/invoice_block.html diff --git a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html new file mode 100644 index 0000000000000000000000000000000000000000..940cd498aae764b52bcc889bcca71295a47cda28 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html @@ -0,0 +1,95 @@ +{% load payment_request_tools humanize %} + +<div id="payment-requests" class="payment-block"> + <div class="payment-block__header"> + <p class="payment-block__title">Payment Requests</p> + <a class="payment-block__button button button--primary" + href="{% url "apply:projects:request" pk=object.pk %}"> + Add Request + </a> + </div> + + <table class="payment-block__table"> + <thead> + <tr> + <th class="payment-block__table-amount">Amount</th> + <th class="payment-block__table-status">Status</th> + <th class="payment-block__table-date">From</th> + <th class="payment-block__table-date">To</th> + <th class="payment-block__table-update"></th> + </tr> + </thead> + <tbody> + {% for payment_request in object.payment_requests.not_rejected %} + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td> + <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> + <td><span class="payment-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td> + <td><span class="payment-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td> + <td> + <a href="{{ payment_request.get_absolute_url }}">View</a> + {% can_edit payment_request user as user_can_edit_request %} + {% if user_can_edit_request %} + <a href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}"> + Edit + </a> + {% endif %} + + {% can_delete payment_request user as user_can_delete_request %} + {% if user_can_delete_request %} + <a href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}"> + Delete + </a> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="5">No active Payment Requests.</td> + </tr> + {% endfor %} + </tbody> + </table> + + {% if object.payment_requests.rejected %} + <p class="payment-block__rejected"> + <a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a> + </p> + + <table class="payment-block__table is-hidden js-payment-block-rejected-table"> + <thead> + <tr> + <th class="payment-block__table-amount">Amount</th> + <th class="payment-block__table-status">Status</th> + <th class="payment-block__table-view"></th> + </tr> + </thead> + <tbody> + {% for payment_request in object.payment_requests.rejected %} + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value }}</td> + <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> + <td><a href="{{ payment_request.get_absolute_url }}">View</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + +</div> + +{% for form in change_payment_request_status_forms %} +<div class="modal" id="change-payment-status-{{ form.instance.pk }}"> + <h4 class="modal__header-bar">Change payment status</h4> + <div class="wrapper--outer-space-medium"> + <p>Current status: <b>{{ form.instance.get_status_display }}</b></p> + + {% if not form.instance.has_changes_requested %} + <p>Requested value: <b>${{ form.instance.requested_value }}</b></p> + {% endif %} + + {% url 'apply:projects:change-payment-status' pk=object.pk payment_request_id=form.instance.pk as change_payment_status_action %} + {% include 'funds/includes/delegated_form_base.html' with form=form value='Update' action=change_payment_status_action %} + </div> +</div> +{% endfor %} diff --git a/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..d5fd9a177e2fca985dad7fb769d4616316fc872a --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html @@ -0,0 +1,28 @@ +{% extends "application-projects/paymentrequest_detail.html" %} + +{% block actions %} + {{ block.super }} + <a + data-fancybox + data-src="#change-status" + class="button button--bottom-space button--primary button--full-width" + href="#" + > + Change Status + </a> + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">Change status</h4> + <p>Current status: {{ object.get_status_display }}</p> + {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%} + </div> +{% endblock %} + +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block extra_js %} +{{ block.super }} +<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +<script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html index 8e3e1acc21c727fc912a6082bbc4aa8143343eba..597258a5b0414070b7ce555d54a5718a1ced06b5 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -13,8 +13,10 @@ <p class="docs-block__title">Proposal</p> </div> <div class="docs-block__row-inner"> - <a class="docs-block__link" href="#">View</a> - <a class="docs-block__link" href="#">Download</a> + <a class="docs-block__link" href="{% url 'apply:submissions:simplified' pk=project.submission.pk %}">View</a> + {% if not user.is_applicant %} + <a class="docs-block__link" href="#">Download</a> + {% endif %} </div> </li> @@ -35,8 +37,8 @@ {% endif %} </a> {% endif %} - {% if object.user_has_updated_details %} - <a class="docs-block__link" href="#"> + {% if object.user_has_updated_details and not user.is_applicant %} + <a class="docs-block__link" href="{% url 'apply:projects:simplified' pk=project.pk %}"> View </a> {% endif %} @@ -45,13 +47,26 @@ <li class="docs-block__row"> <div class="docs-block__row-inner"> - <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg> + <svg class="icon docs-block__icon{% if not remaining_document_categories %} is-complete{% endif %}"> + <use xlink:href="#tick"></use> + </svg> <p class="docs-block__title">Supporting documents</p> </div> {% if editable %} - <div class="docs-block__row-inner"> - <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a> - </div> + <div class="docs-block__row-inner"> + {% if select_document_form.should_show %} + <a + data-fancybox + data-src="#copy-supporting-doc" + class="docs-block__link" + href="#"> + Choose file + </a> + {% else %} + <span class="docs-block__link is-disabled" data-tooltip="No files on submission">Choose file</span> + {% endif %} + <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a> + </div> {% endif %} {% if remaining_document_categories %} <div class="docs-block__info-text"> @@ -76,11 +91,11 @@ </div> <div class="docs-block__document-inner"> <a class="docs-block__document-link" href="{% url 'apply:projects:document' pk=object.pk file_pk=document.pk %}">Download</a> - <form method="POST" id="{{ remove_document_form.name }}"> + <form method="POST" id="{{ remove_document_form.name }}" class="docs-block__document-form"> {% csrf_token %} {{ document.get_remove_form }} <input - class="button button--primary button--top-space" + class="button button--link" id="{{ remove_document_form.name }}-submit" name="{{ form_prefix }}{{ remove_document_form.name }}" type="submit" @@ -97,7 +112,8 @@ </li> </ul> <div class="docs-block__buttons"> - {% if object.can_send_for_approval %} + {% can_send_for_approval object user as can_approve %} + {% if can_approve %} <a data-fancybox data-src="#send-for-approval" class="button button--primary" @@ -105,11 +121,17 @@ Submit for Approval </a> {% endif %} - <!-- <button class="button button--primary" href="#">Ready for contracting</button> --> </div> </div> +<div class="modal" id="copy-supporting-doc"> + <h4 class="modal__header-bar">Select a document</h4> + {% url 'apply:projects:copy-documents' pk=object.pk as select_document_url %} + {% include 'funds/includes/delegated_form_base.html' with form=select_document_form value='Copy' action=select_document_url %} +</div> + <div class="modal" id="upload-supporting-doc"> <h4 class="modal__header-bar">Upload a new document</h4> + <p></p> {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%} </div> diff --git a/opentech/apply/projects/templates/application_projects/overview.html b/opentech/apply/projects/templates/application_projects/overview.html new file mode 100644 index 0000000000000000000000000000000000000000..6adc7c31c7fa1a93580e53243aba9a11d2c182d5 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/overview.html @@ -0,0 +1,72 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Projects{% endblock %} + +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{{ projects.filterset.form.media.css }} +{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">Projects</h1> + <h5>Track and explore recent projects</h5> + </div> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + + {% include "funds/includes/status-block.html" with type="Projects" %} + + {% if projects.table.data %} + <div class="wrapper wrapper--bottom-space"> + + {% include "funds/includes/table_filter_and_search.html" with filter=projects.filterset filter_action=projects.url search_term=search_term search_placeholder="projects" search_action=projects.url use_search=True use_batch_actions=False heading="Projects" %} + + {% render_table projects.table %} + + <div class="all-submissions-table__more"> + <a href="{{ projects.url }}">Show all</a> + </div> + + </div> + {% endif %} + + {% if payment_requests.table.data %} + <div class="wrapper wrapper--bottom-space"> + + {% include "funds/includes/table_filter_and_search.html" with filter=payment_requests.filterset filter_action=payment_requests.url search_term=search_term search_placeholder="payment requests" search_action=payment_requests.url use_search=True use_batch_actions=False heading="Payment Requests" %} + + {% render_table payment_requests.table %} + + <div class="all-submissions-table__more"> + <a href="{{ payment_requests.url }}">Show all</a> + </div> + + </div> + {% endif %} + +</div> + +{% endblock %} + +{% block extra_js %} + {{ projects.filterset.form.media.js }} + <script src="https://cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> + <script src="{% static 'js/apply/fancybox-global.js' %}"></script> + <script src="{% static 'js/apply/all-submissions-table.js' %}"></script> + <script src="https://cdn.jsdelivr.net/npm/symbol-es6@0.1.2/symbol-es6.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script> + <script src="{% static 'js/apply/submission-filters.js' %}"></script> + <script src="{% static 'js/apply/submission-tooltips.js' %}"></script> + <script src="{% static 'js/apply/tabs.js' %}"></script> + <script src="{% static 'js/apply/batch-actions.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/payment_request_list.html b/opentech/apply/projects/templates/application_projects/payment_request_list.html new file mode 100644 index 0000000000000000000000000000000000000000..99fd1135ee847bfe52ee1c9d9d6cc3976c120d87 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/payment_request_list.html @@ -0,0 +1,38 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Payment Requests{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">All Payment Requests</h1> + </div> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action use_batch_actions=True search_placeholder="payment requests" %} + {% render_table table %} + {% else %} + <p>No Requests Available</p> + {% endif %} +</div> + +{% endblock content %} + +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> + {{ filter.form.media.css }} +{% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="{% static 'js/apply/submission-filters.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..edfdd7fc6f4a3e136e0062c7733845b5c52f9fd9 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html @@ -0,0 +1,36 @@ +{% extends "application_projects/paymentrequest_detail.html" %} +{% load static payment_request_tools %} + +{% block actions %} + {{ block.super }} + {% can_change_status object user as user_can_change_status %} + <a + {% if user_can_change_status %} + data-fancybox + data-src="#change-status" + {% else %} + data-tooltip="Cannot change from 'Paid' or 'Declined' state" + {% endif %} + class="button button--bottom-space button--primary button--full-width{% if not user_can_change_status %} button--tooltip-disabled{% endif %}" + href="#" + > + Change Status + </a> + {% if user_can_change_status %} + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">Change status</h4> + <p>Current status: {{ object.get_status_display }}</p> + {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%} + </div> + {% endif %} +{% endblock %} + +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block extra_js %} +{{ block.super }} +<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +<script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..d3907d95c8c673b7f77bde5cbe41056eaca946ed --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html @@ -0,0 +1,37 @@ + +{% extends "base-apply.html" %} +{% load humanize payment_request_tools %} + +{% block title %}Payment Request: {{ object.project.title }}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h2 class="heading heading--no-margin">Delete Payment Request</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar wrapper--outer-space-medium"> + <div class="wrapper--sidebar--inner"> + + <div class="card card--solid"> + <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p> + <p class="card__text"><b>Name of Vendor:</b> {{ object.project.contact_legal_name }}</p> + <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p> + <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p> + <p class="card__text"><b>Total:</b> ${{ object.value|intcomma }}</p> + + </div> + <div class="card card--solid"> + <form method="post">{% csrf_token %} + <p>Are you sure you want to delete this payment request for {{ object.project.title }}?</p> + <input class="button button--primary" type="submit" value="Confirm"> + </form> + + </div> + </div> +</div> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..eb034ad8488de6311e5e479b52786e75a0caafc7 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html @@ -0,0 +1,66 @@ +{% extends "base-apply.html" %} +{% load humanize payment_request_tools %} + +{% block title %}Payment Request: {{ object.project.title }}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h2 class="heading heading--no-margin">Payment Request</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar wrapper--outer-space-medium"> + <div class="wrapper--sidebar--inner"> + <div class="card card--solid"> + <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p> + <p class="card__text"><b>Name of Vendor:</b> {{ object.project.contact_legal_name }}</p> + <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p> + <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p> + <p class="card__text"><b>Total:</b> ${{ object.value|intcomma }}</p> + </div> + + <div class="card card--solid"> + <div class="card__inner"> + <h5 class="card__heading">Invoice</h5> + <p class="card__text"><a href="{% url "apply:projects:payments:invoice" pk=object.pk %}">Download</a></p> + </div> + <div class="card__inner"> + <h5 class="card__heading">Reciepts</h5> + {% for reciept in object.receipts.all %} + <p class="card__text"><a href="{% url "apply:projects:payments:receipt" pk=object.pk file_pk=reciept.pk %}">Download</a></p> + {% endfor %} + </div> + </div> + </div> + <aside class="sidebar"> + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions"> + {% block actions %} + {% can_edit object user as user_can_edit_request %} + <a + {% if not user_can_edit_request %} + data-tooltip="Only editable when 'Submitted' or you have been requested to make changes" + {% endif %} + class="button button--bottom-space button--primary button--full-width{% if not user_can_edit_request %} button--tooltip-disabled{% endif %}" + href={% if user_can_edit_request %} + "{% url "apply:projects:payments:edit" pk=object.pk %}" + {% else %} + "#" + {% endif %} + > + Edit + </a> + {% can_delete object user as user_can_delete_request %} + {% if user_can_delete_request %} + <a + class="button button--bottom-space button--primary button--full-width" + href="{% url 'apply:projects:payments:delete' pk=object.pk %}">Delete</a> + {% endif %} + {% endblock %} + </div> + </aside> +</div> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html new file mode 100644 index 0000000000000000000000000000000000000000..c0b49835b25a1217083a498e88db0c0eb3ee2616 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html @@ -0,0 +1,36 @@ +{% extends "base-apply.html" %} +{% load static %} + +{% block title %}{% if object %}Edit{% else %}Create{% endif %} Payment Request: {% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <h2 class="heading heading--no-margin">{% if object %}Editing{% else %}Create{% endif %} Payment Request</h2> + <h5 class="heading heading--no-margin">{% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}</h5> + </div> +</div> + +{% include "forms/includes/form_errors.html" with form=form %} + +<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar"> + <div class="wrapper--sidebar--inner"> + <form class="form" action="" method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ form.media }} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + <input class="button button--submit button--top-space button--primary" type="submit" name="save" value="Save" /> + </form> + </div> +</div> +{% endblock %} + +{% block extra_js %} +<script src="{% static 'js/apply/list-input-files.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html index 239d18fb9c923900e6e6dac011a4193b54be5821..ffafd3731869879201c6675b6fa2530ac3fe07c9 100644 --- a/opentech/apply/projects/templates/application_projects/project_admin_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html @@ -3,7 +3,7 @@ {% load approval_tools %} {% load static %} -{% block admin_sidebar %} +{% block admin_actions %} <div class="modal" id="send-for-approval"> <h4 class="modal__header-bar">Request Approval</h4> @@ -30,6 +30,8 @@ <div class="modal" id="approve"> <h4 class="modal__header-bar">Add Approval</h4> + <p>This will move the project into contracting and notify the compliance team.</p> + <p>This cannot be undone</p> {% include 'funds/includes/delegated_form_base.html' with form=add_approval_form value='Approve'%} </div> @@ -38,67 +40,68 @@ {% include 'funds/includes/delegated_form_base.html' with form=rejection_form value='Request Changes'%} </div> -{% if mobile %} - <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a> +{% if contract_to_approve %} +<div class="modal" id="approve-contract"> + <h4 class="modal__header-bar">Approve Contract</h4> + <p>You confirm that the uploaded contract is acceptable for commencing the project.</p> + <p>This cannot be undone.</p> + {% include 'funds/includes/delegated_form_base.html' with form=approve_contract_form value='Confirm' %} +</div> {% endif %} -<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> - - <h5>Actions to take</h5> - - {% if object.can_send_for_approval %} - <a data-fancybox - data-src="#send-for-approval" - class="button button--bottom-space button--primary button--full-width" - href="#"> +{% if object.requires_approval %} + <a + {% if not object.can_send_for_approval %} + onclick="return false" + data-tooltip=" + {% if not object.lead %} + A lead must be assigned + {% elif not object.user_has_updated_details %} + Project approval form must be completed + {% elif object.is_locked %} + Currently awaiting approval + {% endif %}" + {% endif %} + data-fancybox + data-src="#send-for-approval" + class="button button--bottom-space button--primary button--full-width {% if not object.can_send_for_approval %}button--tooltip-disabled{% endif %}" + href="#"> Submit for Approval </a> - {% endif %} - - {% if object.can_make_approval %} - {% user_can_approve_project object request.user as user_can_approve %} - <a data-fancybox - data-src="#approve" - class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" - href="#"> - Approve - </a> - - <a data-fancybox - data-src="#request-project-changes" - class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" - href="#"> - Request changes - </a> - {% endif %} - - <!-- <a data-fancybox --> - <!-- data-src="#ready-for-contracting" --> - <!-- class="button button--primary button--full-width" --> - <!-- href="#"> --> - <!-- Ready for contracting --> - <!-- </a> --> +{% endif %} +{% if object.can_make_approval %} + {% user_can_approve_project object request.user as user_can_approve %} + <a data-fancybox + data-src="#approve" + class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" + href="#"> + Approve + </a> + + <a data-fancybox + data-src="#request-project-changes" + class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" + href="#"> + Request changes + </a> +{% endif %} - <p class="sidebar__separator">Assign</p> +{% endblock %} - <a data-fancybox - data-src="#assign-lead" - class="button button--bottom-space button--white button--full-width" - href="#"> - Lead - </a> +{% block admin_assignments %} +<p class="sidebar__separator">Assign</p> - <!-- <a data-fancybox --> - <!-- data-src="#update-meta-categories" --> - <!-- class="button button--bottom-space button--white button--full-width" --> - <!-- href="#"> --> - <!-- Meta Categories --> - <!-- </a> --> +<a data-fancybox + data-src="#assign-lead" + class="button button--bottom-space button--white button--full-width" + href="#"> + Lead +</a> -</div> +{% endblock %} -{% if approvals %} +{% block project_approvals %} <div class="sidebar__inner"> <h5>Approved By:</h5> @@ -106,15 +109,18 @@ <p>{{ approval.by }} - {{ approval.created_at|date:"Y-m-d" }}</p> {% endfor %} </div> -{% endif %} - -{% endblock %} - -{% block extra_css %} - <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> {% endblock %} -{% block extra_js %} +{% block approve_contract %} +{% if contract_to_approve %} {{ block.super }} - <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> + <p> + <a data-fancybox + data-src="#approve-contract" + class="button button--bottom-space button--primary button--full-width" + href="#"> + Approve Contract + </a> + </p> +{% endif %} {% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html index 2ca4b45978990b5f19dc4d1b5537fd11141a2aa4..d7e3d95b391a77c85ad79dfeefb4f5fdebf3298c 100644 --- a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html @@ -2,17 +2,15 @@ {% block notifications %} {% if not object.editable %} -<div class="wrapper wrapper--sidebar"> - <div class="wrapper--sidebar--inner wrapper--error"> - <div> - <p>Your project is not editable at this point.</p> + <div class="error-bar"> + <p class="error-bar__copy"> + Your project is not editable at this point. {% if not object.lead %} - <p>We are awaiting a lead to be assigned.</p> + We are awaiting a lead to be assigned. {% else %} - <p>It is currently under review by a staff member.</p> + It is currently under review by a staff member. {% endif %} - </div> + </p> </div> -</div> {% endif %} {% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index 73ef24b09b9a8ede66a101f35258632a2ba07b28..de8cabd3568a81024811c88e2f42cfca7060fe51 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -1,5 +1,6 @@ {% extends "base-apply.html" %} +{% load contract_tools %} {% load static %} {% load wagtailcore_tags %} @@ -34,7 +35,24 @@ <span>Lead: {{ object.lead }}</span> </h5> - {# {% status_bar object.workflow object.phase request.user same_stage=True%} #} + + <div class="status-bar"> + {% for status, text in statuses %} + {% if forloop.counter0 == current_status_index %} + {% include "funds/includes/status_bar_item.html" with is_current=True is_complete=False label=text %} + {% elif forloop.counter0 < current_status_index %} + {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=True label=text %} + {% else %} + {% include "funds/includes/status_bar_item.html" with is_current=False is_complete=False label=text %} + {% endif %} + {% endfor %} + </div> + + <div class="status-bar--mobile"> + <h6 class="status-bar__subheading"> + {{ object.get_status_display }} + </h6> + </div> <div class="tabs js-tabs"> <div class="tabs__container"> @@ -106,28 +124,115 @@ </div> </div> - {# <div class="wrapper wrapper--outer-space-large"> #} - {# {% include "funds/includes/funding_block.html" %} #} - {# {% include "funds/includes/payment_requests.html" %} #} - {# {% include "funds/includes/invoice_block.html" %} #} - {# </div> #} + {% if object.can_request_funding %} + <div class="wrapper wrapper--outer-space-large"> + {% include "application_projects/includes/funding_block.html" %} + {% include "application_projects/includes/payment_requests.html" %} + </div> + {% endif %} + {% if not object.is_in_progress %} {% include "application_projects/includes/supporting_documents.html" %} + {% endif %} </article> <aside class="sidebar"> - {% if request.user.is_apply_staff %} - {% block admin_sidebar %}{% endblock %} + {% if mobile %} + <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a> {% endif %} + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> + + <h5>Actions to take</h5> + + {% if request.user.is_apply_staff %} + {% block admin_actions %}{% endblock %} + {% endif %} + + {% user_can_upload_contract object request.user as can_upload_contract %} + {% if can_upload_contract %} + <div class="modal" id="upload-contract"> + {% if not user.is_staff %} + <h4 class="modal__header-bar">Upload Signed Contract</h4> + {% else %} + <h4 class="modal__header-bar">Upload Contract</h4> + {% endif %} + {% include 'funds/includes/delegated_form_base.html' with form=contract_form value='Upload'%} + </div> + + <a data-fancybox + data-src="#upload-contract" + class="button button--primary button--full-width button--bottom-space" + href="#"> + Upload contract + </a> + {% endif %} + + {% if object.can_request_funding %} + <a class="button button--primary button--bottom-space button--full-width" + href="{% url "apply:projects:request" pk=object.pk %}"> + Add payment request + </a> + {% endif %} + + {% if request.user.is_apply_staff %} + {% block admin_assignments %}{% endblock %} + {% endif %} + + </div> + + {% if object.is_in_progress %} <div class="sidebar__inner"> - <h5>Meta Categories</h5> + <h5>Supporting Information</h5> - <p>Meta Category</p> - <p>Meta Category</p> - <p>Meta Category</p> + <p><a class="link link--bold" href="{{ object.submission.get_absolute_url }}">Proposal</a></p> + + {% if request.user.is_apply_staff %} + <p><a class="link link--bold" href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p> + {% endif %} </div> + {% endif %} + + {% if request.user.is_apply_staff and approvals %} + {% block project_approvals %}{% endblock %} + {% endif %} + + {% if contracts or contract_to_sign or contract_to_approve %} + <div class="sidebar__inner"> + <h5>Contracts</h5> + {% block approve_contract %} + {% if contract_to_approve %} + <p> + <a href="{{ contract_to_approve.file.url }}" class="link link--bold"> + {{ contract_to_approve.created_at|date:"j F Y" }} + </a> + (Signed) + </p> + {% endif %} + {% endblock %} + + {% block sign_contract %} + {% if contract_to_sign%} + <p> + <a href="{{ contract_to_sign.file.url }}" class="link link--bold"> + {{ contract_to_sign.created_at|date:"j F Y" }} + </a> + (Unsigned) + </p> + {% endif %} + {% endblock %} + + {% for contract in contracts %} + <p> + <a href="{{ contract.file.url }}" class="link link--bold"> + {{ contract.created_at|date:"j F Y" }} + </a> + - Approved by {{ contract.approver }} + </p> + {% endfor %} + </div> + {% endif %} </aside> </div> </div> @@ -149,8 +254,13 @@ </div> {% endblock content %} +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + {% block extra_js %} <script src="{% static 'js/apply/tabs.js' %}"></script> + <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script> <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script> <script src="{% static 'js/apply/file-uploads.js' %}"></script> <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> diff --git a/opentech/apply/projects/templates/application_projects/project_list.html b/opentech/apply/projects/templates/application_projects/project_list.html new file mode 100644 index 0000000000000000000000000000000000000000..81df6e6570d47b02032d51be8eb9102bf5269451 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_list.html @@ -0,0 +1,38 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Projects{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">All Projects</h1> + </div> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term search_placeholder="projects" use_search=True filter_action=filter_action use_batch_actions=True %} + {% render_table table %} + {% else %} + <p>No Projects Available</p> + {% endif %} +</div> + +{% endblock content %} + +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> + {{ filter.form.media.css }} +{% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="{% static 'js/apply/submission-filters.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_simplified_detail.html b/opentech/apply/projects/templates/application_projects/project_simplified_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..d09ea1aa48e0c21c04c5d7a7583c0b126deeba4e --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_simplified_detail.html @@ -0,0 +1,129 @@ +{% extends "base-apply.html" %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block body_class %}light-grey-bg{% endblock %} + +{% block content %} +<div class="simplified"> + <div class="simplified__admin-bar"> + <div class="simplified__admin-bar-inner"> + <a class="simplified__projects-link" href="{{ object.get_absolute_url }}"> + Project + </a> + <h1 class="simplified__heading">{{ object.title }}</h1> + <h5 class="simplified__subheading"> + <span>{{ object.submission.page }}</span> + <span>{{ object.submission.round }}</span> + <span>Lead: {{ object.lead }}</span> + </h5> + </div> + </div> + + <div class="simplified__wrapper"> + <h3>Project Information</h3> + <div class="card card--solid"> + <div class="grid grid--proposal-info"> + <div> + <h5>Proposed start date</h5> + <p>{{ object.proposed_start|date:"j F Y"|default:"-" }}</p> + </div> + + <div> + <h5>Project Proposed end date</h5> + <p>{{ object.proposed_end|date:"j F Y"|default:"-" }}</p> + </div> + + <div> + <h5>Legal name</h5> + <p>{{ object.contact_legal_name|default:"-" }}</p> + </div> + + <div> + <h5>Email</h5> + <p>{{ object.contact_email|default:"-" }}</p> + </div> + + <div> + <h5>Address</h5> + <p>{{ object.get_address_display|default:"-"}}</p> + </div> + + <div> + <h5>Phone</h5> + <p>{{ object.phone|default:"-" }}</p> + </div> + + <div> + <h5>Value</h5> + <p>${{ object.value|default:"-" }}</p> + </div> + + {% if object.sent_to_compliance_at %} + <div> + <h5>Sent to Compliance</h5> + <p>{{ object.sent_to_compliance_at|date:"j F Y" }}</p> + </div> + {% endif %} + + </div> + + {% if object.output_answers %} + <div class="simplified__rich-text"> + {{ object.output_answers }} + </div> + {% endif %} + </div> + + <h3>Approvals</h3> + <div class="card card--solid"> + <h4>Approver</h4> + {% with approval=project.approvals.first %} + <p>{{ approval.by }} ({{ approval.created_at }})</p> + {% endwith %} + </div> + + <h3>Review</h3> + <div class="card card--solid"> + <h4>Submission lead</h4> + <p>{{ project.submission.lead }}</p> + + <h4>Reviews</h4> + <h5>Staff Reviewers</h5> + {% for review in project.submission.reviews.by_staff %} + <div class="card__reviewer-outcome"> + <div class="traffic-light traffic-light--{{ review.outcome|lower }}"></div> + <span class="card__reviewer"> + {{ review.author }} + {% if review.author.role %} + as {{ review.author.role }} + {% endif %} + - {{ review.outcome }} ({{ review.created_at }}) + </span> + </div> + {% empty %} + No reviews + {% endfor %} + <h5>External Reviewers</h5> + {% for review in project.submission.reviews.by_reviewers %} + <div class="card__reviewer-outcome"> + <div class="traffic-light traffic-light--{{ review.outcome|lower }}"></div> + <span class="card__reviewer"> + {{ review.author }} - <span>{{ review.outcome }}</span> ({{ review.created_at }}) + </span> + </div> + {% empty %} + No reviews + {% endfor %} + </div> + + <h3>Supporting Documents</h3> + <div class="card card--solid"> + <p><a href="{% url 'apply:submissions:simplified' pk=object.submission_id %}">Submission</a></p> + {% for packet_file in object.packet_files.all %} + <p><a href="{% url 'apply:projects:document' pk=object.pk file_pk=packet_file.pk %}">{{ packet_file.title }}</a></p> + {% endfor %} + </div> + </div> +</div> +{% endblock content %} diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py index 2c51d1a59462bedbe371bdc18f82c75a6598196a..17de68f47c17ac66f37eca927e38484db404f2ef 100644 --- a/opentech/apply/projects/templatetags/approval_tools.py +++ b/opentech/apply/projects/templatetags/approval_tools.py @@ -8,6 +8,11 @@ def user_has_approved(project, user): return project.approvals.filter(by=user).exists() +@register.simple_tag +def can_send_for_approval(project, user): + return user.is_staff and project.can_send_for_approval + + @register.simple_tag def user_can_approve_project(project, user): return user.is_approver and not user_has_approved(project, user) diff --git a/opentech/apply/projects/templatetags/contract_tools.py b/opentech/apply/projects/templatetags/contract_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..6197b5f03d0d2503bb24686c90cb3b555e56b66f --- /dev/null +++ b/opentech/apply/projects/templatetags/contract_tools.py @@ -0,0 +1,28 @@ +from django import template + +from ..models import COMMITTED + +register = template.Library() + + +@register.simple_tag +def user_can_upload_contract(project, user): + if user.is_apply_staff: + return project.status != COMMITTED + + # Does the Project have any unapproved contracts? + latest_contract = project.contracts.order_by('-created_at').first() + + # No contract ever uploaded - nothing to do + if not latest_contract: + return False + + # Latest contract approved - nothing to do + if latest_contract.approver: + return False + + # Contract is either: + # - Unsigned: Applicant needs to sign it. + # - Signed: Applicant is waiting on approval and may need to upload a new + # version because my scanning was bad. + return True diff --git a/opentech/apply/projects/templatetags/payment_request_tools.py b/opentech/apply/projects/templatetags/payment_request_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..310e8a0bf4712762b03a5c5b70b449b48e5c9b02 --- /dev/null +++ b/opentech/apply/projects/templatetags/payment_request_tools.py @@ -0,0 +1,36 @@ +import decimal + +from django import template + +register = template.Library() + + +@register.simple_tag +def can_change_status(payment_request, user): + return payment_request.can_user_change_status(user) + + +@register.simple_tag +def can_delete(payment_request, user): + return payment_request.can_user_delete(user) + + +@register.simple_tag +def can_edit(payment_request, user): + return payment_request.can_user_edit(user) + + +@register.simple_tag +def percentage(value, total): + if not total: + return decimal.Decimal(0) + + unrounded_total = (value / total) * 100 + + # round using Decimal since we're dealing with currency + rounded_total = unrounded_total.quantize( + decimal.Decimal('0.0'), + rounding=decimal.ROUND_DOWN, + ) + + return rounded_total diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index b91c0aadf1e1c82f41884838451e7852c18018be..23a535ed867e3b44325ac24cb1b5d692c0f67c5b 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -2,17 +2,22 @@ import decimal import json import factory +import pytz from django.utils import timezone from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory from opentech.apply.projects.models import ( + Contract, DocumentCategory, PacketFile, + PaymentReceipt, + PaymentRequest, Project, + ProjectApprovalForm, ) +from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory from opentech.apply.users.tests.factories import StaffFactory, UserFactory - ADDRESS = { 'country': 'GB', 'thoroughfare': factory.Faker('street_name').generate({}), @@ -47,6 +52,18 @@ class DocumentCategoryFactory(factory.DjangoModelFactory): model = DocumentCategory +class ProjectApprovalFormFactory(factory.DjangoModelFactory): + class Meta: + model = ProjectApprovalForm + + name = factory.Faker('word') + form_fields = FormFieldsBlockFactory + + +class ProjectApprovalFormDataFactory(FormDataFactory): + field_factory = FormFieldsBlockFactory + + class ProjectFactory(factory.DjangoModelFactory): submission = factory.SubFactory(ApplicationSubmissionFactory) user = factory.SubFactory(UserFactory) @@ -63,9 +80,30 @@ class ProjectFactory(factory.DjangoModelFactory): is_locked = False + form_fields = FormFieldsBlockFactory + form_data = factory.SubFactory( + ProjectApprovalFormDataFactory, + form_fields=factory.SelfAttribute('..form_fields'), + ) + class Meta: model = Project + class Params: + in_approval = factory.Trait( + is_locked=True, + ) + + +class ContractFactory(factory.DjangoModelFactory): + approver = factory.SubFactory(StaffFactory) + project = factory.SubFactory(ProjectFactory) + + file = factory.django.FileField() + + class Meta: + model = Contract + class PacketFileFactory(factory.DjangoModelFactory): category = factory.SubFactory(DocumentCategoryFactory) @@ -76,3 +114,26 @@ class PacketFileFactory(factory.DjangoModelFactory): class Meta: model = PacketFile + + +class PaymentRequestFactory(factory.DjangoModelFactory): + project = factory.SubFactory(ProjectFactory) + by = factory.SubFactory(UserFactory) + requested_value = factory.Faker('pydecimal', min_value=1, max_value=10000000, right_digits=2) + + date_from = factory.Faker('date_time').generate({'tzinfo': pytz.utc}) + date_to = factory.Faker('date_time').generate({'tzinfo': pytz.utc}) + + invoice = factory.django.FileField() + + class Meta: + model = PaymentRequest + + +class PaymentReceiptFactory(factory.DjangoModelFactory): + payment_request = factory.SubFactory(PaymentRequestFactory) + + file = factory.django.FileField() + + class Meta: + model = PaymentReceipt diff --git a/opentech/apply/projects/tests/test_files.py b/opentech/apply/projects/tests/test_files.py new file mode 100644 index 0000000000000000000000000000000000000000..83c585477f0b7e9ce169802e0c821d77c818c084 --- /dev/null +++ b/opentech/apply/projects/tests/test_files.py @@ -0,0 +1,44 @@ +from django.core.files import File +from django.test import TestCase +from more_itertools import collapse + +from opentech.apply.stream_forms.files import StreamFieldFile + +from ..files import flatten, get_files +from .factories import ProjectFactory + + +class TestFlatten(TestCase): + def test_no_items(self): + with self.assertRaises(TypeError): + list(flatten(1)) + + def test_one_level_of_items(self): + output = list(flatten([1, 2, 3])) + self.assertEqual(output, [1, 2, 3]) + + def test_two_levels_of_items(self): + output = list(flatten([1, [2, 3]])) + self.assertEqual(output, [1, 2, 3]) + + def test_three_levels_of_items(self): + output = list(flatten([1, [2, (3)]])) + self.assertEqual(output, [1, 2, (3)]) + + +class TestGetFiles(TestCase): + def test_get_files(self): + project = ProjectFactory() + + files = list(get_files(project)) + + self.assertTrue(all(issubclass(f.__class__, File) for f in files)) + + fields = project.submission.form_data.values() + fields = collapse(fields, base_type=StreamFieldFile) + fields = [f for f in fields if isinstance(f, StreamFieldFile)] + + self.assertEqual(len(files), len(fields)) + + for f in files: + self.assertIn(f, fields) diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py index 5c0d157d5b81315841a9134cb98551d30d50d1b7..1e24303d66a3e9dc44b60e07d0a90fadc7108f64 100644 --- a/opentech/apply/projects/tests/test_forms.py +++ b/opentech/apply/projects/tests/test_forms.py @@ -1,7 +1,88 @@ -from django.test import TestCase +from io import BytesIO +from unittest import mock -from ..forms import ProjectApprovalForm -from .factories import ProjectFactory, address_to_form_data +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings + +from opentech.apply.users.tests.factories import ( + UserFactory, +) + +from ..files import get_files +from ..forms import ( + ChangePaymentRequestStatusForm, + CreatePaymentRequestForm, + ProjectApprovalForm, + SelectDocumentForm, + StaffUploadContractForm, + UploadContractForm, + filter_choices, + filter_request_choices +) +from ..models import ( + CHANGES_REQUESTED, + DECLINED, + PAID, + SUBMITTED, + UNDER_REVIEW, +) +from .factories import ( + DocumentCategoryFactory, + PaymentRequestFactory, + ProjectFactory, + address_to_form_data +) + + +class TestChangePaymentRequestStatusForm(TestCase): + def test_choices_with_submitted_status(self): + request = PaymentRequestFactory(status=SUBMITTED) + form = ChangePaymentRequestStatusForm(instance=request) + + expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED])) + actual = set(form.fields['status'].choices) + + self.assertEqual(expected, actual) + + def test_choices_with_changes_requested_status(self): + request = PaymentRequestFactory(status=CHANGES_REQUESTED) + form = ChangePaymentRequestStatusForm(instance=request) + + expected = set(filter_request_choices([DECLINED])) + actual = set(form.fields['status'].choices) + + self.assertEqual(expected, actual) + + def test_choices_with_under_review_status(self): + request = PaymentRequestFactory(status=UNDER_REVIEW) + form = ChangePaymentRequestStatusForm(instance=request) + + expected = set(filter_request_choices([PAID])) + actual = set(form.fields['status'].choices) + + self.assertEqual(expected, actual) + + def test_filter_choices(self): + ONE = 'one' + TWO = 'two' + choices = [ + (ONE, 'One'), + (TWO, 'Two'), + ] + + output = filter_choices(choices, [ONE, TWO]) + self.assertTrue(output, choices) + + # order shouldn't matter + output = filter_choices(choices, [TWO, ONE]) + self.assertTrue(output, choices) + + # duplicates shouldn't matter + output = filter_choices(choices, [TWO, ONE, TWO]) + self.assertTrue(output, choices) + + output = filter_choices(choices, [TWO]) + self.assertTrue(output, [(TWO, 'Two')]) class TestProjectApprovalForm(TestCase): @@ -21,6 +102,122 @@ class TestProjectApprovalForm(TestCase): } data.update(address_to_form_data()) form = ProjectApprovalForm(instance=project, data=data) + self.assertTrue(form.is_valid(), form.errors.as_text()) + form.save() self.assertTrue(project.user_has_updated_details) + + +class TestCreatePaymentRequestForm(TestCase): + def test_adding_payment_request(self): + data = { + 'requested_value': '10', + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + } + + invoice = SimpleUploadedFile('invoice.pdf', BytesIO(b'somebinarydata').read()) + receipts = SimpleUploadedFile('receipts.pdf', BytesIO(b'someotherbinarydata').read()) + files = { + 'invoice': invoice, + 'receipts': receipts, + } + + form = CreatePaymentRequestForm(data=data, files=files) + self.assertTrue(form.is_valid(), msg=form.errors) + + form.instance.by = UserFactory() + form.instance.project = ProjectFactory() + payment_request = form.save() + + self.assertEqual(payment_request.receipts.count(), 1) + + def test_payment_request_dates_are_correct(self): + invoice = SimpleUploadedFile('invoice.pdf', BytesIO(b'somebinarydata').read()) + receipts = SimpleUploadedFile('receipts.pdf', BytesIO(b'someotherbinarydata').read()) + files = { + 'invoice': invoice, + 'receipts': receipts, + } + + form = CreatePaymentRequestForm( + files=files, + data={ + 'requested_value': '10', + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + } + ) + self.assertTrue(form.is_valid(), msg=form.errors) + + form = CreatePaymentRequestForm( + files=files, + data={ + 'requested_value': '10', + 'date_from': '2019-08-15', + 'date_to': '2018-08-15', + 'comment': 'test comment', + } + ) + self.assertFalse(form.is_valid()) + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') +class TestSelectDocumentForm(TestCase): + def test_copying_files(self): + category = DocumentCategoryFactory() + project = ProjectFactory() + + self.assertEqual(project.packet_files.count(), 0) + + files = list(get_files(project)) + self.assertEqual(len(files), 4) + + url = files[3].url + + form = SelectDocumentForm( + files, + data={'category': category.id, 'document': url}, + ) + self.assertTrue(form.is_valid(), form.errors) + + form.instance.project = project + form.save() + + packet_files = project.packet_files.order_by('id') + self.assertEqual(len(packet_files), 1) + + self.assertEqual(packet_files.first().document.read(), files[3].read()) + + +class TestStaffContractUploadForm(TestCase): + mock_file = mock.MagicMock(spec=SimpleUploadedFile) + mock_file.read.return_value = b"fake file contents" + + def test_staff_can_upload_unsigned(self): + form = StaffUploadContractForm(data={}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.cleaned_data.get('is_signed')) + + def test_staff_can_upload_signed(self): + form = StaffUploadContractForm(data={'is_signed': True}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.cleaned_data.get('is_signed')) + + +class TestContractUploadForm(TestCase): + mock_file = mock.MagicMock(spec=SimpleUploadedFile) + mock_file.read.return_value = b"fake file contents" + + def test_applicant_cant_upload_unsigned(self): + form = UploadContractForm(data={}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNone(form.cleaned_data.get('is_signed')) + + def test_applicant_can_upload_signed(self): + form = UploadContractForm(data={'is_signed': True}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNone(form.cleaned_data.get('is_signed')) diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index ded83fe644999416caf85afe0ef196c180b707fd..a467f9f1dfa2af32f22ea50197d41a8e1da1d56a 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -1,10 +1,25 @@ +from decimal import Decimal + from django.test import TestCase from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory - -from ..models import Project -from .factories import (DocumentCategoryFactory, PacketFileFactory, - ProjectFactory) +from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory + +from ..models import ( + CHANGES_REQUESTED, + DECLINED, + PAID, + SUBMITTED, + UNDER_REVIEW, + Project, + PaymentRequest, +) +from .factories import ( + DocumentCategoryFactory, + PacketFileFactory, + PaymentRequestFactory, + ProjectFactory +) class TestProjectModel(TestCase): @@ -59,3 +74,97 @@ class TestProjectModel(TestCase): self.assertEqual(missing[0]['difference'], 3) self.assertEqual(missing[1]['category'], category2) self.assertEqual(missing[1]['difference'], 2) + + +class TestPaymentRequestModel(TestCase): + def test_staff_can_delete_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + staff = StaffFactory() + + self.assertTrue(payment_request.can_user_delete(staff)) + + def test_staff_cant_delete_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + staff = StaffFactory() + + self.assertFalse(payment_request.can_user_delete(staff)) + + def test_staff_cant_delete_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + staff = StaffFactory() + + self.assertFalse(payment_request.can_user_delete(staff)) + + def test_staff_cant_delete_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + staff = StaffFactory() + + self.assertFalse(payment_request.can_user_delete(staff)) + + def test_staff_cant_delete_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + staff = StaffFactory() + + self.assertFalse(payment_request.can_user_delete(staff)) + + def test_can_user_delete_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + user = ApplicantFactory() + + self.assertTrue(payment_request.can_user_delete(user)) + + def test_user_cant_delete_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + user = ApplicantFactory() + + self.assertFalse(payment_request.can_user_delete(user)) + + def test_user_can_delete_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + user = ApplicantFactory() + + self.assertTrue(payment_request.can_user_delete(user)) + + def test_user_cant_delete_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + user = ApplicantFactory() + + self.assertFalse(payment_request.can_user_delete(user)) + + def test_user_cant_delete_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + user = ApplicantFactory() + + self.assertFalse(payment_request.can_user_delete(user)) + + def test_requested_value_used_when_no_paid_value(self): + payment_request = PaymentRequestFactory( + requested_value=Decimal('1'), + paid_value=None, + ) + self.assertEqual(payment_request.value, Decimal('1')) + + def test_paid_value_overrides_requested_value(self): + payment_request = PaymentRequestFactory( + requested_value=Decimal('1'), + paid_value=Decimal('2'), + ) + self.assertEqual(payment_request.value, Decimal('2')) + + payment_request = PaymentRequestFactory( + requested_value=Decimal('100'), + paid_value=Decimal('2'), + ) + self.assertEqual(payment_request.value, Decimal('2')) + + +class TestPaymentRequestsQueryset(TestCase): + def test_get_totals(self): + PaymentRequestFactory(requested_value=20) + PaymentRequestFactory(paid_value=10, status=PAID) + self.assertEqual(PaymentRequest.objects.paid_value(), 10) + self.assertEqual(PaymentRequest.objects.unpaid_value(), 20) + + def test_get_totals_no_value(self): + self.assertEqual(PaymentRequest.objects.paid_value(), 0) + self.assertEqual(PaymentRequest.objects.unpaid_value(), 0) diff --git a/opentech/apply/projects/tests/test_templatetags.py b/opentech/apply/projects/tests/test_templatetags.py new file mode 100644 index 0000000000000000000000000000000000000000..72bccc8e31d43e8aeea13d309b4f6266eddfe1f3 --- /dev/null +++ b/opentech/apply/projects/tests/test_templatetags.py @@ -0,0 +1,257 @@ +from django.test import TestCase + +from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory + +from ..models import ( + CHANGES_REQUESTED, + CLOSING, + COMMITTED, + COMPLETE, + CONTRACTING, + DECLINED, + IN_PROGRESS, + PAID, + SUBMITTED, + UNDER_REVIEW +) +from ..templatetags.contract_tools import user_can_upload_contract +from ..templatetags.payment_request_tools import ( + can_change_status, + can_delete, + can_edit +) +from .factories import ContractFactory, PaymentRequestFactory, ProjectFactory + + +class TestContractTools(TestCase): + def test_staff_can_upload_after_state_leaves_committed(self): + staff = StaffFactory() + + project = ProjectFactory(status=COMMITTED) + self.assertFalse(user_can_upload_contract(project, staff)) + + project = ProjectFactory(status=CONTRACTING) + self.assertTrue(user_can_upload_contract(project, staff)) + + project = ProjectFactory(status=IN_PROGRESS) + self.assertTrue(user_can_upload_contract(project, staff)) + + project = ProjectFactory(status=COMPLETE) + self.assertTrue(user_can_upload_contract(project, staff)) + + project = ProjectFactory(status=CLOSING) + self.assertTrue(user_can_upload_contract(project, staff)) + + def test_user_can_only_upload_during_contracting(self): + applicant = ApplicantFactory() + + project = ProjectFactory(status=COMMITTED) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + project = ProjectFactory(status=CONTRACTING) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + project = ProjectFactory(status=IN_PROGRESS) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + project = ProjectFactory(status=COMPLETE) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + project = ProjectFactory(status=CLOSING) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + def test_user_cannot_upload_first_contract(self): + applicant = ApplicantFactory() + + project = ProjectFactory(status=CONTRACTING) + self.assertFalse(user_can_upload_contract(project, applicant)) + + def test_user_cannot_upload_when_latest_is_approved(self): + applicant = ApplicantFactory() + staff = StaffFactory() + + project = ProjectFactory(status=CONTRACTING) + ContractFactory(project=project, is_signed=True, approver=staff) + self.assertFalse(user_can_upload_contract(project, applicant)) + + def test_user_upload_happy_path(self): + applicant = ApplicantFactory() + + project = ProjectFactory(status=CONTRACTING) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + project = ProjectFactory(status=IN_PROGRESS) + ContractFactory(project=project, is_signed=True, approver=None) + self.assertTrue(user_can_upload_contract(project, applicant)) + + +class TestPaymentRequestTools(TestCase): + def test_staff_can_change_status_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + staff = StaffFactory() + + self.assertTrue(can_change_status(payment_request, staff)) + + def test_staff_can_change_status_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + staff = StaffFactory() + + self.assertTrue(can_change_status(payment_request, staff)) + + def test_staff_can_change_status_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + staff = StaffFactory() + + self.assertTrue(can_change_status(payment_request, staff)) + + def test_staff_cant_change_status_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + staff = StaffFactory() + + self.assertFalse(can_change_status(payment_request, staff)) + + def test_staff_cant_change_status_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + staff = StaffFactory() + + self.assertFalse(can_change_status(payment_request, staff)) + + def test_user_cant_change_status_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + user = ApplicantFactory() + + self.assertFalse(can_change_status(payment_request, user)) + + def test_user_cant_change_status_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + user = ApplicantFactory() + + self.assertFalse(can_change_status(payment_request, user)) + + def test_user_cant_change_status_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + user = ApplicantFactory() + + self.assertFalse(can_change_status(payment_request, user)) + + def test_user_cant_change_status_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + user = ApplicantFactory() + + self.assertFalse(can_change_status(payment_request, user)) + + def test_user_cant_change_status_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + user = ApplicantFactory() + + self.assertFalse(can_change_status(payment_request, user)) + + def test_staff_can_delete_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + staff = StaffFactory() + + self.assertTrue(can_delete(payment_request, staff)) + + def test_staff_cant_delete_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + staff = StaffFactory() + + self.assertFalse(can_delete(payment_request, staff)) + + def test_staff_cant_delete_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + staff = StaffFactory() + + self.assertFalse(can_delete(payment_request, staff)) + + def test_staff_cant_delete_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + staff = StaffFactory() + + self.assertFalse(can_delete(payment_request, staff)) + + def test_staff_cant_delete_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + staff = StaffFactory() + + self.assertFalse(can_delete(payment_request, staff)) + + def test_user_can_delete_from_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + user = ApplicantFactory() + + self.assertTrue(can_delete(payment_request, user)) + + def test_user_cant_delete_from_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + user = ApplicantFactory() + + self.assertFalse(can_delete(payment_request, user)) + + def test_user_can_delete_from_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + user = ApplicantFactory() + + self.assertTrue(can_delete(payment_request, user)) + + def test_user_cant_delete_from_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + user = ApplicantFactory() + + self.assertFalse(can_delete(payment_request, user)) + + def test_user_cant_delete_from_declined(self): + payment_request = PaymentRequestFactory(status=DECLINED) + user = ApplicantFactory() + + self.assertFalse(can_delete(payment_request, user)) + + def test_applicant_and_staff_can_edit_in_submitted(self): + payment_request = PaymentRequestFactory(status=SUBMITTED) + applicant = ApplicantFactory() + staff = StaffFactory() + + self.assertTrue(can_edit(payment_request, applicant)) + self.assertTrue(can_edit(payment_request, staff)) + + def test_applicant_can_edit_in_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + applicant = ApplicantFactory() + + self.assertTrue(can_edit(payment_request, applicant)) + + def test_staff_cant_edit_in_changes_requested(self): + payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) + staff = StaffFactory() + + self.assertFalse(can_edit(payment_request, staff)) + + def test_applicant_and_staff_cant_edit_in_under_review(self): + payment_request = PaymentRequestFactory(status=UNDER_REVIEW) + applicant = ApplicantFactory() + staff = StaffFactory() + + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) + + def test_applicant_and_staff_cant_edit_in_paid(self): + payment_request = PaymentRequestFactory(status=PAID) + applicant = ApplicantFactory() + staff = StaffFactory() + + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) + + def test_applicant_and_staff_cant_edit_in_decline(self): + payment_request = PaymentRequestFactory(status=DECLINED) + applicant = ApplicantFactory() + staff = StaffFactory() + + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 511bcdfb0aaa197ac221becc1aaff112b615a1b7..8d84549be4d9b8766df977d36034df4130f61501 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -1,6 +1,9 @@ +from decimal import Decimal from io import BytesIO from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse from opentech.apply.funds.tests.factories import LabSubmissionFactory @@ -10,42 +13,94 @@ from opentech.apply.users.tests.factories import ( ReviewerFactory, StaffFactory, SuperUserFactory, - UserFactory, + UserFactory ) from opentech.apply.utils.testing.tests import BaseViewTestCase from ..forms import SetPendingForm +from ..files import get_files +from ..models import ( + CHANGES_REQUESTED, + COMMITTED, + CONTRACTING, + IN_PROGRESS, + SUBMITTED, +) +from ..views import ContractsMixin, ProjectDetailSimplifiedView from .factories import ( + ContractFactory, DocumentCategoryFactory, PacketFileFactory, - ProjectFactory, + PaymentReceiptFactory, + PaymentRequestFactory, + ProjectFactory ) +class TestUpdateLeadView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = ApproverFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_update_lead(self): + project = ProjectFactory() + + new_lead = self.user_factory() + response = self.post_page(project, {'form-submitted-lead_form': '', 'lead': new_lead.id}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + self.assertEqual(project.lead, new_lead) + + def test_update_lead_from_none(self): + project = ProjectFactory(lead=None) + + new_lead = self.user_factory() + response = self.post_page(project, {'form-submitted-lead_form': '', 'lead': new_lead.id}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + self.assertEqual(project.lead, new_lead) + + class TestCreateApprovalView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' - user_factory = StaffFactory + user_factory = ApproverFactory def get_kwargs(self, instance): return {'pk': instance.id} def test_creating_an_approval_happy_path(self): - project = ProjectFactory() + project = ProjectFactory(in_approval=True) self.assertEqual(project.approvals.count(), 0) response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id}) self.assertEqual(response.status_code, 200) project.refresh_from_db() - approval = project.approvals.first() - self.assertEqual(project.approvals.count(), 1) self.assertFalse(project.is_locked) self.assertEqual(project.status, 'contracting') + approval = project.approvals.first() self.assertEqual(approval.project_id, project.pk) + def test_creating_an_approval_other_approver(self): + project = ProjectFactory(in_approval=True) + self.assertEqual(project.approvals.count(), 0) + + other = self.user_factory() + response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': other.id}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + self.assertEqual(project.approvals.count(), 0) + self.assertTrue(project.is_locked) + class BaseProjectDetailTestCase(BaseViewTestCase): url_name = 'funds:projects:{}' @@ -70,7 +125,7 @@ class TestStaffProjectDetailView(BaseProjectDetailTestCase): class TestUserProjectDetailView(BaseProjectDetailTestCase): - user_factory = UserFactory + user_factory = ApplicantFactory def test_doesnt_have_access(self): project = ProjectFactory() @@ -101,6 +156,62 @@ class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase): self.assertEqual(response.status_code, 403) +class TestStaffProjectRejectView(BaseProjectDetailTestCase): + user_factory = StaffFactory + + def test_cant_reject(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 403) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + +class TestApproverProjectRejectView(BaseProjectDetailTestCase): + user_factory = ApproverFactory + + def test_can_reject(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertFalse(project.is_locked) + + def test_cant_reject_no_comment(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': '', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + +class TestUserProjectRejectView(BaseProjectDetailTestCase): + user_factory = ApplicantFactory + + def test_cant_reject(self): + project = ProjectFactory(in_approval=True, user=self.user) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + class TestRemoveDocumentView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' @@ -166,6 +277,144 @@ class TestSendForApprovalView(BaseViewTestCase): self.assertEqual(project.status, 'committed') +class TestApplicantUploadContractView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_owner_upload_contract(self): + project = ProjectFactory(user=self.user) + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'contract.pdf' + + response = self.post_page(project, { + 'form-submitted-contract_form': '', + 'file': test_doc, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.contracts.count(), 1) + self.assertTrue(project.contracts.first().is_signed) + + def test_non_owner_upload_contract(self): + project = ProjectFactory() + contract_count = project.contracts.count() + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'contract.pdf' + + response = self.post_page(project, { + 'form-submitted-contract_form': '', + 'file': test_doc, + }) + self.assertEqual(response.status_code, 403) + + project.refresh_from_db() + self.assertEqual(project.contracts.count(), contract_count) + + +class TestStaffUploadContractView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_upload_contract(self): + project = ProjectFactory() + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'contract.pdf' + + response = self.post_page(project, { + 'form-submitted-contract_form': '', + 'file': test_doc, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.contracts.count(), 1) + self.assertFalse(project.contracts.first().is_signed) + + def test_upload_contract_with_signed_set_to_true(self): + project = ProjectFactory() + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'contract.pdf' + + response = self.post_page(project, { + 'form-submitted-contract_form': '', + 'file': test_doc, + 'is_signed': True, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.contracts.count(), 1) + self.assertTrue(project.contracts.first().is_signed) + + +class TestStaffSelectDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_can_choose(self): + category = DocumentCategoryFactory() + project = ProjectFactory() + + files = get_files(project) + + response = self.post_page(project, { + 'form-submitted-select_document_form': '', + 'category': category.id, + 'document': files[0].url, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) + + +class TestApplicantSelectDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_can_choose(self): + category = DocumentCategoryFactory() + project = ProjectFactory(user=self.user) + + files = get_files(project) + + response = self.post_page(project, { + 'form-submitted-select_document_form': '', + 'category': category.id, + 'document': files[0].url, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) + + class TestUploadDocumentView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' @@ -257,6 +506,31 @@ class TestStaffProjectEditView(BaseProjectEditTestCase): self.assertEqual(response.status_code, 200) self.assertRedirects(response, self.url(project, 'detail')) + def test_no_paf_form_renders(self): + project = ProjectFactory( + submission__round__parent__approval_form=None, + form_fields=None, + form_data={}, + ) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_pulls_paf_from_fund(self): + project = ProjectFactory(form_fields=None, form_data={}) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_edited_form_renders(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + class TestApproverProjectEditView(BaseProjectEditTestCase): user_factory = ApproverFactory @@ -291,6 +565,211 @@ class TestReviewerProjectEditView(BaseProjectEditTestCase): self.assertEqual(response.redirect_chain, []) +class TestContractsMixin(TestCase): + class DummyView: + def get_context_data(self): + return {} + + class DummyContractsView(ContractsMixin, DummyView): + def __init__(self, project): + self.project = project + + def get_object(self): + return self.project + + def test_all_signed_and_approved_contracts_appear(self): + project = ProjectFactory() + user = StaffFactory() + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=True, approver=user) + + contracts = self.DummyContractsView(project).get_context_data()['contracts'] + + self.assertEqual(len(contracts), 3) + + def test_mixture_with_latest_signed_returns_no_unsigned(self): + project = ProjectFactory() + user = StaffFactory() + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=False, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + + contracts = self.DummyContractsView(project).get_context_data()['contracts'] + + self.assertEqual(len(contracts), 2) + for contract in contracts: + self.assertTrue(contract.is_signed) + + def test_no_contracts_returns_nothing(self): + project = ProjectFactory() + contracts = self.DummyContractsView(project).get_context_data()['contracts'] + + self.assertEqual(len(contracts), 0) + + def test_all_unsigned_and_unapproved_returns_only_latest(self): + project = ProjectFactory() + ContractFactory(project=project, is_signed=False, approver=None) + ContractFactory(project=project, is_signed=False, approver=None) + latest = ContractFactory(project=project, is_signed=False, approver=None) + + context = self.DummyContractsView(project).get_context_data() + + contracts = context['contracts'] + to_approve = context['contract_to_approve'] + to_sign = context['contract_to_sign'] + + self.assertEqual(len(contracts), 0) + self.assertEqual(latest, to_sign) + self.assertIsNone(to_approve) + + def test_all_signed_and_unapproved_returns_latest(self): + project = ProjectFactory() + ContractFactory(project=project, is_signed=True, approver=None) + ContractFactory(project=project, is_signed=True, approver=None) + latest = ContractFactory(project=project, is_signed=True, approver=None) + + context = self.DummyContractsView(project).get_context_data() + + contracts = context['contracts'] + to_approve = context['contract_to_approve'] + to_sign = context['contract_to_sign'] + + self.assertEqual(len(contracts), 0) + self.assertEqual(latest, to_approve) + self.assertIsNone(to_sign) + + def test_mixture_of_both_latest_unsigned_and_unapproved(self): + project = ProjectFactory() + user = StaffFactory() + ContractFactory(project=project, is_signed=True, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=False, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + latest = ContractFactory(project=project, is_signed=False, approver=None) + + context = self.DummyContractsView(project).get_context_data() + + contracts = context['contracts'] + to_approve = context['contract_to_approve'] + to_sign = context['contract_to_sign'] + + self.assertEqual(len(contracts), 2) + self.assertEqual(latest, to_sign) + self.assertIsNone(to_approve) + + def test_mixture_of_both_latest_signed_and_unapproved(self): + project = ProjectFactory() + user = StaffFactory() + ContractFactory(project=project, is_signed=True, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=False, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + latest = ContractFactory(project=project, is_signed=True, approver=None) + + context = self.DummyContractsView(project).get_context_data() + + contracts = context['contracts'] + to_approve = context['contract_to_approve'] + to_sign = context['contract_to_sign'] + + self.assertEqual(len(contracts), 2) + self.assertEqual(latest, to_approve) + self.assertIsNone(to_sign) + + def test_mixture_of_both_latest_signed_and_approved(self): + project = ProjectFactory() + user = StaffFactory() + ContractFactory(project=project, is_signed=True, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=False, approver=None) + ContractFactory(project=project, is_signed=True, approver=user) + ContractFactory(project=project, is_signed=True, approver=user) + + context = self.DummyContractsView(project).get_context_data() + + contracts = context['contracts'] + to_approve = context['contract_to_approve'] + to_sign = context['contract_to_sign'] + + self.assertEqual(len(contracts), 3) + self.assertIsNone(to_approve) + self.assertIsNone(to_sign) + + +class TestApproveContractView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_approve_unapproved_contract(self): + project = ProjectFactory(status=CONTRACTING) + contract = ContractFactory(project=project, is_signed=True, approver=None) + + response = self.post_page(project, { + 'form-submitted-approve_contract_form': '', + 'id': contract.id, + }) + self.assertEqual(response.status_code, 200) + + contract.refresh_from_db() + self.assertEqual(contract.approver, self.user) + + project.refresh_from_db() + self.assertEqual(project.status, IN_PROGRESS) + + def test_approve_already_approved_contract(self): + project = ProjectFactory(status=IN_PROGRESS) + user = StaffFactory() + contract = ContractFactory(project=project, is_signed=True, approver=user) + + response = self.post_page(project, { + 'form-submitted-approve_contract_form': '', + 'id': contract.id, + }) + self.assertEqual(response.status_code, 200) + + contract.refresh_from_db() + self.assertEqual(contract.approver, user) + + project.refresh_from_db() + self.assertEqual(project.status, IN_PROGRESS) + + def test_approve_unsigned_contract(self): + project = ProjectFactory() + contract = ContractFactory(project=project, is_signed=False, approver=None) + + response = self.post_page(project, { + 'form-submitted-approve_contract_form': '', + 'id': contract.id, + }) + self.assertEqual(response.status_code, 200) + + messages = list(response.context['messages']) + self.assertEqual(len(messages), 1) + + def test_attempt_to_approve_non_latest(self): + project = ProjectFactory() + contract_attempt = ContractFactory(project=project, is_signed=True, approver=None) + contract_meant = ContractFactory(project=project, is_signed=True, approver=None) + + response = self.post_page(project, { + 'form-submitted-approve_contract_form': '', + 'id': contract_attempt.id, + }) + self.assertEqual(response.status_code, 200) + + messages = list(response.context['messages']) + self.assertEqual(len(messages), 1) + contract_attempt.refresh_from_db() + contract_meant.refresh_from_db() + self.assertIsNone(contract_attempt.approver) + self.assertIsNone(contract_meant.approver) + + class BasePacketFileViewTestCase(BaseViewTestCase): url_name = 'funds:projects:{}' base_view_name = 'document' @@ -338,3 +817,420 @@ class TestAnonPacketView(BasePacketFileViewTestCase): self.assertEqual(len(response.redirect_chain), 2) for path, _ in response.redirect_chain: self.assertIn(reverse('users_public:login'), path) + + +class TestRequestPaymentViewAsApplicant(BaseViewTestCase): + base_view_name = 'request' + url_name = 'funds:projects:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_creating_a_payment_request(self): + project = ProjectFactory(user=self.user) + self.assertEqual(project.payment_requests.count(), 0) + + invoice = BytesIO(b'somebinarydata') + invoice.name = 'invoice.pdf' + + receipts = BytesIO(b'someotherbinarydata') + receipts.name = 'receipts.pdf' + + response = self.post_page(project, { + 'form-submitted-request_payment_form': '', + 'requested_value': '10', + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'invoice': invoice, + 'receipts': receipts, + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(project.payment_requests.count(), 1) + + self.assertEqual(project.payment_requests.first().by, self.user) + + +class TestRequestPaymentViewAsStaff(BaseViewTestCase): + base_view_name = 'request' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_creating_a_payment_request(self): + project = ProjectFactory() + self.assertEqual(project.payment_requests.count(), 0) + + invoice = BytesIO(b'somebinarydata') + invoice.name = 'invoice.pdf' + + receipts = BytesIO(b'someotherbinarydata') + receipts.name = 'receipts.pdf' + + response = self.post_page(project, { + 'form-submitted-request_payment_form': '', + 'requested_value': '10', + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + 'invoice': invoice, + 'receipts': receipts, + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(project.payment_requests.count(), 1) + + self.assertEqual(project.payment_requests.first().by, self.user) + + +class TestProjectDetailSimplifiedView(TestCase): + def test_staff_only(self): + factory = RequestFactory() + project = ProjectFactory() + + request = factory.get(f'/project/{project.pk}') + request.user = StaffFactory() + + response = ProjectDetailSimplifiedView.as_view()(request, pk=project.pk) + self.assertEqual(response.status_code, 200) + + request.user = ApplicantFactory() + with self.assertRaises(PermissionDenied): + ProjectDetailSimplifiedView.as_view()(request, pk=project.pk) + + +class TestStaffDetailPaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 200) + + def test_wrong_project_cant(self): + other_project = ProjectFactory() + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk}) + self.assertEqual(response.status_code, 404) + + +class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 200) + + def test_other_cant(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 403) + + +class TestApplicantEditPaymentRequestView(BaseViewTestCase): + base_view_name = 'edit' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return {'pk': instance.pk} + + def test_editing_payment_remove_receipt(self): + payment_request = PaymentRequestFactory(project__user=self.user) + receipt = PaymentReceiptFactory(payment_request=payment_request) + + response = self.post_page(payment_request, { + 'requested_value': payment_request.requested_value, + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + 'invoice': None, + 'receipt_list': [receipt.pk], + }) + + self.assertEqual(response.status_code, 200) + + self.assertFalse(payment_request.receipts.exists()) + + def test_editing_payment_keeps_receipts(self): + project = ProjectFactory(user=self.user) + payment_request = PaymentRequestFactory(project=project) + receipt = PaymentReceiptFactory(payment_request=payment_request) + + requested_value = payment_request.requested_value + + response = self.post_page(payment_request, { + 'requested_value': requested_value + 1, + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + 'invoice': None, + 'receipt_list': [], + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(project.payment_requests.count(), 1) + + payment_request.refresh_from_db() + + self.assertEqual(project.payment_requests.first().pk, payment_request.pk) + + self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value) + self.assertEqual(payment_request.receipts.first().file, receipt.file) + + +class TestStaffEditPaymentRequestView(BaseViewTestCase): + base_view_name = 'edit' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.pk} + + def test_editing_payment_remove_receipt(self): + payment_request = PaymentRequestFactory() + receipt = PaymentReceiptFactory(payment_request=payment_request) + + response = self.post_page(payment_request, { + 'requested_value': payment_request.requested_value, + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + 'invoice': None, + 'receipt_list': [receipt.pk], + }) + + self.assertEqual(response.status_code, 200) + + self.assertFalse(payment_request.receipts.exists()) + + def test_editing_payment_keeps_receipts(self): + project = ProjectFactory() + payment_request = PaymentRequestFactory(project=project) + receipt = PaymentReceiptFactory(payment_request=payment_request) + + requested_value = payment_request.requested_value + + invoice = BytesIO(b'somebinarydata') + invoice.name = 'invoice.pdf' + + response = self.post_page(payment_request, { + 'requested_value': requested_value + 1, + 'date_from': '2018-08-15', + 'date_to': '2019-08-15', + 'comment': 'test comment', + 'invoice': invoice, + 'receipt_list': [receipt.pk], + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(project.payment_requests.count(), 1) + + payment_request.refresh_from_db() + + self.assertEqual(project.payment_requests.first().pk, payment_request.pk) + + self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value) + + +class TestStaffChangePaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory() + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + 'comment': 'this is a comment', + }) + self.assertEqual(response.status_code, 200) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, CHANGES_REQUESTED) + + +class TestApplicantChangePaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + }) + self.assertEqual(response.status_code, 200) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, SUBMITTED) + + def test_other_cant(self): + payment_request = PaymentRequestFactory() + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + }) + self.assertEqual(response.status_code, 403) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, SUBMITTED) + + +class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase): + base_view_name = 'invoice' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can_access(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertContains(response, payment_request.invoice.read()) + + def test_cant_access_if_project_wrong(self): + other_project = ProjectFactory() + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk}) + self.assertEqual(response.status_code, 404) + + +class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase): + base_view_name = 'invoice' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can_access_own(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.get_page(payment_request) + self.assertContains(response, payment_request.invoice.read()) + + def test_cant_access_other(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 403) + + +class TestStaffPaymentRequestReceiptPrivateMedia(BaseViewTestCase): + base_view_name = 'receipt' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.payment_request.pk, + 'file_pk': instance.pk, + } + + def test_can_access(self): + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt) + self.assertContains(response, payment_receipt.file.read()) + + +class TestApplicantPaymentRequestReceiptPrivateMedia(BaseViewTestCase): + base_view_name = 'receipt' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.payment_request.pk, + 'file_pk': instance.pk, + } + + def test_can_access_own(self): + payment_receipt = PaymentReceiptFactory(payment_request__project__user=self.user) + response = self.get_page(payment_receipt) + self.assertContains(response, payment_receipt.file.read()) + + def test_cant_access_other(self): + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt) + self.assertEqual(response.status_code, 403) + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') +class TestProjectListView(TestCase): + def test_staff_can_access_project_list_page(self): + ProjectFactory(status=CONTRACTING) + ProjectFactory(status=IN_PROGRESS) + + self.client.force_login(StaffFactory()) + + url = reverse('apply:projects:all') + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + + def test_applicants_cannot_access_project_list_page(self): + ProjectFactory(status=CONTRACTING) + ProjectFactory(status=IN_PROGRESS) + + self.client.force_login(UserFactory()) + + url = reverse('apply:projects:all') + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 403) + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') +class TestProjectOverviewView(TestCase): + def test_staff_can_access(self): + ProjectFactory(status=CONTRACTING) + ProjectFactory(status=IN_PROGRESS) + + self.client.force_login(StaffFactory()) + + url = reverse('apply:projects:overview') + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + + def test_applicants_cannot_access(self): + ProjectFactory(status=CONTRACTING) + ProjectFactory(status=IN_PROGRESS) + + self.client.force_login(UserFactory()) + + url = reverse('apply:projects:overview') + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 403) diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index f3ec5b4485357925701ee72aa5084d631c8ceda4..81c86378d4ed4add4301b5bb8fb535df20c4e2e5 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -1,7 +1,21 @@ from django.conf import settings from django.urls import include, path -from .views import ProjectDetailView, ProjectEditView, ProjectPrivateMediaView +from .views import ( + ContractPrivateMediaView, + CreatePaymentRequestView, + DeletePaymentRequestView, + EditPaymentRequestView, + PaymentRequestListView, + PaymentRequestPrivateMedia, + PaymentRequestView, + ProjectDetailSimplifiedView, + ProjectDetailView, + ProjectEditView, + ProjectListView, + ProjectOverviewView, + ProjectPrivateMediaView, +) app_name = 'projects' @@ -9,9 +23,24 @@ urlpatterns = [] if settings.PROJECTS_ENABLED: urlpatterns = [ + path('', ProjectOverviewView.as_view(), name='overview'), + path('all/', ProjectListView.as_view(), name='all'), path('<int:pk>/', include([ path('', ProjectDetailView.as_view(), name='detail'), path('edit/', ProjectEditView.as_view(), name="edit"), path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"), + path('contract/<int:file_pk>/', ContractPrivateMediaView.as_view(), name="contract"), + path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'), + path('request/', CreatePaymentRequestView.as_view(), name='request'), ])), + path('payment-requests/', include(([ + path('', PaymentRequestListView.as_view(), name='all'), + path('<int:pk>/', include([ + path('', PaymentRequestView.as_view(), name='detail'), + path('edit/', EditPaymentRequestView.as_view(), name='edit'), + path('delete/', DeletePaymentRequestView.as_view(), name='delete'), + path('documents/invoice/', PaymentRequestPrivateMedia.as_view(), name="invoice"), + path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"), + ])), + ], 'payments'))), ] diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py deleted file mode 100644 index 812fee430e921d6fcb3e7ad812bad6a531000799..0000000000000000000000000000000000000000 --- a/opentech/apply/projects/views.py +++ /dev/null @@ -1,261 +0,0 @@ -from copy import copy - -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import UserPassesTestMixin -from django.core.exceptions import PermissionDenied -from django.db import transaction -from django.http import Http404 -from django.shortcuts import get_object_or_404, redirect -from django.utils.decorators import method_decorator -from django.utils.translation import ugettext_lazy as _ -from django.views.generic import CreateView, DetailView, FormView, UpdateView - -from opentech.apply.activity.messaging import MESSAGES, messenger -from opentech.apply.activity.views import ActivityContextMixin, CommentFormView -from opentech.apply.users.decorators import staff_required -from opentech.apply.utils.storage import PrivateMediaView -from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin, - ViewDispatcher) - -from .forms import ( - CreateApprovalForm, - ProjectApprovalForm, - ProjectEditForm, - RejectionForm, - RemoveDocumentForm, - SetPendingForm, - UpdateProjectLeadForm, - UploadDocumentForm, -) -from .models import CONTRACTING, Approval, Project, PacketFile - - -@method_decorator(staff_required, name='dispatch') -class CreateApprovalView(DelegatedViewMixin, CreateView): - context_name = 'add_approval_form' - form_class = CreateApprovalForm - model = Approval - - @transaction.atomic() - def form_valid(self, form): - project = self.kwargs['object'] - form.instance.project = project - response = super().form_valid(form) - - messenger( - MESSAGES.APPROVE_PROJECT, - request=self.request, - user=self.request.user, - source=project, - ) - - project.is_locked = False - project.status = CONTRACTING - project.save(update_fields=['is_locked', 'status']) - - return response - - -@method_decorator(staff_required, name='dispatch') -class RejectionView(DelegatedViewMixin, UpdateView): - context_name = 'rejection_form' - form_class = RejectionForm - model = Project - - def form_valid(self, form): - messenger( - MESSAGES.REQUEST_PROJECT_CHANGE, - request=self.request, - user=self.request.user, - source=self.object, - comment=form.cleaned_data['comment'], - ) - - self.object.is_locked = False - self.object.save(update_fields=['is_locked']) - - return redirect(self.object) - - -@method_decorator(staff_required, name='dispatch') -class RemoveDocumentView(DelegatedViewMixin, FormView): - context_name = 'remove_document_form' - form_class = RemoveDocumentForm - model = Project - - def form_valid(self, form): - document_id = form.cleaned_data["id"] - project = self.kwargs['object'] - - try: - project.packet_files.get(pk=document_id).delete() - except PacketFile.DoesNotExist: - pass - - return redirect(project) - - -@method_decorator(staff_required, name='dispatch') -class SendForApprovalView(DelegatedViewMixin, UpdateView): - context_name = 'request_approval_form' - form_class = SetPendingForm - model = Project - - def form_valid(self, form): - # lock project - response = super().form_valid(form) - - messenger( - MESSAGES.SEND_FOR_APPROVAL, - request=self.request, - user=self.request.user, - source=self.object, - ) - - return response - - -@method_decorator(staff_required, name='dispatch') -class UpdateLeadView(DelegatedViewMixin, UpdateView): - model = Project - form_class = UpdateProjectLeadForm - context_name = 'lead_form' - - def form_valid(self, form): - # Fetch the old lead from the database - old = copy(self.get_object()) - - response = super().form_valid(form) - - messenger( - MESSAGES.UPDATE_PROJECT_LEAD, - request=self.request, - user=self.request.user, - source=form.instance, - related=old.lead or 'Unassigned', - ) - - return response - - -@method_decorator(staff_required, name='dispatch') -class UploadDocumentView(DelegatedViewMixin, CreateView): - context_name = 'document_form' - form_class = UploadDocumentForm - model = Project - - def form_valid(self, form): - project = self.kwargs['object'] - form.instance.project = project - response = super().form_valid(form) - - messenger( - MESSAGES.UPLOAD_DOCUMENT, - request=self.request, - user=self.request.user, - source=project, - title=form.instance.title - ) - - return response - - -class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): - form_views = [ - CommentFormView, - CreateApprovalView, - RejectionView, - RemoveDocumentView, - SendForApprovalView, - UpdateLeadView, - UploadDocumentView, - ] - model = Project - template_name_suffix = '_admin_detail' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['approvals'] = self.object.approvals.distinct('by') - context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) - return context - - -class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): - form_views = [ - CommentFormView, - ] - - model = Project - template_name_suffix = '_applicant_detail' - - def dispatch(self, request, *args, **kwargs): - project = self.get_object() - # This view is only for applicants. - if project.user != request.user: - raise PermissionDenied - return super().dispatch(request, *args, **kwargs) - - -@method_decorator(login_required, name='dispatch') -class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): - raise_exception = True - - def dispatch(self, *args, **kwargs): - project_pk = self.kwargs['pk'] - self.project = get_object_or_404(Project, pk=project_pk) - return super().dispatch(*args, **kwargs) - - def get_media(self, *args, **kwargs): - document = PacketFile.objects.get(pk=kwargs['file_pk']) - if document.project != self.project: - raise Http404 - return document.document - - def test_func(self): - if self.request.user.is_apply_staff: - return True - - if self.request.user == self.project.user: - return True - - return False - - -class ProjectDetailView(ViewDispatcher): - admin_view = AdminProjectDetailView - applicant_view = ApplicantProjectDetailView - - -class ProjectApprovalEditView(UpdateView): - form_class = ProjectApprovalForm - model = Project - - def dispatch(self, request, *args, **kwargs): - project = self.get_object() - if not project.editable_by(request.user): - messages.info(self.request, _('You are not allowed to edit the project at this time')) - return redirect(project) - return super().dispatch(request, *args, **kwargs) - - -class ApplicantProjectEditView(UpdateView): - form_class = ProjectEditForm - model = Project - - def dispatch(self, request, *args, **kwargs): - project = self.get_object() - # This view is only for applicants. - if project.user != request.user: - raise PermissionDenied - - if not project.editable_by(request.user): - messages.info(self.request, _('You are not allowed to edit the project at this time')) - return redirect(project) - - return super().dispatch(request, *args, **kwargs) - - -class ProjectEditView(ViewDispatcher): - admin_view = ProjectApprovalEditView - applicant_view = ApplicantProjectEditView diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa45c29c176b0b33d5b01a6e2e926dc9f487fbc7 --- /dev/null +++ b/opentech/apply/projects/views/__init__.py @@ -0,0 +1,2 @@ +from .payment import * # NOQA +from .project import * # NOQA diff --git a/opentech/apply/projects/views/payment.py b/opentech/apply/projects/views/payment.py new file mode 100644 index 0000000000000000000000000000000000000000..b9306f7bca7273cdd304831858e2976b020c6318 --- /dev/null +++ b/opentech/apply/projects/views/payment.py @@ -0,0 +1,208 @@ +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + UpdateView +) +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin + +from opentech.apply.activity.messaging import MESSAGES, messenger +from opentech.apply.users.decorators import staff_required +from opentech.apply.utils.storage import PrivateMediaView +from opentech.apply.utils.views import ( + DelegateableView, + DelegatedViewMixin, + ViewDispatcher, +) + +from ..forms import ( + ChangePaymentRequestStatusForm, + CreatePaymentRequestForm, + EditPaymentRequestForm, +) +from ..filters import PaymentRequestListFilter +from ..models import ( + PaymentRequest, + Project +) +from ..tables import PaymentRequestsListTable + + +@method_decorator(login_required, name='dispatch') +class PaymentRequestAccessMixin: + model = PaymentRequest + + def dispatch(self, request, *args, **kwargs): + is_admin = request.user.is_apply_staff + is_owner = request.user == self.get_object().project.user + if not (is_owner or is_admin): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + +@method_decorator(staff_required, name='dispatch') +class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMixin, UpdateView): + form_class = ChangePaymentRequestStatusForm + context_name = 'change_payment_status' + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('user') + return kwargs + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object, + ) + + return response + + +class DeletePaymentRequestView(DeleteView): + model = PaymentRequest + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.can_user_delete(request.user): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + @transaction.atomic() + def delete(self, request, *args, **kwargs): + response = super().delete(request, *args, **kwargs) + + messenger( + MESSAGES.DELETE_PAYMENT_REQUEST, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + def get_success_url(self): + return self.project.get_absolute_url() + + +class PaymentRequestAdminView(PaymentRequestAccessMixin, DelegateableView, DetailView): + form_views = [ + ChangePaymentRequestStatusView + ] + template_name_suffix = '_admin_detail' + + +class PaymentRequestApplicantView(PaymentRequestAccessMixin, DelegateableView, DetailView): + form_views = [] + + +class PaymentRequestView(ViewDispatcher): + admin_view = PaymentRequestAdminView + applicant_view = PaymentRequestApplicantView + + +class CreatePaymentRequestView(CreateView): + model = PaymentRequest + form_class = CreatePaymentRequestForm + + def dispatch(self, request, *args, **kwargs): + self.project = Project.objects.get(pk=kwargs['pk']) + if not request.user.is_apply_staff and not self.project.user == request.user: + return redirect(self.project) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return super().get_context_data(project=self.project, **kwargs) + + def form_valid(self, form): + form.instance.project = self.project + form.instance.by = self.request.user + + response = super().form_valid(form) + + messenger( + MESSAGES.REQUEST_PAYMENT, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + +class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView): + form_class = EditPaymentRequestForm + + def dispatch(self, request, *args, **kwargs): + payment_request = self.get_object() + if not payment_request.can_user_edit(request.user): + return redirect(payment_request) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PAYMENT_REQUEST, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object, + ) + + return response + + +@method_decorator(login_required, name='dispatch') +class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + payment_pk = self.kwargs['pk'] + self.payment_request = get_object_or_404(PaymentRequest, pk=payment_pk) + + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + file_pk = kwargs.get('file_pk') + if not file_pk: + return self.payment_request.invoice + + receipt = get_object_or_404(self.payment_request.receipts, pk=file_pk) + return receipt.file + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.payment_request.project.user: + return True + + return False + + +@method_decorator(staff_required, name='dispatch') +class PaymentRequestListView(SingleTableMixin, FilterView): + filterset_class = PaymentRequestListFilter + model = PaymentRequest + table_class = PaymentRequestsListTable + template_name = 'application_projects/payment_request_list.html' + + def get_queryset(self): + return PaymentRequest.objects.order_by('date_to') diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py new file mode 100644 index 0000000000000000000000000000000000000000..88cf33d6b778495279bcb8380fd7741f2b75ef03 --- /dev/null +++ b/opentech/apply/projects/views/project.py @@ -0,0 +1,644 @@ +from copy import copy + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.db.models import Count +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy +from django.utils.decorators import method_decorator +from django.utils.functional import cached_property +from django.utils.text import mark_safe +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import ( + CreateView, + DetailView, + FormView, + TemplateView, + UpdateView +) +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin + +from opentech.apply.activity.messaging import MESSAGES, messenger +from opentech.apply.activity.views import ActivityContextMixin, CommentFormView +from opentech.apply.users.decorators import approver_required, staff_required +from opentech.apply.utils.storage import PrivateMediaView +from opentech.apply.utils.views import ( + DelegateableView, + DelegatedViewMixin, + ViewDispatcher, +) + +from ..files import get_files +from ..filters import ( + PaymentRequestListFilter, + ProjectListFilter, +) +from ..forms import ( + ApproveContractForm, + CreateApprovalForm, + ProjectApprovalForm, + ProjectEditForm, + RejectionForm, + RemoveDocumentForm, + SelectDocumentForm, + SetPendingForm, + StaffUploadContractForm, + UpdateProjectLeadForm, + UploadContractForm, + UploadDocumentForm +) +from ..models import ( + CONTRACTING, + IN_PROGRESS, + PROJECT_STATUS_CHOICES, + Approval, + Contract, + PacketFile, + PaymentRequest, + Project +) +from ..tables import ( + PaymentRequestsListTable, + ProjectsListTable +) + + +# APPROVAL VIEWS + +@method_decorator(staff_required, name='dispatch') +class SendForApprovalView(DelegatedViewMixin, UpdateView): + context_name = 'request_approval_form' + form_class = SetPendingForm + model = Project + + def form_valid(self, form): + # lock project + response = super().form_valid(form) + + messenger( + MESSAGES.SEND_FOR_APPROVAL, + request=self.request, + user=self.request.user, + source=self.object, + ) + + return response + + +@method_decorator(staff_required, name='dispatch') +class CreateApprovalView(DelegatedViewMixin, CreateView): + context_name = 'add_approval_form' + form_class = CreateApprovalForm + model = Approval + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + kwargs.get('initial', {}).update({'by': kwargs.get('user')}) + return kwargs + + @transaction.atomic() + def form_valid(self, form): + project = self.kwargs['object'] + old_stage = project.get_status_display() + + form.instance.project = project + + response = super().form_valid(form) + + messenger( + MESSAGES.APPROVE_PROJECT, + request=self.request, + user=self.request.user, + source=project, + ) + + project.send_to_compliance(self.request) + + project.is_locked = False + project.status = CONTRACTING + project.save(update_fields=['is_locked', 'status']) + + messenger( + MESSAGES.PROJECT_TRANSITION, + request=self.request, + user=self.request.user, + source=project, + related=old_stage, + ) + + return response + + +@method_decorator(approver_required, name='dispatch') +class RejectionView(DelegatedViewMixin, UpdateView): + context_name = 'rejection_form' + form_class = RejectionForm + model = Project + + def form_valid(self, form): + messenger( + MESSAGES.REQUEST_PROJECT_CHANGE, + request=self.request, + user=self.request.user, + source=self.object, + comment=form.cleaned_data['comment'], + ) + + self.object.is_locked = False + self.object.save(update_fields=['is_locked']) + + return redirect(self.object) + + +# PROJECT DOCUMENTS + +@method_decorator(staff_required, name='dispatch') +class UploadDocumentView(DelegatedViewMixin, CreateView): + context_name = 'document_form' + form_class = UploadDocumentForm + model = Project + + def form_valid(self, form): + project = self.kwargs['object'] + form.instance.project = project + response = super().form_valid(form) + + messenger( + MESSAGES.UPLOAD_DOCUMENT, + request=self.request, + user=self.request.user, + source=project, + ) + + return response + + +@method_decorator(staff_required, name='dispatch') +class RemoveDocumentView(DelegatedViewMixin, FormView): + context_name = 'remove_document_form' + form_class = RemoveDocumentForm + model = Project + + def form_valid(self, form): + document_id = form.cleaned_data["id"] + project = self.kwargs['object'] + + try: + project.packet_files.get(pk=document_id).delete() + except PacketFile.DoesNotExist: + pass + + return redirect(project) + + +@method_decorator(login_required, name='dispatch') +class SelectDocumentView(DelegatedViewMixin, CreateView): + form_class = SelectDocumentForm + context_name = 'select_document_form' + model = PacketFile + + @property + def should_show(self): + return bool(self.files) + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + return super().dispatch(request, *args, **kwargs) + + def form_invalid(self, form): + for error in form.errors: + messages.error(self.request, error) + + return redirect(self.project) + + def form_valid(self, form): + form.instance.project = self.project + form.instance.name = form.instance.document.name + + response = super().form_valid(form) + + messenger( + MESSAGES.UPLOAD_DOCUMENT, + request=self.request, + user=self.request.user, + source=self.project, + ) + + return response + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('user') + kwargs.pop('instance') + kwargs['existing_files'] = get_files(self.get_parent_object()) + return kwargs + + +# GENERAL FORM VIEWS + +@method_decorator(staff_required, name='dispatch') +class UpdateLeadView(DelegatedViewMixin, UpdateView): + model = Project + form_class = UpdateProjectLeadForm + context_name = 'lead_form' + + def form_valid(self, form): + # Fetch the old lead from the database + old_lead = copy(self.get_object().lead) + + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PROJECT_LEAD, + request=self.request, + user=self.request.user, + source=form.instance, + related=old_lead or 'Unassigned', + ) + + return response + + +# CONTRACTS + +class ContractsMixin: + def get_context_data(self, **kwargs): + project = self.get_object() + contracts = project.contracts.select_related( + 'approver', + ).order_by('-created_at') + + latest_contract = contracts.first() + contract_to_approve = None + contract_to_sign = None + if latest_contract: + if not latest_contract.is_signed: + contract_to_sign = latest_contract + elif not latest_contract.approver: + contract_to_approve = latest_contract + + context = super().get_context_data(**kwargs) + context['contract_to_approve'] = contract_to_approve + context['contract_to_sign'] = contract_to_sign + context['contracts'] = contracts.approved() + return context + + +@method_decorator(staff_required, name='dispatch') +class ApproveContractView(DelegatedViewMixin, UpdateView): + form_class = ApproveContractForm + model = Contract + context_name = 'approve_contract_form' + + def get_object(self): + project = self.get_parent_object() + latest_contract = project.contracts.order_by('-created_at').first() + if latest_contract and not latest_contract.approver: + return latest_contract + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['instance'] = self.get_object() + kwargs.pop('user') + return kwargs + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + return super().dispatch(request, *args, **kwargs) + + def form_invalid(self, form): + messages.error(self.request, mark_safe(_('Sorry something went wrong') + form.errors.as_ul())) + return super().form_invalid(form) + + def form_valid(self, form): + with transaction.atomic(): + form.instance.approver = self.request.user + form.instance.project = self.project + response = super().form_valid(form) + + old_stage = self.project.get_status_display() + + messenger( + MESSAGES.APPROVE_CONTRACT, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + self.project.status = IN_PROGRESS + self.project.save(update_fields=['status']) + + messenger( + MESSAGES.PROJECT_TRANSITION, + request=self.request, + user=self.request.user, + source=self.project, + related=old_stage, + ) + + return response + + def get_success_url(self): + return self.project.get_absolute_url() + + +@method_decorator(login_required, name='dispatch') +class UploadContractView(DelegatedViewMixin, CreateView): + context_name = 'contract_form' + model = Project + + def dispatch(self, request, *args, **kwargs): + response = super().dispatch(request, *args, **kwargs) + + project = self.kwargs['object'] + is_owner = project.user == request.user + if not (request.user.is_apply_staff or is_owner): + raise PermissionDenied + + return response + + def get_form_class(self): + if self.request.user.is_apply_staff: + return StaffUploadContractForm + return UploadContractForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + kwargs.pop('user') + return kwargs + + def form_valid(self, form): + project = self.kwargs['object'] + + form.instance.project = project + + if self.request.user == project.user: + form.instance.is_signed = True + + response = super().form_valid(form) + + messenger( + MESSAGES.UPLOAD_CONTRACT, + request=self.request, + user=self.request.user, + source=project, + related=form.instance, + ) + + return response + + +# PROJECT VIEW +class BaseProjectDetailView(DetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['statuses'] = PROJECT_STATUS_CHOICES + context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status) + return context + + +class AdminProjectDetailView( + ActivityContextMixin, + DelegateableView, + ContractsMixin, + BaseProjectDetailView, +): + form_views = [ + ApproveContractView, + CommentFormView, + CreateApprovalView, + RejectionView, + RemoveDocumentView, + SelectDocumentView, + SendForApprovalView, + UpdateLeadView, + UploadContractView, + UploadDocumentView, + ] + model = Project + template_name_suffix = '_admin_detail' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['approvals'] = self.object.approvals.distinct('by') + context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) + return context + + +class ApplicantProjectDetailView( + ActivityContextMixin, + DelegateableView, + ContractsMixin, + BaseProjectDetailView, +): + form_views = [ + CommentFormView, + SelectDocumentView, + UploadContractView, + UploadDocumentView, + ] + + model = Project + template_name_suffix = '_applicant_detail' + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + if project.user != request.user: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class ProjectDetailView(ViewDispatcher): + admin_view = AdminProjectDetailView + applicant_view = ApplicantProjectDetailView + + +@method_decorator(login_required, name='dispatch') +class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + project_pk = self.kwargs['pk'] + self.project = get_object_or_404(Project, pk=project_pk) + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + document = PacketFile.objects.get(pk=kwargs['file_pk']) + if document.project != self.project: + raise Http404 + return document.document + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.project.user: + return True + + return False + + +@method_decorator(login_required, name='dispatch') +class ContractPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + project_pk = self.kwargs['pk'] + self.project = get_object_or_404(Project, pk=project_pk) + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + document = Contract.objects.get(pk=kwargs['file_pk']) + if document.project != self.project: + raise Http404 + return document.file + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.project.user: + return True + + return False + + +# PROJECT EDIT + +@method_decorator(staff_required, name='dispatch') +class ProjectDetailSimplifiedView(DetailView): + model = Project + template_name_suffix = '_simplified_detail' + + +class ProjectApprovalEditView(UpdateView): + form_class = ProjectApprovalForm + model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + return super().dispatch(request, *args, **kwargs) + + @cached_property + def approval_form(self): + if self.object.get_defined_fields(): + approval_form = self.object + else: + approval_form = self.object.submission.page.specific.approval_form + + return approval_form + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + + if self.approval_form: + fields = self.approval_form.get_form_fields() + else: + fields = {} + + kwargs['extra_fields'] = fields + kwargs['initial'].update(self.object.raw_data) + return kwargs + + def form_valid(self, form): + try: + form_fields = self.approval_form.form_fields + except AttributeError: + form_fields = [] + + form.instance.form_fields = form_fields + + return super().form_valid(form) + + +class ApplicantProjectEditView(UpdateView): + form_class = ProjectEditForm + model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + # This view is only for applicants. + if project.user != request.user: + raise PermissionDenied + + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + + return super().dispatch(request, *args, **kwargs) + + +class ProjectEditView(ViewDispatcher): + admin_view = ProjectApprovalEditView + applicant_view = ApplicantProjectEditView + + +@method_decorator(staff_required, name='dispatch') +class ProjectListView(SingleTableMixin, FilterView): + filterset_class = ProjectListFilter + model = Project + table_class = ProjectsListTable + template_name = 'application_projects/project_list.html' + + def get_queryset(self): + return Project.objects.for_table() + + +@method_decorator(staff_required, name='dispatch') +class ProjectOverviewView(TemplateView): + template_name = 'application_projects/overview.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['projects'] = self.get_projects(self.request) + context['payment_requests'] = self.get_payment_requests(self.request) + context['status_counts'] = self.get_status_counts() + return context + + def get_payment_requests(self, request): + payment_requests = PaymentRequest.objects.order_by('date_to')[:10] + + return { + 'filterset': PaymentRequestListFilter(request.GET or None, request=request, queryset=payment_requests), + 'table': PaymentRequestsListTable(payment_requests, order_by=()), + 'url': reverse('apply:projects:payments:all'), + } + + def get_projects(self, request): + projects = Project.objects.for_table()[:10] + + return { + 'filterset': ProjectListFilter(request.GET or None, request=request, queryset=projects), + 'table': ProjectsListTable(projects, order_by=()), + 'url': reverse('apply:projects:all'), + } + + def get_status_counts(self): + status_counts = dict( + Project.objects.all().values('status').annotate( + count=Count('status'), + ).values_list( + 'status', + 'count', + ) + ) + + return { + key: { + 'name': display, + 'count': status_counts.get(key, 0), + 'url': reverse_lazy("funds:projects:all") + '?status=' + key, + } + for key, display in PROJECT_STATUS_CHOICES + } diff --git a/opentech/apply/stream_forms/blocks.py b/opentech/apply/stream_forms/blocks.py index cb7f3ff6683382badc86b9016a654c7f00d4ecd5..86fcf0b4b968db919ad77541fbf7b874eb0cfb76 100644 --- a/opentech/apply/stream_forms/blocks.py +++ b/opentech/apply/stream_forms/blocks.py @@ -1,5 +1,6 @@ # Credit to https://github.com/BertrandBordage for initial implementation import bleach +from dateutil.parser import isoparse, parse from django_bleach.templatetags.bleach_tags import bleach_value from django import forms @@ -57,6 +58,10 @@ class FormFieldBlock(StructBlock): field_kwargs = self.get_field_kwargs(struct_value) return self.get_field_class(struct_value)(**field_kwargs) + def decode(self, value): + """Convert JSON representation into actual python objects""" + return value + def serialize(self, value, context): field_kwargs = self.get_field_kwargs(value) return { @@ -124,7 +129,7 @@ class CharFieldBlock(OptionalFormFieldBlock): def get_searchable_content(self, value, data): # CharField acts as a fallback. Force data to string data = str(data) - return bleach.clean(data, tags=[], strip=True) + return bleach.clean(data or '', tags=[], strip=True) class TextFieldBlock(OptionalFormFieldBlock): @@ -137,7 +142,7 @@ class TextFieldBlock(OptionalFormFieldBlock): template = 'stream_forms/render_unsafe_field.html' def get_searchable_content(self, value, data): - return bleach.clean(data, tags=[], strip=True) + return bleach.clean(data or '', tags=[], strip=True) class NumberFieldBlock(OptionalFormFieldBlock): @@ -298,6 +303,10 @@ class DateFieldBlock(OptionalFormFieldBlock): def get_searchable_content(self, value, data): return None + def decode(self, value): + if value: + return parse(value).date() + class HTML5TimeInput(forms.TimeInput): input_type = 'time' @@ -316,6 +325,10 @@ class TimeFieldBlock(OptionalFormFieldBlock): def get_searchable_content(self, value, data): return None + def decode(self, value): + if value: + return parse(value).time() + class DateTimePickerInput(forms.SplitDateTimeWidget): def __init__(self, attrs=None, date_format=None, time_format=None): @@ -345,6 +358,10 @@ class DateTimeFieldBlock(OptionalFormFieldBlock): def get_searchable_content(self, value, data): return None + def decode(self, value): + if value: + return isoparse(value) + class UploadableMediaBlock(OptionalFormFieldBlock): class Meta: diff --git a/opentech/apply/stream_forms/models.py b/opentech/apply/stream_forms/models.py index cfda19a6267faa1abacde766623dd80b0fde2c5b..ca60ffc3ee9f2247d69b71b8c01e4b6f81e4de77 100644 --- a/opentech/apply/stream_forms/models.py +++ b/opentech/apply/stream_forms/models.py @@ -10,6 +10,30 @@ from .forms import BlockFieldWrapper, PageStreamBaseForm class BaseStreamForm: submission_form_class = PageStreamBaseForm + @classmethod + def from_db(cls, db, field_names, values): + instance = super().from_db(db, field_names, values) + if 'form_data' in field_names: + instance.form_data = cls.deserialize_form_data(instance, instance.form_data, instance.form_fields) + return instance + + @classmethod + def deserialize_form_data(cls, instance, form_data, form_fields): + data = form_data.copy() + # PERFORMANCE NOTE: + # Do not attempt to iterate over form_fields - that will fully instantiate the form_fields + # including any sub queries that they do + for i, field_data in enumerate(form_fields.stream_data): + block = form_fields.stream_block.child_blocks[field_data['type']] + field_id = field_data.get('id') + try: + value = data[field_id] + except KeyError: + pass + else: + data[field_id] = block.decode(value) + return data + def get_defined_fields(self): return self.form_fields diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py index f3421670610081f668ae6adeb5b2929cb521468d..045438cefae3c3480053ff85c6c5ad82e803440e 100644 --- a/opentech/apply/stream_forms/testing/factories.py +++ b/opentech/apply/stream_forms/testing/factories.py @@ -3,7 +3,7 @@ import json import uuid from django.core.files.uploadedfile import SimpleUploadedFile - +from django.core.serializers.json import DjangoJSONEncoder import factory from wagtail.core.blocks import RichTextBlock from wagtail.core.rich_text import RichText @@ -11,10 +11,11 @@ import wagtail_factories from opentech.apply.stream_forms import blocks as stream_blocks -__all__ = ['FormFieldBlockFactory', 'CharFieldBlockFactory', - 'NumberFieldBlockFactory', 'RadioFieldBlockFactory', - 'UploadableMediaFactory', 'ImageFieldBlockFactory', - 'FileFieldBlockFactory', 'MultiFileFieldBlockFactory'] +__all__ = ['BLOCK_FACTORY_DEFINITION', 'FormFieldBlockFactory', + 'CharFieldBlockFactory', 'NumberFieldBlockFactory', + 'RadioFieldBlockFactory', 'UploadableMediaFactory', + 'ImageFieldBlockFactory', 'FileFieldBlockFactory', + 'MultiFileFieldBlockFactory'] class AnswerFactory(factory.Factory): @@ -99,12 +100,14 @@ class FormFieldBlockFactory(wagtail_factories.StructBlockFactory): model = stream_blocks.FormFieldBlock @classmethod - def make_answer(cls, params=dict()): - return cls.default_value.generate(params) + def make_answer(cls, params=None): + return cls.default_value.generate(params or {}) @classmethod - def make_form_answer(cls, params=dict()): - return cls.make_answer(params) + def make_form_answer(cls, params=None): + if params: + return params + return cls.make_answer(params or {}) class CharFieldBlockFactory(FormFieldBlockFactory): @@ -114,6 +117,45 @@ class CharFieldBlockFactory(FormFieldBlockFactory): model = stream_blocks.CharFieldBlock +class TextFieldBlockFactory(FormFieldBlockFactory): + default_value = factory.Faker('sentence') + + class Meta: + model = stream_blocks.TextFieldBlock + + +class DateFieldBlockFactory(FormFieldBlockFactory): + default_value = factory.Faker('date_object') + + class Meta: + model = stream_blocks.DateFieldBlock + + +class TimeFieldBlockFactory(FormFieldBlockFactory): + default_value = factory.Faker('time_object') + + class Meta: + model = stream_blocks.TimeFieldBlock + + +class DateTimeFieldBlockFactory(FormFieldBlockFactory): + default_value = factory.Faker('date_time') + + class Meta: + model = stream_blocks.DateTimeFieldBlock + + @classmethod + def make_form_answer(cls, params=None): + if params: + date_time = params + else: + date_time = super().make_form_answer(params) + return { + 'date': str(date_time.date()), + 'time': str(date_time.time()), + } + + class NumberFieldBlockFactory(FormFieldBlockFactory): default_value = 100 @@ -121,10 +163,32 @@ class NumberFieldBlockFactory(FormFieldBlockFactory): model = stream_blocks.NumberFieldBlock @classmethod - def make_answer(cls, params=dict()): + def make_answer(cls, params=None): return cls.default_value +class CheckboxFieldBlockFactory(FormFieldBlockFactory): + choices = ['check_one', 'check_two'] + + class Meta: + model = stream_blocks.CheckboxFieldBlock + + @classmethod + def make_answer(cls, params=None): + return cls.choices[0] + + +class CheckboxesFieldBlockFactory(FormFieldBlockFactory): + checkboxes = ['check_one', 'check_two', 'check_three'] + + class Meta: + model = stream_blocks.CheckboxesFieldBlock + + @classmethod + def make_answer(cls, params=None): + return cls.checkboxes[0:2] + + class RadioFieldBlockFactory(FormFieldBlockFactory): choices = ['first', 'second'] @@ -132,7 +196,18 @@ class RadioFieldBlockFactory(FormFieldBlockFactory): model = stream_blocks.RadioButtonsFieldBlock @classmethod - def make_answer(cls, params=dict()): + def make_answer(cls, params=None): + return cls.choices[0] + + +class DropdownFieldBlockFactory(FormFieldBlockFactory): + choices = ['first', 'second'] + + class Meta: + model = stream_blocks.DropdownFieldBlock + + @classmethod + def make_answer(cls, params=None): return cls.choices[0] @@ -140,8 +215,8 @@ class UploadableMediaFactory(FormFieldBlockFactory): default_value = factory.django.FileField @classmethod - def make_answer(cls, params=dict()): - params = params.copy() + def make_answer(cls, params=None): + params = params or {} params.setdefault('data', b'this is some content') if params.get('filename') is None: params['filename'] = 'example.pdf' @@ -166,7 +241,7 @@ class MultiFileFieldBlockFactory(UploadableMediaFactory): model = stream_blocks.MultiFileFieldBlock @classmethod - def make_answer(cls, params=dict()): + def make_answer(cls, params=None): return [UploadableMediaFactory.make_answer() for _ in range(2)] @@ -180,7 +255,7 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): block = self.factories[block_name]._meta.model() value = block.get_prep_value(value) ret_val.append({'type': block_name, 'value': value, 'id': str(uuid.uuid4())}) - return json.dumps(ret_val) + return json.dumps(ret_val, cls=DjangoJSONEncoder) def build_form(self, data): extras = defaultdict(dict) @@ -216,7 +291,9 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): return form_fields - def form_response(self, fields, field_values=dict()): + def form_response(self, fields, field_values=None): + if not field_values: + field_values = {} data = { field.id: self.factories[field.block.name].make_form_answer(field_values.get(field.id, {})) for field in fields @@ -225,6 +302,26 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): return flatten_for_form(data) +BLOCK_FACTORY_DEFINITION = { + 'text_markup': ParagraphBlockFactory, + 'char': CharFieldBlockFactory, + 'text': TextFieldBlockFactory, + 'number': NumberFieldBlockFactory, + 'checkbox': CheckboxFieldBlockFactory, + 'radios': RadioFieldBlockFactory, + 'dropdown': DropdownFieldBlockFactory, + 'checkboxes': CheckboxesFieldBlockFactory, + 'date': DateFieldBlockFactory, + 'time': TimeFieldBlockFactory, + 'datetime': DateTimeFieldBlockFactory, + 'image': ImageFieldBlockFactory, + 'file': FileFieldBlockFactory, + 'multi_file': MultiFileFieldBlockFactory, +} + +FormFieldsBlockFactory = StreamFieldUUIDFactory(BLOCK_FACTORY_DEFINITION) + + def flatten_for_form(data, field_name='', number=False): result = {} for i, (field, value) in enumerate(data.items()): diff --git a/opentech/apply/stream_forms/tests.py b/opentech/apply/stream_forms/tests.py index 2a861023ffd8f1b6bb9d7173b2b00c0ed85a0a48..52fe2970de36c2393091a69f5501b5d17507c2b9 100644 --- a/opentech/apply/stream_forms/tests.py +++ b/opentech/apply/stream_forms/tests.py @@ -5,6 +5,7 @@ from faker import Faker from .files import StreamFieldFile from .fields import MultiFileField, MultiFileInput +from .blocks import FormFieldBlock, FormFieldsBlock fake = Faker() @@ -87,3 +88,12 @@ class TestMultiFileField(TestCase): initial_files = make_files(3) cleaned = self.field.clean(self.multi_file_value(cleared=[0, 2]), initial_files) self.assertEqual([initial_files[1]], cleaned) + + +class TestBlocks(TestCase): + def test_blocks_decode_none(self): + for block in FormFieldsBlock().child_blocks.values(): + if isinstance(block, FormFieldBlock): + with self.subTest(block=block): + value = block.decode(None) + self.assertIsNone(value) diff --git a/opentech/apply/users/decorators.py b/opentech/apply/users/decorators.py index 26d4016f57356e56ce077dfa4c40d012ee2ccef1..b41a4aaee89d268e53e97700d2fb91b8d897377c 100644 --- a/opentech/apply/users/decorators.py +++ b/opentech/apply/users/decorators.py @@ -19,8 +19,16 @@ def is_apply_staff(user): return True +def is_approver(user): + if not user.is_approver: + raise PermissionDenied + return True + + staff_required = [login_required, user_passes_test(is_apply_staff)] +approver_required = [login_required, user_passes_test(is_approver)] + def superuser_decorator(fn): check = user_passes_test(lambda user: user.is_superuser) diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 768b583c958865dcd2851c39fa5ffeb686d3845b..1c60d84be3766d124aa41c2a7616f04ba16a8a1a 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -2,7 +2,6 @@ from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, BaseUserManager, Group from django.db import models from django.db.models import Q -from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -102,9 +101,6 @@ class User(AbstractUser): objects = UserManager() - def get_absolute_url(self): - return reverse('wagtailusers_users:edit', args=(self.id,)) - def __str__(self): return self.get_full_name() if self.get_full_name() else self.get_short_name() diff --git a/opentech/apply/users/wagtail_hooks.py b/opentech/apply/users/wagtail_hooks.py index 19355eb0e2e4a854fd46168cbf24aecb701d4e62..15783a07d7bf950a23e16a18bffe5477636be0ac 100644 --- a/opentech/apply/users/wagtail_hooks.py +++ b/opentech/apply/users/wagtail_hooks.py @@ -1,4 +1,5 @@ from django.conf.urls import url +from django.urls import reverse from wagtail.core import hooks @@ -20,6 +21,7 @@ def notify_after_create_user(request, user): message=f'{request.user} has crated a new account for {user}.', request=request, related=user, + path=reverse('wagtailusers_users:edit', args=(user.id,)) ) @@ -34,4 +36,5 @@ def notify_after_edit_user(request, user): message=f'{request.user} has edited the account for {user} that now have these roles: {roles}.', request=request, related=user, + path=reverse('wagtailusers_users:edit', args=(user.id,)) ) diff --git a/opentech/apply/utils/notifications.py b/opentech/apply/utils/notifications.py index bed3683da90108043926563a59f6c05e7d8f3178..037b23a32bec317028ad8baf077e2ad85e4f7c92 100644 --- a/opentech/apply/utils/notifications.py +++ b/opentech/apply/utils/notifications.py @@ -19,16 +19,15 @@ class SlackNotifications(): slack_users.append(f'<{user.slack}>') return ' '.join(slack_users) - def slack_link(self, request, related): - slack_link = '' + def slack_link(self, request, related, path=None, **kwargs): try: - link = request.scheme + '://' + request.get_host() + related.get_absolute_url() + url = path or related.get_absolute_url() except AttributeError: - pass - else: - title = str(related) - slack_link = f'<{link}|{title}>' - return slack_link + return '' + + link = request.scheme + '://' + request.get_host() + url + title = str(related) + return f'<{link}|{title}>' def send_message(self, message, request, recipients=None, related=None, **kwargs): if not self.destination or not self.target_room: @@ -41,7 +40,7 @@ class SlackNotifications(): slack_users = self.slack_users(recipients) if recipients else '' - slack_link = self.slack_link(request, related) if related else '' + slack_link = self.slack_link(request, related, **kwargs) if related else '' message = ' '.join([slack_users, message, slack_link]).strip() diff --git a/opentech/apply/utils/templatetags/__init__.py b/opentech/apply/utils/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/utils/templatetags/apply_tags.py b/opentech/apply/utils/templatetags/apply_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..c010e4d6cefb172eecdce993a6aff3e292da214b --- /dev/null +++ b/opentech/apply/utils/templatetags/apply_tags.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +# Get the verbose name of a model instance +@register.filter +def model_verbose_name(instance): + return instance._meta.verbose_name.title() diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py index 2940aceb26b441b25726c68255964da9d9f1db54..b52c1d389828854a3fcb0aff8d0ef2d5891037b6 100644 --- a/opentech/apply/utils/testing/tests.py +++ b/opentech/apply/utils/testing/tests.py @@ -36,7 +36,7 @@ class BaseViewTestCase(TestCase): def get_kwargs(self, instance): return {} - def url(self, instance, view_name=None, absolute=True, kwargs=dict()): + def url(self, instance, view_name=None, absolute=True, url_kwargs=None): view = view_name or self.base_view_name full_url_name = self.url_name.format(view) kwargs_method = f'get_{view}_kwargs' @@ -44,6 +44,8 @@ class BaseViewTestCase(TestCase): kwargs = getattr(self, kwargs_method)(instance) else: kwargs = self.get_kwargs(instance) + if url_kwargs: + kwargs.update(url_kwargs) return self.url_from_pattern(full_url_name, kwargs, secure=True, absolute=absolute) def absolute_url(self, location, secure=True): @@ -57,11 +59,11 @@ class BaseViewTestCase(TestCase): request = self.factory.get(url, secure=secure) return request.path - def get_page(self, instance=None, view_name=None): - return self.client.get(self.url(instance, view_name), secure=True, follow=True) + def get_page(self, instance=None, view_name=None, url_kwargs=None): + return self.client.get(self.url(instance, view_name, url_kwargs=url_kwargs), secure=True, follow=True) - def post_page(self, instance=None, data=dict(), view_name=None): - return self.client.post(self.url(instance, view_name), data, secure=True, follow=True) + def post_page(self, instance=None, data=dict(), view_name=None, url_kwargs=None): + return self.client.post(self.url(instance, view_name, url_kwargs=url_kwargs), data, secure=True, follow=True) def refresh(self, instance): return instance.__class__.objects.get(id=instance.id) diff --git a/opentech/apply/utils/tests/test_views.py b/opentech/apply/utils/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..3b7e4204c8959cecb2a2e725ff4abd3536cd0f59 --- /dev/null +++ b/opentech/apply/utils/tests/test_views.py @@ -0,0 +1,27 @@ +from django.test import TestCase +from django.views.generic import UpdateView + +from opentech.apply.utils.views import DelegatedViewMixin + + +class PatchedUpdateView(UpdateView): + def get_object(self): + return 1 + + +class DelegatedView(DelegatedViewMixin, PatchedUpdateView): + model = int # pretend int is a model for the isinstance check + + def get_parent_kwargs(self): + return {'instance': 3} + + +class TestDelegatedViewMixin(TestCase): + def test_parent_access_if_no_object(self): + self.assertEqual(DelegatedView().get_object(), 3) + + def test__access_if_no_object(self): + view = DelegatedView() + view.object = 3 + self.assertNotEqual(view.get_object(), 3) + self.assertEqual(view.get_object(), 1) diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py index 6ea0fb1673f27e375ac389147740534bf6a3ed73..5f01b74d6cf95f1c7d5e2559d5edb9b9f19d6b68 100644 --- a/opentech/apply/utils/views.py +++ b/opentech/apply/utils/views.py @@ -7,6 +7,7 @@ from django.views.generic import View from django.views.generic.base import ContextMixin from django.views.generic.detail import SingleObjectTemplateResponseMixin from django.views.generic.edit import ModelFormMixin, ProcessFormView +from django.shortcuts import redirect def page_not_found(request, exception=None, template_name='apply/404.html'): @@ -60,12 +61,22 @@ class DelegatableBase(ContextMixin): """ form_prefix = 'form-submitted-' + def __init__(self, *args, **kwargs): + self._form_views = { + self.form_prefix + form_view.context_name: form_view + for form_view in self.form_views + } + def get_form_kwargs(self): return {} def get_context_data(self, **kwargs): - form_kwargs = self.get_form_kwargs() - forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views) + forms = {} + for form_view in self._form_views.values(): + view = form_view() + view.setup(self.request, self.args, self.kwargs) + context_key, form = view.contribute_form(self) + forms[context_key] = form return super().get_context_data( form_prefix=self.form_prefix, @@ -78,12 +89,12 @@ class DelegatableBase(ContextMixin): kwargs['context'] = self.get_context_data() kwargs['template_names'] = self.get_template_names() - for form_view in self.form_views: - if self.form_prefix + form_view.context_name in request.POST: - return form_view.as_view()(request, *args, **kwargs) + for form_key, form_view in self._form_views.items(): + if form_key in request.POST: + return form_view.as_view()(request, *args, parent=self, **kwargs) # Fall back to get if not form exists as submitted - return self.get(request, *args, **kwargs) + return redirect(request.path) class DelegateableView(DelegatableBase): @@ -115,17 +126,49 @@ class DelegateableListView(DelegatableBase): class DelegatedViewMixin(View): """For use on create views accepting forms from another view""" + # TODO: REMOVE IN DJANGO 2.2 + def setup(self, request, *args, **kwargs): + """Initialize attributes shared by all view methods.""" + self.request = request + self.args = args + self.kwargs = kwargs + + def get_object(self): + # Make sure the form instance, bound at the parent class level, is the same as the + # value we work with on the class. + # If we don't have self.object, bind the parent instance to it. This value will then + # be used by the form. Any further calls to get_object will get a new instance of the object + if not hasattr(self, 'object'): + parent_object = self.get_parent_object() + if isinstance(parent_object, self.model): + return parent_object + + return super().get_object() + def get_template_names(self): return self.kwargs['template_names'] + def get_form_name(self): + return self.context_name + def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['user'] = self.request.user - return kwargs + form_kwargs = super().get_form_kwargs() + form_kwargs['user'] = self.request.user + form_kwargs.update(**self.get_parent_kwargs()) + return form_kwargs + + def get_parent_kwargs(self): + try: + return self.parent.get_form_kwargs() + except AttributeError: + return self.kwargs['parent'].get_form_kwargs() + + def get_parent_object(self): + return self.get_parent_kwargs()['instance'] def get_form(self, *args, **kwargs): form = super().get_form(*args, **kwargs) - form.name = self.context_name + form.name = self.get_form_name() return form def get_context_data(self, **kwargs): @@ -139,11 +182,21 @@ class DelegatedViewMixin(View): def is_model_form(cls): return issubclass(cls.form_class, ModelForm) - @classmethod - def contribute_form(cls, **kwargs): - form = cls.form_class(**kwargs) - form.name = cls.context_name - return cls.context_name, form + def contribute_form(self, parent): + self.parent = parent + + # We do not want to bind any forms generated this way + # pretend we are doing a get request to avoid passing data to forms + old_method = None + if self.request.method in ('POST', 'PUT'): + old_method = self.request.method + self.request.method = 'GET' + + form = self.get_form() + + if old_method: + self.request.method = old_method + return self.context_name, form def get_success_url(self): query = self.request.GET.urlencode() diff --git a/opentech/public/navigation/templates/navigation/primarynav-apply.html b/opentech/public/navigation/templates/navigation/primarynav-apply.html index a48968ebc8b9f5415e12efe286bd9d25af079341..f6286eaba49f09a1d90fa5231e44c9521e26a671 100644 --- a/opentech/public/navigation/templates/navigation/primarynav-apply.html +++ b/opentech/public/navigation/templates/navigation/primarynav-apply.html @@ -3,6 +3,7 @@ {% if request.user.is_apply_staff %} {% include "navigation/primarynav-apply-item.html" with name="Dashboard" url="dashboard:dashboard" %} {% include "navigation/primarynav-apply-item.html" with name="Submissions" url="funds:submissions:overview" %} + {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %} {% else %} {% include "navigation/primarynav-apply-item.html" with name="Dashboard" url="dashboard:dashboard" %} {% endif %} diff --git a/opentech/settings/base.py b/opentech/settings/base.py index fc23648e40ec88f2ae3fff3234564afd12a0bd8d..3a19783090da2fddc9072c8b705a2893538033e4 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -639,3 +639,7 @@ REST_FRAMEWORK = { PROJECTS_ENABLED = False if env.get('PROJECTS_ENABLED', 'false').lower().strip() == 'true': PROJECTS_ENABLED = True + +PROJECTS_AUTO_CREATE = False +if env.get('PROJECTS_AUTO_CREATE', 'false').lower().strip() == 'true': + PROJECTS_AUTO_CREATE = True diff --git a/opentech/settings/dev.py b/opentech/settings/dev.py index 01e2301e5edaf0e08dfaff107651eaab54c39f06..873783137117905ee720e1cac3b58f82c0af3732 100644 --- a/opentech/settings/dev.py +++ b/opentech/settings/dev.py @@ -35,6 +35,7 @@ except ImportError: pass PROJECTS_ENABLED = True +PROJECTS_AUTO_CREATE = True # We add these here so they can react on settings made in local.py. diff --git a/opentech/settings/test.py b/opentech/settings/test.py index 3d6102ba8bff38edc37cef28a368432054de92c1..b5990e4589a7d9032045a653a0bcd2d9ad501a14 100644 --- a/opentech/settings/test.py +++ b/opentech/settings/test.py @@ -9,6 +9,7 @@ from .base import * # noqa SECRET_KEY = 'NOT A SECRET' PROJECTS_ENABLED = True +PROJECTS_AUTO_CREATE = True # Need this to ensure white noise doesn't kill the speed of testing # http://whitenoise.evans.io/en/latest/django.html#whitenoise-makes-my-tests-run-slow diff --git a/opentech/static_src/src/images/cross.svg b/opentech/static_src/src/images/cross.svg new file mode 100644 index 0000000000000000000000000000000000000000..b818cbe1e797035b9f6b724613df989d873caf2f --- /dev/null +++ b/opentech/static_src/src/images/cross.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 21.9 21.9" xmlns="http://www.w3.org/2000/svg"> + <path fill="#f05e54" d="M14.1 11.3c-.2-.2-.2-.5 0-.7l7.5-7.5c.2-.2.3-.5.3-.7s-.1-.5-.3-.7L20.2.3c-.2-.2-.5-.3-.7-.3-.3 0-.5.1-.7.3l-7.5 7.5c-.2.2-.5.2-.7 0L3.1.3C2.9.1 2.6 0 2.4 0s-.5.1-.7.3L.3 1.7c-.2.2-.3.5-.3.7s.1.5.3.7l7.5 7.5c.2.2.2.5 0 .7L.3 18.8c-.2.2-.3.5-.3.7s.1.5.3.7l1.4 1.4c.2.2.5.3.7.3s.5-.1.7-.3l7.5-7.5c.2-.2.5-.2.7 0l7.5 7.5c.2.2.5.3.7.3s.5-.1.7-.3l1.4-1.4c.2-.2.3-.5.3-.7s-.1-.5-.3-.7l-7.5-7.5z"/> +</svg> diff --git a/opentech/static_src/src/javascript/apply/submission-filters.js b/opentech/static_src/src/javascript/apply/submission-filters.js index c89cea1431f76204072080fa5a5725ced7e4a2a4..876d4abddd132a6e82d201d88d376c4cad14a2fa 100644 --- a/opentech/static_src/src/javascript/apply/submission-filters.js +++ b/opentech/static_src/src/javascript/apply/submission-filters.js @@ -3,17 +3,12 @@ 'use strict'; // Variables - const $body = $('body'); const $toggleButton = $('.js-toggle-filters'); const $closeButton = $('.js-close-filters'); const $clearButton = $('.js-clear-filters'); const filterOpenClass = 'filters-open'; const filterActiveClass = 'is-active'; - const $searchInput = $('.js-search-input'); - const $queryInput = $('#id_query'); - const $searchForm = $('.js-search-form'); - const $filterForm = $('.js-filter-form'); const urlParams = new URLSearchParams(window.location.search); @@ -27,19 +22,13 @@ ); if ([...urlParams].length > minimumNumberParams && $(window).width() > 1024) { - $body.addClass(filterOpenClass); - updateButtonText(); + $('.filters').addClass(filterOpenClass); + $('.js-toggle-filters').text('Clear filters'); } - $searchForm.submit((e) => { - e.preventDefault(); - $queryInput.val($searchInput.val()); - $filterForm.submit(); - }); - // Add active class to filters - dropdowns are dynamically appended to the dom, // so we have to listen for the event higher up - $body.on('click', '.select2-dropdown', (e) => { + $('body').on('click', '.select2-dropdown', (e) => { // get the id of the dropdown let selectId = e.target.parentElement.parentElement.id; @@ -64,20 +53,25 @@ }); // toggle filters - $toggleButton.on('click', () => { - if ($body.hasClass(filterOpenClass)) { + $toggleButton.on('click', (e) => { + // find the nearest filters + const filters = e.target.closest('.js-table-actions').nextElementSibling; + + if (filters.classList.contains(filterOpenClass)) { handleClearFilters(); } else { - $body.addClass(filterOpenClass); - updateButtonText(); + filters.classList.add(filterOpenClass); + // only update button text on desktop + if (window.innerWidth >= 1024) { + updateButtonText(e.target, filters); + } } }); // close filters on mobile $closeButton.on('click', (e) => { - $body.removeClass(filterOpenClass); - updateButtonText(); + e.target.closest('.filters').classList.remove(filterOpenClass); }); // redirect to submissions home to clear filters @@ -88,12 +82,12 @@ } // toggle filters button wording - function updateButtonText() { - if ($body.hasClass(filterOpenClass)) { - $toggleButton.text('Clear filters'); + function updateButtonText(button, filters) { + if (filters.classList.contains(filterOpenClass)) { + button.textContent = 'Clear filters'; } else { - $toggleButton.text('Filters'); + button.textContent = 'Filters'; } } diff --git a/opentech/static_src/src/sass/apply/abstracts/_variables.scss b/opentech/static_src/src/sass/apply/abstracts/_variables.scss index e104ee77515a074f7cb6651c9e37f43a07aded20..aadcc4a8f3d611d87a5ecc3edc852a17b5a54ec8 100644 --- a/opentech/static_src/src/sass/apply/abstracts/_variables.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_variables.scss @@ -15,7 +15,9 @@ $color--mid-grey: #cfcfcf; $color--mid-dark-grey: #919191; // Brand +$color--lightest-blue: #24aae1; $color--light-blue: #0d7db0; +$color--lighter-blue: #7ab8d4; $color--dark-blue: #0c72a0; $color--darkest-blue: #3d6bdb; $color--mustard: #e6ab32; @@ -60,6 +62,7 @@ $color--default: $color--dark-grey; $color--primary: $color--light-blue; $color--error: $color--tomato; $color--correct: $color--mint; +$color--button-disabled: $color--lighter-blue; // Fonts $font--primary: 'noto-sans'; diff --git a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss index 13f5230c23f064fca76588186ded37ac3d002c9b..3d4474298f362c73b65b428e2b1684bea9f60527 100644 --- a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss +++ b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss @@ -91,7 +91,7 @@ white-space: normal; background: $color--sky-blue; border: 1px solid $color--marine; - content: attr(data-tooltip); + content: attr(data-title-tooltip); opacity: 0; transition: opacity $transition; } diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss index 9a1963268d8322077716280ad7bd3c63eb70a028..f5b89abb31e2b2d794f975eaf89f6b15934e514a 100644 --- a/opentech/static_src/src/sass/apply/components/_button.scss +++ b/opentech/static_src/src/sass/apply/components/_button.scss @@ -313,4 +313,17 @@ transform: rotate(90deg); } } + + &--link { + color: $color--primary; + } + + &--tooltip-disabled { + background-color: $color--button-disabled; + + &:hover { + cursor: default; + background-color: $color--button-disabled; + } + } } diff --git a/opentech/static_src/src/sass/apply/components/_card.scss b/opentech/static_src/src/sass/apply/components/_card.scss new file mode 100644 index 0000000000000000000000000000000000000000..497b6a1d764e5ae24b33395bedefe021f12054c8 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_card.scss @@ -0,0 +1,43 @@ +.card { + padding: 20px; + margin-bottom: 20px; + border: 1px solid $color--mid-grey; + + &--solid { + background-color: $color--white; + } + + &__text { + margin: 0 0 1rem; + + &:last-child { + margin: 0; + } + } + + &__inner { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + + &__heading { + margin-bottom: 0; + } + + &__reviewer-outcome { + display: flex; + align-items: center; + margin-bottom: 1rem; + + @include media-query(small-tablet) { + margin-left: 1rem; + } + } + + &__reviewer { + margin-left: 10px; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss index a2d695259d9c84954a6cd946a29054067539dc5e..eae2ff83ac07d6460de6f5e709f1ba4fb66c92c7 100644 --- a/opentech/static_src/src/sass/apply/components/_docs-block.scss +++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss @@ -95,6 +95,12 @@ font-weight: $weight--bold; margin-right: 1rem; + &:disabled, + &.is-disabled { + color: $color--mid-grey; + cursor: default; + } + &:last-child { margin-right: 0; } @@ -173,4 +179,8 @@ margin-right: 0; } } + + &__document-form { + display: inline; + } } diff --git a/opentech/static_src/src/sass/apply/components/_error-bar.scss b/opentech/static_src/src/sass/apply/components/_error-bar.scss new file mode 100644 index 0000000000000000000000000000000000000000..07465dce7bc77ec71a23ceeb7f59c173d4fd6858 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_error-bar.scss @@ -0,0 +1,15 @@ +.error-bar { + padding: $mobile-gutter; + margin: 0 auto 2rem; + background: $color--tomato; + color: $color--white; + + &__copy { + margin: 0 0 1rem; + font-weight: $weight--bold; + + &:last-child { + margin: 0; + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_filters.scss b/opentech/static_src/src/sass/apply/components/_filters.scss index f6018235d5ad2ecc37b175d9bb8225ac92c9f5c1..9a7f427b9ba3b0ee46246df3979a843e74c9606c 100644 --- a/opentech/static_src/src/sass/apply/components/_filters.scss +++ b/opentech/static_src/src/sass/apply/components/_filters.scss @@ -1,7 +1,7 @@ .filters { display: none; - .filters-open & { + &.filters-open { position: fixed; top: 0; right: 0; @@ -21,7 +21,7 @@ transition-delay: $base-delay; pointer-events: none; - .filters-open & { + &.filters-open { position: relative; top: auto; left: auto; diff --git a/opentech/static_src/src/sass/apply/components/_form.scss b/opentech/static_src/src/sass/apply/components/_form.scss index 03e82401dcf523e979cd425081be221a0eb556a2..ca166d9d4360a96664a6082a35ea52de2de16359 100644 --- a/opentech/static_src/src/sass/apply/components/_form.scss +++ b/opentech/static_src/src/sass/apply/components/_form.scss @@ -418,6 +418,43 @@ background-size: 12px; border: 1px solid $color--dark-blue; } + + &.delete { + + label { + padding-left: 0; + + &::before { + display: none; + } + + &::after { + content: ''; + background: url('./../../images/cross.svg') left no-repeat; + margin-left: 10px; + width: 14px; + height: 13px; + display: inline-block; + } + } + + &:checked + label { + text-decoration: line-through; + color: $color--black-60; + + &::after { + content: '(will be removed on save) - undo'; + background: none; + color: $color--light-blue; + font-size: 14px; + position: absolute; + font-weight: $weight--bold; + width: auto; + height: auto; + } + } + + + } } .errorlist { diff --git a/opentech/static_src/src/sass/apply/components/_funding-block.scss b/opentech/static_src/src/sass/apply/components/_funding-block.scss index 8f18deb78f16368a959a87e627cbe8759cc10567..969bdeca020ea40f79f203a8bb4085f5e58de13d 100644 --- a/opentech/static_src/src/sass/apply/components/_funding-block.scss +++ b/opentech/static_src/src/sass/apply/components/_funding-block.scss @@ -11,10 +11,6 @@ flex-wrap: wrap; } - @include media-query(tablet-portrait) { - max-width: 610px; - } - &__item { margin: 0 .5rem 1rem 0; diff --git a/opentech/static_src/src/sass/apply/components/_grid.scss b/opentech/static_src/src/sass/apply/components/_grid.scss index 23e966a9040b3dbf904e86753fd9c696db540877..06903cce6e6f322b29558cf3a5dc51d6b25d2606 100644 --- a/opentech/static_src/src/sass/apply/components/_grid.scss +++ b/opentech/static_src/src/sass/apply/components/_grid.scss @@ -53,6 +53,10 @@ grid-template-columns: 1fr 1fr; } + &.delete { + grid-template-columns: 1fr; + } + .form--comments & { margin: 20px 0 0; grid-gap: 10px; // sass-lint:disable-line no-misspelled-properties diff --git a/opentech/static_src/src/sass/apply/components/_invoice-block.scss b/opentech/static_src/src/sass/apply/components/_invoice-block.scss index d0bc7af63f87d31c016c72d8b0e7566df6416a0e..273c8a953d9f6988994f42d7aac7098dac7dd1a3 100644 --- a/opentech/static_src/src/sass/apply/components/_invoice-block.scss +++ b/opentech/static_src/src/sass/apply/components/_invoice-block.scss @@ -9,10 +9,6 @@ padding: 2rem; } - @include media-query(tablet-portrait) { - max-width: 610px; - } - &__item { margin-bottom: 1rem; diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss index 1cb21d1282882f263f601bfdaecb3f2b260936b3..3e2a5da4fb1682f97490875a66192a135e5aafab 100644 --- a/opentech/static_src/src/sass/apply/components/_payment-block.scss +++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss @@ -7,10 +7,6 @@ padding: 2rem; } - @include media-query(tablet-portrait) { - max-width: 610px; - } - &__header { margin-bottom: 1rem; @@ -47,10 +43,6 @@ } } - &__status-link { - font-size: map-get($font-sizes, milli); - } - &__rejected { text-align: center; } @@ -83,7 +75,7 @@ padding: 10px; @include media-query(tablet-landscape) { - text-align: center; + text-align: left; } } @@ -122,9 +114,11 @@ &:last-child { padding: 0 0 1rem; - - @include media-query(tablet-landscape) { - padding: 1rem; + display: flex; + flex-wrap: wrap; + & > * { + flex: 1 1 55px; + max-width: 55px; } } @@ -136,18 +130,18 @@ } &__table-amount { - width: 20%; + width: 25%; min-width: 90px; } &__table-status { min-width: 160px; - width: 35%; + width: 25%; } - &__table-docs { + &__table-date { min-width: 180px; - width: 20%; + width: 25%; } &__table-update { diff --git a/opentech/static_src/src/sass/apply/components/_simplified.scss b/opentech/static_src/src/sass/apply/components/_simplified.scss new file mode 100644 index 0000000000000000000000000000000000000000..15b31cb1ef1b5e3b4d4c9b3bec8bb53fb9bdfc29 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_simplified.scss @@ -0,0 +1,115 @@ +// class used for custom styling on the simplified submission and project pages +.simplified { + &__admin-bar { + position: relative; + right: 50%; + left: 50%; + width: 100vw; + padding: $mobile-gutter; + margin-right: -50vw; + margin-left: -50vw; + color: $color--white; + background-color: $color--dark-grey; + } + + &__admin-bar-inner { + max-width: $site-width; + margin: 0 auto; + } + + &__projects-link { + display: inline-flex; + align-items: center; + color: $color--lightest-blue; + font-weight: $weight--bold; + + &::before { + @include triangle(top, currentColor, 5px); + margin-right: .5rem; + transform: rotate(-90deg); + } + } + + &__heading { + @include responsive-font-sizes(30px, map-get($font-sizes, beta)); + letter-spacing: -.02rem; + font-weight: $weight--bold; + margin: 0; + } + + &__subheading { + display: flex; + flex-wrap: wrap; + margin: 0; + + @include media-query(tablet-portrait) { + margin-bottom: 1rem; + } + + span { + &::after { + padding: 0 15px; + content: '|'; + } + + &:last-child { + &::after { + padding: 0; + content: ''; + } + } + } + } + + &__wrapper { + padding: $mobile-gutter 0; + + @include media-query(tablet-portrait) { + padding: 3rem 0; + } + } + + &__meta { + @include heading-text; + @include font-size(zeta); + display: flex; + flex-direction: column; + margin-top: $mobile-gutter; + font-weight: $weight--normal; + color: transparentize($color--dark-grey, .5); + + @include media-query(tablet-landscape) { + flex-direction: row; + margin-top: 0; + } + } + + &__meta-item { + margin-right: 15px; + + &:last-child { + margin-right: 0; + } + } + + &__rich-text { + margin-bottom: 2rem; + word-break: break-word; + + > section { + margin: 0 0 1rem; + + p:first-of-type { + margin-top: 0; + } + + p:empty { + margin: 0; + } + } + + h4 { + margin: 0; + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_status-block.scss b/opentech/static_src/src/sass/apply/components/_status-block.scss index 2657866ac39c119d3e051a081eafa85c596c1f28..6f9e758d59b2e816b69b02aa59b62d10a65e2ff3 100644 --- a/opentech/static_src/src/sass/apply/components/_status-block.scss +++ b/opentech/static_src/src/sass/apply/components/_status-block.scss @@ -16,7 +16,7 @@ @include media-query(tablet-landscape) { border-bottom: 0; border-right: 1px solid $color--light-mid-grey; - width: calc(100% / 9); + width: 100%; padding: 10px; display: flex; align-items: flex-start; diff --git a/opentech/static_src/src/sass/apply/components/_tooltip.scss b/opentech/static_src/src/sass/apply/components/_tooltip.scss new file mode 100644 index 0000000000000000000000000000000000000000..61af9c25ae8f87f33ec7d9e32a056f082d24bd2e --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_tooltip.scss @@ -0,0 +1,30 @@ +[data-tooltip] { + position: relative; + z-index: 2; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +[data-tooltip]::before { + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity, bottom, $transition; + position: absolute; + bottom: 110%; + margin-bottom: 5px; + padding: 7px; + background-color: $color--dark-grey; + color: $color--white; + content: attr(data-tooltip); + text-align: center; + font-size: 14px; +} + +[data-tooltip]:hover::before { + visibility: visible; + opacity: 1; + bottom: 130%; +} diff --git a/opentech/static_src/src/sass/apply/components/_traffic-light.scss b/opentech/static_src/src/sass/apply/components/_traffic-light.scss index 21779a7aad61e379e6231aaee00cbbc431ed902c..7d28aebcd1524560b2ed9f5e7324694f4650388a 100644 --- a/opentech/static_src/src/sass/apply/components/_traffic-light.scss +++ b/opentech/static_src/src/sass/apply/components/_traffic-light.scss @@ -6,16 +6,19 @@ text-align: center; border-radius: 50%; + &--no, &--red { color: $color--tomato; background-color: $color--tomato; } + &--maybe, &--amber { color: $color--mustard; background-color: $color--mustard; } + &--yes, &--green { color: $color--green; background-color: $color--green; diff --git a/opentech/static_src/src/sass/apply/components/_wrapper.scss b/opentech/static_src/src/sass/apply/components/_wrapper.scss index 1a6c227b6ef04de9ae45227eced7bca259d355cb..dfa413a509a54ac5fef19eab2616605f3ff72d83 100644 --- a/opentech/static_src/src/sass/apply/components/_wrapper.scss +++ b/opentech/static_src/src/sass/apply/components/_wrapper.scss @@ -178,6 +178,10 @@ padding-right: 20px; } } + + .card:first-child { + margin-top: 20px; + } } &--search { diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss index 3cb623c756ca2c22160742324637992d989b40ad..2846143b74327a9b989584fa52857fe741d4a99d 100644 --- a/opentech/static_src/src/sass/apply/main.scss +++ b/opentech/static_src/src/sass/apply/main.scss @@ -13,9 +13,11 @@ @import 'components/admin-bar'; @import 'components/activity-feed'; @import 'components/actions-bar'; +@import 'components/card'; @import 'components/comment'; @import 'components/button'; @import 'components/editor'; +@import 'components/error-bar'; @import 'components/feed'; @import 'components/filters'; @import 'components/grid'; @@ -44,10 +46,12 @@ @import 'components/reviews-sidebar'; @import 'components/round-block'; @import 'components/select2'; +@import 'components/simplified'; @import 'components/status-block'; @import 'components/submission-meta'; @import 'components/revision'; @import 'components/table'; +@import 'components/tooltip'; @import 'components/traffic-light'; @import 'components/wrapper'; @import 'components/revisions'; diff --git a/requirements.txt b/requirements.txt index d9cd302adf2df3ee70e16761fc94b2b5c0d1590f..86a1a0ca06c7aa2862217993dca879217e643af2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ # Test dependencies flake8 factory_boy==2.9.2 +Faker==1.0.8 # Pinning to avoid API changes to pydecimal in 0.x - could go higher in future wagtail-factories==1.1.0 responses==0.10.4 @@ -41,6 +42,7 @@ django==2.1.11 gunicorn==19.9.0 mailchimp3==3.0.9 mistune==0.8.4 +more-itertools==7.2.0 Pillow==5.4.1 psycopg2==2.7.3.1 social_auth_app_django==3.1.0