/nullify",
+ self.admin_site.admin_view(self.nullify_contract_view),
+ name="sponsors_contract_nullify",
+ ),
+ ]
+ return my_urls + urls
+
+ def preview_contract_view(self, request, pk):
+ return views_admin.preview_contract_view(self, request, pk)
+
+ def send_contract_view(self, request, pk):
+ return views_admin.send_contract_view(self, request, pk)
+
+ def execute_contract_view(self, request, pk):
+ return views_admin.execute_contract_view(self, request, pk)
+
+ def nullify_contract_view(self, request, pk):
+ return views_admin.nullify_contract_view(self, request, pk)
diff --git a/sponsors/exceptions.py b/sponsors/exceptions.py
index a8e5a30f9..6778b0797 100644
--- a/sponsors/exceptions.py
+++ b/sponsors/exceptions.py
@@ -5,8 +5,15 @@ 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
"""
+
+
+class SponsorshipInvalidDateRangeException(Exception):
+ """
+ Raised when user tries to approve a sponsorship with a start date
+ greater than the end date.
+ """
diff --git a/sponsors/forms.py b/sponsors/forms.py
index 3c1e90bae..ab3fb5c1e 100644
--- a/sponsors/forms.py
+++ b/sponsors/forms.py
@@ -1,11 +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.db.models import Count
+from django_countries.fields import CountryField
from sponsors.models import (
SponsorshipBenefit,
@@ -28,7 +28,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(
@@ -334,6 +334,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/management/__init__.py b/sponsors/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/sponsors/management/commands/__init__.py b/sponsors/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/sponsors/management/commands/create_contracts.py b/sponsors/management/commands/create_contracts.py
new file mode 100644
index 000000000..16bc986e0
--- /dev/null
+++ b/sponsors/management/commands/create_contracts.py
@@ -0,0 +1,34 @@
+from django.core.management import BaseCommand
+
+from sponsors.models import Sponsorship, Contract
+
+# The reason to not use a data migration but a django management command
+# to deal with pre existing approved Sponsorships is due to migrations
+# limitations. A new Contract for a Sponsorship is created by the Contract's new
+# classmethod. This method operates directly in top of other models such as
+# SponsorBenefits and LegalClauses to organize the contract's information.
+# A data migration would have to re-implement the same logic since the migration,
+# when running, doesn't have access to user defined methods in the models.
+# The same limitation is true for the SponsorshipQuerySet's approved method and for
+# the sponsorship.contract reverse lookup.
+
+class Command(BaseCommand):
+ """
+ Create Contract objects for existing approved Sponsorships.
+
+ Run this command as a initial data migration or to make sure
+ all approved Sponsorships do have associated Contract objects.
+ """
+ help = "Create Contract objects for existing approved Sponsorships."
+
+ def handle(self, **options):
+ qs = Sponsorship.objects.approved().filter(contract__isnull=True)
+ if not qs.exists():
+ print("There's no approved Sponsorship without associated Contract. Terminating.")
+ return
+
+ print(f"Creating contract for {qs.count()} approved sponsorships...")
+ for sponsorship in qs:
+ Contract.new(sponsorship)
+
+ print(f"Done!")
diff --git a/sponsors/managers.py b/sponsors/managers.py
index 827a3243d..bbc2a2e88 100644
--- a/sponsors/managers.py
+++ b/sponsors/managers.py
@@ -7,6 +7,9 @@ def in_progress(self):
status = [self.model.APPLIED, self.model.APPROVED]
return self.filter(status__in=status)
+ def approved(self):
+ return self.filter(status=self.model.APPROVED)
+
def visible_to(self, user):
contacts = user.sponsorcontact_set.values_list('sponsor_id', flat=True)
status = [self.model.APPLIED, self.model.APPROVED, self.model.FINALIZED]
@@ -14,3 +17,11 @@ def visible_to(self, user):
Q(submited_by=user) | Q(sponsor_id__in=Subquery(contacts)),
status__in=status,
).select_related('sponsor')
+
+
+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/migrations/0019_statementofwork.py b/sponsors/migrations/0019_statementofwork.py
new file mode 100644
index 000000000..e451ee8f2
--- /dev/null
+++ b/sponsors/migrations/0019_statementofwork.py
@@ -0,0 +1,117 @@
+# Generated by Django 2.0.13 on 2020-12-04 18:02
+
+from django.db import migrations, models
+import django.db.models.deletion
+import markupfield.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("sponsors", "0019_sponsor_twitter_handle"),
+ ]
+
+ 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",
+ editable=False,
+ 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",
+ editable=False,
+ 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/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/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/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/migrations/0023_merge_20210406_1522.py b/sponsors/migrations/0023_merge_20210406_1522.py
new file mode 100644
index 000000000..6280b3f30
--- /dev/null
+++ b/sponsors/migrations/0023_merge_20210406_1522.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.0.13 on 2021-04-06 15:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0022_sponsorcontact_administrative'),
+ ('sponsors', '0020_sponsorshipbenefit_unavailable'),
+ ]
+
+ operations = [
+ ]
diff --git a/sponsors/migrations/0024_auto_20210414_1449.py b/sponsors/migrations/0024_auto_20210414_1449.py
new file mode 100644
index 000000000..bb463b39c
--- /dev/null
+++ b/sponsors/migrations/0024_auto_20210414_1449.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.0.13 on 2021-04-14 14:49
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0023_merge_20210406_1522'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='StatementOfWork',
+ new_name='Contract',
+ ),
+ ]
diff --git a/sponsors/migrations/0025_auto_20210416_1939.py b/sponsors/migrations/0025_auto_20210416_1939.py
new file mode 100644
index 000000000..f289de131
--- /dev/null
+++ b/sponsors/migrations/0025_auto_20210416_1939.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.0.13 on 2021-04-16 19:39
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0024_auto_20210414_1449'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='contract',
+ options={'verbose_name': 'Contract', 'verbose_name_plural': 'Contracts'},
+ ),
+ migrations.AlterField(
+ model_name='contract',
+ name='sponsorship',
+ field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contract', to='sponsors.Sponsorship'),
+ ),
+ migrations.AlterField(
+ model_name='legalclause',
+ name='clause',
+ field=models.TextField(help_text='Legal clause text to be added to contract', verbose_name='Clause'),
+ ),
+ migrations.AlterField(
+ model_name='sponsorbenefit',
+ name='description',
+ field=models.TextField(blank=True, help_text='For display in the contract and sponsor dashboard.', null=True, verbose_name='Benefit Description'),
+ ),
+ migrations.AlterField(
+ model_name='sponsorbenefit',
+ name='name',
+ field=models.CharField(help_text='For display in the contract and sponsor dashboard.', max_length=1024, verbose_name='Benefit Name'),
+ ),
+ migrations.AlterField(
+ model_name='sponsorshipbenefit',
+ name='legal_clauses',
+ field=models.ManyToManyField(blank=True, help_text='Legal clauses to be displayed in the contract', related_name='benefits', to='sponsors.LegalClause', verbose_name='Legal Clauses'),
+ ),
+ migrations.AlterField(
+ model_name='sponsorshipbenefit',
+ name='name',
+ field=models.CharField(help_text='For display in the application form, contract, and sponsor dashboard.', max_length=1024, verbose_name='Benefit Name'),
+ ),
+ ]
diff --git a/sponsors/migrations/0026_auto_20210416_1940.py b/sponsors/migrations/0026_auto_20210416_1940.py
new file mode 100644
index 000000000..d9d170487
--- /dev/null
+++ b/sponsors/migrations/0026_auto_20210416_1940.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.0.13 on 2021-04-16 19:40
+
+from django.db import migrations, models
+import markupfield.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0025_auto_20210416_1939'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='contract',
+ name='_legal_clauses_rendered',
+ field=models.TextField(default='', editable=False),
+ ),
+ migrations.AlterField(
+ model_name='contract',
+ name='legal_clauses',
+ field=markupfield.fields.MarkupField(blank=True, default='', rendered_field=True),
+ ),
+ ]
diff --git a/sponsors/migrations/0027_sponsorbenefit_program_name.py b/sponsors/migrations/0027_sponsorbenefit_program_name.py
new file mode 100644
index 000000000..3271018b6
--- /dev/null
+++ b/sponsors/migrations/0027_sponsorbenefit_program_name.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.13 on 2021-06-29 19:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0026_auto_20210416_1940'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='sponsorbenefit',
+ name='program_name',
+ field=models.CharField(default='Deleted Program', help_text='For display in the contract and sponsor dashboard.', max_length=1024, verbose_name='Program Name'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/sponsors/migrations/0028_auto_20210707_1426.py b/sponsors/migrations/0028_auto_20210707_1426.py
new file mode 100644
index 000000000..861a75517
--- /dev/null
+++ b/sponsors/migrations/0028_auto_20210707_1426.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.13 on 2021-07-07 14:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sponsors', '0027_sponsorbenefit_program_name'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='sponsorshipbenefit',
+ name='program',
+ field=models.ForeignKey(help_text='Which sponsorship program the benefit is associated with.', on_delete=django.db.models.deletion.CASCADE, to='sponsors.SponsorshipProgram', verbose_name='Sponsorship Program'),
+ ),
+ ]
diff --git a/sponsors/models.py b/sponsors/models.py
index a3e3b8aed..334898f33 100644
--- a/sponsors/models.py
+++ b/sponsors/models.py
@@ -1,5 +1,9 @@
+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
from django.db import models
from django.db.models import Sum, Count
from django.template.defaultfilters import truncatechars
@@ -12,12 +16,11 @@
from django_countries.fields import CountryField
from cms.models import ContentManageable
-from companies.models import Company
-
-from .managers import SponsorshipQuerySet
+from .managers import SponsorContactQuerySet, SponsorshipQuerySet
from .exceptions import (
SponsorWithExistingApplicationException,
- SponsorshipInvalidStatusException,
+ InvalidStatusException,
+ SponsorshipInvalidDateRangeException,
)
DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "restructuredtext")
@@ -41,7 +44,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
@@ -101,7 +104,7 @@ class SponsorshipBenefit(OrderedModel):
name = models.CharField(
max_length=1024,
verbose_name="Benefit Name",
- help_text="For display in the application form, statement of work, and sponsor dashboard.",
+ help_text="For display in the application form, contract, and sponsor dashboard.",
)
description = models.TextField(
null=True,
@@ -111,9 +114,9 @@ class SponsorshipBenefit(OrderedModel):
)
program = models.ForeignKey(
SponsorshipProgram,
- null=True,
+ null=False,
blank=False,
- on_delete=models.SET_NULL,
+ on_delete=models.CASCADE,
verbose_name="Sponsorship Program",
help_text="Which sponsorship program the benefit is associated with.",
)
@@ -145,7 +148,7 @@ class SponsorshipBenefit(OrderedModel):
"LegalClause",
related_name="benefits",
verbose_name="Legal Clauses",
- help_text="Legal clauses to be displayed in the statement of work",
+ help_text="Legal clauses to be displayed in the contract",
blank=True,
)
internal_description = models.TextField(
@@ -223,6 +226,8 @@ class Meta(OrderedModel.Meta):
class SponsorContact(models.Model):
+ objects = SponsorContactQuerySet.as_manager()
+
sponsor = models.ForeignKey(
"Sponsor", on_delete=models.CASCADE, related_name="contacts"
)
@@ -232,6 +237,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",
@@ -324,15 +332,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
@@ -346,6 +347,12 @@ def estimated_cost(self):
or 0
)
+ @property
+ def verbose_sponsorship_fee(self):
+ if self.sponsorship_fee is None:
+ return 0
+ return num2words(self.sponsorship_fee)
+
@property
def agreed_fee(self):
valid_status = [Sponsorship.APPROVED, Sponsorship.FINALIZED]
@@ -359,26 +366,40 @@ def agreed_fee(self):
except SponsorshipPackage.DoesNotExist: # sponsorship level names can change over time
return None
-
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):
+ 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)
self.status = self.APPROVED
+ self.start_date = start_date
+ self.end_date = end_date
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)
+ raise InvalidStatusException(msg)
+
+ try:
+ if not self.contract.is_draft:
+ status = self.contract.get_status_display()
+ msg = f"Can't rollback to edit a sponsorship with a { status } Contract."
+ raise InvalidStatusException(msg)
+ self.contract.delete()
+ except ObjectDoesNotExist:
+ pass
+
self.status = self.APPLIED
self.approved_on = None
self.rejected_on = None
@@ -394,6 +415,14 @@ def verified_emails(self):
def admin_url(self):
return reverse("admin:sponsors_sponsorship_change", args=[self.pk])
+ @property
+ def contract_admin_url(self):
+ if not self.contract:
+ return ""
+ return reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+
@property
def detail_url(self):
return reverse("sponsorship_application_detail", args=[self.pk])
@@ -421,7 +450,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"
)
@@ -432,16 +461,21 @@ class SponsorBenefit(models.Model):
on_delete=models.SET_NULL,
help_text="Sponsorship Benefit this Sponsor Benefit came from",
)
+ program_name = models.CharField(
+ max_length=1024,
+ verbose_name="Program Name",
+ help_text="For display in the contract and sponsor dashboard."
+ )
name = models.CharField(
max_length=1024,
verbose_name="Benefit Name",
- help_text="For display in the statement of work and sponsor dashboard.",
+ help_text="For display in the contract and sponsor dashboard.",
)
description = models.TextField(
null=True,
blank=True,
verbose_name="Benefit Description",
- help_text="For display in the statement of work and sponsor dashboard.",
+ help_text="For display in the contract and sponsor dashboard.",
)
program = models.ForeignKey(
SponsorshipProgram,
@@ -461,6 +495,33 @@ class SponsorBenefit(models.Model):
blank=True, default=False, verbose_name="Added by user?"
)
+ def __str__(self):
+ if self.program is not None:
+ return f"{self.program} > {self.name}"
+ return f"{self.program_name} > {self.name}"
+
+
+ @classmethod
+ def new_copy(cls, benefit, **kwargs):
+ return cls.objects.create(
+ sponsorship_benefit=benefit,
+ program_name=benefit.program.name,
+ name=benefit.name,
+ description=benefit.description,
+ program=benefit.program,
+ benefit_internal_value=benefit.internal_value,
+ **kwargs,
+ )
+
+ @property
+ def legal_clauses(self):
+ if self.sponsorship_benefit is not None:
+ return self.sponsorship_benefit.legal_clauses.all()
+ return []
+
+ class Meta(OrderedModel.Meta):
+ pass
+
class Sponsor(ContentManageable):
name = models.CharField(
@@ -529,6 +590,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(
@@ -539,7 +614,7 @@ class LegalClause(OrderedModel):
)
clause = models.TextField(
verbose_name="Clause",
- help_text="Legal clause text to be added to statement of work",
+ help_text="Legal clause text to be added to contract",
blank=False,
)
notes = models.TextField(
@@ -551,3 +626,196 @@ def __str__(self):
class Meta(OrderedModel.Meta):
pass
+
+
+class Contract(models.Model):
+ DRAFT = "draft"
+ OUTDATED = "outdated"
+ AWAITING_SIGNATURE = "awaiting signature"
+ EXECUTED = "executed"
+ NULLIFIED = "nullified"
+
+ STATUS_CHOICES = [
+ (DRAFT, "Draft"),
+ (OUTDATED, "Outdated"),
+ (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=FINAL_VERSION_PDF_DIR,
+ blank=True,
+ verbose_name="Unsigned PDF",
+ )
+ signed_document = models.FileField(
+ upload_to=SIGNED_PDF_DIR,
+ blank=True,
+ verbose_name="Signed PDF",
+ )
+
+ # Contract 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="contract",
+ )
+ 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(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(markup_type="markdown", default="", blank=True)
+
+ # 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 = "Contract"
+ verbose_name_plural = "Contracts"
+
+ def __str__(self):
+ return f"Contract: {self.sponsorship}"
+
+ @classmethod
+ def new(cls, sponsorship):
+ """
+ Factory method to create a new Contract from a Sponsorship
+ """
+ sponsor = sponsorship.sponsor
+ primary_contact = sponsor.primary_contact
+
+ sponsor_info = f"{sponsor.name} with address {sponsor.full_address} and contact {sponsor.primary_phone}"
+ sponsor_contact = ""
+ if primary_contact:
+ 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
+ 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="\n".join([b for b in benefits_list]),
+ legal_clauses=legal_clauses_text,
+ )
+
+ @property
+ def is_draft(self):
+ return self.status == self.DRAFT
+
+ @property
+ def preview_url(self):
+ return reverse("admin:sponsors_contract_preview", args=[self.pk])
+
+ @property
+ def awaiting_signature(self):
+ return self.status == self.AWAITING_SIGNATURE
+
+ @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):
+ if all([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()} contract."
+ 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()
+
+ def execute(self, commit=True):
+ if self.EXECUTED not in self.next_status:
+ msg = f"Can't execute a {self.get_status_display()} contract."
+ raise InvalidStatusException(msg)
+
+ self.status = self.EXECUTED
+ self.sponsorship.status = Sponsorship.FINALIZED
+ if commit:
+ self.sponsorship.save()
+ self.save()
+
+ def nullify(self, commit=True):
+ if self.NULLIFIED not in self.next_status:
+ msg = f"Can't nullify a {self.get_status_display()} contract."
+ raise InvalidStatusException(msg)
+
+ self.status = self.NULLIFIED
+ if commit:
+ self.sponsorship.save()
+ self.save()
diff --git a/sponsors/notifications.py b/sponsors/notifications.py
index f71fc2a75..267854daa 100644
--- a/sponsors/notifications.py
+++ b/sponsors/notifications.py
@@ -1,6 +1,10 @@
-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
+from django.contrib.admin.models import LogEntry, CHANGE
+from django.contrib.contenttypes.models import ContentType
+
+from sponsors.models import Sponsorship, Contract
class BaseEmailSponsorshipNotification:
@@ -17,15 +21,25 @@ 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}
- 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,
)
+ for attachment in self.get_attachments(context):
+ email.attach(*attachment)
+
+ email.send()
class AppliedSponsorshipNotificationToPSF(BaseEmailSponsorshipNotification):
@@ -64,19 +78,83 @@ def get_recipient_list(self, context):
return context["sponsorship"].verified_emails
-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"]
+class ContractNotificationToPSF(BaseEmailSponsorshipNotification):
+ subject_template = "sponsors/email/psf_contract_subject.txt"
+ message_template = "sponsors/email/psf_contract.txt"
+ email_context_keys = ["contract"]
def get_recipient_list(self, context):
return [settings.SPONSORSHIP_NOTIFICATION_TO_EMAIL]
+ def get_attachments(self, context):
+ document = context["contract"].document
+ with document.open("rb") as fd:
+ content = fd.read()
+ return [("Contract.pdf", content, "application/pdf")]
-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"]
+
+class ContractNotificationToSponsors(BaseEmailSponsorshipNotification):
+ subject_template = "sponsors/email/sponsor_contract_subject.txt"
+ message_template = "sponsors/email/sponsor_contract.txt"
+ email_context_keys = ["contract"]
def get_recipient_list(self, context):
- return context["sponsorship"].verified_emails
+ return context["contract"].sponsorship.verified_emails
+
+ def get_attachments(self, context):
+ document = context["contract"].document
+ with document.open("rb") as fd:
+ content = fd.read()
+ return [("Contract.pdf", content, "application/pdf")]
+
+
+class SponsorshipApprovalLogger():
+
+ def notify(self, request, sponsorship, **kwargs):
+ LogEntry.objects.log_action(
+ user_id=request.user.id,
+ content_type_id=ContentType.objects.get_for_model(Sponsorship).pk,
+ object_id=sponsorship.pk,
+ object_repr=str(sponsorship),
+ action_flag=CHANGE,
+ change_message="Sponsorship Approval"
+ )
+
+
+class SentContractLogger():
+
+ def notify(self, request, contract, **kwargs):
+ LogEntry.objects.log_action(
+ user_id=request.user.id,
+ content_type_id=ContentType.objects.get_for_model(Contract).pk,
+ object_id=contract.pk,
+ object_repr=str(contract),
+ action_flag=CHANGE,
+ change_message="Contract Sent"
+ )
+
+
+class ExecutedContractLogger():
+
+ def notify(self, request, contract, **kwargs):
+ LogEntry.objects.log_action(
+ user_id=request.user.id,
+ content_type_id=ContentType.objects.get_for_model(Contract).pk,
+ object_id=contract.pk,
+ object_repr=str(contract),
+ action_flag=CHANGE,
+ change_message="Contract Executed"
+ )
+
+
+class NullifiedContractLogger():
+
+ def notify(self, request, contract, **kwargs):
+ LogEntry.objects.log_action(
+ user_id=request.user.id,
+ content_type_id=ContentType.objects.get_for_model(Contract).pk,
+ object_id=contract.pk,
+ object_repr=str(contract),
+ action_flag=CHANGE,
+ change_message="Contract Nullified"
+ )
diff --git a/sponsors/pdf.py b/sponsors/pdf.py
new file mode 100644
index 000000000..b132854fd
--- /dev/null
+++ b/sponsors/pdf.py
@@ -0,0 +1,41 @@
+"""
+This module is a wrapper around django-easy-pdf so we can reuse code
+"""
+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 _clean_split(text, separator='\n'):
+ return [
+ t.replace('-', '').strip()
+ for t in text.split('\n')
+ if t.replace('-', '').strip()
+ ]
+
+
+def _contract_context(contract, **context):
+ context.update({
+ "contract": contract,
+ "start_date": contract.sponsorship.start_date,
+ "sponsor": contract.sponsorship.sponsor,
+ "sponsorship": contract.sponsorship,
+ "benefits": _clean_split(contract.benefits_list.raw),
+ "legal_clauses": _clean_split(contract.legal_clauses.raw),
+ })
+ return context
+
+
+def render_contract_to_pdf_response(request, contract, **context):
+ template = "sponsors/admin/preview-contract.html"
+ context = _contract_context(contract, **context)
+ from django.shortcuts import render
+ #return render(request, template, context)
+ return render_to_pdf_response(request, template, context)
+
+
+def render_contract_to_pdf_file(contract, **context):
+ template = "sponsors/admin/preview-contract.html"
+ context = _contract_context(contract, **context)
+ return render_to_pdf(template, context)
diff --git a/sponsors/tests/baker_recipes.py b/sponsors/tests/baker_recipes.py
new file mode 100644
index 000000000..49d689f42
--- /dev/null
+++ b/sponsors/tests/baker_recipes.py
@@ -0,0 +1,19 @@
+from model_bakery.recipe import Recipe
+
+from sponsors.models import Contract
+
+
+empty_contract = Recipe(
+ Contract,
+ sponsorship__sponsor__name="Sponsor",
+ benefits_list="",
+ legal_clauses="",
+)
+
+awaiting_signature_contract = Recipe(
+ Contract,
+ sponsorship__sponsor__name="Awaiting Sponsor",
+ benefits_list="- benefit 1",
+ legal_clauses="",
+ status=Contract.AWAITING_SIGNATURE,
+)
diff --git a/sponsors/tests/test_models.py b/sponsors/tests/test_models.py
index e2c69c47d..7967c2638 100644
--- a/sponsors/tests/test_models.py
+++ b/sponsors/tests/test_models.py
@@ -1,14 +1,24 @@
-from datetime import date
-from model_bakery import baker
+from datetime import date, timedelta
+from model_bakery import baker, seq
from django.conf import settings
from django.test import TestCase
from django.utils import timezone
-from ..models import Sponsor, SponsorshipBenefit, Sponsorship
+from ..models import (
+ Sponsor,
+ SponsorshipBenefit,
+ Sponsorship,
+ Contract,
+ SponsorContact,
+ SponsorBenefit,
+ LegalClause,
+ Contract
+)
from ..exceptions import (
SponsorWithExistingApplicationException,
- SponsorshipInvalidStatusException,
+ SponsorshipInvalidDateRangeException,
+ InvalidStatusException,
)
@@ -143,14 +153,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_rollback_sponsorship_to_edit(self):
sponsorship = Sponsorship.new(self.sponsor, self.benefits)
@@ -173,7 +196,55 @@ def test_rollback_sponsorship_to_edit(self):
sponsorship.status = Sponsorship.FINALIZED
sponsorship.save()
sponsorship.refresh_from_db()
- with self.assertRaises(SponsorshipInvalidStatusException):
+ with self.assertRaises(InvalidStatusException):
+ sponsorship.rollback_to_editing()
+
+ def test_rollback_approved_sponsorship_with_contract_should_delete_it(self):
+ sponsorship = Sponsorship.new(self.sponsor, self.benefits)
+ sponsorship.status = Sponsorship.APPROVED
+ sponsorship.save()
+ baker.make_recipe('sponsors.tests.empty_contract', sponsorship=sponsorship)
+
+ sponsorship.rollback_to_editing()
+ sponsorship.save()
+ sponsorship.refresh_from_db()
+
+ self.assertEqual(sponsorship.status, Sponsorship.APPLIED)
+ self.assertEqual(0, Contract.objects.count())
+
+ def test_can_not_rollback_sponsorship_to_edit_if_contract_was_sent(self):
+ sponsorship = Sponsorship.new(self.sponsor, self.benefits)
+ sponsorship.status = Sponsorship.APPROVED
+ sponsorship.save()
+ baker.make_recipe('sponsors.tests.awaiting_signature_contract', sponsorship=sponsorship)
+
+ with self.assertRaises(InvalidStatusException):
+ sponsorship.rollback_to_editing()
+
+ self.assertEqual(1, Contract.objects.count())
+
+ 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(InvalidStatusException):
sponsorship.rollback_to_editing()
def test_raise_exception_when_trying_to_create_sponsorship_for_same_sponsor(self):
@@ -268,3 +339,208 @@ 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 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 ContractModelTests(TestCase):
+ def setUp(self):
+ self.sponsorship = baker.make(Sponsorship, _fill_optional="sponsor")
+ 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):
+ contract = baker.make_recipe("sponsors.tests.empty_contract")
+ self.assertEqual(contract.status, Contract.DRAFT)
+ self.assertEqual(contract.revision, 0)
+
+ num_updates = 5
+ for i in range(num_updates):
+ contract.save()
+ contract.refresh_from_db()
+
+ self.assertEqual(contract.revision, num_updates)
+
+ def test_does_not_auto_increment_draft_revision_on_save_if_other_states(self):
+ contract = baker.make_recipe("sponsors.tests.empty_contract", revision=10)
+
+ choices = Contract.STATUS_CHOICES
+ other_status = [c[0] for c in choices if c[0] != Contract.DRAFT]
+ for status in other_status:
+ contract.status = status
+ contract.save()
+ contract.refresh_from_db()
+ self.assertEqual(contract.status, status)
+ self.assertEqual(contract.revision, 10)
+ contract.save() # perform extra save
+ contract.refresh_from_db()
+ self.assertEqual(contract.revision, 10)
+
+ def test_create_new_contract_from_sponsorship_sets_sponsor_info_and_contact(
+ self,
+ ):
+ contract = Contract.new(self.sponsorship)
+ contract.refresh_from_db()
+
+ sponsor = self.sponsorship.sponsor
+ expected_info = f"{sponsor.name} with address {sponsor.full_address} and contact {sponsor.primary_phone}"
+
+ self.assertEqual(contract.sponsorship, self.sponsorship)
+ self.assertEqual(contract.sponsor_info, expected_info)
+ self.assertEqual(contract.sponsor_contact, "")
+
+ def test_create_new_contract_from_sponsorship_sets_sponsor_contact_and_primary(
+ self,
+ ):
+ sponsor = self.sponsorship.sponsor
+ contact = baker.make(
+ SponsorContact, sponsor=self.sponsorship.sponsor, primary=True
+ )
+
+ contract = Contract.new(self.sponsorship)
+ expected_contact = f"{contact.name} - {contact.phone} | {contact.email}"
+
+ self.assertEqual(contract.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)
+
+ contract = Contract.new(self.sponsorship)
+
+ self.assertEqual(contract.legal_clauses.raw, "")
+ self.assertEqual(contract.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(contract.benefits_list.raw, expected_benefits_list)
+ self.assertEqual(contract.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.first().legal_clauses.add(
+ clause
+ ) # first benefit with 2 legal clauses
+
+ contract = Contract.new(self.sponsorship)
+
+ c1, c2, c3 = legal_clauses
+ expected_legal_clauses = f"""[^1]: {c1.clause}
+[^2]: {c2.clause}
+[^3]: {c3.clause}"""
+ self.assertEqual(contract.legal_clauses.raw, expected_legal_clauses)
+ self.assertEqual(contract.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(contract.benefits_list.raw, expected_benefits_list)
+ self.assertEqual(contract.benefits_list.markup_type, "markdown")
+
+ def test_control_contract_next_status(self):
+ SOW = Contract
+ 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():
+ contract = baker.prepare_recipe(
+ "sponsors.tests.empty_contract",
+ sponsorship__sponsor__name="foo",
+ status=status,
+ )
+ self.assertEqual(contract.next_status, exepcted)
+
+ def test_set_final_document_version(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", sponsorship__sponsor__name="foo"
+ )
+ content = b"pdf binary content"
+ self.assertFalse(contract.document.name)
+
+ contract.set_final_version(content)
+ contract.refresh_from_db()
+
+ self.assertTrue(contract.document.name)
+ self.assertEqual(contract.status, Contract.AWAITING_SIGNATURE)
+
+ def test_raise_invalid_status_exception_if_not_draft(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE
+ )
+
+ with self.assertRaises(InvalidStatusException):
+ contract.set_final_version(b"content")
+
+ def test_execute_contract(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE
+ )
+
+ contract.execute()
+ contract.refresh_from_db()
+
+ self.assertEqual(contract.status, Contract.EXECUTED)
+ self.assertEqual(contract.sponsorship.status, Sponsorship.FINALIZED)
+
+ def test_raise_invalid_status_when_trying_to_execute_contract_if_not_awaiting_signature(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", status=Contract.DRAFT
+ )
+
+ with self.assertRaises(InvalidStatusException):
+ contract.execute()
+
+ def test_nullify_contract(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE
+ )
+
+ contract.nullify()
+ contract.refresh_from_db()
+
+ self.assertEqual(contract.status, Contract.NULLIFIED)
+
+ def test_raise_invalid_status_when_trying_to_nullify_contract_if_not_awaiting_signature(self):
+ contract = baker.make_recipe(
+ "sponsors.tests.empty_contract", status=Contract.DRAFT
+ )
+
+ with self.assertRaises(InvalidStatusException):
+ contract.nullify()
diff --git a/sponsors/tests/test_notifications.py b/sponsors/tests/test_notifications.py
index 884f81d4a..da4d7ed01 100644
--- a/sponsors/tests/test_notifications.py
+++ b/sponsors/tests/test_notifications.py
@@ -4,10 +4,11 @@
from django.conf import settings
from django.core import mail
from django.template.loader import render_to_string
-from django.test import TestCase
+from django.test import TestCase, RequestFactory
+from django.contrib.admin.models import LogEntry, CHANGE
from sponsors import notifications
-from sponsors.models import Sponsorship
+from sponsors.models import Sponsorship, Contract
class AppliedSponsorshipNotificationToPSFTests(TestCase):
@@ -134,23 +135,23 @@ def test_send_email_using_correct_templates(self):
self.assertEqual([self.user.email], email.to)
-class StatementOfWorkNotificationToPSFTests(TestCase):
+class ContractNotificationToPSFTests(TestCase):
def setUp(self):
- self.notification = notifications.StatementOfWorkNotificationToPSF()
- self.sponsorship = baker.make(
- Sponsorship,
- status=Sponsorship.APPROVED,
- _fill_optional=["approved_on", "sponsor"],
+ self.notification = notifications.ContractNotificationToPSF()
+ self.contract = baker.make_recipe(
+ "sponsors.tests.awaiting_signature_contract",
+ _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"
+ self.subject_template = "sponsors/email/psf_contract_subject.txt"
+ self.content_template = "sponsors/email/psf_contract.txt"
def test_send_email_using_correct_templates(self):
- context = {"sponsorship": self.sponsorship}
+ context = {"contract": self.contract}
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(contract=self.contract)
self.assertTrue(mail.outbox)
email = mail.outbox[0]
@@ -159,26 +160,48 @@ 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_contract_pdf(self):
+ self.assertTrue(self.contract.document.name)
+ with self.contract.document.open("rb") as fd:
+ expected_content = fd.read()
+ self.assertTrue(expected_content)
-class StatementOfWorkNotificationToSponsorsTests(TestCase):
+ self.contract.refresh_from_db()
+ self.notification.notify(contract=self.contract)
+ email = mail.outbox[0]
+
+ self.assertEqual(len(email.attachments), 1)
+ name, content, mime = email.attachments[0]
+ self.assertEqual(name, "Contract.pdf")
+ self.assertEqual(mime, "application/pdf")
+ self.assertEqual(content, expected_content)
+
+
+class ContractNotificationToSponsorsTests(TestCase):
def setUp(self):
- self.notification = notifications.StatementOfWorkNotificationToSponsors()
+ self.notification = notifications.ContractNotificationToSponsors()
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.subject_template = "sponsors/email/sponsor_statement_of_work_subject.txt"
- self.content_template = "sponsors/email/sponsor_statement_of_work.txt"
+ self.contract = baker.make_recipe(
+ "sponsors.tests.awaiting_signature_contract",
+ sponsorship=sponsorship,
+ _fill_optional=["document"],
+ _create_files=True,
+ )
+ self.subject_template = "sponsors/email/sponsor_contract_subject.txt"
+ self.content_template = "sponsors/email/sponsor_contract.txt"
def test_send_email_using_correct_templates(self):
- context = {"sponsorship": self.sponsorship}
+ context = {"contract": self.contract}
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(contract=self.contract)
self.assertTrue(mail.outbox)
email = mail.outbox[0]
@@ -186,3 +209,123 @@ 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_contract_pdf(self):
+ self.assertTrue(self.contract.document.name)
+ with self.contract.document.open("rb") as fd:
+ expected_content = fd.read()
+ self.assertTrue(expected_content)
+
+ self.contract.refresh_from_db()
+ self.notification.notify(contract=self.contract)
+ email = mail.outbox[0]
+
+ self.assertEqual(len(email.attachments), 1)
+ name, content, mime = email.attachments[0]
+ self.assertEqual(name, "Contract.pdf")
+ self.assertEqual(mime, "application/pdf")
+ self.assertEqual(content, expected_content)
+
+
+class SponsorshipApprovalLoggerTests(TestCase):
+
+ def setUp(self):
+ self.request = RequestFactory().get('/')
+ self.request.user = baker.make(settings.AUTH_USER_MODEL)
+ self.sponsorship = baker.make(Sponsorship, status=Sponsorship.APPROVED, sponsor__name='foo', _fill_optional=True)
+ self.kwargs = {
+ "request": self.request,
+ "sponsorship": self.sponsorship,
+ }
+ self.logger = notifications.SponsorshipApprovalLogger()
+
+ def test_create_log_entry_for_change_operation_with_approval_message(self):
+ self.assertEqual(LogEntry.objects.count(), 0)
+
+ self.logger.notify(**self.kwargs)
+
+ self.assertEqual(LogEntry.objects.count(), 1)
+ log_entry = LogEntry.objects.get()
+ self.assertEqual(log_entry.user, self.request.user)
+ self.assertEqual(log_entry.object_id, str(self.sponsorship.pk))
+ self.assertEqual(str(self.sponsorship), log_entry.object_repr)
+ self.assertEqual(log_entry.action_flag, CHANGE)
+ self.assertEqual(log_entry.change_message, "Sponsorship Approval")
+
+
+class SentContractLoggerTests(TestCase):
+
+ def setUp(self):
+ self.request = RequestFactory().get('/')
+ self.request.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe('sponsors.tests.empty_contract')
+ self.kwargs = {
+ "request": self.request,
+ "contract": self.contract,
+ }
+ self.logger = notifications.SentContractLogger()
+
+ def test_create_log_entry_for_change_operation_with_approval_message(self):
+ self.assertEqual(LogEntry.objects.count(), 0)
+
+ self.logger.notify(**self.kwargs)
+
+ self.assertEqual(LogEntry.objects.count(), 1)
+ log_entry = LogEntry.objects.get()
+ self.assertEqual(log_entry.user, self.request.user)
+ self.assertEqual(log_entry.object_id, str(self.contract.pk))
+ self.assertEqual(str(self.contract), log_entry.object_repr)
+ self.assertEqual(log_entry.action_flag, CHANGE)
+ self.assertEqual(log_entry.change_message, "Contract Sent")
+
+
+class ExecutedContractLoggerTests(TestCase):
+
+ def setUp(self):
+ self.request = RequestFactory().get('/')
+ self.request.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe('sponsors.tests.empty_contract')
+ self.kwargs = {
+ "request": self.request,
+ "contract": self.contract,
+ }
+ self.logger = notifications.ExecutedContractLogger()
+
+ def test_create_log_entry_for_change_operation_with_approval_message(self):
+ self.assertEqual(LogEntry.objects.count(), 0)
+
+ self.logger.notify(**self.kwargs)
+
+ self.assertEqual(LogEntry.objects.count(), 1)
+ log_entry = LogEntry.objects.get()
+ self.assertEqual(log_entry.user, self.request.user)
+ self.assertEqual(log_entry.object_id, str(self.contract.pk))
+ self.assertEqual(str(self.contract), log_entry.object_repr)
+ self.assertEqual(log_entry.action_flag, CHANGE)
+ self.assertEqual(log_entry.change_message, "Contract Executed")
+
+
+class NullifiedContractLoggerTests(TestCase):
+
+ def setUp(self):
+ self.request = RequestFactory().get('/')
+ self.request.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe('sponsors.tests.empty_contract')
+ self.kwargs = {
+ "request": self.request,
+ "contract": self.contract,
+ }
+ self.logger = notifications.NullifiedContractLogger()
+
+ def test_create_log_entry_for_change_operation_with_approval_message(self):
+ self.assertEqual(LogEntry.objects.count(), 0)
+
+ self.logger.notify(**self.kwargs)
+
+ self.assertEqual(LogEntry.objects.count(), 1)
+ log_entry = LogEntry.objects.get()
+ self.assertEqual(log_entry.user, self.request.user)
+ self.assertEqual(log_entry.object_id, str(self.contract.pk))
+ self.assertEqual(str(self.contract), log_entry.object_repr)
+ self.assertEqual(log_entry.action_flag, CHANGE)
+ self.assertEqual(log_entry.change_message, "Contract Nullified")
diff --git a/sponsors/tests/test_pdf.py b/sponsors/tests/test_pdf.py
new file mode 100644
index 000000000..dc5c6c39d
--- /dev/null
+++ b/sponsors/tests/test_pdf.py
@@ -0,0 +1,46 @@
+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_contract_to_pdf_file, render_contract_to_pdf_response
+
+
+class TestRenderContractToPDF(TestCase):
+ def setUp(self):
+ self.contract = baker.make_recipe("sponsors.tests.empty_contract")
+ text = f"{self.contract.benefits_list.raw}\n\n**Legal Clauses**\n{self.contract.legal_clauses.raw}"
+ html = render_md(text)
+ self.context = {
+ "contract": self.contract,
+ "start_date": self.contract.sponsorship.start_date,
+ "sponsor": self.contract.sponsorship.sponsor,
+ "sponsorship": self.contract.sponsorship,
+ "benefits": [],
+ "legal_clauses": [],
+ }
+ self.template = "sponsors/admin/preview-contract.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_contract_to_pdf_file(self.contract)
+
+ 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_contract_to_pdf_response(request, self.contract)
+
+ self.assertEqual(content, response)
+ mock_render.assert_called_once_with(request, self.template, self.context)
diff --git a/sponsors/tests/test_use_cases.py b/sponsors/tests/test_use_cases.py
index 3d637d422..cba24d5bd 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
@@ -7,7 +8,7 @@
from sponsors import use_cases
from sponsors.notifications import *
-from sponsors.models import Sponsorship
+from sponsors.models import Sponsorship, Contract
class CreateSponsorshipApplicationUseCaseTests(TestCase):
@@ -79,3 +80,117 @@ 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")
+
+ 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_contract(self):
+ 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.contract.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.data)
+
+ for n in self.notifications:
+ n.notify.assert_called_once_with(
+ request=None,
+ sponsorship=self.sponsorship,
+ contract=self.sponsorship.contract,
+ )
+
+ def test_build_use_case_without_notificationss(self):
+ uc = use_cases.ApproveSponsorshipApplicationUseCase.build()
+ self.assertEqual(len(uc.notifications), 1)
+ self.assertIsInstance(uc.notifications[0], SponsorshipApprovalLogger)
+
+
+class SendContractUseCaseTests(TestCase):
+ def setUp(self):
+ self.notifications = [Mock(), Mock()]
+ self.use_case = use_cases.SendContractUseCase(self.notifications)
+ self.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe("sponsors.tests.empty_contract")
+
+ def test_send_and_update_contract_with_document(self):
+ self.use_case.execute(self.contract)
+ self.contract.refresh_from_db()
+
+ self.assertTrue(self.contract.document.name)
+ self.assertTrue(self.contract.awaiting_signature)
+ for n in self.notifications:
+ n.notify.assert_called_once_with(
+ request=None,
+ contract=self.contract,
+ )
+
+ def test_build_use_case_without_notificationss(self):
+ uc = use_cases.SendContractUseCase.build()
+ self.assertEqual(len(uc.notifications), 2)
+ self.assertIsInstance(uc.notifications[0], ContractNotificationToPSF)
+ self.assertIsInstance(
+ uc.notifications[1], SentContractLogger
+ )
+
+
+class ExecuteContractUseCaseTests(TestCase):
+ def setUp(self):
+ self.notifications = [Mock()]
+ self.use_case = use_cases.ExecuteContractUseCase(self.notifications)
+ self.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe("sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE)
+
+ def test_execute_and_update_database_object(self):
+ self.use_case.execute(self.contract)
+ self.contract.refresh_from_db()
+ self.assertEqual(self.contract.status, Contract.EXECUTED)
+
+ def test_build_use_case_without_notificationss(self):
+ uc = use_cases.ExecuteContractUseCase.build()
+ self.assertEqual(len(uc.notifications), 1)
+ self.assertIsInstance(
+ uc.notifications[0], ExecutedContractLogger
+ )
+
+
+class NullifyContractUseCaseTests(TestCase):
+ def setUp(self):
+ self.notifications = [Mock()]
+ self.use_case = use_cases.NullifyContractUseCase(self.notifications)
+ self.user = baker.make(settings.AUTH_USER_MODEL)
+ self.contract = baker.make_recipe("sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE)
+
+ def test_nullify_and_update_database_object(self):
+ self.use_case.execute(self.contract)
+ self.contract.refresh_from_db()
+ self.assertEqual(self.contract.status, Contract.NULLIFIED)
+
+ def test_build_use_case_without_notificationss(self):
+ uc = use_cases.NullifyContractUseCase.build()
+ self.assertEqual(len(uc.notifications), 1)
+ self.assertIsInstance(
+ uc.notifications[0], NullifiedContractLogger
+ )
diff --git a/sponsors/tests/test_views.py b/sponsors/tests/test_views.py
index 38a738a15..4e42c106e 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 unittest.mock import patch, PropertyMock
from django.conf import settings
from django.contrib import messages
@@ -14,14 +15,16 @@
from .utils import get_static_image_file_as_upload
from ..models import (
Sponsor,
- SponsorshipProgram,
SponsorshipBenefit,
- Sponsor,
SponsorContact,
Sponsorship,
+ Contract,
+)
+from sponsors.forms import (
+ SponsorshiptBenefitsForm,
+ SponsorshipApplicationForm,
+ SponsorshipReviewAdminForm,
)
-
-from sponsors.forms import SponsorshiptBenefitsForm, SponsorshipApplicationForm
def assertMessage(msg, expected_content, expected_level):
@@ -536,21 +539,36 @@ 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(
@@ -561,19 +579,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)
@@ -602,8 +632,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(
@@ -615,6 +644,104 @@ def test_message_user_if_approving_invalid_sponsorship(self):
assertMessage(msg, "Can't approve a Finalized sponsorship.", messages.ERROR)
+class SendContractView(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.contract = baker.make_recipe("sponsors.tests.empty_contract")
+ self.url = reverse(
+ "admin:sponsors_contract_send", args=[self.contract.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_contract.html")
+ self.assertEqual(context["contract"], self.contract)
+
+ @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_contract_change", args=[self.contract.pk]
+ )
+ self.contract.refresh_from_db()
+
+ self.assertRedirects(response, expected_url, fetch_redirect_response=True)
+ self.assertTrue(self.contract.document.name)
+ self.assertEqual(1, len(mail.outbox))
+ msg = list(get_messages(response.wsgi_request))[0]
+ assertMessage(msg, "Contract 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.contract.status = Contract.AWAITING_SIGNATURE
+ self.contract.save()
+ expected_url = reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+
+ response = self.client.post(self.url, data=self.data)
+ self.contract.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,
+ "Contract 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.contract.refresh_from_db()
+ self.assertTemplateUsed(response, "sponsors/admin/send_contract.html")
+ self.assertFalse(self.contract.document.name)
+
+ self.data["confirm"] = "invalid"
+ response = self.client.post(self.url, data=self.data)
+ self.assertTemplateUsed(response, "sponsors/admin/send_contract.html")
+ self.assertFalse(self.contract.document.name)
+ self.assertEqual(0, len(mail.outbox))
+
+ def test_404_if_contract_does_not_exist(self):
+ self.contract.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)
+
+
class SponsorshipDetailViewTests(TestCase):
def setUp(self):
@@ -652,3 +779,185 @@ def test_404_if_sponsorship_does_not_belong_to_user(self):
self.client.force_login(baker.make(settings.AUTH_USER_MODEL)) # log in with a new user
response = self.client.get(self.url)
self.assertEqual(response.status_code, 404)
+
+
+class ExecuteContractView(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.contract = baker.make_recipe("sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE)
+ self.url = reverse(
+ "admin:sponsors_contract_execute", args=[self.contract.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/execute_contract.html")
+ self.assertEqual(context["contract"], self.contract)
+
+ def test_execute_sponsorship_on_post(self):
+ response = self.client.post(self.url, data=self.data)
+ expected_url = reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+ self.contract.refresh_from_db()
+ msg = list(get_messages(response.wsgi_request))[0]
+
+ self.assertRedirects(response, expected_url, fetch_redirect_response=True)
+ self.assertEqual(self.contract.status, Contract.EXECUTED)
+ assertMessage(msg, "Contract was executed!", messages.SUCCESS)
+
+ def test_display_error_message_to_user_if_invalid_status(self):
+ self.contract.status = Contract.DRAFT
+ self.contract.save()
+ expected_url = reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+
+ response = self.client.post(self.url, data=self.data)
+ self.contract.refresh_from_db()
+ msg = list(get_messages(response.wsgi_request))[0]
+
+ self.assertRedirects(response, expected_url, fetch_redirect_response=True)
+ self.assertEqual(self.contract.status, Contract.DRAFT)
+ assertMessage(
+ msg,
+ "Contract with status Draft can't be executed.",
+ messages.ERROR,
+ )
+
+ def test_do_not_execute_contract_if_no_confirmation_in_the_post(self):
+ self.data.pop("confirm")
+ response = self.client.post(self.url, data=self.data)
+ self.contract.refresh_from_db()
+ self.assertTemplateUsed(response, "sponsors/admin/execute_contract.html")
+ self.assertEqual(self.contract.status, Contract.AWAITING_SIGNATURE)
+
+ self.data["confirm"] = "invalid"
+ response = self.client.post(self.url, data=self.data)
+ self.assertTemplateUsed(response, "sponsors/admin/execute_contract.html")
+ self.contract.refresh_from_db()
+ self.assertEqual(self.contract.status, Contract.AWAITING_SIGNATURE)
+
+ def test_404_if_contract_does_not_exist(self):
+ self.contract.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)
+
+
+class NullifyContractView(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.contract = baker.make_recipe("sponsors.tests.empty_contract", status=Contract.AWAITING_SIGNATURE)
+ self.url = reverse(
+ "admin:sponsors_contract_nullify", args=[self.contract.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/nullify_contract.html")
+ self.assertEqual(context["contract"], self.contract)
+
+ def test_nullify_sponsorship_on_post(self):
+ response = self.client.post(self.url, data=self.data)
+ expected_url = reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+ self.contract.refresh_from_db()
+ msg = list(get_messages(response.wsgi_request))[0]
+
+ self.assertRedirects(response, expected_url, fetch_redirect_response=True)
+ self.assertEqual(self.contract.status, Contract.NULLIFIED)
+ assertMessage(msg, "Contract was nullified!", messages.SUCCESS)
+
+ def test_display_error_message_to_user_if_invalid_status(self):
+ self.contract.status = Contract.DRAFT
+ self.contract.save()
+ expected_url = reverse(
+ "admin:sponsors_contract_change", args=[self.contract.pk]
+ )
+
+ response = self.client.post(self.url, data=self.data)
+ self.contract.refresh_from_db()
+ msg = list(get_messages(response.wsgi_request))[0]
+
+ self.assertRedirects(response, expected_url, fetch_redirect_response=True)
+ self.assertEqual(self.contract.status, Contract.DRAFT)
+ assertMessage(
+ msg,
+ "Contract with status Draft can't be nullified.",
+ messages.ERROR,
+ )
+
+ def test_do_not_nullify_contract_if_no_confirmation_in_the_post(self):
+ self.data.pop("confirm")
+ response = self.client.post(self.url, data=self.data)
+ self.contract.refresh_from_db()
+ self.assertTemplateUsed(response, "sponsors/admin/nullify_contract.html")
+ self.assertEqual(self.contract.status, Contract.AWAITING_SIGNATURE)
+
+ self.data["confirm"] = "invalid"
+ response = self.client.post(self.url, data=self.data)
+ self.assertTemplateUsed(response, "sponsors/admin/nullify_contract.html")
+ self.contract.refresh_from_db()
+ self.assertEqual(self.contract.status, Contract.AWAITING_SIGNATURE)
+
+ def test_404_if_contract_does_not_exist(self):
+ self.contract.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/use_cases.py b/sponsors/use_cases.py
index 48bb6f547..7c4408cc7 100644
--- a/sponsors/use_cases.py
+++ b/sponsors/use_cases.py
@@ -1,45 +1,115 @@
-from sponsors.models import Sponsorship
from sponsors import notifications
+from sponsors.models import Sponsorship, Contract
+from sponsors.pdf import render_contract_to_pdf_file
-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()
+ self.notify(request=request, sponsorship=sponsorship)
+ return sponsorship
- for notification in self.notifications:
- notification.notify(request=request, sponsorship=sponsorship)
+
+class ApproveSponsorshipApplicationUseCase(BaseUseCaseWithNotifications):
+ notifications = [
+ notifications.SponsorshipApprovalLogger(),
+ ]
+
+ 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()
+ contract = Contract.new(sponsorship)
+
+ self.notify(
+ request=kwargs.get("request"),
+ sponsorship=sponsorship,
+ contract=contract,
+ )
return sponsorship
- @classmethod
- def build(cls):
- uc_notifications = [
- notifications.RejectedSponsorshipNotificationToPSF(),
- notifications.RejectedSponsorshipNotificationToSponsors(),
- ]
- return cls(uc_notifications)
+
+class SendContractUseCase(BaseUseCaseWithNotifications):
+ notifications = [
+ notifications.ContractNotificationToPSF(),
+ # TODO: sponsor's notification will be enabled again once
+ # the generate contract file gets approved by PSF Board.
+ # After that, the line bellow can be uncommented to enable
+ # the desired behavior.
+ #notifications.ContractNotificationToSponsors(),
+ notifications.SentContractLogger(),
+ ]
+
+ def execute(self, contract, **kwargs):
+ pdf_file = render_contract_to_pdf_file(contract)
+ contract.set_final_version(pdf_file)
+ self.notify(
+ request=kwargs.get("request"),
+ contract=contract,
+ )
+
+
+class ExecuteContractUseCase(BaseUseCaseWithNotifications):
+ notifications = [
+ notifications.ExecutedContractLogger(),
+ ]
+
+ def execute(self, contract, **kwargs):
+ contract.execute()
+ self.notify(
+ request=kwargs.get("request"),
+ contract=contract,
+ )
+
+
+class NullifyContractUseCase(BaseUseCaseWithNotifications):
+ notifications = [
+ notifications.NullifiedContractLogger(),
+ ]
+
+ def execute(self, contract, **kwargs):
+ contract.nullify()
+ self.notify(
+ request=kwargs.get("request"),
+ contract=contract,
+ )
diff --git a/sponsors/views_admin.py b/sponsors/views_admin.py
new file mode 100644
index 000000000..40bd6b42c
--- /dev/null
+++ b/sponsors/views_admin.py
@@ -0,0 +1,175 @@
+from django.contrib import messages
+from django.shortcuts import get_object_or_404, render, redirect
+from django.urls import reverse
+
+from sponsors import use_cases
+from sponsors.forms import SponsorshipReviewAdminForm
+from sponsors.exceptions import InvalidStatusException
+from sponsors.pdf import render_contract_to_pdf_response
+
+
+def preview_contract_view(ModelAdmin, request, pk):
+ contract = get_object_or_404(ModelAdmin.get_queryset(request), pk=pk)
+ response = render_contract_to_pdf_response(request, contract)
+ response["X-Frame-Options"] = "SAMEORIGIN"
+ return response
+
+
+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 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/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 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, "form": form}
+ return render(request, "sponsors/admin/approve_application.html", context=context)
+
+
+def send_contract_view(ModelAdmin, request, pk):
+ contract = 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.SendContractUseCase.build()
+ try:
+ use_case.execute(contract, request=request)
+ ModelAdmin.message_user(
+ request, "Contract was sent!", messages.SUCCESS
+ )
+ except InvalidStatusException:
+ status = contract.get_status_display().title()
+ ModelAdmin.message_user(
+ request,
+ f"Contract with status {status} can't be sent.",
+ messages.ERROR,
+ )
+
+ redirect_url = reverse("admin:sponsors_contract_change", args=[contract.pk])
+ return redirect(redirect_url)
+
+ context = {"contract": contract}
+ return render(request, "sponsors/admin/send_contract.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,
+ )
+
+
+def execute_contract_view(ModelAdmin, request, pk):
+ contract = 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.ExecuteContractUseCase.build()
+ try:
+ use_case.execute(contract, request=request)
+ ModelAdmin.message_user(
+ request, "Contract was executed!", messages.SUCCESS
+ )
+ except InvalidStatusException:
+ status = contract.get_status_display().title()
+ ModelAdmin.message_user(
+ request,
+ f"Contract with status {status} can't be executed.",
+ messages.ERROR,
+ )
+
+ redirect_url = reverse("admin:sponsors_contract_change", args=[contract.pk])
+ return redirect(redirect_url)
+
+ context = {"contract": contract}
+ return render(request, "sponsors/admin/execute_contract.html", context=context)
+
+
+def nullify_contract_view(ModelAdmin, request, pk):
+ contract = 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.NullifyContractUseCase.build()
+ try:
+ use_case.execute(contract, request=request)
+ ModelAdmin.message_user(
+ request, "Contract was nullified!", messages.SUCCESS
+ )
+ except InvalidStatusException:
+ status = contract.get_status_display().title()
+ ModelAdmin.message_user(
+ request,
+ f"Contract with status {status} can't be nullified.",
+ messages.ERROR,
+ )
+
+ redirect_url = reverse("admin:sponsors_contract_change", args=[contract.pk])
+ return redirect(redirect_url)
+
+ context = {"contract": contract}
+ return render(request, "sponsors/admin/nullify_contract.html", context=context)
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 %}
diff --git a/templates/sponsors/admin/contract_change_form.html b/templates/sponsors/admin/contract_change_form.html
new file mode 100644
index 000000000..c655d5820
--- /dev/null
+++ b/templates/sponsors/admin/contract_change_form.html
@@ -0,0 +1,26 @@
+{% extends "admin/change_form.html" %}
+{% load i18n admin_urls %}
+
+{% block object-tools-items %}
+ {% with original as contract %}
+
+ {% if contract.is_draft %}
+
+ Review
+
+
+ Send document
+
+ {% elif contract.status == contract.AWAITING_SIGNATURE %}
+
+ Execute
+
+
+ Nullify
+
+ {% endif %}
+
+ {% endwith %}
+
+ {{ block.super }}
+{% endblock %}
diff --git a/templates/sponsors/admin/execute_contract.html b/templates/sponsors/admin/execute_contract.html
new file mode 100644
index 000000000..7c5734f05
--- /dev/null
+++ b/templates/sponsors/admin/execute_contract.html
@@ -0,0 +1,34 @@
+{% extends 'admin/base_site.html' %}
+{% load i18n admin_static sponsors %}
+
+{% block extrastyle %}{{ block.super }} {% endblock %}
+
+{% block title %}Execute {{ contract }} | python.org{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+Execute Contract
+
+
+
Important: By executing this contract, the sponsor logo will be placed accordingly to the benefits linked to the sponsorship during the sponsorship period (from {{ contract.sponsorship.start_date|date }} until {{ contract.sponsorship.end_date|date }})
+
+
+
{% endblock %}
diff --git a/templates/sponsors/admin/nullify_contract.html b/templates/sponsors/admin/nullify_contract.html
new file mode 100644
index 000000000..ac98510dc
--- /dev/null
+++ b/templates/sponsors/admin/nullify_contract.html
@@ -0,0 +1,34 @@
+{% extends 'admin/base_site.html' %}
+{% load i18n admin_static sponsors %}
+
+{% block extrastyle %}{{ block.super }} {% endblock %}
+
+{% block title %}Nullify {{ contract }} | python.org{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+Nullify Contract
+
+
+
Please confirm if you want to proceed with this operation.
+
+
+
{% endblock %}
diff --git a/templates/sponsors/admin/preview-contract.html b/templates/sponsors/admin/preview-contract.html
new file mode 100644
index 000000000..daa867058
--- /dev/null
+++ b/templates/sponsors/admin/preview-contract.html
@@ -0,0 +1,283 @@
+{% extends "easy_pdf/base.html" %}
+{% load humanize %}
+
+{% block extra_style %}
+
+{% endblock %}
+
+{% block content %}
+SPONSORSHIP AGREEMENT
+
+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” .
+
+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 {{ contract.sponsor_info}}; and
+
+WHEREAS , Sponsor wishes to support the Programs by making a contribution to the
+PSF.
+
+AGREEMENT
+
+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:
+
+
+ Recitals Incorporated . Each of the above Recitals is incorporated into and is made a part of this Agreement.
+
+ 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.
+
+ 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.
+
+ 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 ”).
+
+ 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.
+
+
+ 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.
+
+ 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.
+
+
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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).
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ Captions . The captions and headings are included herein for convenience and do not constitute a part of this Agreement.
+
+ 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.
+
+ 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.
+
+ 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.
+
+
+[Signature Page Follows]
+
+
+SPONSORSHIP AGREEMENT
+
+
+IN WITNESS WHEREOF , the Parties hereto have duly executed this __________________ Agreement as of the Effective Date.
+
+
+
PSF:
+
PYTHON SOFTWARE FOUNDATION ,
+
a Delaware nonprofit corporation
+
+
+
+
+
By:
+ ___________________________________
+
+
Ewa Jodlowska
+
Executive Director
+
+
+
+
+
+
SPONSOR :
+
______________________________________,
+
a {{ sponsor.state }} entity.
+
+
By: ___________________________________
+
+
+
+
+SPONSORSHIP AGREEMENT
+
+EXHIBIT A
+
+
+ Sponsorship . During the Term of this Agreement, in return for the Sponsorship Payment, the PSF agrees to identify and acknowledge Sponsor as a {{ start_date|date:'Y' }} {{ sponsorship.level_name }} Sponsor of the Programs and of the PSF, in accordance with the United States Internal Revenue Service guidance applicable to qualified sponsorship payments.
+
+ Acknowledgments of appreciation for the Sponsorship Payment may identify and briefly describe Sponsor and its products or product lines in neutral terms and may include Sponsor’s name, logo, well-established slogan, locations, telephone numbers, or website addresses, but such acknowledgments shall not include (a) comparative or qualitative descriptions of Sponsor’s products, services, or facilities; (b) price information or other indications of savings or value associated with Sponsor’s products or services; (c) a call to action; (d) an endorsement; or (e) an inducement to buy, sell, or use Sponsor’s products or services. Any such acknowledgments will be created, or subject to prior review and approval, by the PSF.
+
+ The PSF’s acknowledgment may include the following:
+
+
+ Display of Logo. The PSF will display Sponsor’s logo and other agreed-upon identifying information on www.python.org, and on any marketing and promotional media made by the PSF in connection with the Programs, solely for the purpose of acknowledging Sponsor as a sponsor of the Programs in a manner (placement, form, content, etc.) reasonably determined by the PSF in its sole discretion. Sponsor agrees to provide all the necessary content and materials for use in connection with such display.
+
+ [Other use or Acknowledgement.]
+
+
+ Sponsorship Payment . The amount of Sponsorship Payment shall be {{ sponsorship.verbose_sponsorship_fee|title }} USD ($ {{ sponsorship.sponsorship_fee|intcomma }}). The Sponsorship Payment is due within thirty (30) days of the Effective Date. To the extent that any portion of a payment under this section would not (if made as a Separate payment) be deemed a qualified sponsorship payment under IRC § 513(i), such portion shall be deemed and treated as separate from the qualified sponsorship payment.
+
+ Receipt of Payment . Sponsor must submit full payment in order to secure Sponsor Benefits.
+
+ Refunds . The PSF does not offer refunds for sponsorships. The PSF may cancel the event(s) or any part thereof. In that event, the PSF shall determine and refund to Sponsor the proportionate share of the balance of the aggregate Sponsorship fees applicable to event(s) received which remain after deducting all expenses incurred by the PSF.
+
+ Sponsor Benefits . Sponsor Benefits per the Agreement are:
+
+
+ Acknowledgement as described under “Sponsorship” above.
+ {% for benefit in benefits %}
+ {{ benefit }}
+ {% endfor %}
+
+
+
+ {% if legal_clauses %}
+ Legal Clauses :
+
+ {% for clause in legal_clauses %}
+ {{ clause }}
+ {% endfor %}
+
+ {% endif %}
+
+
+{% endblock %}
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/send_contract.html b/templates/sponsors/admin/send_contract.html
new file mode 100644
index 000000000..55d9ed52c
--- /dev/null
+++ b/templates/sponsors/admin/send_contract.html
@@ -0,0 +1,40 @@
+{% extends 'admin/base_site.html' %}
+{% load i18n admin_static sponsors %}
+
+{% block extrastyle %}{{ block.super }} {% endblock %}
+
+{% block title %}Send {{ contract }} | python.org{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+Send Contract
+
+
+
+
+
+
+
+
Help: If you can't see the document's content above this text, please turn off your webbrowser's extensions which blocks HTML iframes (ad-blocker, ghostery, etc) and reload this page.
+
Or you can also open the PDF here .
+
+
+
{% endblock %}
diff --git a/templates/sponsors/admin/sponsorship_change_form.html b/templates/sponsors/admin/sponsorship_change_form.html
index c366bd081..939119039 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 Contract
+
+ {% endif %}
+
{% if sp.status != sp.FINALIZED and sp.status != sp.APPLIED %}
Rollback to Edit
diff --git a/templates/sponsors/email/psf_statement_of_work.txt b/templates/sponsors/email/psf_contract.txt
similarity index 100%
rename from templates/sponsors/email/psf_statement_of_work.txt
rename to templates/sponsors/email/psf_contract.txt
diff --git a/templates/sponsors/email/psf_statement_of_work_subject.txt b/templates/sponsors/email/psf_contract_subject.txt
similarity index 100%
rename from templates/sponsors/email/psf_statement_of_work_subject.txt
rename to templates/sponsors/email/psf_contract_subject.txt
diff --git a/templates/sponsors/email/sponsor_statement_of_work.txt b/templates/sponsors/email/sponsor_contract.txt
similarity index 100%
rename from templates/sponsors/email/sponsor_statement_of_work.txt
rename to templates/sponsors/email/sponsor_contract.txt
diff --git a/templates/sponsors/email/sponsor_statement_of_work_subject.txt b/templates/sponsors/email/sponsor_contract_subject.txt
similarity index 100%
rename from templates/sponsors/email/sponsor_statement_of_work_subject.txt
rename to templates/sponsors/email/sponsor_contract_subject.txt
diff --git a/templates/sponsors/email/sponsor_new_application.txt b/templates/sponsors/email/sponsor_new_application.txt
index bf8bc7732..a2cb67f71 100644
--- a/templates/sponsors/email/sponsor_new_application.txt
+++ b/templates/sponsors/email/sponsor_new_application.txt
@@ -3,7 +3,7 @@ Dear {{ sponsorship.sponsor.name }}
{% if sponsorship.for_modified_package %}
Thank you for submitting your sponsorship application. We will contact you to discuss finalizing the customized sponsorship package within 2 business days.
{% else %}
-Thank you for submitting your sponsorship application. We will be sending a Statement of Work(SOW) reflecting the sponsorship contract details.
+Thank you for submitting your sponsorship application. We will be sending a Contract reflecting the sponsorship contract details.
{% endif %}
You can review your full application and a list of benefits you will receive below.
diff --git a/templates/sponsors/partials/full_sponsorship.txt b/templates/sponsors/partials/full_sponsorship.txt
index e128a4891..fd5a16e83 100644
--- a/templates/sponsors/partials/full_sponsorship.txt
+++ b/templates/sponsors/partials/full_sponsorship.txt
@@ -15,7 +15,7 @@
{% if sponsorship.level_name %} * Level {{ sponsorship.level_name }}{% else %} * Level Custom Package{% endif %}
{% if not display_fee %} * Sponsorship Fee To be determined{% else %} * Sponsorship Fee ${{ sponsorship.sponsorship_fee|intcomma }} USD{% endif %}
{% if sponsorship.package_benefits %}* Package Benefits
-{% for benefit in sponsorship.package_benefits %} - {{ benefit.program.name }} - {{ benefit.name }}
+{% for benefit in sponsorship.package_benefits %} - {% if benefit.program %}{{ benefit.program.name }}{% else %}{{ benefit.program_name }}{% endif %} - {{ benefit.name }}
{% endfor %}{% endif %} {% if sponsorship.added_benefits %}* Added Benefits
{% for benefit in sponsorship.added_benefits %} - {{ benefit.program.name }} - {{ benefit.name }}
{% endfor %}{% endif %}