From 50f36a99e1ede747e233a07bcbd56ac6203c30f9 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 09:54:15 -0300 Subject: [PATCH 01/82] Model statement of work's information --- sponsors/migrations/0019_statementofwork.py | 115 ++++++++++++++++++++ sponsors/models.py | 72 ++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 sponsors/migrations/0019_statementofwork.py diff --git a/sponsors/migrations/0019_statementofwork.py b/sponsors/migrations/0019_statementofwork.py new file mode 100644 index 000000000..ca62e6ad2 --- /dev/null +++ b/sponsors/migrations/0019_statementofwork.py @@ -0,0 +1,115 @@ +# Generated by Django 2.0.13 on 2020-12-04 13:05 + +from django.db import migrations, models +import django.db.models.deletion +import markupfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsors", "0018_auto_20201201_1659"), + ] + + operations = [ + migrations.CreateModel( + name="StatementOfWork", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("outdated", "Outdated"), + ("approved review", "Approved by reviewer"), + ("awaiting signature", "Awaiting signature"), + ("executed", "Executed"), + ("nullified", "Nullified"), + ], + db_index=True, + default="draft", + max_length=20, + ), + ), + ( + "revision", + models.PositiveIntegerField(default=0, verbose_name="Revision nº"), + ), + ( + "document", + models.FileField( + blank=True, + upload_to="sponsors/statements_of_work/", + verbose_name="Unsigned PDF", + ), + ), + ( + "signed_document", + models.FileField( + blank=True, + upload_to="sponsors/statmentes_of_work/signed/", + verbose_name="Signed PDF", + ), + ), + ("sponsor_info", models.TextField(verbose_name="Sponsor information")), + ("sponsor_contact", models.TextField(verbose_name="Sponsor contact")), + ("benefits_list", markupfield.fields.MarkupField(rendered_field=True)), + ( + "benefits_list_markup_type", + models.CharField( + choices=[ + ("", "--"), + ("html", "HTML"), + ("plain", "Plain"), + ("markdown", "Markdown"), + ("restructuredtext", "Restructured Text"), + ], + default="markdown", + max_length=30, + ), + ), + ("legal_clauses", markupfield.fields.MarkupField(rendered_field=True)), + ("_benefits_list_rendered", models.TextField(editable=False)), + ( + "legal_clauses_markup_type", + models.CharField( + choices=[ + ("", "--"), + ("html", "HTML"), + ("plain", "Plain"), + ("markdown", "Markdown"), + ("restructuredtext", "Restructured Text"), + ], + default="markdown", + max_length=30, + ), + ), + ("created_on", models.DateField(auto_now_add=True)), + ("_legal_clauses_rendered", models.TextField(editable=False)), + ("last_update", models.DateField(auto_now=True)), + ("sent_on", models.DateField(null=True)), + ( + "sponsorship", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="statement_of_work", + to="sponsors.Sponsorship", + ), + ), + ], + options={ + "verbose_name": "Statement of Work", + "verbose_name_plural": "Statements of Work", + }, + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index fd8d28856..9f65e7f89 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -496,3 +496,75 @@ def __str__(self): class Meta(OrderedModel.Meta): pass + + +class StatementOfWork(models.Model): + DRAFT = "draft" + OUTDATED = "outdated" + APPROVED_REVIEW = "approved review" + AWAITING_SIGNATURE = "awaiting signature" + EXECUTED = "executed" + NULLIFIED = "nullified" + + STATUS_CHOICES = [ + (DRAFT, "Draft"), + (OUTDATED, "Outdated"), + (APPROVED_REVIEW, "Approved by reviewer"), + (AWAITING_SIGNATURE, "Awaiting signature"), + (EXECUTED, "Executed"), + (NULLIFIED, "Nullified"), + ] + + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default=DRAFT, db_index=True + ) + revision = models.PositiveIntegerField(default=0, verbose_name="Revision nº") + document = models.FileField( + upload_to="sponsors/statements_of_work/", + blank=True, + verbose_name="Unsigned PDF", + ) + signed_document = models.FileField( + upload_to="sponsors/statmentes_of_work/signed/", + blank=True, + verbose_name="Signed PDF", + ) + + # Statement of Work information gets populated during object's creation. + # The sponsorship FK ís just a reference to keep track of related objects. + # It shouldn't be used to fetch for any of the sponsorship's data. + sponsorship = models.OneToOneField( + Sponsorship, + null=True, + on_delete=models.SET_NULL, + related_name="statement_of_work", + ) + sponsor_info = models.TextField(verbose_name="Sponsor information") + sponsor_contact = models.TextField(verbose_name="Sponsor contact") + + # benefits_list = """ + # - Foundation - Promotion of Python case study [^1] + # - PyCon - PyCon website Listing [^1][^2] + # - PyPI - Social media promotion of your sponsorship + # """ + benefits_list = MarkupField(default_markup_type="markdown") + # legal_clauses = """ + # [^1]: Here's one with multiple paragraphs and code. + # Indent paragraphs to include them in the footnote. + # `{ my code }` + # Add as many paragraphs as you like. + # [^2]: Here's one with multiple paragraphs and code. + # Indent paragraphs to include them in the footnote. + # `{ my code }` + # Add as many paragraphs as you like. + # """ + legal_clauses = MarkupField(default_markup_type="markdown") + + # Activity control fields + created_on = models.DateField(auto_now_add=True) + last_update = models.DateField(auto_now=True) + sent_on = models.DateField(null=True) + + class Meta: + verbose_name = "Statement of Work" + verbose_name_plural = "Statements of Work" From 790b7f2c63af190c33cc9e430e241bc566565987 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 10:29:29 -0300 Subject: [PATCH 02/82] Auto increment revision no --- sponsors/models.py | 10 ++++++++++ sponsors/tests/baker_recipes.py | 8 ++++++++ sponsors/tests/test_models.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sponsors/tests/baker_recipes.py diff --git a/sponsors/models.py b/sponsors/models.py index 9f65e7f89..db72b6869 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -568,3 +568,13 @@ class StatementOfWork(models.Model): class Meta: verbose_name = "Statement of Work" verbose_name_plural = "Statements of Work" + + @property + def is_draft(self): + return self.status == self.DRAFT + + def save(self, **kwargs): + commit = kwargs.get("commit", True) + if all([commit, self.pk, self.is_draft]): + self.revision += 1 + return super().save(**kwargs) diff --git a/sponsors/tests/baker_recipes.py b/sponsors/tests/baker_recipes.py new file mode 100644 index 000000000..12f916cf3 --- /dev/null +++ b/sponsors/tests/baker_recipes.py @@ -0,0 +1,8 @@ +from model_bakery.recipe import Recipe + + +empty_sow = Recipe( + "sponsors.StatementOfWork", + benefits_list="", + legal_clauses="", +) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index e18f8a111..bb40c9473 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -5,7 +5,7 @@ from django.test import TestCase from django.utils import timezone -from ..models import Sponsor, SponsorshipBenefit, Sponsorship +from ..models import Sponsor, SponsorshipBenefit, Sponsorship, StatementOfWork from ..exceptions import SponsorWithExistingApplicationException @@ -221,3 +221,33 @@ def test_user_customization_if_missing_benefit_with_conflict_from_one_or_more_co ] # missing benefits with index 2 or 3 customization = self.package.has_user_customization(benefits) self.assertTrue(customization) + + +class StatementOfWorkModelTests(TestCase): + + def test_auto_increment_draft_revision_on_save(self): + statement = baker.make_recipe("sponsors.tests.empty_sow") + self.assertEqual(statement.status, StatementOfWork.DRAFT) + self.assertEqual(statement.revision, 0) + + num_updates = 5 + for i in range(num_updates): + statement.save() + statement.refresh_from_db() + + self.assertEqual(statement.revision, num_updates) + + def test_does_not_auto_increment_draft_revision_on_save_if_other_states(self): + statement = baker.make_recipe("sponsors.tests.empty_sow", revision=10) + + choices = StatementOfWork.STATUS_CHOICES + other_status = [c[0] for c in choices if c[0] != StatementOfWork.DRAFT] + for status in other_status: + statement.status = status + statement.save() + statement.refresh_from_db() + self.assertEqual(statement.status, status) + self.assertEqual(statement.revision, 10) + statement.save() # perform extra save + statement.refresh_from_db() + self.assertEqual(statement.revision, 10) From 4a48cda90793bf9311f8ce6a963ece8c88440edf Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 11:20:52 -0300 Subject: [PATCH 03/82] Automatically populate sponsor info and contact fields --- sponsors/managers.py | 8 +++++ sponsors/models.py | 38 ++++++++++++++++++++++- sponsors/tests/test_models.py | 57 ++++++++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/sponsors/managers.py b/sponsors/managers.py index 2e8ce4ad3..11cefb536 100644 --- a/sponsors/managers.py +++ b/sponsors/managers.py @@ -5,3 +5,11 @@ class SponsorshipQuerySet(QuerySet): def in_progress(self): status = [self.model.APPLIED, self.model.APPROVED] return self.filter(status__in=status) + + +class SponsorContactQuerySet(QuerySet): + def get_primary_contact(self, sponsor): + contact = self.filter(sponsor=sponsor, primary=True).first() + if not contact: + raise self.model.DoesNotExist() + return contact diff --git a/sponsors/models.py b/sponsors/models.py index db72b6869..0c9546a4f 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -14,7 +14,7 @@ from cms.models import ContentManageable from companies.models import Company -from .managers import SponsorshipQuerySet +from .managers import SponsorshipQuerySet, SponsorContactQuerySet from .exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidStatusException, @@ -206,6 +206,8 @@ class Meta(OrderedModel.Meta): class SponsorContact(models.Model): + objects = SponsorContactQuerySet.as_manager() + sponsor = models.ForeignKey( "Sponsor", on_delete=models.CASCADE, related_name="contacts" ) @@ -474,6 +476,20 @@ def verified_emails(self, initial_emails=None): def __str__(self): return f"{self.name}" + @property + def full_address(self): + addr = self.mailing_address_line_1 + if self.mailing_address_line_2: + addr += f" {self.mailing_address_line_2}" + return f"{addr}, {self.city}, {self.state}, {self.country}" + + @property + def primary_contact(self): + try: + return SponsorContact.objects.get_primary_contact(self) + except SponsorContact.DoesNotExist: + return None + class LegalClause(OrderedModel): internal_name = models.CharField( @@ -569,6 +585,26 @@ class Meta: verbose_name = "Statement of Work" verbose_name_plural = "Statements of Work" + @classmethod + def new(cls, sponsorship): + """ + Factory method to create a new Statement of Work from a Sponsorship + """ + sponsor = sponsorship.sponsor + primary_contact = sponsor.primary_contact + + sponsor_info = f"{sponsor.name} with address {sponsor.full_address}" + if primary_contact: + sponsor_contact = f"Contacts {sponsor.primary_phone} - {primary_contact.name}, {primary_contact.phone}" + else: + sponsor_contact = f"Contacts {sponsor.primary_phone}" + + return cls.objects.create( + sponsorship=sponsorship, + sponsor_info=sponsor_info, + sponsor_contact=sponsor_contact, + ) + @property def is_draft(self): return self.status == self.DRAFT diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index bb40c9473..7d966e023 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -5,7 +5,13 @@ from django.test import TestCase from django.utils import timezone -from ..models import Sponsor, SponsorshipBenefit, Sponsorship, StatementOfWork +from ..models import ( + Sponsor, + SponsorshipBenefit, + Sponsorship, + StatementOfWork, + SponsorContact, +) from ..exceptions import SponsorWithExistingApplicationException @@ -223,7 +229,27 @@ def test_user_customization_if_missing_benefit_with_conflict_from_one_or_more_co self.assertTrue(customization) +class SponsorContactModelTests(TestCase): + def test_get_primary_contact_for_sponsor(self): + sponsor = baker.make(Sponsor) + baker.make(SponsorContact, sponsor=sponsor, primary=False, _quantity=5) + baker.make(SponsorContact, primary=True) # from other sponsor + + self.assertEqual(5, SponsorContact.objects.filter(sponsor=sponsor).count()) + with self.assertRaises(SponsorContact.DoesNotExist): + SponsorContact.objects.get_primary_contact(sponsor) + self.assertIsNone(sponsor.primary_contact) + + primary_contact = baker.make(SponsorContact, primary=True, sponsor=sponsor) + self.assertEqual( + SponsorContact.objects.get_primary_contact(sponsor), primary_contact + ) + self.assertEqual(sponsor.primary_contact, primary_contact) + + class StatementOfWorkModelTests(TestCase): + def setUp(self): + self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") def test_auto_increment_draft_revision_on_save(self): statement = baker.make_recipe("sponsors.tests.empty_sow") @@ -251,3 +277,32 @@ def test_does_not_auto_increment_draft_revision_on_save_if_other_states(self): statement.save() # perform extra save statement.refresh_from_db() self.assertEqual(statement.revision, 10) + + def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_info_and_contact( + self, + ): + statement = StatementOfWork.new(self.sponsorship) + statement.refresh_from_db() + + sponsor = self.sponsorship.sponsor + expected_info = f"{sponsor.name} with address {sponsor.full_address}" + expected_contact = f"Contacts {sponsor.primary_phone}" + + self.assertEqual(statement.sponsorship, self.sponsorship) + self.assertEqual(statement.sponsor_info, expected_info) + self.assertEqual(statement.sponsor_contact, expected_contact) + + def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_contact_and_primary( + self, + ): + sponsor = self.sponsorship.sponsor + contact = baker.make( + SponsorContact, sponsor=self.sponsorship.sponsor, primary=True + ) + + statement = StatementOfWork.new(self.sponsorship) + expected_contact = ( + f"Contacts {sponsor.primary_phone} - {contact.name}, {contact.phone}" + ) + + self.assertEqual(statement.sponsor_contact, expected_contact) From 40b63e53504901a0a778cfed1a9720b2133811d3 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 11:30:30 -0300 Subject: [PATCH 04/82] Refactor SponsorBenefit creation --- sponsors/models.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 0c9546a4f..1304a80e4 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -308,15 +308,8 @@ def new(cls, sponsor, benefits, package=None, submited_by=None): for benefit in benefits: added_by_user = for_modified_package and benefit not in package_benefits - - SponsorBenefit.objects.create( - sponsorship=sponsorship, - sponsorship_benefit=benefit, - name=benefit.name, - description=benefit.description, - program=benefit.program, - benefit_internal_value=benefit.internal_value, - added_by_user=added_by_user, + SponsorBenefit.new_copy( + benefit, sponsorship=sponsorship, added_by_user=added_by_user ) return sponsorship @@ -414,6 +407,17 @@ class SponsorBenefit(models.Model): blank=True, default=False, verbose_name="Added by user?" ) + @classmethod + def new_copy(cls, benefit, **kwargs): + return cls.objects.create( + sponsorship_benefit=benefit, + name=benefit.name, + description=benefit.description, + program=benefit.program, + benefit_internal_value=benefit.internal_value, + **kwargs, + ) + class Sponsor(ContentManageable): name = models.CharField( From 6c3c6eb758fab47481bf248508f4195db4b579b7 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 11:41:42 -0300 Subject: [PATCH 05/82] List benefits info --- sponsors/models.py | 4 ++++ sponsors/tests/test_models.py | 26 +++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index 1304a80e4..8f503895b 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -603,10 +603,14 @@ def new(cls, sponsorship): else: sponsor_contact = f"Contacts {sponsor.primary_phone}" + benefits = sponsorship.benefits.all() + benefits_list = "\n".join([f" - {b.program.name} - {b.name}" for b in benefits]) + return cls.objects.create( sponsorship=sponsorship, sponsor_info=sponsor_info, sponsor_contact=sponsor_contact, + benefits_list=benefits_list, ) @property diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 7d966e023..070c30274 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -1,5 +1,5 @@ from datetime import date -from model_bakery import baker +from model_bakery import baker, seq from django.conf import settings from django.test import TestCase @@ -11,6 +11,7 @@ Sponsorship, StatementOfWork, SponsorContact, + SponsorBenefit, ) from ..exceptions import SponsorWithExistingApplicationException @@ -250,6 +251,12 @@ def test_get_primary_contact_for_sponsor(self): class StatementOfWorkModelTests(TestCase): def setUp(self): self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") + self.sponsorship_benefits = baker.make( + SponsorshipBenefit, + program__name='PSF', + name=seq("benefit"), + _quantity=3, + ) def test_auto_increment_draft_revision_on_save(self): statement = baker.make_recipe("sponsors.tests.empty_sow") @@ -306,3 +313,20 @@ def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_contact_and_ ) self.assertEqual(statement.sponsor_contact, expected_contact) + + def test_format_benefits_without_legal_clauses(self): + for benefit in self.sponsorship_benefits: + SponsorBenefit.new_copy(benefit, sponsorship=self.sponsorship) + + statement = StatementOfWork.new(self.sponsorship) + + self.assertEqual(statement.legal_clauses.raw, "") + self.assertEqual(statement.legal_clauses.markup_type, "markdown") + + b1, b2, b3 = self.sponsorship.benefits.all() + expected_benefits_list = f""" - PSF - {b1.name} + - PSF - {b2.name} + - PSF - {b3.name}""" + + self.assertEqual(statement.benefits_list.raw, expected_benefits_list) + self.assertEqual(statement.benefits_list.markup_type, 'markdown') From b2df24e39f55129ee006d9c40ce2b10c34003abc Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 13:59:21 -0300 Subject: [PATCH 06/82] Format legal clauses list --- sponsors/models.py | 25 +++++++++++++++++++++++-- sponsors/tests/test_models.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 8f503895b..8d6ae3b9a 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -418,6 +418,10 @@ def new_copy(cls, benefit, **kwargs): **kwargs, ) + @property + def legal_clauses(self): + return self.sponsorship_benefit.legal_clauses.all() + class Sponsor(ContentManageable): name = models.CharField( @@ -604,13 +608,30 @@ def new(cls, sponsorship): sponsor_contact = f"Contacts {sponsor.primary_phone}" benefits = sponsorship.benefits.all() - benefits_list = "\n".join([f" - {b.program.name} - {b.name}" for b in benefits]) + # must query for Legal Clauses again to respect model's ordering + clauses_ids = [c.id for c in chain(*[b.legal_clauses for b in benefits])] + legal_clauses = list(LegalClause.objects.filter(id__in=clauses_ids)) + benefits_list = [] + for benefit in benefits: + item = f" - {benefit.program.name} - {benefit.name}" + index_str = "" + for legal_clause in benefit.legal_clauses: + index = legal_clauses.index(legal_clause) + 1 + index_str += f"[^{index}]" + if index_str: + item += f" {index_str}" + benefits_list.append(item) + + legal_clauses_text = "\n".join( + [f" [^{i}]: {c.clause}" for i, c in enumerate(legal_clauses, start=1)] + ) return cls.objects.create( sponsorship=sponsorship, sponsor_info=sponsor_info, sponsor_contact=sponsor_contact, - benefits_list=benefits_list, + benefits_list="\n".join([b for b in benefits_list]), + legal_clauses=legal_clauses_text, ) @property diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 070c30274..82ac22a04 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -12,6 +12,7 @@ StatementOfWork, SponsorContact, SponsorBenefit, + LegalClause, ) from ..exceptions import SponsorWithExistingApplicationException @@ -253,8 +254,9 @@ def setUp(self): self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") self.sponsorship_benefits = baker.make( SponsorshipBenefit, - program__name='PSF', + program__name="PSF", name=seq("benefit"), + order=seq(1), _quantity=3, ) @@ -329,4 +331,33 @@ def test_format_benefits_without_legal_clauses(self): - PSF - {b3.name}""" self.assertEqual(statement.benefits_list.raw, expected_benefits_list) - self.assertEqual(statement.benefits_list.markup_type, 'markdown') + self.assertEqual(statement.benefits_list.markup_type, "markdown") + + def test_format_benefits_with_legal_clauses(self): + baker.make(LegalClause, _quantity=len(self.sponsorship_benefits)) + legal_clauses = list(LegalClause.objects.all()) + + for i, benefit in enumerate(self.sponsorship_benefits): + clause = legal_clauses[i] + benefit.legal_clauses.add(clause) + SponsorBenefit.new_copy(benefit, sponsorship=self.sponsorship) + self.sponsorship_benefits[0].legal_clauses.add( + clause + ) # first benefit with 2 legal clauses + + statement = StatementOfWork.new(self.sponsorship) + + c1, c2, c3 = legal_clauses + expected_legal_clauses = f""" [^1]: {c1.clause} + [^2]: {c2.clause} + [^3]: {c3.clause}""" + self.assertEqual(statement.legal_clauses.raw, expected_legal_clauses) + self.assertEqual(statement.legal_clauses.markup_type, "markdown") + + b1, b2, b3 = self.sponsorship.benefits.all() + expected_benefits_list = f""" - PSF - {b1.name} [^1][^3] + - PSF - {b2.name} [^2] + - PSF - {b3.name} [^3]""" + + self.assertEqual(statement.benefits_list.raw, expected_benefits_list) + self.assertEqual(statement.benefits_list.markup_type, "markdown") From 1129a97a4a1dded2db4a5c7952c494066ed00b8e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 20 Nov 2020 12:49:58 -0300 Subject: [PATCH 07/82] Prevents from rejecting or accepting reviewd applications --- sponsors/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index 8d6ae3b9a..ce06ad4a1 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -14,7 +14,7 @@ from cms.models import ContentManageable from companies.models import Company -from .managers import SponsorshipQuerySet, SponsorContactQuerySet +from .managers import SponsorContactQuerySet, SponsorContactQuerySet from .exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidStatusException, From 9d590fb4a1f88dc77b385456d070eb86baf81ab5 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 24 Nov 2020 09:39:18 -0300 Subject: [PATCH 08/82] Display detailed sponsor's address information --- templates/sponsors/partials/full_sponsorship.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/sponsors/partials/full_sponsorship.txt b/templates/sponsors/partials/full_sponsorship.txt index e128a4891..517239cc6 100644 --- a/templates/sponsors/partials/full_sponsorship.txt +++ b/templates/sponsors/partials/full_sponsorship.txt @@ -5,8 +5,8 @@ * Landing Page URL {{ sponsor.landing_page_url }} * Primary Phone {{ sponsor.primary_phone }} * City {{ sponsor.city }}{% if sponsor.state %} - {{ sponsor.state }}{% endif %} - {{ sponsor.get_country_display}} ({{ sponsor.country}}) - * Mailing Address {{ sponsor.mailing_address_line_1 }}{% if sponsor.mailing_address_line_1 %} - {{ sponsor.mailing_address_line_2 }}{% endif %} * Zip/Postal Code {{ sponsor.postal_code }} + * Mailing Address {{ sponsor.mailing_address_line_1 }}{% if sponsor.mailing_address_line_1 %} - {{ sponsor.mailing_address_line_2 }}{% endif %} * Contacts {% for contact in sponsor.contacts.all %} - {{ contact.name }}, {{ contact.email }}, {{ contact.phone }}{% if contact.primary %} - *Primary*{%endif %} {% endfor %} From 52105948e0b8626865ca3c4bad65c7f2c7683f5a Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 24 Nov 2020 09:50:13 -0300 Subject: [PATCH 09/82] Fix f-string and change postal code order --- templates/sponsors/partials/full_sponsorship.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/sponsors/partials/full_sponsorship.txt b/templates/sponsors/partials/full_sponsorship.txt index 517239cc6..e128a4891 100644 --- a/templates/sponsors/partials/full_sponsorship.txt +++ b/templates/sponsors/partials/full_sponsorship.txt @@ -5,8 +5,8 @@ * Landing Page URL {{ sponsor.landing_page_url }} * Primary Phone {{ sponsor.primary_phone }} * City {{ sponsor.city }}{% if sponsor.state %} - {{ sponsor.state }}{% endif %} - {{ sponsor.get_country_display}} ({{ sponsor.country}}) - * Zip/Postal Code {{ sponsor.postal_code }} * Mailing Address {{ sponsor.mailing_address_line_1 }}{% if sponsor.mailing_address_line_1 %} - {{ sponsor.mailing_address_line_2 }}{% endif %} + * Zip/Postal Code {{ sponsor.postal_code }} * Contacts {% for contact in sponsor.contacts.all %} - {{ contact.name }}, {{ contact.email }}, {{ contact.phone }}{% if contact.primary %} - *Primary*{%endif %} {% endfor %} From 2b84431da8dc8eb28a2b600101afe4fdbd873381 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 14:34:39 -0300 Subject: [PATCH 10/82] Implement use case to approve sponsorship --- sponsors/admin.py | 4 ++-- sponsors/tests/test_use_cases.py | 33 ++++++++++++++++++++++++++++++++ sponsors/use_cases.py | 26 ++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 5ca16f960..ff0471f23 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -329,8 +329,8 @@ def approve_sponsorship_view(self, request, pk): if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": try: - sponsorship.approve() - sponsorship.save() + use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() + use_case.execute(sponsorship) self.message_user( request, "Sponsorship was approved!", messages.SUCCESS ) diff --git a/sponsors/tests/test_use_cases.py b/sponsors/tests/test_use_cases.py index 3d637d422..d48ebbdce 100644 --- a/sponsors/tests/test_use_cases.py +++ b/sponsors/tests/test_use_cases.py @@ -79,3 +79,36 @@ def test_build_use_case_with_correct_notifications(self): self.assertIsInstance( uc.notifications[1], RejectedSponsorshipNotificationToSponsors ) + + +class ApproveSponsorshipApplicationUseCaseTests(TestCase): + def setUp(self): + self.notifications = [Mock(), Mock()] + self.use_case = use_cases.ApproveSponsorshipApplicationUseCase( + self.notifications + ) + self.user = baker.make(settings.AUTH_USER_MODEL) + self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") + + def test_update_sponsorship_as_approved_and_create_statement_of_work(self): + self.use_case.execute(self.sponsorship) + self.sponsorship.refresh_from_db() + + today = timezone.now().date() + self.assertEqual(self.sponsorship.approved_on, today) + self.assertEqual(self.sponsorship.status, Sponsorship.APPROVED) + self.assertTrue(self.sponsorship.statement_of_work.pk) + + def test_send_notifications_using_sponsorship(self): + self.use_case.execute(self.sponsorship) + + for n in self.notifications: + n.notify.assert_called_once_with( + request=None, + sponsorship=self.sponsorship, + statement_of_work=self.sponsorship.statement_of_work, + ) + + def test_build_use_case_without_notificationss(self): + uc = use_cases.ApproveSponsorshipApplicationUseCase.build() + self.assertEqual(len(uc.notifications), 0) diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 48bb6f547..6c91d11ec 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -1,4 +1,4 @@ -from sponsors.models import Sponsorship +from sponsors.models import Sponsorship, StatementOfWork from sponsors import notifications @@ -43,3 +43,27 @@ def build(cls): notifications.RejectedSponsorshipNotificationToSponsors(), ] return cls(uc_notifications) + + +class ApproveSponsorshipApplicationUseCase: + def __init__(self, notifications): + self.notifications = notifications + + def execute(self, sponsorship, request=None): + sponsorship.approve() + sponsorship.save() + statement_of_work = StatementOfWork.new(sponsorship) + + for notification in self.notifications: + notification.notify( + request=request, + sponsorship=sponsorship, + statement_of_work=statement_of_work, + ) + + return sponsorship + + @classmethod + def build(cls): + uc_notifications = [] + return cls(uc_notifications) From 47f13a383da8fd63f44f6abdb111d9171a843754 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 14:39:52 -0300 Subject: [PATCH 11/82] Refactor use cases --- sponsors/use_cases.py | 72 ++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 6c91d11ec..981b2463a 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -2,68 +2,56 @@ from sponsors import notifications -class CreateSponsorshipApplicationUseCase: +class BaseUseCaseWithNotifications: + notifications = [] + def __init__(self, notifications): self.notifications = notifications - def execute(self, user, sponsor, benefits, package=None, request=None): - sponsorship = Sponsorship.new(sponsor, benefits, package, submited_by=user) - + def notify(self, **kwargs): for notification in self.notifications: - notification.notify(request=request, sponsorship=sponsorship) - - return sponsorship + notification.notify(**kwargs) @classmethod def build(cls): - uc_notifications = [ - notifications.AppliedSponsorshipNotificationToPSF(), - notifications.AppliedSponsorshipNotificationToSponsors(), - ] - return cls(uc_notifications) + return cls(cls.notifications) -class RejectSponsorshipApplicationUseCase: - def __init__(self, notifications): - self.notifications = notifications +class CreateSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): + notifications = [ + notifications.AppliedSponsorshipNotificationToPSF(), + notifications.AppliedSponsorshipNotificationToSponsors(), + ] + + def execute(self, user, sponsor, benefits, package=None, request=None): + sponsorship = Sponsorship.new(sponsor, benefits, package, submited_by=user) + self.notify(sponsorship=sponsorship, request=request) + return sponsorship + + +class RejectSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): + notifications = [ + notifications.RejectedSponsorshipNotificationToPSF(), + notifications.RejectedSponsorshipNotificationToSponsors(), + ] def execute(self, sponsorship, request=None): sponsorship.reject() sponsorship.save() - - for notification in self.notifications: - notification.notify(request=request, sponsorship=sponsorship) - + self.notify(request=request, sponsorship=sponsorship) return sponsorship - @classmethod - def build(cls): - uc_notifications = [ - notifications.RejectedSponsorshipNotificationToPSF(), - notifications.RejectedSponsorshipNotificationToSponsors(), - ] - return cls(uc_notifications) - - -class ApproveSponsorshipApplicationUseCase: - def __init__(self, notifications): - self.notifications = notifications +class ApproveSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): def execute(self, sponsorship, request=None): sponsorship.approve() sponsorship.save() statement_of_work = StatementOfWork.new(sponsorship) - for notification in self.notifications: - notification.notify( - request=request, - sponsorship=sponsorship, - statement_of_work=statement_of_work, - ) + self.notify( + request=request, + sponsorship=sponsorship, + statement_of_work=statement_of_work, + ) return sponsorship - - @classmethod - def build(cls): - uc_notifications = [] - return cls(uc_notifications) From 6d002a481b05ca15507c2d744faac5c514e10813 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 15:03:10 -0300 Subject: [PATCH 12/82] Model admin for statements of work --- sponsors/admin.py | 71 +++++++++++++++++++++ sponsors/migrations/0019_statementofwork.py | 4 +- sponsors/models.py | 7 +- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index ff0471f23..e9cd99ecf 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -16,6 +16,7 @@ SponsorContact, SponsorBenefit, LegalClause, + StatementOfWork, ) from sponsors import use_cases from sponsors.forms import SponsorshipReviewAdminForm @@ -351,3 +352,73 @@ def approve_sponsorship_view(self, request, pk): @admin.register(LegalClause) class LegalClauseModelAdmin(OrderedModelAdmin): list_display = ["internal_name"] + + +@admin.register(StatementOfWork) +class StatementOfWorkModelAdmin(admin.ModelAdmin): + readonly_fields = [ + "status", + "created_on", + "last_update", + "sent_on", + "sponsorship", + "revision", + "document", + ] + list_display = [ + "id", + "sponsorship", + "created_on", + "last_update", + "status", + "get_revision", + ] + + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + return qs.select_related("sponsorship__sponsor") + + def get_revision(self, obj): + return obj.revision if obj.is_draft else "Final" + + get_revision.short_description = "Revision" + + fieldsets = [ + ( + "Info", + { + "fields": ("sponsorship", "status", "revision"), + }, + ), + ( + "Editable", + { + "fields": ( + "sponsor_info", + "sponsor_contact", + "benefits_list", + "legal_clauses", + ), + }, + ), + ( + "Files", + { + "fields": ( + "document", + "signed_document", + ) + }, + ), + ( + "Activities log", + { + "fields": ( + "created_on", + "last_update", + "sent_on", + ), + "classes": ["collapse"], + }, + ), + ] diff --git a/sponsors/migrations/0019_statementofwork.py b/sponsors/migrations/0019_statementofwork.py index ca62e6ad2..0f7faed67 100644 --- a/sponsors/migrations/0019_statementofwork.py +++ b/sponsors/migrations/0019_statementofwork.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.13 on 2020-12-04 13:05 +# Generated by Django 2.0.13 on 2020-12-04 18:02 from django.db import migrations, models import django.db.models.deletion @@ -74,6 +74,7 @@ class Migration(migrations.Migration): ("restructuredtext", "Restructured Text"), ], default="markdown", + editable=False, max_length=30, ), ), @@ -90,6 +91,7 @@ class Migration(migrations.Migration): ("restructuredtext", "Restructured Text"), ], default="markdown", + editable=False, max_length=30, ), ), diff --git a/sponsors/models.py b/sponsors/models.py index ce06ad4a1..d8bc18906 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -571,7 +571,7 @@ class StatementOfWork(models.Model): # - PyCon - PyCon website Listing [^1][^2] # - PyPI - Social media promotion of your sponsorship # """ - benefits_list = MarkupField(default_markup_type="markdown") + benefits_list = MarkupField(markup_type="markdown") # legal_clauses = """ # [^1]: Here's one with multiple paragraphs and code. # Indent paragraphs to include them in the footnote. @@ -582,7 +582,7 @@ class StatementOfWork(models.Model): # `{ my code }` # Add as many paragraphs as you like. # """ - legal_clauses = MarkupField(default_markup_type="markdown") + legal_clauses = MarkupField(markup_type="markdown") # Activity control fields created_on = models.DateField(auto_now_add=True) @@ -593,6 +593,9 @@ class Meta: verbose_name = "Statement of Work" verbose_name_plural = "Statements of Work" + def __str__(self): + return f"Statement of work: {self.sponsorship}" + @classmethod def new(cls, sponsorship): """ From d7bd3a4d4e298fcf3dfc702631dfd9eaf7a79900 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 15:03:36 -0300 Subject: [PATCH 13/82] Add link from approved sponsorship to draft SOW --- sponsors/models.py | 8 ++++++++ templates/sponsors/admin/sponsorship_change_form.html | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/sponsors/models.py b/sponsors/models.py index d8bc18906..97729160d 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -348,6 +348,14 @@ def verified_emails(self): def admin_url(self): return reverse("admin:sponsors_sponsorship_change", args=[self.pk]) + @property + def sow_admin_url(self): + if not self.statement_of_work: + return "" + return reverse( + "admin:sponsors_statementofwork_change", args=[self.statement_of_work.pk] + ) + @cached_property def package_benefits(self): return self.benefits.filter(added_by_user=False) diff --git a/templates/sponsors/admin/sponsorship_change_form.html b/templates/sponsors/admin/sponsorship_change_form.html index 5a983f31a..96822890e 100644 --- a/templates/sponsors/admin/sponsorship_change_form.html +++ b/templates/sponsors/admin/sponsorship_change_form.html @@ -16,6 +16,12 @@ {% endif %} + {% if sp.FINALIZED in sp.next_status %} +
  • + Review Statement of Work +
  • + {% endif %} + {% endwith %} {{ block.super }} From 26eb676aaf5ecbf33f08d3bb1ce4ccb96a233649 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 15:17:37 -0300 Subject: [PATCH 14/82] Replace sponsorships preview by SoW preview --- sponsors/admin.py | 53 +++++++++++++------ .../sponsors/admin/preview-sponsorship.html | 20 ------- .../admin/preview-statement-of-work.html | 21 ++++++++ 3 files changed, 57 insertions(+), 37 deletions(-) delete mode 100644 templates/sponsors/admin/preview-sponsorship.html create mode 100644 templates/sponsors/admin/preview-statement-of-work.html diff --git a/sponsors/admin.py b/sponsors/admin.py index e9cd99ecf..04b8f7f0c 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -113,7 +113,6 @@ class SponsorshipAdmin(admin.ModelAdmin): "approved_on", "start_date", "end_date", - "display_sponsorship_link", ] list_filter = ["status"] readonly_fields = [ @@ -184,12 +183,6 @@ def get_queryset(self, *args, **kwargs): qs = super().get_queryset(*args, **kwargs) return qs.select_related("sponsor") - def display_sponsorship_link(self, obj): - url = reverse("admin:sponsors_sponsorship_preview", args=[obj.pk]) - return mark_safe(f'Click to preview') - - display_sponsorship_link.short_description = "Preview sponsorship" - def get_estimated_cost(self, obj): cost = None html = "This sponsorship has not customizations so there's no estimated cost" @@ -201,19 +194,9 @@ def get_estimated_cost(self, obj): get_estimated_cost.short_description = "Estimated cost" - def preview_sponsorship_view(self, request, pk): - sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) - ctx = {"sponsorship": sponsorship} - return render(request, "sponsors/admin/preview-sponsorship.html", context=ctx) - def get_urls(self): urls = super().get_urls() my_urls = [ - path( - "/preview", - self.admin_site.admin_view(self.preview_sponsorship_view), - name="sponsors_sponsorship_preview", - ), path( "/reject", # TODO: maybe it would be better to create a specific @@ -372,6 +355,7 @@ class StatementOfWorkModelAdmin(admin.ModelAdmin): "last_update", "status", "get_revision", + "document_link", ] def get_queryset(self, *args, **kwargs): @@ -422,3 +406,38 @@ def get_revision(self, obj): }, ), ] + + def document_link(self, obj): + html, url, msg = "---", "", "" + + if obj.is_draft: + url = reverse("admin:sponsors_statementofwork_preview", args=[obj.pk]) + msg = "Preview document" + elif obj.document: + url = obj.document.url + msg = "Download Contract" + elif obj.signed_document: + url = obj.signed_document.url + msg = "Download Signed Contract" + + if url and msg: + html = f'{msg}' + return mark_safe(html) + + document_link.short_description = "Contract document" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path( + "/preview", + self.admin_site.admin_view(self.preview_statement_of_work_view), + name="sponsors_statementofwork_preview", + ), + ] + return my_urls + urls + + def preview_statement_of_work_view(self, request, pk): + statement_of_work = get_object_or_404(self.get_queryset(request), pk=pk) + ctx = {"sow": statement_of_work} + return render(request, "sponsors/admin/preview-statement-of-work.html", context=ctx) diff --git a/templates/sponsors/admin/preview-sponsorship.html b/templates/sponsors/admin/preview-sponsorship.html deleted file mode 100644 index 67a7bb771..000000000 --- a/templates/sponsors/admin/preview-sponsorship.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - -

    Sponsorship Terms

    - -

    Term 1: some content...

    - -

    {{ sponsorship.sponsor_info.name }}

    - - -
      -{% for benefit in sponsorship.benefits.all %} -
    • {{ benefit.name }}
    • -{% endfor %} -
    - - - diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html new file mode 100644 index 000000000..ab322bb1b --- /dev/null +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -0,0 +1,21 @@ + + + + + +

    Statement of Work Terms

    + +Info +

    {{ sow.sponsor_info }}

    + +Contact +

    {{ sow.sponsor_contact }}

    + +Benefits +

    {{ sow.benefits_list }}

    + +Legal Clauses +

    {{ sow.legal_clauses }}

    + + + From 2c68d71bcd4311004f0f48d101605cb5738177b2 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 15:18:25 -0300 Subject: [PATCH 15/82] Black =] --- sponsors/admin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 04b8f7f0c..92205df3b 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -249,17 +249,19 @@ def get_sponsor_primary_phone(self, obj): def get_sponsor_mailing_address(self, obj): sponsor = obj.sponsor - city_row = f'{sponsor.city} - {sponsor.get_country_display()} ({sponsor.country})' + city_row = ( + f"{sponsor.city} - {sponsor.get_country_display()} ({sponsor.country})" + ) if sponsor.state: - city_row = f'{sponsor.city} - {sponsor.state} - {sponsor.get_country_display()} ({sponsor.country})' + city_row = f"{sponsor.city} - {sponsor.state} - {sponsor.get_country_display()} ({sponsor.country})" mail_row = sponsor.mailing_address_line_1 if sponsor.mailing_address_line_2: - mail_row += f' - {sponsor.mailing_address_line_2}' + mail_row += f" - {sponsor.mailing_address_line_2}" - html = f'

    {city_row}

    ' - html += f'

    {mail_row}

    ' - html += f'

    {sponsor.postal_code}

    ' + html = f"

    {city_row}

    " + html += f"

    {mail_row}

    " + html += f"

    {sponsor.postal_code}

    " return mark_safe(html) get_sponsor_mailing_address.short_description = "Mailing/Billing Address" From a7e3fa877e0f12cbd297cbef75164842deec0f76 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 4 Dec 2020 15:25:29 -0300 Subject: [PATCH 16/82] Add button to preview SoW from change form --- sponsors/admin.py | 3 ++- sponsors/models.py | 4 ++++ .../admin/statement_of_work_change_form.html | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 templates/sponsors/admin/statement_of_work_change_form.html diff --git a/sponsors/admin.py b/sponsors/admin.py index 92205df3b..35ecf8d80 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -341,6 +341,7 @@ class LegalClauseModelAdmin(OrderedModelAdmin): @admin.register(StatementOfWork) class StatementOfWorkModelAdmin(admin.ModelAdmin): + change_form_template = "sponsors/admin/statement_of_work_change_form.html" readonly_fields = [ "status", "created_on", @@ -413,7 +414,7 @@ def document_link(self, obj): html, url, msg = "---", "", "" if obj.is_draft: - url = reverse("admin:sponsors_statementofwork_preview", args=[obj.pk]) + url = obj.preview_url msg = "Preview document" elif obj.document: url = obj.document.url diff --git a/sponsors/models.py b/sponsors/models.py index 97729160d..c3dea3fab 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -649,6 +649,10 @@ def new(cls, sponsorship): def is_draft(self): return self.status == self.DRAFT + @property + def preview_url(self): + return reverse("admin:sponsors_statementofwork_preview", args=[self.pk]) + def save(self, **kwargs): commit = kwargs.get("commit", True) if all([commit, self.pk, self.is_draft]): diff --git a/templates/sponsors/admin/statement_of_work_change_form.html b/templates/sponsors/admin/statement_of_work_change_form.html new file mode 100644 index 000000000..b66f6d9c5 --- /dev/null +++ b/templates/sponsors/admin/statement_of_work_change_form.html @@ -0,0 +1,16 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} + {% with original as sow %} + + {% if sow.is_draft %} +
  • + Review Statement of Work +
  • + {% endif %} + + {% endwith %} + + {{ block.super }} +{% endblock %} From 4104f09a38b2fdacef8d70f56352a6f301eba5a3 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 7 Dec 2020 13:26:47 -0300 Subject: [PATCH 17/82] Move sponsor contact to sponsor info and display primary contact's email --- sponsors/models.py | 7 +++---- sponsors/tests/test_models.py | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index c3dea3fab..6dc828a40 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -612,11 +612,10 @@ def new(cls, sponsorship): sponsor = sponsorship.sponsor primary_contact = sponsor.primary_contact - sponsor_info = f"{sponsor.name} with address {sponsor.full_address}" + sponsor_info = f"{sponsor.name} with address {sponsor.full_address} and contact {sponsor.primary_phone}" + sponsor_contact = "" if primary_contact: - sponsor_contact = f"Contacts {sponsor.primary_phone} - {primary_contact.name}, {primary_contact.phone}" - else: - sponsor_contact = f"Contacts {sponsor.primary_phone}" + sponsor_contact = f"{primary_contact.name} - {primary_contact.phone} | {primary_contact.email}" benefits = sponsorship.benefits.all() # must query for Legal Clauses again to respect model's ordering diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 82ac22a04..8082708a5 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -294,12 +294,11 @@ def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_info_and_con statement.refresh_from_db() sponsor = self.sponsorship.sponsor - expected_info = f"{sponsor.name} with address {sponsor.full_address}" - expected_contact = f"Contacts {sponsor.primary_phone}" + expected_info = f"{sponsor.name} with address {sponsor.full_address} and contact {sponsor.primary_phone}" self.assertEqual(statement.sponsorship, self.sponsorship) self.assertEqual(statement.sponsor_info, expected_info) - self.assertEqual(statement.sponsor_contact, expected_contact) + self.assertEqual(statement.sponsor_contact, "") def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_contact_and_primary( self, @@ -311,7 +310,7 @@ def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_contact_and_ statement = StatementOfWork.new(self.sponsorship) expected_contact = ( - f"Contacts {sponsor.primary_phone} - {contact.name}, {contact.phone}" + f"{contact.name} - {contact.phone} | {contact.email}" ) self.assertEqual(statement.sponsor_contact, expected_contact) From b589c8f7286ed0b3ac08e562db7737a4fca118fd Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 15:52:54 -0300 Subject: [PATCH 18/82] Approve method requires start/end date --- sponsors/exceptions.py | 7 +++++++ sponsors/models.py | 8 +++++++- sponsors/tests/test_models.py | 21 +++++++++++++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/sponsors/exceptions.py b/sponsors/exceptions.py index a8e5a30f9..4d3d2bc94 100644 --- a/sponsors/exceptions.py +++ b/sponsors/exceptions.py @@ -10,3 +10,10 @@ class SponsorshipInvalidStatusException(Exception): Raised when user tries to change the Sponsorship's status to a new one but from an invalid current status """ + + +class SponsorshipInvalidDateRangeException(Exception): + """ + Raised when user tries to approve a sponsorship with a start date + greater than the end date. + """ \ No newline at end of file diff --git a/sponsors/models.py b/sponsors/models.py index 6dc828a40..56b5bc5c7 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -18,6 +18,7 @@ from .exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidStatusException, + SponsorshipInvalidDateRangeException, ) DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "restructuredtext") @@ -330,11 +331,16 @@ def reject(self): self.status = self.REJECTED self.rejected_on = timezone.now().date() - def approve(self): + def approve(self, start_date, end_date): if self.APPROVED not in self.next_status: msg = f"Can't approve a {self.get_status_display()} sponsorship." raise SponsorshipInvalidStatusException(msg) + if start_date >= end_date: + msg = f"Start date greater or equal than end date" + raise SponsorshipInvalidDateRangeException(msg) self.status = self.APPROVED + self.start_date = start_date + self.end_date = end_date self.approved_on = timezone.now().date() @property diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 8082708a5..829cfa8a2 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta from model_bakery import baker, seq from django.conf import settings @@ -14,7 +14,7 @@ SponsorBenefit, LegalClause, ) -from ..exceptions import SponsorWithExistingApplicationException +from ..exceptions import SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException class SponsorshipBenefitModelTests(TestCase): @@ -141,14 +141,27 @@ def test_estimated_cost_property(self): self.assertEqual(estimated_cost, sponsorship.estimated_cost) def test_approve_sponsorship(self): + start = date.today() + end = start + timedelta(days=10) sponsorship = Sponsorship.new(self.sponsor, self.benefits) self.assertEqual(sponsorship.status, Sponsorship.APPLIED) self.assertIsNone(sponsorship.approved_on) - sponsorship.approve() + sponsorship.approve(start, end) - self.assertEqual(sponsorship.status, Sponsorship.APPROVED) self.assertEqual(sponsorship.approved_on, timezone.now().date()) + self.assertEqual(sponsorship.status, Sponsorship.APPROVED) + self.assertTrue(sponsorship.start_date, start) + self.assertTrue(sponsorship.end_date, end) + + def test_exception_if_invalid_date_range_when_approving(self): + start = date.today() + sponsorship = Sponsorship.new(self.sponsor, self.benefits) + self.assertEqual(sponsorship.status, Sponsorship.APPLIED) + self.assertIsNone(sponsorship.approved_on) + + with self.assertRaises(SponsorshipInvalidDateRangeException): + sponsorship.approve(start, start) def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self): sponsorship = Sponsorship.new(self.sponsor, self.benefits) From 1cd894b2937909371fa11c91a5d462397dc796ee Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 15:53:42 -0300 Subject: [PATCH 19/82] Accept use case now updates the sponsorship with more data --- sponsors/tests/test_use_cases.py | 17 +++++++++++++++-- sponsors/use_cases.py | 15 +++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/sponsors/tests/test_use_cases.py b/sponsors/tests/test_use_cases.py index d48ebbdce..8da41f0fc 100644 --- a/sponsors/tests/test_use_cases.py +++ b/sponsors/tests/test_use_cases.py @@ -1,5 +1,6 @@ from unittest.mock import Mock from model_bakery import baker +from datetime import timedelta, date from django.conf import settings from django.test import TestCase @@ -90,17 +91,29 @@ def setUp(self): self.user = baker.make(settings.AUTH_USER_MODEL) self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") + today = date.today() + self.data = { + "sponsorship_fee": 100, + "level_name": "level", + "start_date": today, + "end_date": today + timedelta(days=10), + } + def test_update_sponsorship_as_approved_and_create_statement_of_work(self): - self.use_case.execute(self.sponsorship) + self.use_case.execute(self.sponsorship, **self.data) self.sponsorship.refresh_from_db() today = timezone.now().date() self.assertEqual(self.sponsorship.approved_on, today) self.assertEqual(self.sponsorship.status, Sponsorship.APPROVED) self.assertTrue(self.sponsorship.statement_of_work.pk) + self.assertTrue(self.sponsorship.start_date) + self.assertTrue(self.sponsorship.end_date) + self.assertEqual(self.sponsorship.sponsorship_fee, 100) + self.assertEqual(self.sponsorship.level_name, 'level') def test_send_notifications_using_sponsorship(self): - self.use_case.execute(self.sponsorship) + self.use_case.execute(self.sponsorship, **self.data) for n in self.notifications: n.notify.assert_called_once_with( diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 981b2463a..7d8f9ef42 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -1,5 +1,5 @@ -from sponsors.models import Sponsorship, StatementOfWork from sponsors import notifications +from sponsors.models import Sponsorship, StatementOfWork class BaseUseCaseWithNotifications: @@ -43,13 +43,20 @@ def execute(self, sponsorship, request=None): class ApproveSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): - def execute(self, sponsorship, request=None): - sponsorship.approve() + def execute(self, sponsorship, start_date, end_date, **kwargs): + sponsorship.approve(start_date, end_date) + level_name = kwargs.get('level_name') + fee = kwargs.get('sponsorship_fee') + if level_name: + sponsorship.level_name = level_name + if fee: + sponsorship.sponsorship_fee = fee + sponsorship.save() statement_of_work = StatementOfWork.new(sponsorship) self.notify( - request=request, + request=kwargs.get("request"), sponsorship=sponsorship, statement_of_work=statement_of_work, ) From 34bd7a0cba0c2c552a66a788f13d1007e2aadd16 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 15:59:01 -0300 Subject: [PATCH 20/82] Display form when reviewing sponsorship application --- sponsors/admin.py | 38 +++++++++++------ sponsors/forms.py | 17 ++++++-- sponsors/tests/test_views.py | 42 +++++++++++++++---- .../sponsors/admin/approve_application.html | 10 ++++- 4 files changed, 80 insertions(+), 27 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 35ecf8d80..706a28d29 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -312,23 +312,35 @@ def reject_sponsorship_view(self, request, pk): def approve_sponsorship_view(self, request, pk): sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) + initial = { + "level_name": sponsorship.level_name, + "start_date": sponsorship.start_date, + "end_date": sponsorship.end_date, + "sponsorship_fee": sponsorship.sponsorship_fee, + } + + form = SponsorshipReviewAdminForm(initial=initial, force_required=True) if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": - try: - use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() - use_case.execute(sponsorship) - self.message_user( - request, "Sponsorship was approved!", messages.SUCCESS + form = SponsorshipReviewAdminForm(data=request.POST) + if form.is_valid(): + kwargs = form.cleaned_data + kwargs["request"] = request + try: + use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() + use_case.execute(sponsorship, **kwargs) + self.message_user( + request, "Sponsorship was approved!", messages.SUCCESS + ) + except SponsorshipInvalidStatusException as e: + self.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] ) - except SponsorshipInvalidStatusException as e: - self.message_user(request, str(e), messages.ERROR) + return redirect(redirect_url) - redirect_url = reverse( - "admin:sponsors_sponsorship_change", args=[sponsorship.pk] - ) - return redirect(redirect_url) - - context = {"sponsorship": sponsorship} + context = {"sponsorship": sponsorship, "form": form} return render( request, "sponsors/admin/approve_application.html", context=context ) diff --git a/sponsors/forms.py b/sponsors/forms.py index 92478b242..0ddfa6b63 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -1,10 +1,11 @@ from itertools import chain -from django_countries.fields import CountryField from django import forms +from django.conf import settings +from django.contrib.admin.widgets import AdminDateWidget +from django.utils.functional import cached_property from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ -from django.utils.functional import cached_property -from django.conf import settings +from django_countries.fields import CountryField from sponsors.models import ( SponsorshipBenefit, @@ -318,6 +319,16 @@ def user_with_previous_sponsors(self): class SponsorshipReviewAdminForm(forms.ModelForm): + start_date = forms.DateField(widget=AdminDateWidget(), required=False) + end_date = forms.DateField(widget=AdminDateWidget(), required=False) + + def __init__(self, *args, **kwargs): + force_required = kwargs.pop('force_required', False) + super().__init__(*args, **kwargs) + if force_required: + for field_name in self.fields: + self.fields[field_name].required = True + class Meta: model = Sponsorship fields = ["start_date", "end_date", "level_name", "sponsorship_fee"] diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py index 7641f4d2f..1a8390702 100644 --- a/sponsors/tests/test_views.py +++ b/sponsors/tests/test_views.py @@ -1,6 +1,7 @@ import json from model_bakery import baker from itertools import chain +from datetime import date, timedelta from django.conf import settings from django.contrib import messages @@ -20,8 +21,7 @@ SponsorContact, Sponsorship, ) - -from sponsors.forms import SponsorshiptBenefitsForm, SponsorshipApplicationForm +from sponsors.forms import SponsorshiptBenefitsForm, SponsorshipApplicationForm, SponsorshipReviewAdminForm def assertMessage(msg, expected_content, expected_level): @@ -428,21 +428,34 @@ def setUp(self): self.url = reverse( "admin:sponsors_sponsorship_approve", args=[self.sponsorship.pk] ) + today = date.today() + self.data = { + "confirm": "yes", + "start_date": today, + "end_date": today + timedelta(days=100), + "level_name": 'Level', + } def test_display_confirmation_form_on_get(self): response = self.client.get(self.url) context = response.context + form = context['form'] self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") self.assertEqual(context["sponsorship"], self.sponsorship) + self.assertIsInstance(form, SponsorshipReviewAdminForm) + self.assertEqual(form.initial["level_name"], self.sponsorship.level_name) + self.assertEqual(form.initial["start_date"], self.sponsorship.start_date) + self.assertEqual(form.initial["end_date"], self.sponsorship.end_date) + self.assertEqual(form.initial["sponsorship_fee"], self.sponsorship.sponsorship_fee) self.assertNotEqual( self.sponsorship.status, Sponsorship.APPROVED ) # did not update def test_approve_sponsorship_on_post(self): - data = {"confirm": "yes"} - response = self.client.post(self.url, data=data) + response = self.client.post(self.url, data=self.data) + self.sponsorship.refresh_from_db() expected_url = reverse( @@ -453,19 +466,31 @@ def test_approve_sponsorship_on_post(self): msg = list(get_messages(response.wsgi_request))[0] assertMessage(msg, "Sponsorship was approved!", messages.SUCCESS) - def test_do_not_approve_if_invalid_post(self): - response = self.client.post(self.url, data={}) + def test_do_not_approve_if_no_confirmation_in_the_post(self): + self.data.pop('confirm') + response = self.client.post(self.url, data=self.data) self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") self.assertNotEqual( self.sponsorship.status, Sponsorship.APPROVED ) # did not update - response = self.client.post(self.url, data={"confirm": "invalid"}) + self.data["confirm"] = "invalid" + response = self.client.post(self.url, data=self.data) self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") self.assertNotEqual(self.sponsorship.status, Sponsorship.APPROVED) + def test_do_not_approve_if_form_with_invalid_data(self): + self.data = {"confirm": 'yes'} + response = self.client.post(self.url, data=self.data) + self.sponsorship.refresh_from_db() + self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") + self.assertNotEqual( + self.sponsorship.status, Sponsorship.APPROVED + ) # did not update + self.assertTrue(response.context['form'].errors) + def test_404_if_sponsorship_does_not_exist(self): self.sponsorship.delete() response = self.client.get(self.url) @@ -494,8 +519,7 @@ def test_staff_required(self): def test_message_user_if_approving_invalid_sponsorship(self): self.sponsorship.status = Sponsorship.FINALIZED self.sponsorship.save() - data = {"confirm": "yes"} - response = self.client.post(self.url, data=data) + response = self.client.post(self.url, data=self.data) self.sponsorship.refresh_from_db() expected_url = reverse( diff --git a/templates/sponsors/admin/approve_application.html b/templates/sponsors/admin/approve_application.html index 6d83ec67c..289a3bcd8 100644 --- a/templates/sponsors/admin/approve_application.html +++ b/templates/sponsors/admin/approve_application.html @@ -1,5 +1,5 @@ -{% extends 'admin/base_site.html' %} +{% extends 'admin/change_form.html' %} {% load i18n admin_static sponsors %} {% block extrastyle %}{{ block.super }}{% endblock %} @@ -25,6 +25,9 @@

    Approve Sponsorship

    {% full_sponsorship sponsorship display_fee=True %}
    +{{ form.media }} +{{ form.as_p }} +
    @@ -33,4 +36,7 @@

    Approve Sponsorship

    -
    {% endblock %} +
    + + +{% endblock %} From c8805e532cdbe0bd1ecb9b6b254dd5bda751f5a9 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 15:59:23 -0300 Subject: [PATCH 21/82] Prevent sponsorship from being changed after approval/rejection --- sponsors/admin.py | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 706a28d29..312db218c 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -99,6 +99,7 @@ class SponsorBenefitInline(admin.TabularInline): fields = ["name", "benefit_internal_value"] extra = 0 can_delete = False + can_add = False @admin.register(Sponsorship) @@ -115,24 +116,6 @@ class SponsorshipAdmin(admin.ModelAdmin): "end_date", ] list_filter = ["status"] - readonly_fields = [ - "for_modified_package", - "sponsor", - "status", - "applied_on", - "rejected_on", - "approved_on", - "finalized_on", - "get_estimated_cost", - "get_sponsor_name", - "get_sponsor_description", - "get_sponsor_landing_page_url", - "get_sponsor_web_logo", - "get_sponsor_print_logo", - "get_sponsor_primary_phone", - "get_sponsor_mailing_address", - "get_sponsor_contacts", - ] fieldsets = [ ( @@ -178,6 +161,32 @@ class SponsorshipAdmin(admin.ModelAdmin): }, ), ] + + def get_readonly_fields(self, request, obj): + readonly_fields = [ + "for_modified_package", + "sponsor", + "status", + "applied_on", + "rejected_on", + "approved_on", + "finalized_on", + "get_estimated_cost", + "get_sponsor_name", + "get_sponsor_description", + "get_sponsor_landing_page_url", + "get_sponsor_web_logo", + "get_sponsor_print_logo", + "get_sponsor_primary_phone", + "get_sponsor_mailing_address", + "get_sponsor_contacts", + ] + + if obj and obj.status != Sponsorship.APPLIED: + extra = ["start_date", "end_date", "level_name", "sponsorship_fee"] + readonly_fields.extend(extra) + + return readonly_fields def get_queryset(self, *args, **kwargs): qs = super().get_queryset(*args, **kwargs) From d2ab3f499a40c73d8dd87f1248698f01914f392d Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 15:59:39 -0300 Subject: [PATCH 22/82] Black =S --- sponsors/admin.py | 12 +++++++----- sponsors/exceptions.py | 2 +- sponsors/forms.py | 6 +++--- sponsors/tests/test_models.py | 9 +++++---- sponsors/tests/test_use_cases.py | 2 +- sponsors/tests/test_views.py | 22 ++++++++++++++-------- sponsors/use_cases.py | 6 +++--- 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 312db218c..d64f17d8e 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -161,7 +161,7 @@ class SponsorshipAdmin(admin.ModelAdmin): }, ), ] - + def get_readonly_fields(self, request, obj): readonly_fields = [ "for_modified_package", @@ -181,11 +181,11 @@ def get_readonly_fields(self, request, obj): "get_sponsor_mailing_address", "get_sponsor_contacts", ] - + if obj and obj.status != Sponsorship.APPLIED: extra = ["start_date", "end_date", "level_name", "sponsorship_fee"] readonly_fields.extend(extra) - + return readonly_fields def get_queryset(self, *args, **kwargs): @@ -327,7 +327,7 @@ def approve_sponsorship_view(self, request, pk): "end_date": sponsorship.end_date, "sponsorship_fee": sponsorship.sponsorship_fee, } - + form = SponsorshipReviewAdminForm(initial=initial, force_required=True) if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": @@ -464,4 +464,6 @@ def get_urls(self): def preview_statement_of_work_view(self, request, pk): statement_of_work = get_object_or_404(self.get_queryset(request), pk=pk) ctx = {"sow": statement_of_work} - return render(request, "sponsors/admin/preview-statement-of-work.html", context=ctx) + return render( + request, "sponsors/admin/preview-statement-of-work.html", context=ctx + ) diff --git a/sponsors/exceptions.py b/sponsors/exceptions.py index 4d3d2bc94..6628fd53c 100644 --- a/sponsors/exceptions.py +++ b/sponsors/exceptions.py @@ -16,4 +16,4 @@ class SponsorshipInvalidDateRangeException(Exception): """ Raised when user tries to approve a sponsorship with a start date greater than the end date. - """ \ No newline at end of file + """ diff --git a/sponsors/forms.py b/sponsors/forms.py index 0ddfa6b63..0c2e1eeba 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -321,14 +321,14 @@ def user_with_previous_sponsors(self): class SponsorshipReviewAdminForm(forms.ModelForm): start_date = forms.DateField(widget=AdminDateWidget(), required=False) end_date = forms.DateField(widget=AdminDateWidget(), required=False) - + def __init__(self, *args, **kwargs): - force_required = kwargs.pop('force_required', False) + force_required = kwargs.pop("force_required", False) super().__init__(*args, **kwargs) if force_required: for field_name in self.fields: self.fields[field_name].required = True - + class Meta: model = Sponsorship fields = ["start_date", "end_date", "level_name", "sponsorship_fee"] diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 829cfa8a2..206ed1162 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -14,7 +14,10 @@ SponsorBenefit, LegalClause, ) -from ..exceptions import SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException +from ..exceptions import ( + SponsorWithExistingApplicationException, + SponsorshipInvalidDateRangeException, +) class SponsorshipBenefitModelTests(TestCase): @@ -322,9 +325,7 @@ def test_create_new_statement_of_work_from_sponsorship_sets_sponsor_contact_and_ ) statement = StatementOfWork.new(self.sponsorship) - expected_contact = ( - f"{contact.name} - {contact.phone} | {contact.email}" - ) + expected_contact = f"{contact.name} - {contact.phone} | {contact.email}" self.assertEqual(statement.sponsor_contact, expected_contact) diff --git a/sponsors/tests/test_use_cases.py b/sponsors/tests/test_use_cases.py index 8da41f0fc..411d94d23 100644 --- a/sponsors/tests/test_use_cases.py +++ b/sponsors/tests/test_use_cases.py @@ -110,7 +110,7 @@ def test_update_sponsorship_as_approved_and_create_statement_of_work(self): self.assertTrue(self.sponsorship.start_date) self.assertTrue(self.sponsorship.end_date) self.assertEqual(self.sponsorship.sponsorship_fee, 100) - self.assertEqual(self.sponsorship.level_name, 'level') + self.assertEqual(self.sponsorship.level_name, "level") def test_send_notifications_using_sponsorship(self): self.use_case.execute(self.sponsorship, **self.data) diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py index 1a8390702..29c49ddd5 100644 --- a/sponsors/tests/test_views.py +++ b/sponsors/tests/test_views.py @@ -21,7 +21,11 @@ SponsorContact, Sponsorship, ) -from sponsors.forms import SponsorshiptBenefitsForm, SponsorshipApplicationForm, SponsorshipReviewAdminForm +from sponsors.forms import ( + SponsorshiptBenefitsForm, + SponsorshipApplicationForm, + SponsorshipReviewAdminForm, +) def assertMessage(msg, expected_content, expected_level): @@ -433,13 +437,13 @@ def setUp(self): "confirm": "yes", "start_date": today, "end_date": today + timedelta(days=100), - "level_name": 'Level', + "level_name": "Level", } def test_display_confirmation_form_on_get(self): response = self.client.get(self.url) context = response.context - form = context['form'] + form = context["form"] self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") @@ -448,14 +452,16 @@ def test_display_confirmation_form_on_get(self): self.assertEqual(form.initial["level_name"], self.sponsorship.level_name) self.assertEqual(form.initial["start_date"], self.sponsorship.start_date) self.assertEqual(form.initial["end_date"], self.sponsorship.end_date) - self.assertEqual(form.initial["sponsorship_fee"], self.sponsorship.sponsorship_fee) + self.assertEqual( + form.initial["sponsorship_fee"], self.sponsorship.sponsorship_fee + ) self.assertNotEqual( self.sponsorship.status, Sponsorship.APPROVED ) # did not update def test_approve_sponsorship_on_post(self): response = self.client.post(self.url, data=self.data) - + self.sponsorship.refresh_from_db() expected_url = reverse( @@ -467,7 +473,7 @@ def test_approve_sponsorship_on_post(self): assertMessage(msg, "Sponsorship was approved!", messages.SUCCESS) def test_do_not_approve_if_no_confirmation_in_the_post(self): - self.data.pop('confirm') + self.data.pop("confirm") response = self.client.post(self.url, data=self.data) self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") @@ -482,14 +488,14 @@ def test_do_not_approve_if_no_confirmation_in_the_post(self): self.assertNotEqual(self.sponsorship.status, Sponsorship.APPROVED) def test_do_not_approve_if_form_with_invalid_data(self): - self.data = {"confirm": 'yes'} + self.data = {"confirm": "yes"} response = self.client.post(self.url, data=self.data) self.sponsorship.refresh_from_db() self.assertTemplateUsed(response, "sponsors/admin/approve_application.html") self.assertNotEqual( self.sponsorship.status, Sponsorship.APPROVED ) # did not update - self.assertTrue(response.context['form'].errors) + self.assertTrue(response.context["form"].errors) def test_404_if_sponsorship_does_not_exist(self): self.sponsorship.delete() diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 7d8f9ef42..28a2068a4 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -45,13 +45,13 @@ def execute(self, sponsorship, request=None): class ApproveSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): def execute(self, sponsorship, start_date, end_date, **kwargs): sponsorship.approve(start_date, end_date) - level_name = kwargs.get('level_name') - fee = kwargs.get('sponsorship_fee') + level_name = kwargs.get("level_name") + fee = kwargs.get("sponsorship_fee") if level_name: sponsorship.level_name = level_name if fee: sponsorship.sponsorship_fee = fee - + sponsorship.save() statement_of_work = StatementOfWork.new(sponsorship) From 1d3bfc9c764bbc6e549392513040beedd620e856 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 16:41:02 -0300 Subject: [PATCH 23/82] Join markdowns so footnotes can work --- base-requirements.txt | 1 + sponsors/admin.py | 8 ++++++-- templates/sponsors/admin/preview-statement-of-work.html | 9 +++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/base-requirements.txt b/base-requirements.txt index 9eb3ab595..986511b32 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -29,6 +29,7 @@ requests[security]>=2.20.0 django-honeypot==0.6.0 django-markupfield==1.4.3 +django-markupfield-helpers==0.1.1 django-allauth==0.41.0 diff --git a/sponsors/admin.py b/sponsors/admin.py index d64f17d8e..f2f5f491c 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -1,4 +1,5 @@ from ordered_model.admin import OrderedModelAdmin +from markupfield_helpers.helpers import render_md from django.contrib import messages from django.urls import path, reverse @@ -462,8 +463,11 @@ def get_urls(self): return my_urls + urls def preview_statement_of_work_view(self, request, pk): - statement_of_work = get_object_or_404(self.get_queryset(request), pk=pk) - ctx = {"sow": statement_of_work} + sow = get_object_or_404(self.get_queryset(request), pk=pk) + # footnotes only work if in same markdown text as the references + text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" + html = render_md(text) + ctx = {"sow": sow, "benefits_and_clauses": mark_safe(html)} return render( request, "sponsors/admin/preview-statement-of-work.html", context=ctx ) diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index ab322bb1b..90d498096 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -11,11 +11,8 @@

    Statement of Work Terms

    Contact

    {{ sow.sponsor_contact }}

    -Benefits -

    {{ sow.benefits_list }}

    - -Legal Clauses -

    {{ sow.legal_clauses }}

    +Content +

    {{ benefits_and_clauses }}

    - + \ No newline at end of file From 1c01f98b4fdf46c156f55982936d38bc4ce44b63 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 16:44:09 -0300 Subject: [PATCH 24/82] Remove unecessary trailing spaces --- sponsors/models.py | 4 ++-- sponsors/tests/test_models.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 56b5bc5c7..fcf4056e4 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -630,7 +630,7 @@ def new(cls, sponsorship): benefits_list = [] for benefit in benefits: - item = f" - {benefit.program.name} - {benefit.name}" + item = f"- {benefit.program.name} - {benefit.name}" index_str = "" for legal_clause in benefit.legal_clauses: index = legal_clauses.index(legal_clause) + 1 @@ -640,7 +640,7 @@ def new(cls, sponsorship): benefits_list.append(item) legal_clauses_text = "\n".join( - [f" [^{i}]: {c.clause}" for i, c in enumerate(legal_clauses, start=1)] + [f"[^{i}]: {c.clause}" for i, c in enumerate(legal_clauses, start=1)] ) return cls.objects.create( sponsorship=sponsorship, diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 206ed1162..54c1ec194 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -339,9 +339,9 @@ def test_format_benefits_without_legal_clauses(self): self.assertEqual(statement.legal_clauses.markup_type, "markdown") b1, b2, b3 = self.sponsorship.benefits.all() - expected_benefits_list = f""" - PSF - {b1.name} - - PSF - {b2.name} - - PSF - {b3.name}""" + expected_benefits_list = f"""- PSF - {b1.name} +- PSF - {b2.name} +- PSF - {b3.name}""" self.assertEqual(statement.benefits_list.raw, expected_benefits_list) self.assertEqual(statement.benefits_list.markup_type, "markdown") @@ -361,16 +361,16 @@ def test_format_benefits_with_legal_clauses(self): statement = StatementOfWork.new(self.sponsorship) c1, c2, c3 = legal_clauses - expected_legal_clauses = f""" [^1]: {c1.clause} - [^2]: {c2.clause} - [^3]: {c3.clause}""" + expected_legal_clauses = f"""[^1]: {c1.clause} +[^2]: {c2.clause} +[^3]: {c3.clause}""" self.assertEqual(statement.legal_clauses.raw, expected_legal_clauses) self.assertEqual(statement.legal_clauses.markup_type, "markdown") b1, b2, b3 = self.sponsorship.benefits.all() - expected_benefits_list = f""" - PSF - {b1.name} [^1][^3] - - PSF - {b2.name} [^2] - - PSF - {b3.name} [^3]""" + expected_benefits_list = f"""- PSF - {b1.name} [^1][^3] +- PSF - {b2.name} [^2] +- PSF - {b3.name} [^3]""" self.assertEqual(statement.benefits_list.raw, expected_benefits_list) self.assertEqual(statement.benefits_list.markup_type, "markdown") From de28ebe68bb4b3a96b5cc9449bb479c45cb1428b Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 9 Dec 2020 17:02:03 -0300 Subject: [PATCH 25/82] Fix issue after rebase --- sponsors/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index fcf4056e4..040d81db1 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -14,7 +14,7 @@ from cms.models import ContentManageable from companies.models import Company -from .managers import SponsorContactQuerySet, SponsorContactQuerySet +from .managers import SponsorContactQuerySet, SponsorshipQuerySet from .exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidStatusException, From 225232508f6a9589105195b493761fa1c183f7d8 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 10 Dec 2020 14:51:08 -0300 Subject: [PATCH 26/82] Respect db ordering to avoid tests inconsistencies --- .../migrations/0020_auto_20201210_1802.py | 25 +++++++++++++++++++ sponsors/models.py | 5 +++- sponsors/tests/test_models.py | 5 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 sponsors/migrations/0020_auto_20201210_1802.py diff --git a/sponsors/migrations/0020_auto_20201210_1802.py b/sponsors/migrations/0020_auto_20201210_1802.py new file mode 100644 index 000000000..c4c4fdd21 --- /dev/null +++ b/sponsors/migrations/0020_auto_20201210_1802.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2020-12-10 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsors", "0019_statementofwork"), + ] + + operations = [ + migrations.AlterModelOptions( + name="sponsorbenefit", + options={"ordering": ("order",)}, + ), + migrations.AddField( + model_name="sponsorbenefit", + name="order", + field=models.PositiveIntegerField( + db_index=True, default=1, editable=False, verbose_name="order" + ), + preserve_default=False, + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index 040d81db1..8373b2d56 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -381,7 +381,7 @@ def next_status(self): return states_map[self.status] -class SponsorBenefit(models.Model): +class SponsorBenefit(OrderedModel): sponsorship = models.ForeignKey( Sponsorship, on_delete=models.CASCADE, related_name="benefits" ) @@ -436,6 +436,9 @@ def new_copy(cls, benefit, **kwargs): def legal_clauses(self): return self.sponsorship_benefit.legal_clauses.all() + class Meta(OrderedModel.Meta): + pass + class Sponsor(ContentManageable): name = models.CharField( diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 54c1ec194..9193f912c 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -268,13 +268,14 @@ def test_get_primary_contact_for_sponsor(self): class StatementOfWorkModelTests(TestCase): def setUp(self): self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor") - self.sponsorship_benefits = baker.make( + baker.make( SponsorshipBenefit, program__name="PSF", name=seq("benefit"), order=seq(1), _quantity=3, ) + self.sponsorship_benefits = SponsorshipBenefit.objects.all() def test_auto_increment_draft_revision_on_save(self): statement = baker.make_recipe("sponsors.tests.empty_sow") @@ -354,7 +355,7 @@ def test_format_benefits_with_legal_clauses(self): clause = legal_clauses[i] benefit.legal_clauses.add(clause) SponsorBenefit.new_copy(benefit, sponsorship=self.sponsorship) - self.sponsorship_benefits[0].legal_clauses.add( + self.sponsorship_benefits.first().legal_clauses.add( clause ) # first benefit with 2 legal clauses From 1635f7f8d79de23246f31cf913688b9772627360 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 10 Dec 2020 15:41:02 -0300 Subject: [PATCH 27/82] Move admin views to a specific file --- sponsors/admin.py | 73 +++------------------------------------ sponsors/views_admin.py | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 68 deletions(-) create mode 100644 sponsors/views_admin.py diff --git a/sponsors/admin.py b/sponsors/admin.py index f2f5f491c..2abaa9fec 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -1,12 +1,9 @@ from ordered_model.admin import OrderedModelAdmin -from markupfield_helpers.helpers import render_md -from django.contrib import messages -from django.urls import path, reverse from django.contrib import admin from django.contrib.humanize.templatetags.humanize import intcomma +from django.urls import path from django.utils.html import mark_safe -from django.shortcuts import get_object_or_404, render, redirect from .models import ( SponsorshipPackage, @@ -19,9 +16,8 @@ LegalClause, StatementOfWork, ) -from sponsors import use_cases +from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm -from sponsors.exceptions import SponsorshipInvalidStatusException from cms.admin import ContentManageableModelAdmin @@ -298,62 +294,10 @@ def get_sponsor_contacts(self, obj): get_sponsor_contacts.short_description = "Contacts" def reject_sponsorship_view(self, request, pk): - sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) - - if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": - try: - use_case = use_cases.RejectSponsorshipApplicationUseCase.build() - use_case.execute(sponsorship) - self.message_user( - request, "Sponsorship was rejected!", messages.SUCCESS - ) - except SponsorshipInvalidStatusException as e: - self.message_user(request, str(e), messages.ERROR) - - redirect_url = reverse( - "admin:sponsors_sponsorship_change", args=[sponsorship.pk] - ) - return redirect(redirect_url) - - context = {"sponsorship": sponsorship} - return render( - request, "sponsors/admin/reject_application.html", context=context - ) + return views_admin.reject_sponsorship_view(self, request, pk) def approve_sponsorship_view(self, request, pk): - sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) - initial = { - "level_name": sponsorship.level_name, - "start_date": sponsorship.start_date, - "end_date": sponsorship.end_date, - "sponsorship_fee": sponsorship.sponsorship_fee, - } - - form = SponsorshipReviewAdminForm(initial=initial, force_required=True) - - if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": - form = SponsorshipReviewAdminForm(data=request.POST) - if form.is_valid(): - kwargs = form.cleaned_data - kwargs["request"] = request - try: - use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() - use_case.execute(sponsorship, **kwargs) - self.message_user( - request, "Sponsorship was approved!", messages.SUCCESS - ) - except SponsorshipInvalidStatusException as e: - self.message_user(request, str(e), messages.ERROR) - - redirect_url = reverse( - "admin:sponsors_sponsorship_change", args=[sponsorship.pk] - ) - return redirect(redirect_url) - - context = {"sponsorship": sponsorship, "form": form} - return render( - request, "sponsors/admin/approve_application.html", context=context - ) + return views_admin.approve_sponsorship_view(self, request, pk) @admin.register(LegalClause) @@ -463,11 +407,4 @@ def get_urls(self): return my_urls + urls def preview_statement_of_work_view(self, request, pk): - sow = get_object_or_404(self.get_queryset(request), pk=pk) - # footnotes only work if in same markdown text as the references - text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" - html = render_md(text) - ctx = {"sow": sow, "benefits_and_clauses": mark_safe(html)} - return render( - request, "sponsors/admin/preview-statement-of-work.html", context=ctx - ) + return views_admin.preview_statement_of_work_view(self, request, pk) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py new file mode 100644 index 000000000..b16dc6624 --- /dev/null +++ b/sponsors/views_admin.py @@ -0,0 +1,75 @@ +from markupfield_helpers.helpers import render_md + +from django.contrib import messages +from django.shortcuts import get_object_or_404, render, redirect +from django.urls import reverse +from django.utils.html import mark_safe + +from sponsors import use_cases +from sponsors.forms import SponsorshipReviewAdminForm +from sponsors.exceptions import SponsorshipInvalidStatusException + + +def preview_statement_of_work_view(ModelAdmin, request, pk): + sow = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + # footnotes only work if in same markdown text as the references + text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" + html = render_md(text) + ctx = {"sow": sow, "benefits_and_clauses": mark_safe(html)} + return render(request, "sponsors/admin/preview-statement-of-work.html", context=ctx) + + +def reject_sponsorship_view(ModelAdmin, request, pk): + sponsorship = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + try: + use_case = use_cases.RejectSponsorshipApplicationUseCase.build() + use_case.execute(sponsorship) + ModelAdmin.message_user( + request, "Sponsorship was rejected!", messages.SUCCESS + ) + except SponsorshipInvalidStatusException as e: + ModelAdmin.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] + ) + return redirect(redirect_url) + + context = {"sponsorship": sponsorship} + return render(request, "sponsors/admin/reject_application.html", context=context) + + +def approve_sponsorship_view(ModelAdmin, request, pk): + sponsorship = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + initial = { + "level_name": sponsorship.level_name, + "start_date": sponsorship.start_date, + "end_date": sponsorship.end_date, + "sponsorship_fee": sponsorship.sponsorship_fee, + } + + form = SponsorshipReviewAdminForm(initial=initial, force_required=True) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + form = SponsorshipReviewAdminForm(data=request.POST) + if form.is_valid(): + kwargs = form.cleaned_data + kwargs["request"] = request + try: + use_case = use_cases.ApproveSponsorshipApplicationUseCase.build() + use_case.execute(sponsorship, **kwargs) + ModelAdmin.message_user( + request, "Sponsorship was approved!", messages.SUCCESS + ) + except SponsorshipInvalidStatusException as e: + ModelAdmin.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] + ) + return redirect(redirect_url) + + context = {"sponsorship": sponsorship, "form": form} + return render(request, "sponsors/admin/approve_application.html", context=context) From be2e862f886f6fbace15649e79f5c37064f9b7b0 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 10 Dec 2020 16:01:07 -0300 Subject: [PATCH 28/82] Install django easy pdf --- base-requirements.txt | 2 ++ pydotorg/settings/base.py | 1 + 2 files changed, 3 insertions(+) diff --git a/base-requirements.txt b/base-requirements.txt index 986511b32..a26f8073d 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -40,3 +40,5 @@ django-filter==1.1.0 django-ordered-model==3.4.1 django-widget-tweaks==1.4.8 django-countries==6.1.3 +xhtml2pdf==0.2.5 +django-easy-pdf==0.1.1 diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index 567061a2e..d4ae59036 100644 --- a/pydotorg/settings/base.py +++ b/pydotorg/settings/base.py @@ -155,6 +155,7 @@ 'ordered_model', 'widget_tweaks', 'django_countries', + 'easy_pdf', 'users', 'boxes', From 7ea7570444c15d1910c661fa9d72f8f9730ee61c Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Thu, 10 Dec 2020 16:02:16 -0300 Subject: [PATCH 29/82] Display SoW preview as PDF --- sponsors/views_admin.py | 6 ++- .../admin/preview-statement-of-work.html | 45 ++++++++++++++----- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index b16dc6624..bf4046ee3 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -1,4 +1,5 @@ from markupfield_helpers.helpers import render_md +from easy_pdf.rendering import render_to_pdf_response from django.contrib import messages from django.shortcuts import get_object_or_404, render, redirect @@ -15,8 +16,9 @@ def preview_statement_of_work_view(ModelAdmin, request, pk): # footnotes only work if in same markdown text as the references text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" html = render_md(text) - ctx = {"sow": sow, "benefits_and_clauses": mark_safe(html)} - return render(request, "sponsors/admin/preview-statement-of-work.html", context=ctx) + context = {"sow": sow, "benefits_and_clauses": mark_safe(html)} + template = "sponsors/admin/preview-statement-of-work.html" + return render_to_pdf_response(request, template, context) def reject_sponsorship_view(ModelAdmin, request, pk): diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index 90d498096..1f46156c4 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -1,18 +1,43 @@ - - - - +{% extends "easy_pdf/base.html" %} +{% block extra_style %} + +{% endblock %} + +{% block content %}

    Statement of Work Terms

    +

    This is a initial draf of the final contract. All the information with a grey background color is dynamic and comes from our database.

    + +

    This document is an HTML file that is being converted to this PDF.

    + +

    Header 1

    +

    Header 2

    +

    Header 3

    +

    Header 4

    +
    Header 5
    +
    Header 6
    + +
      +
    • this
    • +
    • is
    • +
    • how
    • +
    • a list
    • +
    • looks
    • +
    + +

    We can have text with external references, underline important contant or use italic just because we want to.

    + Info -

    {{ sow.sponsor_info }}

    +

    {{ sow.sponsor_info }}

    Contact -

    {{ sow.sponsor_contact }}

    +

    {{ sow.sponsor_contact }}

    Content -

    {{ benefits_and_clauses }}

    - - - \ No newline at end of file +
    {{ benefits_and_clauses }}
    +{% endblock %} From 1ad0efe4fdfe83a1c603d6eb28cf88b3d3eebf57 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 17:07:28 -0300 Subject: [PATCH 30/82] Move django-easy-pdf code to a specific module --- sponsors/pdf.py | 16 ++++++++++++++++ sponsors/views_admin.py | 12 ++---------- 2 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 sponsors/pdf.py diff --git a/sponsors/pdf.py b/sponsors/pdf.py new file mode 100644 index 000000000..adcc3fa04 --- /dev/null +++ b/sponsors/pdf.py @@ -0,0 +1,16 @@ +""" +This module is a wrapper around django-easy-pdf so we can reuse code +""" +from easy_pdf.rendering import render_to_pdf_response + +from markupfield_helpers.helpers import render_md +from django.utils.html import mark_safe + + +def render_sow_to_pdf_response(request, sow, **context): + # footnotes only work if in same markdown text as the references + text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" + html = render_md(text) + context = {"sow": sow, "benefits_and_clauses": mark_safe(html)} + template = "sponsors/admin/preview-statement-of-work.html" + return render_to_pdf_response(request, template, context) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index bf4046ee3..f714cd544 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -1,24 +1,16 @@ -from markupfield_helpers.helpers import render_md -from easy_pdf.rendering import render_to_pdf_response - from django.contrib import messages from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse -from django.utils.html import mark_safe from sponsors import use_cases from sponsors.forms import SponsorshipReviewAdminForm from sponsors.exceptions import SponsorshipInvalidStatusException +from sponsors.pdf import render_sow_to_pdf_response def preview_statement_of_work_view(ModelAdmin, request, pk): sow = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) - # footnotes only work if in same markdown text as the references - text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" - html = render_md(text) - context = {"sow": sow, "benefits_and_clauses": mark_safe(html)} - template = "sponsors/admin/preview-statement-of-work.html" - return render_to_pdf_response(request, template, context) + return render_sow_to_pdf_response(request, sow) def reject_sponsorship_view(ModelAdmin, request, pk): From 61a3f98b3bd0036f1c04e495003ddb46324a9764 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:20:52 -0300 Subject: [PATCH 31/82] Refactor model's contants --- .../migrations/0021_auto_20201211_2120.py | 38 +++++++++++++++++++ sponsors/models.py | 9 +++-- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 sponsors/migrations/0021_auto_20201211_2120.py diff --git a/sponsors/migrations/0021_auto_20201211_2120.py b/sponsors/migrations/0021_auto_20201211_2120.py new file mode 100644 index 000000000..87c076f14 --- /dev/null +++ b/sponsors/migrations/0021_auto_20201211_2120.py @@ -0,0 +1,38 @@ +# Generated by Django 2.0.13 on 2020-12-11 21:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsors", "0020_auto_20201210_1802"), + ] + + operations = [ + migrations.AlterField( + model_name="statementofwork", + name="document", + field=models.FileField( + blank=True, + upload_to="sponsors/statmentes_of_work/", + verbose_name="Unsigned PDF", + ), + ), + migrations.AlterField( + model_name="statementofwork", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("outdated", "Outdated"), + ("awaiting signature", "Awaiting signature"), + ("executed", "Executed"), + ("nullified", "Nullified"), + ], + db_index=True, + default="draft", + max_length=20, + ), + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index 8373b2d56..a5dcbe2b3 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -542,7 +542,6 @@ class Meta(OrderedModel.Meta): class StatementOfWork(models.Model): DRAFT = "draft" OUTDATED = "outdated" - APPROVED_REVIEW = "approved review" AWAITING_SIGNATURE = "awaiting signature" EXECUTED = "executed" NULLIFIED = "nullified" @@ -550,23 +549,25 @@ class StatementOfWork(models.Model): STATUS_CHOICES = [ (DRAFT, "Draft"), (OUTDATED, "Outdated"), - (APPROVED_REVIEW, "Approved by reviewer"), (AWAITING_SIGNATURE, "Awaiting signature"), (EXECUTED, "Executed"), (NULLIFIED, "Nullified"), ] + FINAL_VERSION_PDF_DIR = "sponsors/statmentes_of_work/" + SIGNED_PDF_DIR = FINAL_VERSION_PDF_DIR + "signed/" + status = models.CharField( max_length=20, choices=STATUS_CHOICES, default=DRAFT, db_index=True ) revision = models.PositiveIntegerField(default=0, verbose_name="Revision nº") document = models.FileField( - upload_to="sponsors/statements_of_work/", + upload_to=FINAL_VERSION_PDF_DIR, blank=True, verbose_name="Unsigned PDF", ) signed_document = models.FileField( - upload_to="sponsors/statmentes_of_work/signed/", + upload_to=SIGNED_PDF_DIR, blank=True, verbose_name="Signed PDF", ) From 8b2e3bbdfa29a4e8b7acb060dcf329a9765cf730 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:28:29 -0300 Subject: [PATCH 32/82] Create auxiliar function to render PDF document as bytes --- sponsors/pdf.py | 22 ++++++++++++++++++--- sponsors/tests/test_pdf.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 sponsors/tests/test_pdf.py diff --git a/sponsors/pdf.py b/sponsors/pdf.py index adcc3fa04..286cb99e5 100644 --- a/sponsors/pdf.py +++ b/sponsors/pdf.py @@ -1,16 +1,32 @@ """ This module is a wrapper around django-easy-pdf so we can reuse code """ -from easy_pdf.rendering import render_to_pdf_response +from easy_pdf.rendering import render_to_pdf_response, render_to_pdf from markupfield_helpers.helpers import render_md from django.utils.html import mark_safe -def render_sow_to_pdf_response(request, sow, **context): +def _sow_context(sow, **context): # footnotes only work if in same markdown text as the references text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" html = render_md(text) - context = {"sow": sow, "benefits_and_clauses": mark_safe(html)} + context.update( + { + "sow": sow, + "benefits_and_clauses": mark_safe(html), + } + ) + return context + + +def render_sow_to_pdf_response(request, sow, **context): template = "sponsors/admin/preview-statement-of-work.html" + context = _sow_context(sow, **context) return render_to_pdf_response(request, template, context) + + +def render_sow_to_pdf_file(sow, **context): + template = "sponsors/admin/preview-statement-of-work.html" + context = _sow_context(sow, **context) + return render_to_pdf(template, context) diff --git a/sponsors/tests/test_pdf.py b/sponsors/tests/test_pdf.py new file mode 100644 index 000000000..158d92adf --- /dev/null +++ b/sponsors/tests/test_pdf.py @@ -0,0 +1,39 @@ +from unittest.mock import patch, Mock +from model_bakery import baker +from markupfield_helpers.helpers import render_md + +from django.http import HttpResponse, HttpRequest +from django.template.loader import render_to_string +from django.test import TestCase +from django.utils.html import mark_safe + +from sponsors.pdf import render_sow_to_pdf_file, render_sow_to_pdf_response + + +class TestRenderStatementOfWorkToPDF(TestCase): + def setUp(self): + self.sow = baker.make_recipe("sponsors.tests.empty_sow") + text = f"{self.sow.benefits_list.raw}\n\n**Legal Clauses**\n{self.sow.legal_clauses.raw}" + html = render_md(text) + self.context = {"sow": self.sow, "benefits_and_clauses": mark_safe(html)} + self.template = "sponsors/admin/preview-statement-of-work.html" + + @patch("sponsors.pdf.render_to_pdf") + def test_render_pdf_using_django_easy_pdf(self, mock_render): + mock_render.return_value = "pdf content" + + content = render_sow_to_pdf_file(self.sow) + + self.assertEqual(content, "pdf content") + mock_render.assert_called_once_with(self.template, self.context) + + @patch("sponsors.pdf.render_to_pdf_response") + def test_render_response_using_django_easy_pdf(self, mock_render): + response = Mock(HttpResponse) + mock_render.return_value = response + + request = Mock(HttpRequest) + content = render_sow_to_pdf_response(request, self.sow) + + self.assertEqual(content, response) + mock_render.assert_called_once_with(request, self.template, self.context) From 6df2a644bcd6e35bc92f9c5f95bf9c9679c905fd Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:44:18 -0300 Subject: [PATCH 33/82] Add status control to Statement of Work model --- sponsors/models.py | 11 +++++++++++ sponsors/tests/test_models.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/sponsors/models.py b/sponsors/models.py index a5dcbe2b3..22714e3db 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -662,6 +662,17 @@ def is_draft(self): def preview_url(self): return reverse("admin:sponsors_statementofwork_preview", args=[self.pk]) + @property + def next_status(self): + states_map = { + self.DRAFT: [self.AWAITING_SIGNATURE], + self.OUTDATED: [], + self.AWAITING_SIGNATURE: [self.EXECUTED, self.NULLIFIED], + self.EXECUTED: [], + self.NULLIFIED: [self.DRAFT], + } + return states_map[self.status] + def save(self, **kwargs): commit = kwargs.get("commit", True) if all([commit, self.pk, self.is_draft]): diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 9193f912c..61634f085 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -375,3 +375,20 @@ def test_format_benefits_with_legal_clauses(self): self.assertEqual(statement.benefits_list.raw, expected_benefits_list) self.assertEqual(statement.benefits_list.markup_type, "markdown") + + def test_control_statement_of_work_next_status(self): + SOW = StatementOfWork + states_map = { + SOW.DRAFT: [SOW.AWAITING_SIGNATURE], + SOW.OUTDATED: [], + SOW.AWAITING_SIGNATURE: [SOW.EXECUTED, SOW.NULLIFIED], + SOW.EXECUTED: [], + SOW.NULLIFIED: [SOW.DRAFT], + } + for status, exepcted in states_map.items(): + statement = baker.prepare_recipe( + "sponsors.tests.empty_sow", + sponsorship__sponsor__name="foo", + status=status, + ) + self.assertEqual(statement.next_status, exepcted) \ No newline at end of file From 1d8fbdd081799eabca61b0ce013b1730613600ff Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:45:03 -0300 Subject: [PATCH 34/82] Rename exception --- sponsors/exceptions.py | 2 +- sponsors/models.py | 6 +++--- sponsors/views_admin.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sponsors/exceptions.py b/sponsors/exceptions.py index 6628fd53c..6778b0797 100644 --- a/sponsors/exceptions.py +++ b/sponsors/exceptions.py @@ -5,7 +5,7 @@ class SponsorWithExistingApplicationException(Exception): """ -class SponsorshipInvalidStatusException(Exception): +class InvalidStatusException(Exception): """ Raised when user tries to change the Sponsorship's status to a new one but from an invalid current status diff --git a/sponsors/models.py b/sponsors/models.py index 22714e3db..acd2a44c5 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -17,7 +17,7 @@ from .managers import SponsorContactQuerySet, SponsorshipQuerySet from .exceptions import ( SponsorWithExistingApplicationException, - SponsorshipInvalidStatusException, + InvalidStatusException, SponsorshipInvalidDateRangeException, ) @@ -327,14 +327,14 @@ def estimated_cost(self): def reject(self): if self.REJECTED not in self.next_status: msg = f"Can't reject a {self.get_status_display()} sponsorship." - raise SponsorshipInvalidStatusException(msg) + raise InvalidStatusException(msg) self.status = self.REJECTED self.rejected_on = timezone.now().date() def approve(self, start_date, end_date): if self.APPROVED not in self.next_status: msg = f"Can't approve a {self.get_status_display()} sponsorship." - raise SponsorshipInvalidStatusException(msg) + raise InvalidStatusException(msg) if start_date >= end_date: msg = f"Start date greater or equal than end date" raise SponsorshipInvalidDateRangeException(msg) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index f714cd544..af771251c 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -4,7 +4,7 @@ from sponsors import use_cases from sponsors.forms import SponsorshipReviewAdminForm -from sponsors.exceptions import SponsorshipInvalidStatusException +from sponsors.exceptions import InvalidStatusException from sponsors.pdf import render_sow_to_pdf_response @@ -23,7 +23,7 @@ def reject_sponsorship_view(ModelAdmin, request, pk): ModelAdmin.message_user( request, "Sponsorship was rejected!", messages.SUCCESS ) - except SponsorshipInvalidStatusException as e: + except InvalidStatusException as e: ModelAdmin.message_user(request, str(e), messages.ERROR) redirect_url = reverse( @@ -57,7 +57,7 @@ def approve_sponsorship_view(ModelAdmin, request, pk): ModelAdmin.message_user( request, "Sponsorship was approved!", messages.SUCCESS ) - except SponsorshipInvalidStatusException as e: + except InvalidStatusException as e: ModelAdmin.message_user(request, str(e), messages.ERROR) redirect_url = reverse( From 767785fcbe89fb2b5f98f2c5b8b5e49af9447896 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:45:37 -0300 Subject: [PATCH 35/82] Create function to save the final document version --- sponsors/models.py | 30 ++++++++++++++++++++++++++++++ sponsors/tests/test_models.py | 24 +++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index acd2a44c5..b5c14404f 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -1,5 +1,7 @@ +from pathlib import Path from itertools import chain from django.conf import settings +from django.core.files.storage import default_storage from django.db import models from django.db.models import Sum from django.template.defaultfilters import truncatechars @@ -678,3 +680,31 @@ def save(self, **kwargs): if all([commit, self.pk, self.is_draft]): self.revision += 1 return super().save(**kwargs) + + def set_final_version(self, pdf_file): + if self.AWAITING_SIGNATURE not in self.next_status: + msg = f"Can't send a {self.get_status_display()} statement of work." + raise InvalidStatusException(msg) + + path = f"{self.FINAL_VERSION_PDF_DIR}" + sponsor = self.sponsorship.sponsor.name.upper() + filename = f"{path}SoW: {sponsor}.pdf" + + mode = "wb" + try: + # if using S3 Storage the file will always exist + file = default_storage.open(filename, mode) + except FileNotFoundError as e: + # local env, not using S3 + path = Path(e.filename).parent + if not path.exists(): + path.mkdir(parents=True) + Path(e.filename).touch() + file = default_storage.open(filename, mode) + + file.write(pdf_file) + file.close() + + self.document = filename + self.status = self.AWAITING_SIGNATURE + self.save() diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 61634f085..0fc4340f0 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -17,6 +17,7 @@ from ..exceptions import ( SponsorWithExistingApplicationException, SponsorshipInvalidDateRangeException, + InvalidStatusException, ) @@ -391,4 +392,25 @@ def test_control_statement_of_work_next_status(self): sponsorship__sponsor__name="foo", status=status, ) - self.assertEqual(statement.next_status, exepcted) \ No newline at end of file + self.assertEqual(statement.next_status, exepcted) + + def test_set_final_document_version(self): + statement = baker.make_recipe( + "sponsors.tests.empty_sow", sponsorship__sponsor__name="foo" + ) + content = b"pdf binary content" + self.assertFalse(statement.document.name) + + statement.set_final_version(content) + statement.refresh_from_db() + + self.assertTrue(statement.document.name) + self.assertEqual(statement.status, StatementOfWork.AWAITING_SIGNATURE) + + def test_raise_invalid_status_exception_if_not_draft(self): + statement = baker.make_recipe( + "sponsors.tests.empty_sow", status=StatementOfWork.AWAITING_SIGNATURE + ) + + with self.assertRaises(InvalidStatusException): + statement.set_final_version(b'content') \ No newline at end of file From fab8096afdbc971ba505f5c5b99aef68f8769476 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:46:27 -0300 Subject: [PATCH 36/82] Add vscode dir to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 652363d27..7919bb0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ static/stylesheets/no-mq.css static/stylesheets/style.css __pycache__ *.db +.vscode From 492761409366dc7edee572a1fbd257e3cfc865e7 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 11 Dec 2020 18:46:33 -0300 Subject: [PATCH 37/82] UC to send SoW --- sponsors/models.py | 6 +++++- sponsors/tests/baker_recipes.py | 1 + sponsors/tests/test_models.py | 4 ++-- sponsors/tests/test_use_cases.py | 30 +++++++++++++++++++++++++++++- sponsors/use_cases.py | 16 ++++++++++++++++ 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index b5c14404f..174024044 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -664,6 +664,10 @@ def is_draft(self): def preview_url(self): return reverse("admin:sponsors_statementofwork_preview", args=[self.pk]) + @property + def awaiting_signature(self): + return self.status == self.AWAITING_SIGNATURE + @property def next_status(self): states_map = { @@ -685,7 +689,7 @@ def set_final_version(self, pdf_file): if self.AWAITING_SIGNATURE not in self.next_status: msg = f"Can't send a {self.get_status_display()} statement of work." raise InvalidStatusException(msg) - + path = f"{self.FINAL_VERSION_PDF_DIR}" sponsor = self.sponsorship.sponsor.name.upper() filename = f"{path}SoW: {sponsor}.pdf" diff --git a/sponsors/tests/baker_recipes.py b/sponsors/tests/baker_recipes.py index 12f916cf3..7c0a80041 100644 --- a/sponsors/tests/baker_recipes.py +++ b/sponsors/tests/baker_recipes.py @@ -3,6 +3,7 @@ empty_sow = Recipe( "sponsors.StatementOfWork", + sponsorship__sponsor__name="Sponsor", benefits_list="", legal_clauses="", ) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index 0fc4340f0..a8208afe4 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -388,7 +388,7 @@ def test_control_statement_of_work_next_status(self): } for status, exepcted in states_map.items(): statement = baker.prepare_recipe( - "sponsors.tests.empty_sow", + "sponsors.tests.empty_sow", sponsorship__sponsor__name="foo", status=status, ) @@ -413,4 +413,4 @@ def test_raise_invalid_status_exception_if_not_draft(self): ) with self.assertRaises(InvalidStatusException): - statement.set_final_version(b'content') \ No newline at end of file + statement.set_final_version(b"content") diff --git a/sponsors/tests/test_use_cases.py b/sponsors/tests/test_use_cases.py index 411d94d23..c6e3b3dd5 100644 --- a/sponsors/tests/test_use_cases.py +++ b/sponsors/tests/test_use_cases.py @@ -8,7 +8,7 @@ from sponsors import use_cases from sponsors.notifications import * -from sponsors.models import Sponsorship +from sponsors.models import Sponsorship, StatementOfWork class CreateSponsorshipApplicationUseCaseTests(TestCase): @@ -125,3 +125,31 @@ def test_send_notifications_using_sponsorship(self): def test_build_use_case_without_notificationss(self): uc = use_cases.ApproveSponsorshipApplicationUseCase.build() self.assertEqual(len(uc.notifications), 0) + + +class SendStatementOfWorkUseCaseTests(TestCase): + def setUp(self): + self.notifications = [Mock(), Mock()] + self.use_case = use_cases.SendStatementOfWorkUseCase(self.notifications) + self.user = baker.make(settings.AUTH_USER_MODEL) + self.statement = baker.make_recipe("sponsors.tests.empty_sow") + + def test_send_and_update_statement_of_work_with_document(self): + self.use_case.execute(self.statement) + self.statement.refresh_from_db() + + self.assertTrue(self.statement.document.name) + self.assertTrue(self.statement.awaiting_signature) + for n in self.notifications: + n.notify.assert_called_once_with( + request=None, + statement_of_work=self.statement, + ) + + def test_build_use_case_without_notificationss(self): + uc = use_cases.SendStatementOfWorkUseCase.build() + self.assertEqual(len(uc.notifications), 2) + self.assertIsInstance(uc.notifications[0], StatementOfWorkNotificationToPSF) + self.assertIsInstance( + uc.notifications[1], StatementOfWorkNotificationToSponsors + ) diff --git a/sponsors/use_cases.py b/sponsors/use_cases.py index 28a2068a4..2fd4534c4 100644 --- a/sponsors/use_cases.py +++ b/sponsors/use_cases.py @@ -1,5 +1,6 @@ from sponsors import notifications from sponsors.models import Sponsorship, StatementOfWork +from sponsors.pdf import render_sow_to_pdf_file class BaseUseCaseWithNotifications: @@ -62,3 +63,18 @@ def execute(self, sponsorship, start_date, end_date, **kwargs): ) return sponsorship + + +class SendStatementOfWorkUseCase(BaseUseCaseWithNotifications): + notifications = [ + notifications.StatementOfWorkNotificationToPSF(), + notifications.StatementOfWorkNotificationToSponsors(), + ] + + def execute(self, statement_of_work, **kwargs): + pdf_file = render_sow_to_pdf_file(statement_of_work) + statement_of_work.set_final_version(pdf_file) + self.notify( + request=kwargs.get("request"), + statement_of_work=statement_of_work, + ) From e73dbf36248d0ad6a045b093f5bd7f6158abb0b7 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 14 Dec 2020 12:42:35 -0300 Subject: [PATCH 38/82] Update notifications to use SoW instead of sponsorship obj --- sponsors/notifications.py | 8 +++++--- sponsors/tests/test_notifications.py | 19 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/sponsors/notifications.py b/sponsors/notifications.py index f71fc2a75..c163d6ede 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -64,19 +64,21 @@ def get_recipient_list(self, context): return context["sponsorship"].verified_emails +# TODO add PDF attachment class StatementOfWorkNotificationToPSF(BaseEmailSponsorshipNotification): subject_template = "sponsors/email/psf_statement_of_work_subject.txt" message_template = "sponsors/email/psf_statement_of_work.txt" - email_context_keys = ["sponsorship"] + email_context_keys = ["statement_of_work"] def get_recipient_list(self, context): return [settings.SPONSORSHIP_NOTIFICATION_TO_EMAIL] +# TODO add PDF attachment class StatementOfWorkNotificationToSponsors(BaseEmailSponsorshipNotification): subject_template = "sponsors/email/sponsor_statement_of_work_subject.txt" message_template = "sponsors/email/sponsor_statement_of_work.txt" - email_context_keys = ["sponsorship"] + email_context_keys = ["statement_of_work"] def get_recipient_list(self, context): - return context["sponsorship"].verified_emails + return context["statement_of_work"].sponsorship.verified_emails diff --git a/sponsors/tests/test_notifications.py b/sponsors/tests/test_notifications.py index 884f81d4a..2c402b9c7 100644 --- a/sponsors/tests/test_notifications.py +++ b/sponsors/tests/test_notifications.py @@ -137,20 +137,16 @@ def test_send_email_using_correct_templates(self): class StatementOfWorkNotificationToPSFTests(TestCase): def setUp(self): self.notification = notifications.StatementOfWorkNotificationToPSF() - self.sponsorship = baker.make( - Sponsorship, - status=Sponsorship.APPROVED, - _fill_optional=["approved_on", "sponsor"], - ) + self.sow = baker.make_recipe("sponsors.tests.empty_sow") self.subject_template = "sponsors/email/psf_statement_of_work_subject.txt" self.content_template = "sponsors/email/psf_statement_of_work.txt" def test_send_email_using_correct_templates(self): - context = {"sponsorship": self.sponsorship} + context = {"statement_of_work": self.sow} expected_subject = render_to_string(self.subject_template, context).strip() expected_content = render_to_string(self.content_template, context).strip() - self.notification.notify(sponsorship=self.sponsorship) + self.notification.notify(statement_of_work=self.sow) self.assertTrue(mail.outbox) email = mail.outbox[0] @@ -164,21 +160,24 @@ class StatementOfWorkNotificationToSponsorsTests(TestCase): def setUp(self): self.notification = notifications.StatementOfWorkNotificationToSponsors() self.user = baker.make(settings.AUTH_USER_MODEL, email="email@email.com") - self.sponsorship = baker.make( + sponsorship = baker.make( Sponsorship, status=Sponsorship.APPROVED, _fill_optional=["approved_on", "sponsor"], submited_by=self.user, ) + self.sow = baker.make_recipe( + "sponsors.tests.empty_sow", sponsorship=sponsorship + ) self.subject_template = "sponsors/email/sponsor_statement_of_work_subject.txt" self.content_template = "sponsors/email/sponsor_statement_of_work.txt" def test_send_email_using_correct_templates(self): - context = {"sponsorship": self.sponsorship} + context = {"statement_of_work": self.sow} expected_subject = render_to_string(self.subject_template, context).strip() expected_content = render_to_string(self.content_template, context).strip() - self.notification.notify(sponsorship=self.sponsorship) + self.notification.notify(statement_of_work=self.sow) self.assertTrue(mail.outbox) email = mail.outbox[0] From 15e26b87670ac42b95d2cc9e960d2b005c055c38 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 14 Dec 2020 12:43:18 -0300 Subject: [PATCH 39/82] Impleent view to send statement of work to users --- sponsors/admin.py | 8 ++ sponsors/tests/test_views.py | 103 ++++++++++++++++++++++++- sponsors/views_admin.py | 26 +++++++ templates/sponsors/admin/send_sow.html | 33 ++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 templates/sponsors/admin/send_sow.html diff --git a/sponsors/admin.py b/sponsors/admin.py index 2abaa9fec..5fdcad27a 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -403,8 +403,16 @@ def get_urls(self): self.admin_site.admin_view(self.preview_statement_of_work_view), name="sponsors_statementofwork_preview", ), + path( + "/send", + self.admin_site.admin_view(self.send_statement_of_work_view), + name="sponsors_statementofwork_send", + ), ] return my_urls + urls def preview_statement_of_work_view(self, request, pk): return views_admin.preview_statement_of_work_view(self, request, pk) + + def send_statement_of_work_view(self, request, pk): + return views_admin.send_statement_of_work_view(self, request, pk) diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py index 29c49ddd5..437ab9c5f 100644 --- a/sponsors/tests/test_views.py +++ b/sponsors/tests/test_views.py @@ -1,7 +1,7 @@ import json from model_bakery import baker -from itertools import chain from datetime import date, timedelta +from unittest.mock import patch, PropertyMock from django.conf import settings from django.contrib import messages @@ -15,11 +15,10 @@ from .utils import get_static_image_file_as_upload from ..models import ( Sponsor, - SponsorshipProgram, SponsorshipBenefit, - Sponsor, SponsorContact, Sponsorship, + StatementOfWork, ) from sponsors.forms import ( SponsorshiptBenefitsForm, @@ -535,3 +534,101 @@ def test_message_user_if_approving_invalid_sponsorship(self): self.assertEqual(self.sponsorship.status, Sponsorship.FINALIZED) msg = list(get_messages(response.wsgi_request))[0] assertMessage(msg, "Can't approve a Finalized sponsorship.", messages.ERROR) + + +class SendStatementOfWorkView(TestCase): + def setUp(self): + self.user = baker.make( + settings.AUTH_USER_MODEL, is_staff=True, is_superuser=True + ) + self.client.force_login(self.user) + self.statement_of_work = baker.make_recipe("sponsors.tests.empty_sow") + self.url = reverse( + "admin:sponsors_statementofwork_send", args=[self.statement_of_work.pk] + ) + self.data = { + "confirm": "yes", + } + + def test_display_confirmation_form_on_get(self): + response = self.client.get(self.url) + context = response.context + + self.assertTemplateUsed(response, "sponsors/admin/send_sow.html") + self.assertEqual(context["statement_of_work"], self.statement_of_work) + + @patch.object( + Sponsorship, "verified_emails", PropertyMock(return_value=["email@email.com"]) + ) + def test_approve_sponsorship_on_post(self): + response = self.client.post(self.url, data=self.data) + expected_url = reverse( + "admin:sponsors_statementofwork_change", args=[self.statement_of_work.pk] + ) + self.statement_of_work.refresh_from_db() + + self.assertRedirects(response, expected_url, fetch_redirect_response=True) + self.assertTrue(self.statement_of_work.document.name) + self.assertEqual(2, len(mail.outbox)) + msg = list(get_messages(response.wsgi_request))[0] + assertMessage(msg, "Statement of Work was sent!", messages.SUCCESS) + + @patch.object( + Sponsorship, "verified_emails", PropertyMock(return_value=["email@email.com"]) + ) + def test_display_error_message_to_user_if_invalid_status(self): + self.statement_of_work.status = StatementOfWork.AWAITING_SIGNATURE + self.statement_of_work.save() + expected_url = reverse( + "admin:sponsors_statementofwork_change", args=[self.statement_of_work.pk] + ) + + response = self.client.post(self.url, data=self.data) + self.statement_of_work.refresh_from_db() + + self.assertRedirects(response, expected_url, fetch_redirect_response=True) + self.assertEqual(0, len(mail.outbox)) + msg = list(get_messages(response.wsgi_request))[0] + assertMessage( + msg, + "Statement of work with status Awaiting Signature can't be sent.", + messages.ERROR, + ) + + def test_do_not_send_if_no_confirmation_in_the_post(self): + self.data.pop("confirm") + response = self.client.post(self.url, data=self.data) + self.statement_of_work.refresh_from_db() + self.assertTemplateUsed(response, "sponsors/admin/send_sow.html") + self.assertFalse(self.statement_of_work.document.name) + + self.data["confirm"] = "invalid" + response = self.client.post(self.url, data=self.data) + self.assertTemplateUsed(response, "sponsors/admin/send_sow.html") + self.assertFalse(self.statement_of_work.document.name) + self.assertEqual(0, len(mail.outbox)) + + def test_404_if_sow_does_not_exist(self): + self.statement_of_work.delete() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + def test_login_required(self): + login_url = reverse("admin:login") + redirect_url = f"{login_url}?next={self.url}" + self.client.logout() + + r = self.client.get(self.url) + + self.assertRedirects(r, redirect_url) + + def test_staff_required(self): + login_url = reverse("admin:login") + redirect_url = f"{login_url}?next={self.url}" + self.user.is_staff = False + self.user.save() + self.client.force_login(self.user) + + r = self.client.get(self.url) + + self.assertRedirects(r, redirect_url, fetch_redirect_response=False) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index af771251c..1d1e7b742 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -67,3 +67,29 @@ def approve_sponsorship_view(ModelAdmin, request, pk): context = {"sponsorship": sponsorship, "form": form} return render(request, "sponsors/admin/approve_application.html", context=context) + + +def send_statement_of_work_view(ModelAdmin, request, pk): + sow = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + + use_case = use_cases.SendStatementOfWorkUseCase.build() + try: + use_case.execute(sow, request=request) + ModelAdmin.message_user( + request, "Statement of Work was sent!", messages.SUCCESS + ) + except InvalidStatusException: + status = sow.get_status_display().title() + ModelAdmin.message_user( + request, + f"Statement of work with status {status} can't be sent.", + messages.ERROR, + ) + + redirect_url = reverse("admin:sponsors_statementofwork_change", args=[sow.pk]) + return redirect(redirect_url) + + context = {"statement_of_work": sow} + return render(request, "sponsors/admin/send_sow.html", context=context) diff --git a/templates/sponsors/admin/send_sow.html b/templates/sponsors/admin/send_sow.html new file mode 100644 index 000000000..752d4c62d --- /dev/null +++ b/templates/sponsors/admin/send_sow.html @@ -0,0 +1,33 @@ +{% extends 'admin/base_site.html' %} +{% load i18n admin_static sponsors %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block title %}Send {{ statement_of_work }} | python.org{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    Send Statement of Work

    +

    Statement of Work summary and emails later...

    +
    +
    +{% csrf_token %} + + + +
    + +
    + +
    +
    +
    {% endblock %} From 4240f8dd53db8f10bbf374598aa119baf754354f Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 15 Dec 2020 10:19:49 -0300 Subject: [PATCH 40/82] Refactor to use EmailMessage instead of send_mail shortcut --- sponsors/notifications.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sponsors/notifications.py b/sponsors/notifications.py index c163d6ede..6cb20f317 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -1,4 +1,4 @@ -from django.core.mail import send_mail +from django.core.mail import EmailMessage from django.template.loader import render_to_string from django.conf import settings @@ -20,12 +20,13 @@ def get_recipient_list(self, context): def notify(self, **kwargs): context = {k: kwargs.get(k) for k in self.email_context_keys} - send_mail( + email = EmailMessage( subject=self.get_subject(context), - message=self.get_message(context), - recipient_list=self.get_recipient_list(context), + body=self.get_message(context), + to=self.get_recipient_list(context), from_email=settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, ) + email.send() class AppliedSponsorshipNotificationToPSF(BaseEmailSponsorshipNotification): From 620cab307a34b18ac9c9a9dd38008de3fc470a1e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 15 Dec 2020 10:38:22 -0300 Subject: [PATCH 41/82] Attach SoW PDF to emails --- sponsors/notifications.py | 21 ++++++++++++++ sponsors/tests/baker_recipes.py | 7 +++++ sponsors/tests/test_notifications.py | 43 ++++++++++++++++++++++++++-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/sponsors/notifications.py b/sponsors/notifications.py index 6cb20f317..3290b8e77 100644 --- a/sponsors/notifications.py +++ b/sponsors/notifications.py @@ -17,6 +17,12 @@ def get_message(self, context): def get_recipient_list(self, context): raise NotImplementedError + def get_attachments(self, context): + """ + Returns list with attachments tuples (filename, content, mime type) + """ + return [] + def notify(self, **kwargs): context = {k: kwargs.get(k) for k in self.email_context_keys} @@ -26,6 +32,9 @@ def notify(self, **kwargs): to=self.get_recipient_list(context), from_email=settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, ) + for attachment in self.get_attachments(context): + email.attach(*attachment) + email.send() @@ -74,6 +83,12 @@ class StatementOfWorkNotificationToPSF(BaseEmailSponsorshipNotification): def get_recipient_list(self, context): return [settings.SPONSORSHIP_NOTIFICATION_TO_EMAIL] + def get_attachments(self, context): + document = context["statement_of_work"].document + with document.open("rb") as fd: + content = fd.read() + return [("StatementOfWork.pdf", content, "application/pdf")] + # TODO add PDF attachment class StatementOfWorkNotificationToSponsors(BaseEmailSponsorshipNotification): @@ -83,3 +98,9 @@ class StatementOfWorkNotificationToSponsors(BaseEmailSponsorshipNotification): def get_recipient_list(self, context): return context["statement_of_work"].sponsorship.verified_emails + + def get_attachments(self, context): + document = context["statement_of_work"].document + with document.open("rb") as fd: + content = fd.read() + return [("StatementOfWork.pdf", content, "application/pdf")] diff --git a/sponsors/tests/baker_recipes.py b/sponsors/tests/baker_recipes.py index 7c0a80041..b161b3fc6 100644 --- a/sponsors/tests/baker_recipes.py +++ b/sponsors/tests/baker_recipes.py @@ -7,3 +7,10 @@ benefits_list="", legal_clauses="", ) + +awaiting_signature_sow = Recipe( + "sponsors.StatementOfWork", + sponsorship__sponsor__name="Awaiting Sponsor", + benefits_list="- benefit 1", + legal_clauses="", +) diff --git a/sponsors/tests/test_notifications.py b/sponsors/tests/test_notifications.py index 2c402b9c7..50b60d9cf 100644 --- a/sponsors/tests/test_notifications.py +++ b/sponsors/tests/test_notifications.py @@ -137,7 +137,11 @@ def test_send_email_using_correct_templates(self): class StatementOfWorkNotificationToPSFTests(TestCase): def setUp(self): self.notification = notifications.StatementOfWorkNotificationToPSF() - self.sow = baker.make_recipe("sponsors.tests.empty_sow") + self.sow = baker.make_recipe( + "sponsors.tests.awaiting_signature_sow", + _fill_optional=["document"], + _create_files=True, + ) self.subject_template = "sponsors/email/psf_statement_of_work_subject.txt" self.content_template = "sponsors/email/psf_statement_of_work.txt" @@ -155,6 +159,22 @@ def test_send_email_using_correct_templates(self): self.assertEqual(settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, email.from_email) self.assertEqual([settings.SPONSORSHIP_NOTIFICATION_TO_EMAIL], email.to) + def test_attach_statement_of_work_pdf(self): + self.assertTrue(self.sow.document.name) + with self.sow.document.open("rb") as fd: + expected_content = fd.read() + self.assertTrue(expected_content) + + self.sow.refresh_from_db() + self.notification.notify(statement_of_work=self.sow) + email = mail.outbox[0] + + self.assertEqual(len(email.attachments), 1) + name, content, mime = email.attachments[0] + self.assertEqual(name, "StatementOfWork.pdf") + self.assertEqual(mime, "application/pdf") + self.assertEqual(content, expected_content) + class StatementOfWorkNotificationToSponsorsTests(TestCase): def setUp(self): @@ -167,7 +187,10 @@ def setUp(self): submited_by=self.user, ) self.sow = baker.make_recipe( - "sponsors.tests.empty_sow", sponsorship=sponsorship + "sponsors.tests.awaiting_signature_sow", + sponsorship=sponsorship, + _fill_optional=["document"], + _create_files=True, ) self.subject_template = "sponsors/email/sponsor_statement_of_work_subject.txt" self.content_template = "sponsors/email/sponsor_statement_of_work.txt" @@ -185,3 +208,19 @@ def test_send_email_using_correct_templates(self): self.assertEqual(expected_content, email.body) self.assertEqual(settings.SPONSORSHIP_NOTIFICATION_FROM_EMAIL, email.from_email) self.assertEqual([self.user.email], email.to) + + def test_attach_statement_of_work_pdf(self): + self.assertTrue(self.sow.document.name) + with self.sow.document.open("rb") as fd: + expected_content = fd.read() + self.assertTrue(expected_content) + + self.sow.refresh_from_db() + self.notification.notify(statement_of_work=self.sow) + email = mail.outbox[0] + + self.assertEqual(len(email.attachments), 1) + name, content, mime = email.attachments[0] + self.assertEqual(name, "StatementOfWork.pdf") + self.assertEqual(mime, "application/pdf") + self.assertEqual(content, expected_content) From 8faad472abe55ebd30821f50d2671d3b680695e9 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 15 Dec 2020 10:59:53 -0300 Subject: [PATCH 42/82] Add button to send SoW --- templates/sponsors/admin/statement_of_work_change_form.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/sponsors/admin/statement_of_work_change_form.html b/templates/sponsors/admin/statement_of_work_change_form.html index b66f6d9c5..8dc71de1e 100644 --- a/templates/sponsors/admin/statement_of_work_change_form.html +++ b/templates/sponsors/admin/statement_of_work_change_form.html @@ -6,7 +6,10 @@ {% if sow.is_draft %}
  • - Review Statement of Work + Review +
  • +
  • + Send document
  • {% endif %} From 39508b4166129e3f7268dbeedab11599debd464d Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 15 Dec 2020 11:00:09 -0300 Subject: [PATCH 43/82] Display an iframe with the PDF file before sending the document --- sponsors/views_admin.py | 4 +++- templates/sponsors/admin/send_sow.html | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index 1d1e7b742..fe14fc131 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -10,7 +10,9 @@ def preview_statement_of_work_view(ModelAdmin, request, pk): sow = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) - return render_sow_to_pdf_response(request, sow) + response = render_sow_to_pdf_response(request, sow) + response["X-Frame-Options"] = "SAMEORIGIN" + return response def reject_sponsorship_view(ModelAdmin, request, pk): diff --git a/templates/sponsors/admin/send_sow.html b/templates/sponsors/admin/send_sow.html index 752d4c62d..d84ccde44 100644 --- a/templates/sponsors/admin/send_sow.html +++ b/templates/sponsors/admin/send_sow.html @@ -17,8 +17,14 @@ {% block content %}

    Send Statement of Work

    -

    Statement of Work summary and emails later...

    + + +
    + +
    +
    +

    Help: If you can't see the document's content above this texxt, please turn off your webbrowser's extensions which blocks HTML iframes (ad-blocker, ghostery, etc)

    {% csrf_token %} From e4b67439728197168b0938da77942d87987ca94e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Tue, 15 Dec 2020 11:00:37 -0300 Subject: [PATCH 44/82] Shouldn't edit document fields if not a draft version --- sponsors/admin.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 5fdcad27a..e02563a78 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -308,15 +308,6 @@ class LegalClauseModelAdmin(OrderedModelAdmin): @admin.register(StatementOfWork) class StatementOfWorkModelAdmin(admin.ModelAdmin): change_form_template = "sponsors/admin/statement_of_work_change_form.html" - readonly_fields = [ - "status", - "created_on", - "last_update", - "sent_on", - "sponsorship", - "revision", - "document", - ] list_display = [ "id", "sponsorship", @@ -376,6 +367,28 @@ def get_revision(self, obj): ), ] + def get_readonly_fields(self, request, obj): + readonly_fields = [ + "status", + "created_on", + "last_update", + "sent_on", + "sponsorship", + "revision", + "document", + ] + + if obj and not obj.is_draft: + extra = [ + "sponsor_info", + "sponsor_contact", + "benefits_list", + "legal_clauses", + ] + readonly_fields.extend(extra) + + return readonly_fields + def document_link(self, obj): html, url, msg = "---", "", "" From 8d60ed969a6dd5fa312c2a661fc35b8e1b2f37c9 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 21 Dec 2020 08:49:55 -0300 Subject: [PATCH 45/82] Add administrative flag to sponsor's contact --- sponsors/forms.py | 2 +- .../0022_sponsorcontact_administrative.py | 21 +++++++++++++++++++ sponsors/models.py | 3 +++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 sponsors/migrations/0022_sponsorcontact_administrative.py diff --git a/sponsors/forms.py b/sponsors/forms.py index 0c2e1eeba..9fa7cfd59 100644 --- a/sponsors/forms.py +++ b/sponsors/forms.py @@ -27,7 +27,7 @@ def label_from_instance(self, obj): class SponsorContactForm(forms.ModelForm): class Meta: model = SponsorContact - fields = ["name", "email", "phone", "primary"] + fields = ["name", "email", "phone", "primary", "administrative"] SponsorContactFormSet = forms.formset_factory( diff --git a/sponsors/migrations/0022_sponsorcontact_administrative.py b/sponsors/migrations/0022_sponsorcontact_administrative.py new file mode 100644 index 000000000..3872f16b5 --- /dev/null +++ b/sponsors/migrations/0022_sponsorcontact_administrative.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.13 on 2020-12-21 11:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsors", "0021_auto_20201211_2120"), + ] + + operations = [ + migrations.AddField( + model_name="sponsorcontact", + name="administrative", + field=models.BooleanField( + default=False, + help_text="If this is an administrative contact for the sponsor", + ), + ), + ] diff --git a/sponsors/models.py b/sponsors/models.py index 174024044..6180458b4 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -220,6 +220,9 @@ class SponsorContact(models.Model): primary = models.BooleanField( default=False, help_text="If this is the primary contact for the sponsor" ) + administrative = models.BooleanField( + default=False, help_text="If this is an administrative contact for the sponsor" + ) manager = models.BooleanField( default=False, help_text="If this contact can manage sponsorship information on python.org", From 83cc78104e31ca7c9ea5c5aa2855161dce3cc566 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 09:53:12 -0300 Subject: [PATCH 46/82] Enable rollback sponsorship to edit --- sponsors/models.py | 9 +++++++++ sponsors/tests/test_models.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/sponsors/models.py b/sponsors/models.py index d0a90cbe5..8594e372c 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -342,6 +342,15 @@ def approve(self): self.status = self.APPROVED self.approved_on = timezone.now().date() + def rollback_to_editing(self): + accepts_rollback = [self.APPLIED, self.APPROVED, self.REJECTED] + if self.status not in accepts_rollback: + msg = f"Can't rollback to edit a {self.get_status_display()} sponsorship." + raise SponsorshipInvalidStatusException(msg) + self.status = self.APPLIED + self.approved_on = None + self.rejected_on = None + @property def verified_emails(self): emails = [self.submited_by.email] diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index e18f8a111..53aab55e7 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -6,7 +6,10 @@ from django.utils import timezone from ..models import Sponsor, SponsorshipBenefit, Sponsorship -from ..exceptions import SponsorWithExistingApplicationException +from ..exceptions import ( + SponsorWithExistingApplicationException, + SponsorshipInvalidStatusException, +) class SponsorshipBenefitModelTests(TestCase): @@ -142,6 +145,30 @@ def test_approve_sponsorship(self): self.assertEqual(sponsorship.status, Sponsorship.APPROVED) self.assertEqual(sponsorship.approved_on, timezone.now().date()) + def test_rollback_sponsorship_to_edit(self): + sponsorship = Sponsorship.new(self.sponsor, self.benefits) + can_rollback_from = [ + Sponsorship.APPLIED, + Sponsorship.APPROVED, + Sponsorship.REJECTED, + ] + for status in can_rollback_from: + sponsorship.status = status + sponsorship.save() + sponsorship.refresh_from_db() + + sponsorship.rollback_to_editing() + + self.assertEqual(sponsorship.status, Sponsorship.APPLIED) + self.assertIsNone(sponsorship.approved_on) + self.assertIsNone(sponsorship.rejected_on) + + sponsorship.status = Sponsorship.FINALIZED + sponsorship.save() + sponsorship.refresh_from_db() + with self.assertRaises(SponsorshipInvalidStatusException): + sponsorship.rollback_to_editing() + def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self): sponsorship = Sponsorship.new(self.sponsor, self.benefits) finalized_status = [Sponsorship.REJECTED, Sponsorship.FINALIZED] From 15fac679fc4d3d1fb323450459b6bf0fbcdd762f Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 09:53:48 -0300 Subject: [PATCH 47/82] Admin view to rollback to edit --- sponsors/admin.py | 30 ++++++ sponsors/tests/test_views.py | 102 ++++++++++++++++++ .../rollback_sponsorship_to_editing.html | 35 ++++++ 3 files changed, 167 insertions(+) create mode 100644 templates/sponsors/admin/rollback_sponsorship_to_editing.html diff --git a/sponsors/admin.py b/sponsors/admin.py index 405ad9cbe..d49cf3cea 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -244,6 +244,11 @@ def get_urls(self): self.admin_site.admin_view(self.approve_sponsorship_view), name="sponsors_sponsorship_approve", ), + path( + "/enable-edit", + self.admin_site.admin_view(self.rollback_to_editing_view), + name="sponsors_sponsorship_rollback_to_edit", + ), ] return my_urls + urls @@ -322,6 +327,31 @@ def get_sponsor_contacts(self, obj): get_sponsor_contacts.short_description = "Contacts" + def rollback_to_editing_view(self, request, pk): + sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + try: + sponsorship.rollback_to_editing() + sponsorship.save() + self.message_user( + request, "Sponsorship is now editable!", messages.SUCCESS + ) + except SponsorshipInvalidStatusException as e: + self.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] + ) + return redirect(redirect_url) + + context = {"sponsorship": sponsorship} + return render( + request, + "sponsors/admin/rollback_sponsorship_to_editing.html", + context=context, + ) + def reject_sponsorship_view(self, request, pk): sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py index 7641f4d2f..99bf18cef 100644 --- a/sponsors/tests/test_views.py +++ b/sponsors/tests/test_views.py @@ -321,6 +321,108 @@ def test_redirect_user_back_to_benefits_selection_if_post_without_valid_set_of_b self.assertRedirects(r, reverse("select_sponsorship_application_benefits")) +class RollbackSponsorshipToEditingAdminViewTests(TestCase): + def setUp(self): + self.user = baker.make( + settings.AUTH_USER_MODEL, is_staff=True, is_superuser=True + ) + self.client.force_login(self.user) + self.sponsorship = baker.make( + Sponsorship, + status=Sponsorship.APPROVED, + submited_by=self.user, + _fill_optional=True, + ) + self.url = reverse( + "admin:sponsors_sponsorship_rollback_to_edit", args=[self.sponsorship.pk] + ) + + def test_display_confirmation_form_on_get(self): + response = self.client.get(self.url) + context = response.context + self.sponsorship.refresh_from_db() + + self.assertTemplateUsed( + response, "sponsors/admin/rollback_sponsorship_to_editing.html" + ) + self.assertEqual(context["sponsorship"], self.sponsorship) + self.assertNotEqual( + self.sponsorship.status, Sponsorship.APPLIED + ) # did not update + + def test_rollback_sponsorship_to_applied_on_post(self): + data = {"confirm": "yes"} + response = self.client.post(self.url, data=data) + self.sponsorship.refresh_from_db() + + expected_url = reverse( + "admin:sponsors_sponsorship_change", args=[self.sponsorship.pk] + ) + self.assertRedirects(response, expected_url, fetch_redirect_response=True) + self.assertEqual(self.sponsorship.status, Sponsorship.APPLIED) + msg = list(get_messages(response.wsgi_request))[0] + assertMessage(msg, "Sponsorship is now editable!", messages.SUCCESS) + + def test_do_not_rollback_if_invalid_post(self): + response = self.client.post(self.url, data={}) + self.sponsorship.refresh_from_db() + self.assertTemplateUsed( + response, "sponsors/admin/rollback_sponsorship_to_editing.html" + ) + self.assertNotEqual( + self.sponsorship.status, Sponsorship.APPLIED + ) # did not update + + response = self.client.post(self.url, data={"confirm": "invalid"}) + self.sponsorship.refresh_from_db() + self.assertTemplateUsed( + response, "sponsors/admin/rollback_sponsorship_to_editing.html" + ) + self.assertNotEqual(self.sponsorship.status, Sponsorship.APPLIED) + + def test_404_if_sponsorship_does_not_exist(self): + self.sponsorship.delete() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + def test_login_required(self): + login_url = reverse("admin:login") + redirect_url = f"{login_url}?next={self.url}" + self.client.logout() + + r = self.client.get(self.url) + + self.assertRedirects(r, redirect_url) + + def test_staff_required(self): + login_url = reverse("admin:login") + redirect_url = f"{login_url}?next={self.url}" + self.user.is_staff = False + self.user.save() + self.client.force_login(self.user) + + r = self.client.get(self.url) + + self.assertRedirects(r, redirect_url, fetch_redirect_response=False) + + def test_message_user_if_rejecting_invalid_sponsorship(self): + self.sponsorship.status = Sponsorship.FINALIZED + self.sponsorship.save() + data = {"confirm": "yes"} + response = self.client.post(self.url, data=data) + self.sponsorship.refresh_from_db() + + expected_url = reverse( + "admin:sponsors_sponsorship_change", args=[self.sponsorship.pk] + ) + self.assertRedirects(response, expected_url, fetch_redirect_response=True) + self.assertEqual(self.sponsorship.status, Sponsorship.FINALIZED) + msg = list(get_messages(response.wsgi_request))[0] + assertMessage( + msg, "Can't rollback to edit a Finalized sponsorship.", messages.ERROR + ) + + class RejectedSponsorshipAdminViewTests(TestCase): def setUp(self): self.user = baker.make( diff --git a/templates/sponsors/admin/rollback_sponsorship_to_editing.html b/templates/sponsors/admin/rollback_sponsorship_to_editing.html new file mode 100644 index 000000000..29d157495 --- /dev/null +++ b/templates/sponsors/admin/rollback_sponsorship_to_editing.html @@ -0,0 +1,35 @@ +{% extends 'admin/base_site.html' %} +{% load i18n admin_static sponsors %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block title %}Rollback {{ sponsorship }} to editing| python.org{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    Rollback to Editing

    +

    Please review the sponsorship application and click in the Rollback button if you want to proceed.

    +
    + +{% csrf_token %} + +
    {% full_sponsorship sponsorship display_fee=True %}
    + + + +
    + +
    + + +
    +
    {% endblock %} From c70d9233a3473e5d91b9e5c8ba212a3a9504b845 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 09:53:58 -0300 Subject: [PATCH 48/82] Add button in sponsorship's change form --- templates/sponsors/admin/sponsorship_change_form.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/sponsors/admin/sponsorship_change_form.html b/templates/sponsors/admin/sponsorship_change_form.html index 5a983f31a..c366bd081 100644 --- a/templates/sponsors/admin/sponsorship_change_form.html +++ b/templates/sponsors/admin/sponsorship_change_form.html @@ -16,6 +16,12 @@ {% endif %} + {% if sp.status != sp.FINALIZED and sp.status != sp.APPLIED %} +
  • + Rollback to Edit +
  • + {% endif %} + {% endwith %} {{ block.super }} From 5f96c0124a188718284f5a0b2c66d1eebf062331 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 10:13:31 -0300 Subject: [PATCH 49/82] Move rollback view to views_admin to respect internals structure --- sponsors/admin.py | 25 +------------------------ sponsors/views_admin.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/sponsors/admin.py b/sponsors/admin.py index 41cb53b08..c300fddd0 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -18,7 +18,6 @@ ) from sponsors import views_admin from sponsors.forms import SponsorshipReviewAdminForm, SponsorBenefitAdminInlineForm -from sponsors.exceptions import InvalidStatusException from cms.admin import ContentManageableModelAdmin @@ -318,29 +317,7 @@ def get_sponsor_contacts(self, obj): get_sponsor_contacts.short_description = "Contacts" def rollback_to_editing_view(self, request, pk): - sponsorship = get_object_or_404(self.get_queryset(request), pk=pk) - - if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": - try: - sponsorship.rollback_to_editing() - sponsorship.save() - self.message_user( - request, "Sponsorship is now editable!", messages.SUCCESS - ) - except InvalidStatusException as e: - self.message_user(request, str(e), messages.ERROR) - - redirect_url = reverse( - "admin:sponsors_sponsorship_change", args=[sponsorship.pk] - ) - return redirect(redirect_url) - - context = {"sponsorship": sponsorship} - return render( - request, - "sponsors/admin/rollback_sponsorship_to_editing.html", - context=context, - ) + return views_admin.rollback_to_editing_view(self, request, pk) def reject_sponsorship_view(self, request, pk): return views_admin.reject_sponsorship_view(self, request, pk) diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py index fe14fc131..2f499aaf7 100644 --- a/sponsors/views_admin.py +++ b/sponsors/views_admin.py @@ -95,3 +95,29 @@ def send_statement_of_work_view(ModelAdmin, request, pk): context = {"statement_of_work": sow} return render(request, "sponsors/admin/send_sow.html", context=context) + + +def rollback_to_editing_view(ModelAdmin, request, pk): + sponsorship = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk) + + if request.method.upper() == "POST" and request.POST.get("confirm") == "yes": + try: + sponsorship.rollback_to_editing() + sponsorship.save() + ModelAdmin.message_user( + request, "Sponsorship is now editable!", messages.SUCCESS + ) + except InvalidStatusException as e: + ModelAdmin.message_user(request, str(e), messages.ERROR) + + redirect_url = reverse( + "admin:sponsors_sponsorship_change", args=[sponsorship.pk] + ) + return redirect(redirect_url) + + context = {"sponsorship": sponsorship} + return render( + request, + "sponsors/admin/rollback_sponsorship_to_editing.html", + context=context, + ) From b426a682c694827c69579b4de05e493b967e5849 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 10:56:04 -0300 Subject: [PATCH 50/82] Manage SoW before rolling back an application --- sponsors/models.py | 11 +++++++++++ sponsors/tests/baker_recipes.py | 7 +++++-- sponsors/tests/test_models.py | 25 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index 509e2abdb..f9868c0bb 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -2,6 +2,7 @@ from itertools import chain from django.conf import settings from django.core.files.storage import default_storage +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Sum from django.template.defaultfilters import truncatechars @@ -353,6 +354,16 @@ def rollback_to_editing(self): if self.status not in accepts_rollback: msg = f"Can't rollback to edit a {self.get_status_display()} sponsorship." raise InvalidStatusException(msg) + + try: + if not self.statement_of_work.is_draft: + status = self.statement_of_work.get_status_display() + msg = f"Can't rollback to edit a sponsorship with a { status } Statement of Work." + raise InvalidStatusException(msg) + self.statement_of_work.delete() + except ObjectDoesNotExist: + pass + self.status = self.APPLIED self.approved_on = None self.rejected_on = None diff --git a/sponsors/tests/baker_recipes.py b/sponsors/tests/baker_recipes.py index b161b3fc6..eb4b71172 100644 --- a/sponsors/tests/baker_recipes.py +++ b/sponsors/tests/baker_recipes.py @@ -1,16 +1,19 @@ from model_bakery.recipe import Recipe +from sponsors.models import StatementOfWork + empty_sow = Recipe( - "sponsors.StatementOfWork", + StatementOfWork, sponsorship__sponsor__name="Sponsor", benefits_list="", legal_clauses="", ) awaiting_signature_sow = Recipe( - "sponsors.StatementOfWork", + StatementOfWork, sponsorship__sponsor__name="Awaiting Sponsor", benefits_list="- benefit 1", legal_clauses="", + status=StatementOfWork.AWAITING_SIGNATURE, ) diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py index eda1d62be..ead36b865 100644 --- a/sponsors/tests/test_models.py +++ b/sponsors/tests/test_models.py @@ -13,6 +13,7 @@ SponsorContact, SponsorBenefit, LegalClause, + StatementOfWork ) from ..exceptions import ( SponsorWithExistingApplicationException, @@ -191,6 +192,30 @@ def test_rollback_sponsorship_to_edit(self): with self.assertRaises(InvalidStatusException): sponsorship.rollback_to_editing() + def test_rollback_approved_sponsorship_with_statement_of_work_should_delete_it(self): + sponsorship = Sponsorship.new(self.sponsor, self.benefits) + sponsorship.status = Sponsorship.APPROVED + sponsorship.save() + baker.make_recipe('sponsors.tests.empty_sow', sponsorship=sponsorship) + + sponsorship.rollback_to_editing() + sponsorship.save() + sponsorship.refresh_from_db() + + self.assertEqual(sponsorship.status, Sponsorship.APPLIED) + self.assertEqual(0, StatementOfWork.objects.count()) + + def test_can_not_rollback_sponsorship_to_edit_if_sow_was_sent(self): + sponsorship = Sponsorship.new(self.sponsor, self.benefits) + sponsorship.status = Sponsorship.APPROVED + sponsorship.save() + baker.make_recipe('sponsors.tests.awaiting_signature_sow', sponsorship=sponsorship) + + with self.assertRaises(InvalidStatusException): + sponsorship.rollback_to_editing() + + self.assertEqual(1, StatementOfWork.objects.count()) + def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self): sponsorship = Sponsorship.new(self.sponsor, self.benefits) finalized_status = [Sponsorship.REJECTED, Sponsorship.FINALIZED] From f0c7d3b449583b15e59372a0760b7f5f8fe7b41e Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 23 Dec 2020 10:57:27 -0300 Subject: [PATCH 51/82] Minor lint warnings --- sponsors/models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sponsors/models.py b/sponsors/models.py index f9868c0bb..7defff72e 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -15,8 +15,6 @@ from django_countries.fields import CountryField from cms.models import ContentManageable -from companies.models import Company - from .managers import SponsorContactQuerySet, SponsorshipQuerySet from .exceptions import ( SponsorWithExistingApplicationException, @@ -45,7 +43,7 @@ def has_user_customization(self, benefits): # check if all packages' benefits without conflict are present in benefits list from_pkg_benefits = set( - [b for b in benefits if not b in pkg_benefits_with_conflicts] + [b for b in benefits if b not in pkg_benefits_with_conflicts] ) if from_pkg_benefits != set(self.benefits.without_conflicts()): return True From 3c8e7fe1339501645135d3e799ce743f19075182 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 19 Feb 2021 12:48:45 -0300 Subject: [PATCH 52/82] Add document summary content --- sponsors/pdf.py | 2 ++ sponsors/tests/test_pdf.py | 7 ++++++- templates/sponsors/admin/preview-statement-of-work.html | 7 +++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/sponsors/pdf.py b/sponsors/pdf.py index 286cb99e5..edd32adc0 100644 --- a/sponsors/pdf.py +++ b/sponsors/pdf.py @@ -15,6 +15,8 @@ def _sow_context(sow, **context): { "sow": sow, "benefits_and_clauses": mark_safe(html), + "start_date": sow.sponsorship.start_date, + "sponsor": sow.sponsorship.sponsor, } ) return context diff --git a/sponsors/tests/test_pdf.py b/sponsors/tests/test_pdf.py index 158d92adf..62dddbd8e 100644 --- a/sponsors/tests/test_pdf.py +++ b/sponsors/tests/test_pdf.py @@ -15,7 +15,12 @@ def setUp(self): self.sow = baker.make_recipe("sponsors.tests.empty_sow") text = f"{self.sow.benefits_list.raw}\n\n**Legal Clauses**\n{self.sow.legal_clauses.raw}" html = render_md(text) - self.context = {"sow": self.sow, "benefits_and_clauses": mark_safe(html)} + self.context = { + "sow": self.sow, + "benefits_and_clauses": mark_safe(html), + "start_date": self.sow.sponsorship.start_date, + "sponsor": self.sow.sponsorship.sponsor, + } self.template = "sponsors/admin/preview-statement-of-work.html" @patch("sponsors.pdf.render_to_pdf") diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index 1f46156c4..9c70a7b21 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -9,9 +9,12 @@ {% endblock %} {% block content %} -

    Statement of Work Terms

    +

    SPONSORSHIP AGREEMENT

    -

    This is a initial draf of the final contract. All the information with a grey background color is dynamic and comes from our database.

    +

    THIS SPONSORSHIP AGREEMENT (the “Agreement”) is entered into and made +effective as of the {{ start_date|date:"dS" }} day of {{ start_date|date:"F, Y"}} (the “Effective Date”), by and between +Python Software Foundation (the “PSF”), a Delaware nonprofit corporation, and {{ sponsor.name|upper }} (“Sponsor”), a {{ sponsor.state }} corporation. Each of the PSF and Sponsor are +hereinafter sometimes individually referred to as a “Party” and collectively as the “Parties.”

    This document is an HTML file that is being converted to this PDF.

    From af982954af2b4f31a175dc27734fd7492b3e101f Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Fri, 19 Feb 2021 14:03:11 -0300 Subject: [PATCH 53/82] Style page to closer to the reference --- .../admin/preview-statement-of-work.html | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index 9c70a7b21..284eb95ac 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -5,18 +5,48 @@ .dynamic { background-color: #dddddd; } + + .center { + text-align: center; + } + + .paragraph { + text-align: justify; + text-justify: inter-word; + text-indent: 50px; + } + + body { + font-family: "Times New Roman"; + } + + @media print { + .pb {page-break-after: always;} /* page break */ + } {% endblock %} {% block content %} -

    SPONSORSHIP AGREEMENT

    +

    SPONSORSHIP AGREEMENT

    -

    THIS SPONSORSHIP AGREEMENT (the “Agreement”) is entered into and made +

    THIS SPONSORSHIP AGREEMENT (the “Agreement”) is entered into and made effective as of the {{ start_date|date:"dS" }} day of {{ start_date|date:"F, Y"}} (the “Effective Date”), by and between Python Software Foundation (the “PSF”), a Delaware nonprofit corporation, and {{ sponsor.name|upper }} (“Sponsor”), a {{ sponsor.state }} corporation. Each of the PSF and Sponsor are -hereinafter sometimes individually referred to as a “Party” and collectively as the “Parties.”

    +hereinafter sometimes individually referred to as a “Party” and collectively as the “Parties”.

    + +

    RECITALS

    + +

    WHEREAS, the PSF is a tax-exempt charitable organization (EIN 04-3594598) whose +mission is to promote, protect, and advance the Python programming language, and to support +and facilitate the growth of a diverse and international community of Python programmers (the +“Programs”);

    + +

    WHEREAS, Sponsor is {{ sponsor.description }}; and

    + +

    WHEREAS, Sponsor wishes to support the Programs by making a contribution to the +PSF.

    -

    This document is an HTML file that is being converted to this PDF.

    +

    AGREEMENT

    Header 1

    Header 2

    From c44f404f667699cbf263804b0a2a2fff73795c78 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Mon, 22 Feb 2021 09:32:53 -0300 Subject: [PATCH 54/82] Add contract bullet items --- .../admin/preview-statement-of-work.html | 154 +++++++++++++++--- 1 file changed, 135 insertions(+), 19 deletions(-) diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index 284eb95ac..b9d95d96b 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -11,13 +11,41 @@ } .paragraph { - text-align: justify; - text-justify: inter-word; - text-indent: 50px; + text-indent: 25px; + } + + .items-list { + padding-left: 50px; + margin-left: 25px; + } + + .items-list ol { + list-style-type: lower-alpha; + padding-left: 10px; + } + + .items-list ol li .item-title { + font-weight: normal; + } + + .item-title { + font-weight: bold; + text-decoration: underline; + } + + #mailing-info { + padding-left: 0px; + } + + #mailing-info * { + margin: 0; } body { font-family: "Times New Roman"; + text-align: justify; + text-justify: inter-word; + font-size: 120%; } @media print { @@ -48,22 +76,110 @@

    RECITALS

    AGREEMENT

    -

    Header 1

    -

    Header 2

    -

    Header 3

    -

    Header 4

    -
    Header 5
    -
    Header 6
    - -
      -
    • this
    • -
    • is
    • -
    • how
    • -
    • a list
    • -
    • looks
    • -
    - -

    We can have text with external references, underline important contant or use italic just because we want to.

    +

    NOW, THEREFORE, in consideration of the foregoing and the mutual covenants +contained herein, and for other good and valuable consideration, the receipt and sufficiency of +which are hereby acknowledged, the Parties hereto agree as follows:

    + +
      +
    1. Recitals Incorporated. Each of the above Recitals is incorporated into and is made a part of this Agreement.
    2. + +
    3. Exhibits Incorporated by Reference. All exhibits referenced in this Agreement are incorporated herein as integral parts of this Agreement and shall be considered reiterated herein as fully as if such provisions had been set forth verbatim in this Agreement.
    4. + +
    5. Sponsorship Payment. In consideration for the right to sponsor the PSF and its Programs, and to be acknowledged by the PSF as a sponsor in the manner described herein, Sponsor shall make a contribution to the PSF (the “Sponsorship Payment”) in the amount shown in Exhibit A.
    6. + +
    7. Acknowledgement of Sponsor. In return for the Sponsorship Payment, Sponsor will be entitled to receive the sponsorship package described in Exhibit A attached hereto (the “Sponsor Benefits”).
    8. + +
    9. Intellectual Property. The PSF is the sole owner of all right, title, and interest to all the PSF information, including the PSF’s logo, trademarks, trade names, and copyrighted information, unless otherwise provided.
    10. + +
        +
      1. Grant of License by the PSF. The PSF hereby grants to Sponsor a limited, non-exclusive license to use certain of the PSF’s intellectual property, including the PSF’s name, acronym, and logo (collectively, the “PSF Intellectual Property”), solely in connection with promotion of Sponsor’s sponsorship of the Programs. Sponsor agrees that it shall not use the PSF’s Property in a manner that states or implies that the PSF endorses Sponsor (or Sponsor’s products or services). The PSF retains the right, in its sole and absolute discretion, to review and approve in advance all uses of the PSF Intellectual Property, which approval shall not be unreasonably withheld.
      2. + +
      3. Grant of License by Sponsor. Sponsor hereby grants to the PSF a limited, non-exclusive license to use certain of Sponsor’s intellectual property, including names, trademarks, and copyrights (collectively, “Sponsor Intellectual Property”), solely to identify Sponsor as a sponsor of the Programs and the PSF. Sponsor retains the right to review and approve in advance all uses of the Sponsor Intellectual Property, which approval shall not be unreasonably withheld.
      4. +
      + + +
    11. Term. The Term of this Agreement will begin on {{ start_date|date:"dS, F Y"}} and continue for a period of one (1) year. The Agreement may be renewed for one (1) year by written notice from Sponsor to the PSF.
    12. + +
    13. Termination. The Agreement may be terminated (i) by either Party for any reason upon sixty (60) days prior written notice to the other Party; (ii) if one Party notifies the other Party that the other Party is in material breach of its obligations under this Agreement and such breach (if curable) is not cured with fifteen (15) days of such notice; (iii) if both Parties agree to terminate by mutual written consent; or (iv) if any of Sponsor information is found or is reasonably alleged to violate the rights of a third party. The PSF shall also have the unilateral right to terminate this Agreement at any time if it reasonably determines that it would be detrimental to the reputation and goodwill of the PSF or the Programs to continue to accept or use funds from Sponsor. Upon expiration or termination, no further use may be made by either Party of the other’s name, marks, logo or other intellectual property without the express prior written authorization of the other Party.
    14. + +
    15. Code of Conduct. Sponsor and all of its representatives shall conduct themselves at all times in accordance with the Python Software Foundation Code of Conduct (https://www.python.org/psf/codeofconduct) and/or the PyCon Code of Conduct (https://us.pycon.org/2021/about/code-of-conduct/), as applicable. The PSF reserves the right to eject from any event any Sponsor or representative violating those standards.
    16. + +
    17. Deadlines. Company logos, descriptions, banners, advertising pages, tote bag inserts and similar items and information must be provided by the applicable deadlines for inclusion in the promotional materials for the PSF.
    18. + +
    19. Assignment of Space. If the Sponsor Benefits in Exhibit A include a booth or other display space, the PSF shall assign display space to Sponsor for the period of the display. Location assignments will be on a first-come, first-served basis and will be made solely at the discretion of the PSF. Failure to use a reserved space will result in penalties (up to 50% of your Sponsorship Payment).
    20. + +
    21. Job Postings. Sponsor will ensure that any job postings to be published by the PSF on Sponsor’s behalf comply with all applicable municipal, state, provincial, and federal laws.
    22. + +
    23. Representations and Warranties. Each Party represents and warrants for the benefit of the other Party that it has the legal authority to enter into this Agreement and is able to comply with the terms herein. Sponsor represents and warrants for the benefit of the PSF that it has full right and title to the Sponsor Intellectual Property to be provided under this Agreement and is not under any obligation to any party that restricts the Sponsor Intellectual Property or would prevent Sponsor’s performance under this Agreement.
    24. + +
    25. Successors and Assigns. This Agreement and all the terms and provisions hereof shall be binding upon and inure to the benefit of the Parties and their respective legal representatives, heirs, successors, and/or assigns. The transfer, or any attempted assignment or transfer, of all or any portion of this Agreement by a Party without the prior written consent of the other Party shall be null and void and of no effect.
    26. + +
    27. No Third-Party Beneficiaries. This Agreement is not intended to benefit and shall not be construed to confer upon any person, other than the Parties, any rights, remedies, or other benefits, including but not limited to third-party beneficiary rights.
    28. + +
    29. Severability. If any one or more of the provisions of this Agreement shall be held to be invalid, illegal, or unenforceable, the validity, legality, or enforceability of the remaining provisions of this Agreement shall not be affected thereby. To the extent permitted by applicable law, each Party waives any provision of law which renders any provision of this Agreement invalid, illegal, or unenforceable in any respect.
    30. + +
    31. Confidential Information. As used herein, “Confidential Information” means all confidential information disclosed by a Party (“Disclosing Party”) to the other Party (“Receiving Party”), whether orally or in writing, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information. Each Party agrees: (a) to observe complete confidentiality with respect to the Confidential Information of the Disclosing Party; (b) not to disclose, or permit any third party or entity access to disclose, the Confidential Information (or any portion thereof) of the Disclosing Party without prior written permission of Disclosing Party; and (c) to ensure that any employees, or any third parties who receive access to the Confidential Information, are advised of the confidential and proprietary nature thereof and are prohibited from disclosing the Confidential Information and using the Confidential Information other than for the benefit of the Receiving Party in accordance with this Agreement. Without limiting the foregoing, each Party shall use the same degree of care that it uses to protect the confidentiality of its own confidential information of like kind, but in no event less than reasonable care. Neither Party shall have any liability with respect to Confidential Information to the extent such information: (w) is or becomes publicly available (other than through a breach of this Agreement); (x) is or becomes available to the Receiving Party on a non-confidential basis, provided that the source of such information was not known by the Receiving Party (after such inquiry as would be reasonable in the circumstances) to be the subject of a confidentiality agreement or other legal or contractual obligation of confidentiality with respect to such information; (y) is developed by the Receiving Party independently and without reference to information provided by the Disclosing Party; or (z) is required to be disclosed by law or court order, provided the Receiving Party gives the Disclosing Party prior notice of such compelled disclosure (to the extent legally permitted) and reasonable assistance, at the Disclosing Party’s cost.
    32. + +
    33. Independent Contractors. Nothing contained herein shall constitute or be construed as the creation of any partnership, agency, or joint venture relationship between the Parties. Neither of the Parties shall have the right to obligate or bind the other Party in any manner whatsoever, and nothing herein contained shall give or is intended to give any rights of any kind to any third party. The relationship of the Parties shall be as independent contractors.
    34. + +
    35. Indemnification. Sponsor agrees to indemnify and hold harmless the PSF, its officers, directors, employees, and agents, for any and all claims, losses, damages, liabilities, judgments, or settlements, including reasonable attorneys’ fees, costs (including costs associated with any official investigations or inquiries) and other expenses, incurred on account of Sponsor’s acts or omissions in connection with the performance of this Agreement or breach of this Agreement or with respect to the manufacture, marketing, sale, or dissemination of any of Sponsor’s products or services. The PSF shall have no liability to Sponsor with respect to its participation in this Agreement or receipt of the Sponsorship Payment, except for intentional or willful acts of the PSF or its employees or agents. The rights and responsibilities established in this section shall survive indefinitely beyond the term of this Agreement.
    36. + +
    37. + Notices. All notices or other communications to be given or delivered under the provisions of this Agreement shall be in writing and shall be mailed by certified or registered mail, return receipt requested, or given or delivered by reputable courier, facsimile, or electronic mail to the Party to receive notice at the following addresses or at such other address as any Party may by notice direct in accordance with this Section: + +
      +
      +

      If to Sponsor:

      +
      +

      {{ sponsor.primary_contact.name }}

      +

      {{ sponsor.name }}

      +

      {{ sponsor.mailing_address_line_1 }}

      + {% if sponsor.mailing_address_line_2 %} +

      {{ sponsor.mailing_address_line_2 }}

      + {% endif %} +

      Facsimile: {{ sponsor.primary_contact.phone }}

      +

      Email: {{ sponsor.primary_contact.email }}

      +
      +

      If to the PSF:

      +
      +

      Ewa Jodlowska

      +

      Executive Director

      +

      Python Software Foundation

      +

      9450 SW Gemini Dr. ECM # 90772

      +

      Beaverton, OR 97008 USA

      +

      Facsimile: +1 (858) 712-8966

      +

      Email: ewa@python.org

      +
      +

      With a copy to:

      +

      Fleming Petenko Law

      +

      1800 John F. Kennedy Blvd

      +

      Suite 904

      +

      Philadelphia, PA 19103 USA

      +

      Facsimile: (267) 422-9864

      +

      Email: info@nonprofitlawllc.com

      + +
      +

      Notices given by registered or certified mail shall be deemed as given on the delivery date shown on the return receipt, and notices given in any other manner shall be deemed as given when received.

      +
    38. + +
    39. Governing Law; Jurisdiction. This Agreement shall be construed in accordance with the laws of the State of Delaware, without regard to its conflicts of law principles. Jurisdiction and venue for litigation of any dispute, controversy, or claim arising out of or in connection with this Agreement shall be only in a United States federal court in Delaware or a Delaware state court having subject matter jurisdiction. Each of the Parties hereto hereby expressly submits to the personal jurisdiction of the foregoing courts located in Delaware and hereby waives any objection or defense based on personal jurisdiction or venue that might otherwise be asserted to proceedings in such courts.
    40. + +
    41. Force Majeure. The PSF shall not be liable for any failure or delay in performing its obligations hereunder if such failure or delay is due in whole or in part to any cause beyond its reasonable control or the reasonable control of its contractors, agents, or suppliers, including, but not limited to, strikes, or other labor disturbances, acts of God, acts of war or terror, floods, sabotage, fire, natural, or other disasters, including pandemics. To the extent the PSF is unable to substantially perform hereunder due to any cause beyond its control as contemplated herein, it may terminate this Agreement as it may decide in its sole discretion. To the extent the PSF so terminates the Agreement, Sponsor releases the PSF and waives any claims for damages or compensation on account of such termination.
    42. + +
    43. No Waiver. A waiver of any breach of any provision of this Agreement shall not be deemed a waiver of any repetition of such breach or in any manner affect any other terms of this Agreement.
    44. + +
    45. Limitation of Damages. Except as otherwise provided herein, neither Party shall be liable to the other for any consequential, incidental, or punitive damages for any claims arising directly or indirectly out of this Agreement.
    46. + +
    47. Cumulative Remedies. All rights and remedies provided in this Agreement are cumulative and not exclusive, and the exercise by either Party of any right or remedy does not preclude the exercise of any other rights or remedies that may now or subsequently be available at law, in equity, by statute, in any other agreement between the Parties, or otherwise.
    48. + +
    49. Captions. The captions and headings are included herein for convenience and do not constitute a part of this Agreement.
    50. + +
    51. Amendments. No addition to or change in the terms of this Agreement will be binding on any Party unless set forth in writing and executed by both Parties.
    52. + +
    53. Counterparts. This Agreement may be executed in one or more counterparts, each of which shall be deemed an original and all of which shall be taken together and deemed to be one instrument. A signed copy of this Agreement delivered by facsimile, electronic mail, or other means of electronic transmission shall be deemed to have the same legal effect as delivery of an original signed copy of this Agreement.
    54. + +
    55. Entire Agreement. This Agreement (including the Exhibits) sets forth the entire agreement of the Parties and supersedes all prior oral or written agreements or understandings between the Parties as to the subject matter of this Agreement. Except as otherwise expressly provided herein, neither Party is relying upon any warranties, representations, assurances, or inducements of the other Party.
    56. +
    Info

    {{ sow.sponsor_info }}

    From d708d4791a89a0e65724e9e6ddb61f2e4896ea84 Mon Sep 17 00:00:00 2001 From: Bernardo Fontes Date: Wed, 24 Feb 2021 11:51:15 -0300 Subject: [PATCH 55/82] List benefits and legal clauses --- requirements.txt | 1 + sponsors/models.py | 5 + sponsors/pdf.py | 7 ++ .../admin/preview-statement-of-work.html | 104 +++++++++++++++++- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1b66eee00..ea8d03d95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -r base-requirements.txt -r prod-requirements.txt +num2words==0.5.10 diff --git a/sponsors/models.py b/sponsors/models.py index 9168ebb55..672c389a5 100644 --- a/sponsors/models.py +++ b/sponsors/models.py @@ -1,5 +1,6 @@ from pathlib import Path from itertools import chain +from num2words import num2words from django.conf import settings from django.core.files.storage import default_storage from django.core.exceptions import ObjectDoesNotExist @@ -328,6 +329,10 @@ def estimated_cost(self): or 0 ) + @property + def verbose_sponsorship_fee(self): + return num2words(self.sponsorship_fee) + def reject(self): if self.REJECTED not in self.next_status: msg = f"Can't reject a {self.get_status_display()} sponsorship." diff --git a/sponsors/pdf.py b/sponsors/pdf.py index edd32adc0..54768c24f 100644 --- a/sponsors/pdf.py +++ b/sponsors/pdf.py @@ -10,6 +10,8 @@ def _sow_context(sow, **context): # footnotes only work if in same markdown text as the references text = f"{sow.benefits_list.raw}\n\n**Legal Clauses**\n{sow.legal_clauses.raw}" + benefits = [b.replace('-', '').strip() for b in sow.benefits_list.raw.split('\n')] + legal_clauses = [l.replace('-', '').strip() for l in sow.legal_clauses.raw.split('\n')] html = render_md(text) context.update( { @@ -17,6 +19,9 @@ def _sow_context(sow, **context): "benefits_and_clauses": mark_safe(html), "start_date": sow.sponsorship.start_date, "sponsor": sow.sponsorship.sponsor, + "sponsorship": sow.sponsorship, + "benefits": benefits, + "legal_clauses": legal_clauses, } ) return context @@ -25,6 +30,8 @@ def _sow_context(sow, **context): def render_sow_to_pdf_response(request, sow, **context): template = "sponsors/admin/preview-statement-of-work.html" context = _sow_context(sow, **context) + from django.shortcuts import render + #return render(request, template, context) return render_to_pdf_response(request, template, context) diff --git a/templates/sponsors/admin/preview-statement-of-work.html b/templates/sponsors/admin/preview-statement-of-work.html index b9d95d96b..f3c3e6a2e 100644 --- a/templates/sponsors/admin/preview-statement-of-work.html +++ b/templates/sponsors/admin/preview-statement-of-work.html @@ -1,4 +1,5 @@ {% extends "easy_pdf/base.html" %} +{% load humanize %} {% block extra_style %}