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/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 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/forms.py b/main/forms.py index ccd5647..b177512 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 @@ -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: @@ -128,6 +131,29 @@ def clean_statement(self): 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) email = forms.EmailField(required=True) 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/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/__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/gocardless_mandate.py b/main/models/gocardless_mandate.py index 90be55f..49ed30c 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.py b/main/models/space.py index b5b593c..9d8ffcd 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,22 @@ def as_geojson_feature(self): "logo": self.logo_image_url } } + + # get membership type, returns: None, Member + def member_type(self): + if self.membership_status() != 'None': + return 'Member' + else: + return 'None' + + # 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..dad2216 --- /dev/null +++ b/main/models/space_membership.py @@ -0,0 +1,290 @@ +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 we 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.space.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.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-membership-approval', + kwargs={'session_token': self.session_token, 'action': 'approve'})), + 'reject_url': request.build_absolute_uri( + reverse('space-membership-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.applied_by.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.applied_by.email + cc = self.space.email + message = htmly.render(d) + try: + msg = EmailMessage(subject, message, to=[to], cc=[cc], 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 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/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 %} +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 %} + +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 + +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.
+ ++ 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 %}