From a2e0176942bd83236d82eff7f299587dcfb7a284 Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Mon, 1 Jul 2019 12:41:50 +0000 Subject: [PATCH 1/6] Add ports and importing space data info into README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index e7347f2..f479bf7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ in the console. Once it's running, you should now be able to access your develop [http://localhost:8000](http://localhost:8000). Any changes you make to your development copy should be automatically reloaded. +If you need to run it on another port, edit the `docker-compose.yml` and change the first port, keep the second the same as this is the port docker is using internally, example: + + - "8080:8000" + If you `Ctrl+c` the process, it'll stop the containers. If you run `docker-compose down`, it'll destroy the containers *along with your development database*. @@ -58,6 +62,10 @@ u.is_staff = True u.save() ``` +## Import some spaces + +The repository has a static file: static/data.json containing details on some spaces (is unlikely to be uptodate!), to import it as a starting set, visit [http://localhost:8000/import_spaces](http://localhost:8000/import_spaces) after you have made your user an admin. + ## Managing Dependencies This app uses [Pipenv](https://pipenv.readthedocs.io) to manage dependencies. If you want From 19eb2b51812ec29092393e95d7e73f62714c7304 Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Mon, 1 Jul 2019 12:42:38 +0000 Subject: [PATCH 2/6] Send space join approval request when user changes/sets a space from a profile edit --- main/forms.py | 3 +++ main/models/user.py | 1 + main/templates/main/profile.html | 6 +++++- main/views.py | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/main/forms.py b/main/forms.py index ccd5647..25493f9 100644 --- a/main/forms.py +++ b/main/forms.py @@ -104,6 +104,9 @@ def send_confirmation_email(self): # TODO: oh dear - how should we handle this gracefully?!? print("Error sending email" + str(e)) + user.space_status = 'Emailed' + user.save() + class SupporterMembershipForm(ModelForm): class Meta: diff --git a/main/models/user.py b/main/models/user.py index 296639b..9da6e3a 100644 --- a/main/models/user.py +++ b/main/models/user.py @@ -48,6 +48,7 @@ class User(AbstractUser): APPROVAL_STATUS_CHOICES = ( ("Blank", "Blank"), # space is blank ("Pending", "Pending"), # space relationship has changed, approval is pending + ("Emailed", "Emailed"), # approval request sent ("Approved", "Approved"), # space relationship has been approved ("Rejected", "Rejected"), # space relationship has been rejected ) diff --git a/main/templates/main/profile.html b/main/templates/main/profile.html index 8dcbf41..0e585e1 100644 --- a/main/templates/main/profile.html +++ b/main/templates/main/profile.html @@ -214,10 +214,14 @@

Your Hackspace

Last Updated
{{ user.space.changed_date }}
- {% if user.space_status == 'Pending' %} + {% if user.space_status == 'Emailed' %}
Note: We have emailed the primary contact for {{ user.space.name }} to verify your association with them. Once this is approved, you will be able to maintain the {{ user.space.name }} profile information and apply for foundation membership.
+ {% elif user.space_status == 'Pending' %} +
+ Note: We attempted to email the primary contact for {{ user.space.name }} to verify your association with them. The attempt failed, we will retry soon. You can force a retry by editing and saving your profile. +
{% elif user.space_status == 'Rejected' %}
Your association with {{ user.space.name }} has been rejected by the primary contact for the space. Please contact them directly to discuss the situation further: {{ user.space_approver }} diff --git a/main/views.py b/main/views.py index da119a2..9f6974d 100644 --- a/main/views.py +++ b/main/views.py @@ -77,6 +77,23 @@ def get_form_kwargs(self): def get_object(self, queryset=None): return self.request.user + def form_valid(self, form): + obj = form.save(commit=False) + obj.save() + + try: + form.send_confirmation_email() + + return redirect(reverse_lazy('profile')) + except Exception as e: + # boo - most likely error is ConnectionRefused, but could be others + messages.error(self.request, "Error emailing verification link: %s" % e, + extra_tags='alert-danger') + logger.exception("Error in UserUpdate - unable to send space confirmation email") + + self.request.session['signup_form'] = form.data + return redirect(reverse_lazy('edit-profile')) + class SpaceUpdate(AccessMixin, UpdateView): model = Space From 5ef20817ea7416c4ccf669b9451e64ec4374d931 Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Mon, 1 Jul 2019 17:00:48 +0000 Subject: [PATCH 3/6] Create SpaceMembership (mostly duplicated from SupporterMembership) --- main/admin.py | 3 +- main/migrations/0041_auto_20190701_1658.py | 50 ++++ main/models/__init__.py | 2 + main/models/space.py | 22 ++ main/models/space_membership.py | 289 +++++++++++++++++++++ 5 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 main/migrations/0041_auto_20190701_1658.py create mode 100644 main/models/space_membership.py diff --git a/main/admin.py b/main/admin.py index 678fded..eb31405 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin -from .models import User, Space, SupporterMembership, GocardlessMandate, GocardlessPayment +from .models import User, Space, SupporterMembership, SpaceMembership, GocardlessMandate, GocardlessPayment admin.site.register(User) admin.site.register(Space) admin.site.register(SupporterMembership) +admin.site.register(SpaceMembership) admin.site.register(GocardlessMandate) admin.site.register(GocardlessPayment) diff --git a/main/migrations/0041_auto_20190701_1658.py b/main/migrations/0041_auto_20190701_1658.py new file mode 100644 index 0000000..6060b7f --- /dev/null +++ b/main/migrations/0041_auto_20190701_1658.py @@ -0,0 +1,50 @@ +# Generated by Django 2.0.13 on 2019-07-01 16:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0040_char_to_text'), + ] + + operations = [ + migrations.CreateModel( + name='SpaceMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.TextField(choices=[('Pending', 'Pending'), ('Approved', 'Approved'), ('Rejected', 'Rejected')], default='Pending')), + ('approval_request_count', models.IntegerField(default=0)), + ('fee', models.DecimalField(decimal_places=2, default=20.0, max_digits=8)), + ('statement', models.TextField(blank=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('started_at', models.DateField(null=True)), + ('expired_at', models.DateField(null=True)), + ('redirect_flow_id', models.TextField(blank=True)), + ('session_token', models.TextField(default='')), + ], + options={ + 'db_table': 'spacemembership', + 'ordering': ['created_at'], + }, + ), + migrations.AlterField( + model_name='user', + name='space_status', + field=models.CharField(choices=[('Blank', 'Blank'), ('Pending', 'Pending'), ('Emailed', 'Emailed'), ('Approved', 'Approved'), ('Rejected', 'Rejected')], default='Blank', max_length=8), + ), + migrations.AddField( + model_name='spacemembership', + name='applied_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='spacemembership', + name='space', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Space'), + ), + ] diff --git a/main/models/__init__.py b/main/models/__init__.py index d330522..5ba82fc 100644 --- a/main/models/__init__.py +++ b/main/models/__init__.py @@ -1,6 +1,7 @@ from .user import User, SpaceUserManager from .space import Space, SpaceManager from .supporter_membership import SupporterMembership, SupporterMembershipManager +from .space_membership import SpaceMembership, SpaceMembershipManager from .gocardless_mandate import GocardlessMandate, GocardlessMandateManager from .gocardless_payment import GocardlessPayment, GocardlessPaymentManager @@ -8,6 +9,7 @@ 'User', 'SpaceUserManager', 'Space', 'SpaceManager', 'SupporterMembership', 'SupporterMembershipManager', + 'SpaceMembership', 'SpaceMembershipManager', 'GocardlessMandate', 'GocardlessMandateManager', 'GocardlessPayment', 'GocardlessPaymentManager' ] diff --git a/main/models/space.py b/main/models/space.py index b5b593c..e7fee96 100644 --- a/main/models/space.py +++ b/main/models/space.py @@ -2,6 +2,8 @@ from django.utils import timezone import logging +from .space_membership import SpaceMembership + # get instance of a logger logger = logging.getLogger(__name__) @@ -96,3 +98,23 @@ def as_geojson_feature(self): "logo": self.logo_image_url } } + + # get membership type, returns: None, Supporter, Representative + def member_type(self): + if self.membership_status() != 'None': + return 'Member' + else: + return 'None' + # TODO: implement Representative stuff + + # get membership status, will return a APPROVAL_STATUS_CHOICES value + def membership_status(self): + return SpaceMembership.objects.get_membership_status(self) + + # get latest membership record for this user + def current_membership(self): + return SpaceMembership.objects.get_membership(self) + + # get all membership records for this user + def memberships(self): + return SpaceMembership.objects.get_memberships(self) diff --git a/main/models/space_membership.py b/main/models/space_membership.py new file mode 100644 index 0000000..269f262 --- /dev/null +++ b/main/models/space_membership.py @@ -0,0 +1,289 @@ +from django.db import models +from django.conf import settings +from django.core.mail import EmailMessage +from django.template.loader import get_template +from django.urls import reverse +from django.utils import timezone +import logging +import uuid +from .gocardless_mandate import GocardlessMandate +from datetime import timedelta + +from .gocardless import get_gocardless_client + +# get instance of a logger +logger = logging.getLogger(__name__) + + +class SpaceMembershipManager(models.Manager): + # get all membership records for space + def get_memberships(self, space): + return super(SpaceMembershipManager, self).get_queryset().filter(space=space) + + # get latest membership for space + def get_membership(self, space): + return self.get_memberships(space).latest('created_at') + + # get latest membership status for space + def get_membership_status(self, space): + try: + return self.get_membership(space).status + except SpaceMembership.DoesNotExist: + return 'None' + + +class SpaceMembership(models.Model): + + APPROVAL_STATUS_CHOICES = ( + ("Pending", "Pending"), # approval is pending + ("Approved", "Approved"), # application has been approved + ("Rejected", "Rejected"), # application has been rejected + ) + + # application status + status = models.TextField(choices=APPROVAL_STATUS_CHOICES, default='Pending') + # how many times have we successfully sent an approval request email: + approval_request_count = models.IntegerField(default=0) + # subscription fee (chosen by space) + fee = models.DecimalField(max_digits=8, decimal_places=2, default=20.00) + # application statement - aka: why i should be a member statement + statement = models.TextField(blank=True) + # when was the application membership created + created_at = models.DateTimeField(default=timezone.now) + # when was the first payment received + started_at = models.DateField(null=True) + # when did the membership expire + expired_at = models.DateField(null=True) + # what space is this associated with: + space = models.ForeignKey('Space', models.CASCADE) + # who did the application? + applied_by = models.ForeignKey('User', models.CASCADE) + # gocardless redirect flow id + redirect_flow_id = models.TextField(blank=True) + # session token (for redirect flow) + session_token = models.TextField(default='') + + objects = SpaceMembershipManager() + + class Meta: + ordering = ["created_at"] + db_table = 'spacemembership' + app_label = 'main' + + def __str__(self): + return '{} - {} - {}'.format(self.space.name(), self.status, self.created_at.strftime('%Y-%m-%d')) + + def is_active(self): + if self.expired_at is not None: + return self.status == 'Approved' and self.expired_at > timezone.now().date() + else: + return False + + # is there an active mandate? + def has_active_mandate(self): + try: + return self.mandate().status != '' + except GocardlessMandate.DoesNotExist: + return False + + # get mandate status or throw DoesNotExist + def mandate_status(self): + return self.mandate().status + + # get all mandate records for this space membership + def mandates(self): + return GocardlessMandate.objects.get_mandates_for_space_membership(self) + + # get latest mandate record for this space membership + def mandate(self): + return GocardlessMandate.objects.get_mandate_for_space_membership(self) + + # create a new gocardless redirect flow and return redirect_url + def get_redirect_flow_url(self, request): + # get gocardless client object + client = get_gocardless_client() + + # generate a new session_token + self.session_token = uuid.uuid4().hex + + # create a redirect_flow, pre-fill the spaces name and email + redirect_flow = client.redirect_flows.create( + params={ + "description": "Hackspace Foundation Space Membership", + "session_token": self.session_token, + "success_redirect_url": request.build_absolute_uri(reverse('join_space_step3')), + "prefilled_customer": { + "given_name": self.applied_by.first_name, + "family_name": self.applied_by.last_name, + "company_name": self.name, + "email": self.space.email + } + } + ) + + self.redirect_flow_id = redirect_flow.id + self.save() + + return redirect_flow.redirect_url + + # attempt to complete a redirect flow and return new mandate object + # will throw Gocardless and/or other exceptions + def complete_redirect_flow(self, request): + # get gocardless client object + client = get_gocardless_client() + + # try to complete the redirect flow + logger.info("Completing redirect flow") + redirect_flow = client.redirect_flows.complete( + request.GET.get('redirect_flow_id', ''), + params={ + 'session_token': self.session_token + } + ) + + # fetch the detailed mandate info + logger.info("Fetch detailed mandate info") + mandate_detail = client.mandates.get(redirect_flow.links.mandate) + + # create new mandate object + logger.info("Create new mandate object") + mandate = GocardlessMandate( + id=redirect_flow.links.mandate, + space_membership=self, + reference=mandate_detail.reference, + status=mandate_detail.status, + customer_id=mandate_detail.links.customer, + creditor_id=mandate_detail.links.creditor, + customer_bank_account_id=mandate_detail.links.customer_bank_account + ) + mandate.save() + + logger.info("Mandate object created: {}".format(mandate.id)) + return mandate + + # send approval request email + def send_approval_request(self, request): + # get template + htmly = get_template('join_space/space_application_email.html') + + # build context + d = { + 'email': self.space.email, + 'first_name': self.applied_by.first_name, + 'last_name': self.applied_by.last_name, + 'space_name': self.space.name, + 'note': self.statement, + 'fee': self.fee, + 'approve_url': request.build_absolute_uri( + reverse('space-approval', + kwargs={'session_token': self.session_token, 'action': 'approve'})), + 'reject_url': request.build_absolute_uri( + reverse('space-approval', + kwargs={'session_token': self.session_token, 'action': 'reject'})) + } + + # prep headers + subject = "Space Member Application from %s" % (self.space.name) + from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None) + to = getattr(settings, "BOARD_EMAIL", None) + + # render template + message = htmly.render(d) + try: + # send email + msg = EmailMessage(subject, message, to=[to], from_email=from_email) + msg.content_subtype = 'html' + msg.send() + + # track how many times we've sent a request + self.approval_request_count += 1 + self.save() + + except Exception: + # TODO: oh dear - how should we handle this gracefully?!? + logger.exception("Error in send_approval_request - failed to send email", + extra={'SpaceMembership': self}) + + # email space to notify of decision + def send_application_decision(self): + htmly = get_template('join_space/space_decision_email.html') + + d = { + 'email': self.space.email, + 'first_name': self.applied_by.first_name, + 'last_name': self.applied_by.last_name, + 'space_name': self.space.name, + 'fee': self.fee, + 'status': self.status + } + + subject = "Hackspace Foundation Membership Application" + from_email = getattr(settings, "BOARD_EMAIL", None) + to = self.space.email + message = htmly.render(d) + try: + msg = EmailMessage(subject, message, to=[to], from_email=from_email) + msg.content_subtype = 'html' + msg.send() + + return True + except Exception: + logger.exception("Error in send_application_decision - unable to send email", + extra={'membership application': self}) + return False + + # approve the membership application (and create initial payment) + def approve(self): + # check space has not already been approved/rejected (e.g. by someone else!) + if self.status != 'Pending': + return False + + # update membership status + self.status = 'Approved' + self.save() + + self.send_application_decision() + + self.request_payment() + + return True + + # reject the membership application (and cancel mandate) + def reject(self): + # check space has not already been approved/rejected (e.g. by someone else!) + if self.status != 'Pending': + return False + + # update membership status + self.status = 'Rejected' + self.save() + + self.send_application_decision() + + if self.has_active_mandate(): + return self.mandate().cancel() + + return True + + # request new payment for this membership (e.g. start of a new year) + def request_payment(self): + if self.has_active_mandate(): + return self.mandate().create_payment(self.fee) + + def handle_payment_received(self, payment): + if payment.payout_date is not None: + # update started_at when first payment received + self.started_at = payment.payout_date + + # update expired_at when new payment received + self.expired_at = payment.payout_date + timedelta(days=365) + + self.save() + else: + logger.error("handle_payment_received - payout_date is null") + + # TODO: send notification email of payment received and membership active + + def handle_mandate_updated(self, mandate): + # TODO: something useful + pass From 7cba9381eadef86e87670f94403b60094f8d2a65 Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Tue, 2 Jul 2019 16:37:29 +0000 Subject: [PATCH 4/6] Rest of space-membership flow, including gocardless mandates/payments --- .gitignore | 4 + main/forms.py | 24 ++- ...0042_gocardlessmandate_space_membership.py | 19 +++ main/models/gocardless_mandate.py | 23 ++- main/models/space_membership.py | 17 +- .../join_space/space_application_email.html | 14 ++ main/templates/join_space/space_approval.html | 14 ++ .../join_space/space_decision_email.html | 18 ++ main/templates/join_space/space_step3.html | 18 ++ main/templates/join_space/step1.html | 79 +++++++++ main/templates/main/profile.html | 22 ++- main/urls.py | 5 + main/views.py | 158 +++++++++++++++++- 13 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 main/migrations/0042_gocardlessmandate_space_membership.py create mode 100644 main/templates/join_space/space_application_email.html create mode 100644 main/templates/join_space/space_approval.html create mode 100644 main/templates/join_space/space_decision_email.html create mode 100644 main/templates/join_space/space_step3.html create mode 100644 main/templates/join_space/step1.html diff --git a/.gitignore b/.gitignore index 9b435d6..e4d3bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ db.sqlite3 hsf/dev_settings.py .*_cache/ + + +# Editor save files +*~ diff --git a/main/forms.py b/main/forms.py index 25493f9..756dfc0 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,6 +1,6 @@ from django.forms import ModelForm from django.contrib.auth.forms import UserCreationForm -from .models import User, SupporterMembership +from .models import User, SupporterMembership, SpaceMembership from django.utils import timezone from django.core.mail import EmailMessage from django.template.loader import get_template @@ -130,6 +130,28 @@ def clean_statement(self): raise forms.ValidationError("Please write at least a few words :)") return data +class SpaceMembershipForm(ModelForm): + class Meta: + model = SpaceMembership + fields = ('fee', 'statement') + widgets = { + 'fee': forms.NumberInput(attrs={'step': 0.25, 'min': 20.0}) + } + + # ensure fee is not less than £20.00 + def clean_fee(self): + data = self.cleaned_data['fee'] + if data < 20: + raise forms.ValidationError("Minimum £20.00") + return data + + # ensure statement is not empty + def clean_statement(self): + data = self.cleaned_data['statement'] + if data == "": + raise forms.ValidationError("Please write at least a few words :)") + return data + class NewSpaceForm(forms.Form): name = forms.CharField(required=True) diff --git a/main/migrations/0042_gocardlessmandate_space_membership.py b/main/migrations/0042_gocardlessmandate_space_membership.py new file mode 100644 index 0000000..2544265 --- /dev/null +++ b/main/migrations/0042_gocardlessmandate_space_membership.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.13 on 2019-07-02 12:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0041_auto_20190701_1658'), + ] + + operations = [ + migrations.AddField( + model_name='gocardlessmandate', + name='space_membership', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='main.SpaceMembership'), + ), + ] diff --git a/main/models/gocardless_mandate.py b/main/models/gocardless_mandate.py index 90be55f..ab34eaa 100644 --- a/main/models/gocardless_mandate.py +++ b/main/models/gocardless_mandate.py @@ -24,7 +24,20 @@ def get_mandate_for_supporter_membership(self, supporter_membership): def get_membership_status_for_supporter_membership(self, supporter_membership): return self.get_mandate_for_supporter_membership(supporter_membership).status - # get_or_create that populates fields from json + # get all mandate records for space membership + def get_mandates_for_space_membership(self, space_membership): + return super(GocardlessMandateManager, self).get_queryset().filter( + space_membership=space_membership) + + # get latest mandate for space_membership + def get_mandate_for_space_membership(self, space_membership): + return self.get_mandates_for_space_membership(space_membership).latest('created_at') + + # get latest mandate status for space_membership or throw DoesNotExist + def get_membership_status_for_space_membership(self, space_membership): + return self.get_mandate_for_space_membership(space_membership).status + +# get_or_create that populates fields from json # e.g. as received from gocardless webhook or mandate creation api # payload should be a dict, e.g. parsed from webhook json def get_or_create_from_payload(self, payload): @@ -99,7 +112,7 @@ class GocardlessMandate(models.Model): # which Supporter membership is this mandate associated with (or null) supporter_membership = models.ForeignKey('SupporterMembership', models.CASCADE, null=True) # which Space Membership is this mandate associated with (or null) - # TODO: ^^ + space_membership = models.ForeignKey('SpaceMembership', models.CASCADE, null=True) # override default manager objects = GocardlessMandateManager() @@ -131,7 +144,9 @@ def save(self, force_insert=False, force_update=False): def is_supporter_mandate(self): return self.supporter_membership is not None - # TODO: is_space_mandate + # is_space_mandate + def is_space_mandate(self): + return self.space_membership is not None # is_active - is this mandate active def is_active(self): @@ -206,3 +221,5 @@ def handle_payment_updated(self, payment): if payment.status == 'paid_out': if self.supporter_membership is not None: self.supporter_membership.handle_payment_received(payment) + if self.space_membership is not None: + self.space_membership.handle_payment_received(payment) diff --git a/main/models/space_membership.py b/main/models/space_membership.py index 269f262..dad2216 100644 --- a/main/models/space_membership.py +++ b/main/models/space_membership.py @@ -46,7 +46,7 @@ class SpaceMembership(models.Model): approval_request_count = models.IntegerField(default=0) # subscription fee (chosen by space) fee = models.DecimalField(max_digits=8, decimal_places=2, default=20.00) - # application statement - aka: why i should be a member statement + # application statement - aka: why we should be a member statement statement = models.TextField(blank=True) # when was the application membership created created_at = models.DateTimeField(default=timezone.now) @@ -115,7 +115,7 @@ def get_redirect_flow_url(self, request): "prefilled_customer": { "given_name": self.applied_by.first_name, "family_name": self.applied_by.last_name, - "company_name": self.name, + "company_name": self.space.name, "email": self.space.email } } @@ -168,17 +168,17 @@ def send_approval_request(self, request): # build context d = { - 'email': self.space.email, + 'email': self.applied_by.email, 'first_name': self.applied_by.first_name, 'last_name': self.applied_by.last_name, 'space_name': self.space.name, 'note': self.statement, 'fee': self.fee, 'approve_url': request.build_absolute_uri( - reverse('space-approval', + reverse('space-membership-approval', kwargs={'session_token': self.session_token, 'action': 'approve'})), 'reject_url': request.build_absolute_uri( - reverse('space-approval', + reverse('space-membership-approval', kwargs={'session_token': self.session_token, 'action': 'reject'})) } @@ -209,7 +209,7 @@ def send_application_decision(self): htmly = get_template('join_space/space_decision_email.html') d = { - 'email': self.space.email, + 'email': self.applied_by.email, 'first_name': self.applied_by.first_name, 'last_name': self.applied_by.last_name, 'space_name': self.space.name, @@ -219,10 +219,11 @@ def send_application_decision(self): subject = "Hackspace Foundation Membership Application" from_email = getattr(settings, "BOARD_EMAIL", None) - to = self.space.email + to = self.applied_by.email + cc = self.space.email message = htmly.render(d) try: - msg = EmailMessage(subject, message, to=[to], from_email=from_email) + msg = EmailMessage(subject, message, to=[to], cc=[cc], from_email=from_email) msg.content_subtype = 'html' msg.send() diff --git a/main/templates/join_space/space_application_email.html b/main/templates/join_space/space_application_email.html new file mode 100644 index 0000000..4551285 --- /dev/null +++ b/main/templates/join_space/space_application_email.html @@ -0,0 +1,14 @@ +

You are receiving this email because {{ first_name }} {{ last_name }} ({{ email }}) has applied for Space Membership for their space {{ space_name }}. +

+ +

Note to the Board from {{ first_name }}:

+ +

{{ note }}

+ +

Chosen subscription fee: £{{ fee }}

+ +

As a member of the Hackspace Foundation board, please could you use the following links to Approve or Reject this request. +

+ +

Thanks, the Hackspace Foundation +

diff --git a/main/templates/join_space/space_approval.html b/main/templates/join_space/space_approval.html new file mode 100644 index 0000000..7e07d9c --- /dev/null +++ b/main/templates/join_space/space_approval.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Member Space Approval{% endblock %} + +{% block content %} +

Member Space Approval

+ +

Thank you for {{ action }} {{ space_name }}'s application for Membership.

+ +{% if action == 'rejecting' %} +

Please don't forget to contact {{ space_name }} directly and explain the reason(s) for {{ action }} - {{ email }}

+{% endif %} + +{% endblock %} diff --git a/main/templates/join_space/space_decision_email.html b/main/templates/join_space/space_decision_email.html new file mode 100644 index 0000000..41b9e47 --- /dev/null +++ b/main/templates/join_space/space_decision_email.html @@ -0,0 +1,18 @@ +

Thank you for your application for Space Membership to the Hackspace Foundation +

+ +{% if status == 'Approved' %} +

We're pleased to inform you that your application has been approved - welcome aboard!

+ +

We will now request payment of your annual subscription fee, £{{ fee }}, via GoCardless - the payment should be withdrawn within 3-5 days.

+ +{% else %} +

We're sorry to inform you that your application has been rejected. One of the board members will be in touch shortly to explain the decision.

+ +

As a result, we will cancel your Direct Debit mandate and no payment will be taken.

+ +{% endif %} + + +

Thanks, the Hackspace Foundation Board +

diff --git a/main/templates/join_space/space_step3.html b/main/templates/join_space/space_step3.html new file mode 100644 index 0000000..f0a2808 --- /dev/null +++ b/main/templates/join_space/space_step3.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}Space Membership{% endblock %} + +{% block content %} + +
+

Application Complete

+ +

Thank you for your application - it will now be reviewed by the Board and you should have a response within a week or so.

+ +

Your Direct Debit mandate is also setup, but we won't collect your subscription fee until your membership is approved.

+ + Back to Profile + +
+ +{% endblock %} diff --git a/main/templates/join_space/step1.html b/main/templates/join_space/step1.html new file mode 100644 index 0000000..d2a2c0a --- /dev/null +++ b/main/templates/join_space/step1.html @@ -0,0 +1,79 @@ + +{% extends "base.html" %} +{% load widget_tweaks %} + +{% block title %}Space Membership{% endblock %} + +{% block content %} + +
+

Join as a Member Space

+ +

Membership of the Hackspace Foundation is available for 'spaces, providing they are run according to the foundation's member space definition

+ +

If you feel your space mostly fits the definition, feel free to apply and appeal to the directors below.

+ +

A space does not need to have a physical premises to be a member.

+ +

Member Spaces are full, voting members of the Foundation, the voting power of all space members is capped at 80%, with the remaining 20% being Individual Members.

+ +

The membership fee for a Member Space is a minimum of £20 per year, or 2% of their surplus, collected by Direct Debit. Admission as a member space is at the discretion of the Board.

+ +

You can read more about the Hackspace Foundation's organisational structure here.

+ +
+
+ + {{ form.non_field_errors }} + +
+ {% csrf_token %} + +

Please complete this form to start your application. + On the next screen you will be guided through setting up a Direct Debit mandate. + Please note no money will be taken until your application has been approved.

+ +
+ +
+ {% if form.fee.errors %} + {% for error in form.fee.errors %} +
{{ error|escape }}
+ {% endfor %} + {% endif %} +
+ {% render_field form.fee class+="form-control" %} + £ +
+
+
+ +

Please review/amend your annual subscription fee (minimum of £20).

+ +
+ +
+ {% if form.statement.errors %} + {% for error in form.statement.errors %} +
{{ error|escape }}
+ {% endfor %} + {% endif %} + {% render_field form.statement class+="form-control" %} +
+
+ +

Please provide links to a document (or documents) on how your space is run, together with any exceptions you are claiming.

+ +
+
+ +
+
+
+ +
+
+ +
+ +{% endblock %} diff --git a/main/templates/main/profile.html b/main/templates/main/profile.html index 0e585e1..b5e603b 100644 --- a/main/templates/main/profile.html +++ b/main/templates/main/profile.html @@ -241,9 +241,27 @@

Your Hackspace

{{ user.space.name }}'s Membership

+ {% if user.space.current_membership.status == 'Pending' %} +
+ Your space has already requested to become a Member Space of the Hackspace Foundation, the board will hopefully make a decision soon. +
+ {% elif user.space.current_membership.status == 'Approved' %} +

+ Your Space is a Member of the Hackspace Foundation! +

+ {% if user.space.current_membership.mandate.payment.status != "failed" %} +

+ Paid: £{{ user.space.current_membership.mandate.payment.amount|div:100|floatformat:2 }} on {{ user.space.current_membership.mandate.payment.charge_date }} +

+ {% else %} +

+ Payment failed - please check you have sufficient funds in your account and try again Retry Payment +

+ {% endif %} + {% else %}

If you are able to act as a Representative for your space, you may apply for it to become a Member Space:

- Apply - + Apply + {% endif %}
{% endif %} diff --git a/main/urls.py b/main/urls.py index f5409de..3464a6c 100644 --- a/main/urls.py +++ b/main/urls.py @@ -21,6 +21,11 @@ url(r'^join/supporter/3$', views.join_supporter_step3, name='join_supporter_step3'), url(r'^supporter-approval/(?P.*)/(?P.*)$', views.supporter_approval, name='supporter-approval'), + url(r'^join/space/1$', views.JoinSpaceStep1.as_view(), name='join_space_step1'), + url(r'^join/space/2$', views.join_space_step2, name='join_space_step2'), + url(r'^join/space/3$', views.join_space_step3, name='join_space_step3'), + url(r'^space-membership-approval/(?P.*)/(?P.*)$', views.space_membership_approval, + name='space-membership-approval'), url(r'^login$', views.Login.as_view(), name='login'), url(r'^logout$', views.logout_view, name='logout'), url(r'^payment-history$', views.payment_history, name='payment-history'), diff --git a/main/views.py b/main/views.py index 9f6974d..7c4fa70 100644 --- a/main/views.py +++ b/main/views.py @@ -8,8 +8,8 @@ from django.contrib.admin.views.decorators import staff_member_required from django.views.decorators.csrf import csrf_exempt from django.views.generic.edit import CreateView -from .models import Space, SupporterMembership, GocardlessMandate, GocardlessPayment -from .forms import CustomUserCreationForm, SupporterMembershipForm, NewSpaceForm +from .models import Space, SupporterMembership, GocardlessMandate, GocardlessPayment, SpaceMembership +from .forms import CustomUserCreationForm, SupporterMembershipForm, SpaceMembershipForm, NewSpaceForm from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin from django.utils.decorators import method_decorator @@ -191,6 +191,138 @@ def join(request): return render(request, 'join/join.html') +class JoinSpaceStep1(AccessMixin, CreateView): + form_class = SpaceMembershipForm + success_url = reverse_lazy('join_space_step2') + template_name = 'join_space/step1.html' + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + # if user doesn't have a space then return to profile page + if request.user.member_type is None: + messages.error(request, 'You can\'t apply for a space membership when you\'re not attached to a space', extra_tags='alert-danger') + logger.error("Error in JoinSupporterStep1 - user has no space", + extra={'user': request.user}) + return redirect(reverse('profile')) + elif request.user.space.membership_status() == 'Pending' or request.user.space.membership_status() == 'Approved': + messages.error(request, 'Your space has already applied for a space membership', extra_tags='alert-danger') + logger.error("Error in JoinSpaceStep1 - user\'s space has an existing membership", + extra={'user': request.user}) + return redirect(reverse('profile')) + + return super(JoinSpaceStep1, self).dispatch(request, *args, **kwargs) + + def form_valid(self, form): + # hook up the new application to the current user + form.instance.applied_by = self.request.user + form.instance.space = self.request.user.space + return super().form_valid(form) + +@login_required +def join_space_step2(request): + try: + # get membership application + ma = request.user.space.current_membership() + + try: + # see if the application already has a mandate + mandate = ma.mandate() + + # if it's active... + if mandate.is_active(): + # redirect to step 3: + return redirect(reverse('join_space_step3')) + + except GocardlessMandate.DoesNotExist: + # if not, continue... + pass + + # redirect user to create a new mandate + return redirect(ma.get_redirect_flow_url(request)) + + except SpaceMembership.DoesNotExist: + # odd, space does not have an active membership application - send them to step1 + logger.error("Error in join_space_step2 - space does not have a membership application", + extra={'user.space': request.user.space}) + return redirect(reverse('join_space_step1')) + + +@login_required +def join_space_step3(request): + + try: + # get membership application + ma = request.user.space.current_membership() + mandate = None + + try: + # see if the application already has a mandate + mandate = ma.mandate() + logger.info("Found existing mandate: {}".format(mandate.id)) + + except GocardlessMandate.DoesNotExist: + # if not, complete the flow and create a new mandate record + logger.info("No existing mandate, completing redirect flow") + mandate = ma.complete_redirect_flow(request) + + # now do the email approval stuff + logger.info("Sending space membership approval request email") + ma.send_approval_request(request) + + # finally, render the completion page + return render(request, 'join_space/space_step3.html') + + except SpaceMembership.DoesNotExist: + # odd, space does not have an active membership application - send them to step1 + logger.error("Error in join_space_step3 - space does not have a membership application", + extra={'space': request.user.space}) + return redirect(reverse('join_space_step1')) + +@staff_member_required(login_url='/login') +def space_membership_approval(request, session_token, action): + if action != 'approve' and action != 'reject': + # this shouldn't happen + logger.error("Error in space_approval - unexpected action: %s", action, + extra={'user': request.user}) + return redirect(reverse('error')) + + try: + # lookup membership application based on session_token + ma = SpaceMembership.objects.get(session_token=session_token) + + # apply approval action + error = False + if action == 'approve': + error = not ma.approve() + else: + error = not ma.reject() + + if error: + messages.error(request, "Error - " + ma.space.name + " appears to have already been " + + ma.status, extra_tags='alert-danger') + return redirect(reverse('error')) + + # thank the approver/reviewer for their response + context = { + 'first_name': ma.applied_by.first_name, + 'last_name': ma.applied_by.last_name, + 'space_name': ma.space.name, + 'email': ma.applied_by.email, + 'action': ('approving' if action == 'approve' else 'rejecting') + } + return render(request, 'join_space/space_approval.html', context) + + except Exception as e: + # unknown/invalid membership application + logger.error("Error in space_approval: %s", e, extra={ + 'user': request.user, + 'session_token': session_token, + 'action': action + }) + messages.error(request, "Error in space_approval: %s" % e, extra_tags='alert-danger') + return redirect(reverse('error')) + class JoinSupporterStep1(AccessMixin, CreateView): form_class = SupporterMembershipForm success_url = reverse_lazy('join_supporter_step2') @@ -200,7 +332,7 @@ def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() # if user already has an active membership then return to profile page - if request.user.supporter_status() == 'Pending' or request.user.supporter_status() == 'Approved': + if request.user.supporter_status() == 'Pending' or request.user.supporter_status() == 'Approved' or request.user.supporter_status() == 'Emailed': messages.error(request, 'You already have an active membership', extra_tags='alert-danger') logger.error("Error in JoinSupporterStep1 - user has an existing membership", extra={'user': request.user}) @@ -213,6 +345,24 @@ def form_valid(self, form): form.instance.user = self.request.user return super().form_valid(form) +@login_required +def space_membership_payment(request): + # request new payment and return to profile + + try: + # lookup membership application based on session_token + ma = SpaceMembership.objects.get_membership(request.user) + + # attempt to request new payment + ma.request_payment() + + messages.info(request, "Payment requested", extra_tags='alert-success') + + except Exception as e: + messages.error(request, "Error in supporter_approval: %s" % e, extra_tags='alert-danger') + + return redirect(reverse('profile')) + @login_required def join_supporter_step2(request): @@ -262,7 +412,7 @@ def join_supporter_step3(request): mandate = ma.complete_redirect_flow(request) # now do the email approval stuff - logger.info("Sending approval request email") + logger.info("Sending individual member approval request email") ma.send_approval_request(request) # finally, render the completion page From bfed506e7f70438037e438fe1ad2ac47dfcf9c4c Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Thu, 4 Jul 2019 09:41:49 +0000 Subject: [PATCH 5/6] Appeasing the style critic gods.. --- main/forms.py | 1 + main/views.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/main/forms.py b/main/forms.py index 756dfc0..b177512 100644 --- a/main/forms.py +++ b/main/forms.py @@ -130,6 +130,7 @@ def clean_statement(self): raise forms.ValidationError("Please write at least a few words :)") return data + class SpaceMembershipForm(ModelForm): class Meta: model = SpaceMembership diff --git a/main/views.py b/main/views.py index 7c4fa70..5bf053f 100644 --- a/main/views.py +++ b/main/views.py @@ -201,12 +201,16 @@ def dispatch(self, request, *args, **kwargs): return self.handle_no_permission() # if user doesn't have a space then return to profile page if request.user.member_type is None: - messages.error(request, 'You can\'t apply for a space membership when you\'re not attached to a space', extra_tags='alert-danger') + messages.error(request, + 'You can\'t apply for a space membership when you\'re not attached to a space', + extra_tags='alert-danger') logger.error("Error in JoinSupporterStep1 - user has no space", extra={'user': request.user}) return redirect(reverse('profile')) - elif request.user.space.membership_status() == 'Pending' or request.user.space.membership_status() == 'Approved': - messages.error(request, 'Your space has already applied for a space membership', extra_tags='alert-danger') + elif (request.user.space.membership_status() == 'Pending' + or request.user.space.membership_status() == 'Approved'): + messages.error(request, 'Your space has already applied for a space membership', + extra_tags='alert-danger') logger.error("Error in JoinSpaceStep1 - user\'s space has an existing membership", extra={'user': request.user}) return redirect(reverse('profile')) @@ -219,6 +223,7 @@ def form_valid(self, form): form.instance.space = self.request.user.space return super().form_valid(form) + @login_required def join_space_step2(request): try: @@ -279,6 +284,7 @@ def join_space_step3(request): extra={'space': request.user.space}) return redirect(reverse('join_space_step1')) + @staff_member_required(login_url='/login') def space_membership_approval(request, session_token, action): if action != 'approve' and action != 'reject': @@ -323,6 +329,7 @@ def space_membership_approval(request, session_token, action): messages.error(request, "Error in space_approval: %s" % e, extra_tags='alert-danger') return redirect(reverse('error')) + class JoinSupporterStep1(AccessMixin, CreateView): form_class = SupporterMembershipForm success_url = reverse_lazy('join_supporter_step2') @@ -332,7 +339,10 @@ def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() # if user already has an active membership then return to profile page - if request.user.supporter_status() == 'Pending' or request.user.supporter_status() == 'Approved' or request.user.supporter_status() == 'Emailed': + if (request.user.supporter_status() == 'Pending' + or request.user.supporter_status() == 'Approved' + or request.user.supporter_status() == 'Emailed'): + messages.error(request, 'You already have an active membership', extra_tags='alert-danger') logger.error("Error in JoinSupporterStep1 - user has an existing membership", extra={'user': request.user}) @@ -345,6 +355,7 @@ def form_valid(self, form): form.instance.user = self.request.user return super().form_valid(form) + @login_required def space_membership_payment(request): # request new payment and return to profile From d96f6bd9f1c2826c64ee4156d79a23c70f056c93 Mon Sep 17 00:00:00 2001 From: Jess Robinson Date: Fri, 15 Jul 2022 18:01:53 +0000 Subject: [PATCH 6/6] Space membership: Tidy up some lint/confusing bits from review --- main/models/gocardless_mandate.py | 2 +- main/models/space.py | 3 +-- main/templates/join_space/step1.html | 1 - main/views.py | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/main/models/gocardless_mandate.py b/main/models/gocardless_mandate.py index ab34eaa..49ed30c 100644 --- a/main/models/gocardless_mandate.py +++ b/main/models/gocardless_mandate.py @@ -24,7 +24,7 @@ def get_mandate_for_supporter_membership(self, supporter_membership): def get_membership_status_for_supporter_membership(self, supporter_membership): return self.get_mandate_for_supporter_membership(supporter_membership).status - # get all mandate records for space membership + # get all mandate records for space membership def get_mandates_for_space_membership(self, space_membership): return super(GocardlessMandateManager, self).get_queryset().filter( space_membership=space_membership) diff --git a/main/models/space.py b/main/models/space.py index e7fee96..9d8ffcd 100644 --- a/main/models/space.py +++ b/main/models/space.py @@ -99,13 +99,12 @@ def as_geojson_feature(self): } } - # get membership type, returns: None, Supporter, Representative + # get membership type, returns: None, Member def member_type(self): if self.membership_status() != 'None': return 'Member' else: return 'None' - # TODO: implement Representative stuff # get membership status, will return a APPROVAL_STATUS_CHOICES value def membership_status(self): diff --git a/main/templates/join_space/step1.html b/main/templates/join_space/step1.html index d2a2c0a..d0ce603 100644 --- a/main/templates/join_space/step1.html +++ b/main/templates/join_space/step1.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% load widget_tweaks %} diff --git a/main/views.py b/main/views.py index 5bf053f..0a52b24 100644 --- a/main/views.py +++ b/main/views.py @@ -340,8 +340,8 @@ def dispatch(self, request, *args, **kwargs): return self.handle_no_permission() # if user already has an active membership then return to profile page if (request.user.supporter_status() == 'Pending' - or request.user.supporter_status() == 'Approved' - or request.user.supporter_status() == 'Emailed'): + or request.user.supporter_status() == 'Approved' + or request.user.supporter_status() == 'Emailed'): messages.error(request, 'You already have an active membership', extra_tags='alert-danger') logger.error("Error in JoinSupporterStep1 - user has an existing membership",