├── theme ├── __init__.py ├── static_src │ ├── .gitignore │ ├── postcss.config.js │ ├── bs.config.js │ ├── src │ │ └── styles.css │ ├── package.json │ └── tailwind.config.js ├── apps.py ├── urls.py └── templates │ └── theme.html ├── memberships ├── __init__.py ├── tests │ ├── __init__.py │ ├── utils.py │ ├── test_login_form.py │ ├── test_member_model.py │ ├── test_stripe_gateway.py │ ├── test_stripe_webhooks.py │ └── test_registration_form.py ├── migrations │ ├── __init__.py │ ├── 0014_merge_20210421_1702.py │ ├── 0010_remove_member_profile_image.py │ ├── 0013_membership_payment_status.py │ ├── 0012_member_email_verified.py │ ├── 0004_auto_20200807_1947.py │ ├── 0002_member_stripe_customer_id.py │ ├── 0008_member_renewal_date.py │ ├── 0009_auto_20210323_1611.py │ ├── 0006_failedpayment.py │ ├── 0012_auto_20210413_1903.py │ ├── 0003_membership.py │ ├── 0001_initial.py │ ├── 0007_auto_20210125_0057.py │ ├── 0011_auto_20210330_2019.py │ └── 0005_auto_20201120_1445.py ├── templates │ ├── memberships │ │ ├── email_message.html │ │ ├── robots.txt │ │ ├── verify_email.html │ │ ├── logout.html │ │ ├── 404.html │ │ ├── verify_sent.html │ │ ├── home.html │ │ ├── verify_confirmation.html │ │ ├── humans.txt │ │ ├── welcome_email.html │ │ ├── confirm.html │ │ ├── member_details.html │ │ ├── member_settings.html │ │ ├── login.html │ │ └── register.html │ ├── inc │ │ ├── input_text.html │ │ ├── help_text.html │ │ └── logo.html │ └── base.html ├── apps.py ├── context_processors.py ├── tokens.py ├── email.py ├── urls.py ├── admin.py ├── services.py ├── tasks.py ├── static │ └── js │ │ └── main.js ├── payments.py ├── forms.py ├── models.py └── views.py ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── FEATURE-REQUEST.yml │ └── BUG-REPORT.yml ├── dependabot.yml ├── workflows │ ├── snyk.yaml │ ├── eisenhower.yml │ ├── take-action.yml │ └── black.yml ├── linters │ └── .htmlhintrc └── pull_request_template.md ├── gzweb-phone-dark.png ├── gzweb-phone-light.png ├── gzweb-desktop-dark.png ├── gzweb-desktop-light.png ├── .dockerignore ├── CONTRIBUTING.md ├── web ├── views.py ├── __init__.py ├── .env.dev ├── .env.example ├── asgi.py ├── celery.py ├── wsgi.py ├── urls.py └── settings.py ├── test-results └── readme.md ├── CODEOWNERS ├── docker ├── proxy │ ├── default.conf │ ├── Dockerfile │ └── nginx.conf ├── development │ └── Dockerfile └── backend │ └── Dockerfile ├── manage.py ├── stripe.sequence ├── requirements.txt ├── docker-compose.yml ├── docker-compose.dev.yml ├── funky_time.py ├── .gitignore ├── .circleci ├── config.yml.only-ci ├── config.yml.aws └── config.yml ├── erd.xml └── readme.md /theme/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /memberships/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /memberships/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /memberships/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /theme/static_src/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | -------------------------------------------------------------------------------- /gzweb-phone-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekZoneHQ/web/HEAD/gzweb-phone-dark.png -------------------------------------------------------------------------------- /gzweb-phone-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekZoneHQ/web/HEAD/gzweb-phone-light.png -------------------------------------------------------------------------------- /gzweb-desktop-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekZoneHQ/web/HEAD/gzweb-desktop-dark.png -------------------------------------------------------------------------------- /gzweb-desktop-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeekZoneHQ/web/HEAD/gzweb-desktop-light.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This prevents secrets from being copied to the image 2 | web/.env 3 | web/.env.dev 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please see [contributing](https://github.com/GeekZoneHQ/contributing). 4 | -------------------------------------------------------------------------------- /theme/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ThemeConfig(AppConfig): 5 | name = "theme" 6 | -------------------------------------------------------------------------------- /memberships/templates/memberships/email_message.html: -------------------------------------------------------------------------------- 1 | Hello {{name|safe}}! 2 | 3 | {{body|safe}} 4 | 5 | Thanks! 6 | 7 | Geek.Zone -------------------------------------------------------------------------------- /web/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import logout 2 | from django.shortcuts import redirect 3 | from django.urls import reverse 4 | -------------------------------------------------------------------------------- /memberships/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MembershipsConfig(AppConfig): 5 | name = "memberships" 6 | -------------------------------------------------------------------------------- /memberships/templates/memberships/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | 3 | # Allow crawling of all content 4 | User-agent: * 5 | Disallow: 6 | -------------------------------------------------------------------------------- /web/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | from .celery import app as celery_app 3 | 4 | __all__ = ("celery_app",) 5 | -------------------------------------------------------------------------------- /test-results/readme.md: -------------------------------------------------------------------------------- 1 | This is where we store the results of our tests. Please see 2 | [CircleCI docs](https://circleci.com/docs/2.0/configuration-reference/#storetestresults). -------------------------------------------------------------------------------- /theme/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | 4 | urlpatterns = [path("", TemplateView.as_view(template_name="theme.html"))] 5 | -------------------------------------------------------------------------------- /memberships/templates/memberships/verify_email.html: -------------------------------------------------------------------------------- 1 | You are almost there! 2 | Please click the link below to verify your membership: 3 | http://{{ domain }}{% url 'verify' uidb64=uid token=token %} 4 | -------------------------------------------------------------------------------- /memberships/templates/memberships/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Member Logout

5 | 6 |

Thanks for choosing Geek.Zone!

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /memberships/context_processors.py: -------------------------------------------------------------------------------- 1 | from web import settings 2 | 3 | 4 | def recaptcha_enabled(request): 5 | return { 6 | "recaptcha_enabled": settings.RECAPTCHA_SECRET_KEY 7 | and settings.RECAPTCHA_SITE_KEY, 8 | } 9 | -------------------------------------------------------------------------------- /theme/static_src/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "postcss-simple-vars": {}, 5 | "postcss-nested": {}, 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /web/.env.dev: -------------------------------------------------------------------------------- 1 | DATABASE_USER=postgres 2 | DATABASE_NAME=postgres 3 | DATABASE_HOST=db 4 | DATABASE_PASSWORD=password 5 | DATABASE_PORT=5432 6 | CELERY_BROKER_URL=amqp://@rabbitmq 7 | TEST_USER_PASSWORD=k38m1KIhIUzeA^UL 8 | TEST_USER_PASSWORD_BAD=password -------------------------------------------------------------------------------- /memberships/templates/memberships/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Page Not Found

5 | 6 |

Sorry, but the page you were trying to view does not exist.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | target-branch: master 9 | reviewers: 10 | - "carwynnelson" 11 | - "jamesgeddes" 12 | 13 | -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=on 2 | ALLOWED_HOSTS=localhost 3 | DATABASE_URL=sqlite:/db.sqlite3 4 | STRIPE_SECRET_KEY=example_secret_key 5 | STRIPE_PUBLIC_KEY=example_public_key 6 | MEMBERSHIP_PRICE_ID=example_stripe_membership_price_id 7 | DONATION_PRODUCT_ID=example_annual_donation_product_id 8 | -------------------------------------------------------------------------------- /memberships/migrations/0014_merge_20210421_1702.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-21 16:02 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0012_member_email_verified"), 9 | ("memberships", "0013_membership_payment_status"), 10 | ] 11 | 12 | operations = [] 13 | -------------------------------------------------------------------------------- /memberships/templates/memberships/verify_sent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% extends "base.html" %} 4 | {% block content %} 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Verification email sent


12 |

Make sure to check you spam folder too!

13 | 14 | {% endblock %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yaml: -------------------------------------------------------------------------------- 1 | name: Example workflow for Python using Snyk 2 | on: push 3 | jobs: 4 | security: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: Run Snyk to check for vulnerabilities 9 | uses: snyk/actions/python-3.10@master 10 | env: 11 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 12 | with: 13 | args: --fail-on=all 14 | -------------------------------------------------------------------------------- /.github/workflows/eisenhower.yml: -------------------------------------------------------------------------------- 1 | name: Eisenhower 2 | on: 3 | issues: 4 | types: [opened, reopened, edited] 5 | jobs: 6 | prioritise: 7 | runs-on: ubuntu-latest 8 | env: 9 | GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} 10 | GH_REPOSITORY: ${{ github.repository }} 11 | GH_ISSUE_NUMBER: ${{ github.event.issue.number }} 12 | steps: 13 | - name: Run Eisenhower action 14 | uses: GeekZoneHQ/eisenhower@main 15 | -------------------------------------------------------------------------------- /memberships/tokens.py: -------------------------------------------------------------------------------- 1 | import six 2 | from django.contrib.auth.tokens import PasswordResetTokenGenerator 3 | 4 | 5 | class TokenGenerator(PasswordResetTokenGenerator): 6 | def _create_hash_value(self, user, timeStamp): 7 | return ( 8 | six.text_type(user.pk) 9 | + six.text_type(timeStamp) 10 | + six.text_type(user.username) 11 | ) 12 | 13 | 14 | email_verification_token = TokenGenerator() 15 | -------------------------------------------------------------------------------- /memberships/migrations/0010_remove_member_profile_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-30 16:03 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0009_auto_20210323_1611"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="member", 14 | name="profile_image", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /web/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for web project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /web/celery.py: -------------------------------------------------------------------------------- 1 | # https://docs.celeryproject.org/en/stable/django/first-steps-with-django.html#django-first-steps 2 | import os 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings") 6 | app = Celery("web") 7 | app.config_from_object("django.conf:settings", namespace="CELERY") 8 | app.autodiscover_tasks() 9 | 10 | 11 | @app.task(bind=True) 12 | def debug_task(self): 13 | print(f"Request: {self.request!r}") 14 | -------------------------------------------------------------------------------- /web/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for web project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /memberships/templates/memberships/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Welcome to Geek.Zone

5 | 6 |

This page will show the Geek.Zone member something interesting once they have logged in, or show a wiki page if the guest is not logged in.

7 | 8 |

For now, you can edit your settings.

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * @GeekZoneHQ/q 6 | 7 | # Order is important. The last matching pattern has the most precedence. 8 | # So if a pull request only touches javascript files, only these owners 9 | # will be requested to review. 10 | 11 | 12 | # You can also use email addresses if you prefer. 13 | -------------------------------------------------------------------------------- /docker/proxy/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 0.0.0.0:8080; #Need port 8080 to run container as user "nginx" 3 | 4 | location / { 5 | # this is localhost because gunicorn is 6 | # hosted in the same pod. 7 | proxy_pass http://localhost:8000; 8 | proxy_set_header Host $host; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | } 11 | 12 | location /static { 13 | alias /var/www/static/; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /memberships/templates/memberships/verify_confirmation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% extends "base.html" %} 4 | {% block content %} 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Congratulations


12 |

You are now a verified geek!

13 |

Head over to the Membership Details page to view your verified account

14 | 15 | {% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/take-action.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/take.yml 2 | name: Assign issue to contributor 3 | on: 4 | issue_comment: 5 | 6 | jobs: 7 | assign: 8 | name: Take an issue 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: take the issue 12 | uses: bdougie/take-action@main 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GZBOT_GHPAT_TAKE_ACTION }} 15 | with: 16 | message: Thanks for taking this issue! Let us know if you have any questions! 17 | trigger: /mine 18 | -------------------------------------------------------------------------------- /memberships/migrations/0013_membership_payment_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-19 14:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0012_auto_20210413_1903"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="membership", 14 | name="payment_status", 15 | field=models.CharField(max_length=255, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /memberships/migrations/0012_member_email_verified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-16 14:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0011_auto_20210330_2019"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="member", 14 | name="email_verified", 15 | field=models.BooleanField(default=False, verbose_name="Email verified"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /memberships/migrations/0004_auto_20200807_1947.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-07 19:47 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("memberships", "0003_membership"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="membership", 15 | name="start_date", 16 | field=models.DateTimeField(default=django.utils.timezone.now), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /memberships/templates/memberships/humans.txt: -------------------------------------------------------------------------------- 1 | # humanstxt.org 2 | # The humans responsible & technology colophon 3 | 4 | # TEAM 5 | 6 | Carwyn Nelson -- Developer 7 | James Geddes -- Django Padawan -- @JamesGeddes 8 | Tristan Bentham -- Front End Developer 9 | Giulio Giunta -- Infrastructure Engineer Padawan 10 | Sam Winterhalder -- Developer 11 | 12 | # THANKS 13 | 14 | Everyone at the Django Project. Rockstars every one. 15 | 16 | # TECHNOLOGY COLOPHON 17 | 18 | HTML5, CSS3 19 | Python, Django 20 | Kubernetes, Terraform 21 | -------------------------------------------------------------------------------- /memberships/migrations/0002_member_stripe_customer_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-07 14:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="member", 14 | name="stripe_customer_id", 15 | field=models.CharField(default="", max_length=255), 16 | preserve_default=False, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /memberships/templates/inc/input_text.html: -------------------------------------------------------------------------------- 1 | {% load widget_tweaks %} 2 | 3 |
4 | {{ field | add_label_class:"mb-1"}} 5 | 6 | {% if field.help_text %} 7 | {% include "inc/help_text.html" with field=field target=field|add_class:"input p-2 rounded-sm"|add_error_class:"input-error" %} 8 | {% else %} 9 | {{ field | add_class:"input p-2" | add_error_class:"input-error" }} 10 | {% endif %} 11 | 12 | {% if field.errors %}
{{ field.errors }}
{% endif %} 13 |
14 | -------------------------------------------------------------------------------- /memberships/migrations/0008_member_renewal_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-15 22:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0007_auto_20210125_0057"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="member", 14 | name="renewal_date", 15 | field=models.DateTimeField( 16 | null=True, verbose_name="Membership renewal date" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /docker/development/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.0rc2-alpine 2 | 3 | RUN apk update && \ 4 | apk add --update --virtual build-deps gcc libc-dev linux-headers && \ 5 | apk add jpeg-dev zlib-dev libpq python3-dev && \ 6 | apk add postgresql-dev && \ 7 | apk add netcat-openbsd 8 | 9 | RUN apk upgrade 10 | 11 | RUN apk add --update nodejs-current npm 12 | 13 | ENV PYTHONDONTWRITEBYTECODE 1 14 | ENV PYTHONUNBUFFERED 1 15 | 16 | WORKDIR /usr/src/app 17 | 18 | COPY . . 19 | 20 | RUN pip install --no-cache-dir -r requirements.txt 21 | RUN pip install gunicorn 22 | 23 | CMD [ "gunicorn", "web.wsgi", "-b 0.0.0.0:8000" ] -------------------------------------------------------------------------------- /memberships/email.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import render_to_string 2 | from django.core.mail import EmailMessage 3 | from django.conf import settings 4 | 5 | 6 | def send_email(to_name, to_email, subject, body): 7 | # NB Must be handled via Celery 8 | context = {"name": to_name, "email": to_email, "body": body} 9 | email_body = render_to_string("memberships/email_message.html", context) 10 | 11 | email = EmailMessage( 12 | subject, 13 | email_body, 14 | settings.DEFAULT_FROM_EMAIL, 15 | [to_name + " <" + to_email + ">"], 16 | ) 17 | return email.send(fail_silently=False) 18 | -------------------------------------------------------------------------------- /theme/templates/theme.html: -------------------------------------------------------------------------------- 1 | {% load static tailwind_tags %} 2 | 3 | 4 | 5 | Django Tailwind 6 | 7 | 8 | 9 | {% tailwind_css %} 10 | 11 | 12 | 13 |
14 |
15 |

Django + Tailwind = ❤️

16 |
17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /docker/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.0a5-alpine 2 | 3 | RUN apk update && \ 4 | apk add --update --virtual build-deps gcc libc-dev linux-headers && \ 5 | apk add jpeg-dev zlib-dev libpq=16.2-r1 python3-dev && \ 6 | apk add postgresql-dev && \ 7 | apk add netcat-openbsd 8 | 9 | RUN apk upgrade 10 | 11 | ENV PYTHONDONTWRITEBYTECODE 1 12 | ENV PYTHONUNBUFFERED 1 13 | 14 | WORKDIR /usr/src/app 15 | 16 | RUN adduser -D django 17 | 18 | COPY --chown=django:django . . 19 | 20 | RUN chmod -R 755 /usr/src/app/ 21 | 22 | RUN pip install --no-cache-dir -r requirements.txt 23 | RUN pip install gunicorn 24 | 25 | USER django 26 | 27 | CMD [ "gunicorn", "web.wsgi", "-b 0.0.0.0:8000" ] -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /memberships/templates/memberships/welcome_email.html: -------------------------------------------------------------------------------- 1 | I'm James & I am one of the Trustees here at Geek.Zone. I want to thank you for joining Geek.Zone! You have just become part of a fantastic geeky community where you will learn interesting skills, create amazing stuff and, most importantly, make new nerdy friends! Remember that our community needs you, so, if you are able, please donate to support us. 2 | 3 | Take a look at our events to find out about what's going on in your area as well as online. There's something happening every week! If you have any questions, comments or ideas, please do not hesitate to let me know. For now though, thanks again for joining! 4 | 5 | See you soon! 6 | 7 | James & everyone at Geek.Zone 8 | -------------------------------------------------------------------------------- /stripe.sequence: -------------------------------------------------------------------------------- 1 | title GeekZone Registration and Payment 2 | 3 | actor customer as c 4 | participant GeekZone as gz 5 | participant Stripe as s 6 | 7 | alt sync registration process 8 | c->gz: Initial registration form including donation amount 9 | gz->c: donation confirmation page with link to stripe 10 | c->s: fills in direct debit information 11 | s->c: sends customer to registration confirmation page 12 | c->gz: visits registration confirmation 13 | end 14 | 15 | alt async stripe payment_intent confirm 16 | s->gz: confirms customer payment intent succeeded 17 | gz->s: create subscription 18 | gz->gz: create membership for customer 19 | end 20 | 21 | alt async stripe payment success 22 | s->gz: payment confirmed 23 | gz->gz: note initial payment confirmed in database? 24 | end 25 | -------------------------------------------------------------------------------- /.github/linters/.htmlhintrc: -------------------------------------------------------------------------------- 1 | { 2 | "tagname-lowercase": true, 3 | "attr-lowercase": true, 4 | "attr-value-double-quotes": true, 5 | "attr-value-not-empty": false, 6 | "attr-no-duplication": true, 7 | "doctype-first": false, 8 | "tag-pair": true, 9 | "tag-self-close": false, 10 | "spec-char-escape": false, 11 | "id-unique": true, 12 | "src-not-empty": true, 13 | "title-require": true, 14 | "alt-require": true, 15 | "doctype-html5": true, 16 | "id-class-value": "dash", 17 | "style-disabled": false, 18 | "inline-style-disabled": false, 19 | "inline-script-disabled": false, 20 | "space-tab-mixed-disabled": "space", 21 | "id-class-ad-disabled": false, 22 | "href-abs-or-rel": false, 23 | "attr-unsafe-chars": true, 24 | "head-script-disabled": true 25 | } -------------------------------------------------------------------------------- /theme/static_src/bs.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Browser-sync config file 4 | |-------------------------------------------------------------------------- 5 | | 6 | | For up-to-date information about the options: 7 | | http://www.browsersync.io/docs/options/ 8 | | 9 | | There are more options than you see here, these are just the ones that are 10 | | set internally. See the website for more info. 11 | | 12 | | 13 | */ 14 | 15 | const tailwindConfig = require('./tailwind.config.js'); 16 | 17 | module.exports = { 18 | port: 8383, 19 | ui: false, 20 | logSnippet: false, 21 | open: false, 22 | reloadOnRestart: true, 23 | files: [ 24 | '../static/css/dist/styles.css', 25 | ...tailwindConfig.purge.content 26 | ] 27 | }; -------------------------------------------------------------------------------- /docker/proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.0rc2-alpine 2 | 3 | RUN apk update && \ 4 | apk add --update --virtual build-deps gcc libc-dev linux-headers && \ 5 | apk add jpeg-dev zlib-dev && \ 6 | apk add postgresql-dev && \ 7 | apk add netcat-openbsd 8 | 9 | RUN apk upgrade 10 | 11 | WORKDIR /usr/src/app 12 | 13 | COPY . . 14 | RUN pip install --no-cache-dir -r requirements.txt 15 | RUN ["python", "manage.py", "collectstatic"] 16 | 17 | CMD [ "gunicorn", "web.wsgi", "-b 0.0.0.0:8000" ] 18 | 19 | FROM nginx:alpine 20 | 21 | RUN apk update && apk upgrade 22 | 23 | # Install the fixed versions of libwebp and curl 24 | RUN apk add --no-cache libwebp=1.3.2-r0 curl=8.5.0-r0 25 | 26 | COPY docker/proxy/default.conf /etc/nginx/conf.d/ 27 | COPY docker/proxy/nginx.conf /etc/nginx/ 28 | COPY --from=0 /usr/src/app/static /var/www/static 29 | 30 | USER nginx -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref~=3.3 2 | backports.functools-lru-cache>=1.6.4 3 | beautifulsoup4==4.9.3 4 | cached-property==1.5.2 5 | certifi==2023.7.22 6 | chardet==4.0.0 7 | Django==4.2.11 8 | django-environ==0.4.5 9 | django-extensions==3.1.1 10 | django-livereload-server~=0.3 11 | django-probes~=1.6 12 | django-tailwind~=3.3.0 13 | django-widget-tweaks~=1.4 14 | idna==3.7 15 | importlib-metadata>=6.6.0 16 | Pillow==10.3.0 17 | psycopg[c]==3.1.18 18 | pytz==2023.3 19 | requests==2.31.0 20 | six==1.16.0 21 | soupsieve~=2.2 22 | sqlparse==0.5.0 23 | stripe==5.4.0 24 | tornado==6.3.3 25 | urllib3>=2.0.7 26 | celery==5.2.2 27 | django-clacks>=0.1.0 28 | cookiecutter==2.1.1 # not directly required, pinned by Snyk to avoid a vulnerability 29 | setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability 30 | typing-extensions>=4.5.0 # not directly required, pinned by Snyk to avoid a vulnerability 31 | -------------------------------------------------------------------------------- /web/urls.py: -------------------------------------------------------------------------------- 1 | """web URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | from web import views 19 | 20 | urlpatterns = [ 21 | path("admin/", admin.site.urls), 22 | path("memberships/", include("memberships.urls")), 23 | path("theme/", include("theme.urls")), 24 | ] 25 | -------------------------------------------------------------------------------- /memberships/migrations/0009_auto_20210323_1611.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-23 16:11 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("memberships", "0008_member_renewal_date"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="failedpayment", 15 | name="stripe_customer_id", 16 | ), 17 | migrations.AddField( 18 | model_name="failedpayment", 19 | name="member", 20 | field=models.ForeignKey( 21 | on_delete=django.db.models.deletion.CASCADE, 22 | to="memberships.member", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="member", 27 | name="stripe_customer_id", 28 | field=models.CharField(max_length=255, unique=True), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /docker/proxy/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | error_log /var/log/nginx/error.log warn; 4 | pid /tmp/nginx.pid; 5 | 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | 12 | http { 13 | client_body_temp_path /tmp/client_temp; 14 | proxy_temp_path /tmp/proxy_temp_path; 15 | fastcgi_temp_path /tmp/fastcgi_temp; 16 | uwsgi_temp_path /tmp/uwsgi_temp; 17 | scgi_temp_path /tmp/scgi_temp; 18 | include /etc/nginx/mime.types; 19 | default_type application/octet-stream; 20 | 21 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 22 | '$status $body_bytes_sent "$http_referer" ' 23 | '"$http_user_agent" "$http_x_forwarded_for"'; 24 | 25 | access_log /var/log/nginx/access.log main; 26 | 27 | sendfile on; 28 | #tcp_nopush on; 29 | 30 | keepalive_timeout 65; 31 | 32 | #gzip on; 33 | 34 | include /etc/nginx/conf.d/*.conf; 35 | } 36 | -------------------------------------------------------------------------------- /memberships/migrations/0006_failedpayment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2021-01-22 22:41 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0005_auto_20201120_1445"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="FailedPayment", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("stripe_user_id", models.CharField(max_length=255)), 25 | ("stripe_subscription_id", models.CharField(max_length=255)), 26 | ("stripe_event_type", models.CharField(max_length=255)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /memberships/migrations/0012_auto_20210413_1903.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-13 18:03 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0011_auto_20210330_2019"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="member", 14 | options={ 15 | "permissions": ( 16 | ("has_membership", "Member has paid"), 17 | ( 18 | "reminder_email_24hr", 19 | "New member sent 24hr payment email", 20 | ), 21 | ( 22 | "reminder_email_72hr", 23 | "New member sent 72hr payment email", 24 | ), 25 | ), 26 | "verbose_name": "member", 27 | "verbose_name_plural": "members", 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /memberships/templates/memberships/confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Confirm your Geek.Zone membership payment

5 | 6 |

Your membership will cost £{{total}} a year

7 | 8 |

9 | {% if donation %} 10 | This is made up of a £1 membership charge and a £{{donation}} donation 11 | {% else %} 12 | This is made up of a £1 membership charge with no donation 13 | {% endif %} 14 |

15 | 16 | 17 | 18 | 19 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /theme/static_src/src/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | .btn { 7 | @apply appearance-none py-2 px-4 text-sm text-gray-900 bg-yellow-300 hover:bg-yellow-400 border-none shadow rounded-sm active:shadow-inner focus:ring-2 focus:ring-red-500 outline-none cursor-pointer; 8 | } 9 | .field-error { 10 | @apply text-red-600 dark:text-red-400; 11 | } 12 | .header { 13 | @apply text-xl font-semibold; 14 | } 15 | .input:not(input[type="checkbox"]) { 16 | @apply appearance-none bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-400 focus:ring-1 focus:ring-yellow-300 focus:border-yellow-300 outline-none; 17 | } 18 | .input-error:not(input[type="checkbox"]) { 19 | @apply border-red-400 ring-1 ring-red-500 outline-none; 20 | } 21 | .link { 22 | @apply text-blue-700 dark:text-blue-300 hover:underline; 23 | } 24 | .nav-link { 25 | @apply inline-block py-1 px-2 w-full text-center hover:bg-yellow-200 dark:hover:bg-gray-800 rounded; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /memberships/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | from django.contrib.auth.views import * 4 | from django.urls import path, include 5 | 6 | urlpatterns = [ 7 | path("register/", views.register, name="register"), 8 | path("confirm/", views.confirm, name="confirm"), 9 | path("thanks/", views.thanks, name="thanks"), 10 | path("stripe-webhook/", views.stripe_webhook, name="stripe_webhook"), 11 | path("settings/", views.settings_view, name="memberships_settings"), 12 | path("details/", views.details_view, name="memberships_details"), 13 | path("verify", views.sendVerification, name="send_verification"), 14 | path("verify//", views.verify, name="verify"), 15 | path("change-password/", PasswordChangeView.as_view()), 16 | path( 17 | "login/", 18 | LoginView.as_view(template_name="memberships/login.html"), 19 | name="memberships_login", 20 | ), 21 | path( 22 | "logout/", 23 | LogoutView.as_view(template_name="memberships/logout.html"), 24 | name="memberships_logout", 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /memberships/templates/memberships/member_details.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block content %} 5 |

Member Details

6 | 7 |
8 | {% csrf_token %} 9 | 10 |
11 | {% for field in form %} 12 | {{ field | add_label_class:"-mb-2 sm:mb-0 font-bold"}} 13 | 14 |
15 | {% if field.widget_type == "checkbox" %} 16 | {% if field.value %}Yes{% else %}No{% endif %} 17 | {% else %} 18 | {% if field.value != "" %}{% render_field field.value %}{% else %}-{% endif %} 19 | {% endif %} 20 |
21 | {% endfor %} 22 |
23 | 24 |
25 | 26 | 27 | {% if not verified %} 28 | 29 | {% endif %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /memberships/templates/inc/help_text.html: -------------------------------------------------------------------------------- 1 | {% load widget_tweaks %} 2 | 3 |
4 | 12 | 13 | {% if target %} 14 | {{ target | add_class:"inset-0 w-full" | attr:"onfocus:showHelpText(this.previousElementSibling)" | attr:"onblur:hideHelpText(this.previousElementSibling)" }} 15 | {% else %} 16 |
17 | 18 | More details 19 |
20 | {% endif %} 21 |
22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web: 5 | image: geekzone/backend:${TAG} 6 | build: 7 | context: . 8 | dockerfile: docker/backend/Dockerfile 9 | container_name: backend 10 | command: > 11 | sh -c "python3 manage.py wait_for_database && 12 | python3 manage.py migrate && \ 13 | python3 manage.py runserver 0.0.0.0:8000" 14 | env_file: 15 | - web/.env.dev 16 | ports: 17 | - "8000:8000" 18 | depends_on: 19 | - db 20 | - celery_worker 21 | 22 | db: 23 | image: postgres 24 | container_name: postgres 25 | environment: 26 | - POSTGRES_PASSWORD=password 27 | env_file: 28 | - web/.env.dev 29 | restart: unless-stopped 30 | 31 | celery_worker: 32 | build: 33 | context: . 34 | dockerfile: docker/backend/Dockerfile 35 | container_name: celery 36 | command: celery -A web worker -l INFO # If logging level omitted, default is "warning" 37 | env_file: 38 | - web/.env.dev 39 | depends_on: 40 | - rabbitmq 41 | restart: 'no' 42 | 43 | rabbitmq: 44 | image: rabbitmq:3-management 45 | container_name: rabbitmq 46 | hostname: geekzone-rabbit 47 | user: rabbitmq 48 | ports: 49 | - "15672:15672" 50 | -------------------------------------------------------------------------------- /memberships/migrations/0003_membership.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-07 19:33 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("memberships", "0002_member_stripe_customer_id"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Membership", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("stripe_subscription_id", models.CharField(max_length=255)), 26 | ("start_date", models.DateTimeField()), 27 | ("end_date", models.DateTimeField(null=True)), 28 | ( 29 | "member", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | to="memberships.Member", 33 | ), 34 | ), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | dockerfile: docker/development/Dockerfile 8 | container_name: web 9 | command: > 10 | sh -c "python3 manage.py wait_for_database && 11 | python3 manage.py migrate && \ 12 | python3 manage.py runserver 0.0.0.0:8000" 13 | env_file: 14 | - web/.env.dev 15 | ports: 16 | - "8000:8000" 17 | depends_on: 18 | - db 19 | - celery_worker 20 | volumes: 21 | - .:/usr/src/app 22 | 23 | db: 24 | image: postgres 25 | container_name: postgres 26 | environment: 27 | - POSTGRES_PASSWORD=password 28 | env_file: 29 | - web/.env.dev 30 | restart: unless-stopped 31 | 32 | celery_worker: 33 | build: 34 | context: . 35 | dockerfile: docker/development/Dockerfile 36 | container_name: celery 37 | command: celery -A web worker -l INFO # If logging level omitted, default is "warning" 38 | env_file: 39 | - web/.env.dev 40 | depends_on: 41 | - rabbitmq 42 | restart: 'no' 43 | 44 | rabbitmq: 45 | image: rabbitmq:3-management 46 | container_name: rabbitmq 47 | hostname: geekzone-rabbit 48 | user: rabbitmq 49 | ports: 50 | - "15672:15672" 51 | 52 | 53 | -------------------------------------------------------------------------------- /memberships/templates/memberships/member_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block content %} 5 |

Member Settings

6 | 7 |
8 | {% csrf_token %} 9 | 10 |
11 | {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} 12 | 13 | {% for field in form %} 14 | {{ field | add_label_class:"-mb-2 sm:mb-0 sm:mr-2 font-bold"}} 15 | 16 |
17 | {{ field | add_class:"input p-1 rounded-sm" | add_error_class:"input-error" }} 18 | 19 | {% if field.field.required %} * {% endif %} 20 | 21 | {% if field.help_text %} 22 | {% include "inc/help_text.html" with field=field %} 23 | {% endif %} 24 |
25 | 26 | {% if field.errors %}
{{ field.errors }}
{% endif %} 27 | {% endfor %} 28 |
29 | 30 | 31 |
32 | {% endblock %} -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | # GitHub Action that uses Black to reformat the Python code in an incoming pull request. 2 | # If all Python code in the pull request is compliant with Black then this Action does nothing. 3 | # Othewrwise, Black is run and its changes are committed back to the incoming pull request. 4 | # https://github.com/cclauss/autoblack 5 | # Thanks to the PSF! 6 | # https://github.com/psf/black/actions/runs/17913292/workflow 7 | 8 | name: autoblack 9 | on: [pull_request] 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install Black 20 | run: pip install black 21 | - name: Run black 22 | run: black --check . 23 | - name: If needed, commit black changes to the pull request 24 | if: failure() 25 | run: | 26 | black . 27 | git config --global user.name 'Geek.Zone/Bot' 28 | git config --global user.email 'geekzonebot@users.noreply.github.com' 29 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 30 | git checkout $GITHUB_HEAD_REF 31 | git commit -am "fixup: Format Python code with Black" 32 | git push 33 | -------------------------------------------------------------------------------- /memberships/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.models import User 3 | from django.contrib.auth.admin import UserAdmin as DefaultUserAdmin 4 | from django.utils.translation import gettext_lazy as _ 5 | from .models import Member 6 | 7 | 8 | @admin.register(Member) 9 | class MemberAdmin(admin.ModelAdmin): 10 | list_display = ("full_name", "preferred_name", "user_email") 11 | readonly_fields = ("user",) 12 | 13 | def user_email(self, obj): 14 | return obj.user 15 | 16 | # Membership registration should be done 17 | # via the custom member registration form. 18 | def has_add_permission(self, request): 19 | return False 20 | 21 | 22 | admin.site.unregister(User) 23 | 24 | 25 | @admin.register(User) 26 | class UserAdmin(DefaultUserAdmin): 27 | fieldsets = ( 28 | (None, {"fields": ("username", "password", "email")}), 29 | ( 30 | _("Permissions"), 31 | { 32 | "fields": ( 33 | "is_active", 34 | "is_staff", 35 | "is_superuser", 36 | "groups", 37 | "user_permissions", 38 | ), 39 | }, 40 | ), 41 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 42 | ) 43 | readonly_fields = ("last_login", "date_joined") 44 | -------------------------------------------------------------------------------- /memberships/tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | from unittest import mock 3 | 4 | 5 | class StripeTestCase(TransactionTestCase): 6 | def setup_stripe_mocks(self): 7 | self._upload_member() 8 | self._create_subscription() 9 | self._create_checkout_session() 10 | 11 | def tear_down_stripe_mocks(self): 12 | self.upload_member_patcher.stop() 13 | self.create_subscription_patcher.stop() 14 | self.create_checkout_session_patcher.stop() 15 | 16 | def patch(self, function): 17 | return mock.patch( 18 | f"memberships.services.StripeGateway.{function}", autospec=True 19 | ) 20 | 21 | def _upload_member(self): 22 | self.upload_member_patcher = self.patch("upload_member") 23 | self.upload_member = self.upload_member_patcher.start() 24 | self.upload_member.return_value = "example_stripe_customer" 25 | 26 | def _create_subscription(self): 27 | self.create_subscription_patcher = self.patch("create_subscription") 28 | self.create_subscription = self.create_subscription_patcher.start() 29 | self.create_subscription.return_value = { 30 | "email": "test@example.com", 31 | "id": "stripe_subscription_id", 32 | } 33 | 34 | def _create_checkout_session(self): 35 | self.create_checkout_session_patcher = self.patch("create_checkout_session") 36 | self.create_checkout_session = self.create_checkout_session_patcher.start() 37 | self.create_checkout_session.return_value = "example_session_id" 38 | -------------------------------------------------------------------------------- /memberships/tests/test_login_form.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.test import override_settings 3 | from .utils import StripeTestCase 4 | 5 | 6 | @override_settings(RECAPTCHA_SECRET_KEY=None, RECAPTCHA_SITE_KEY=None) 7 | class LoginFormTestCase(StripeTestCase): 8 | def setUp(self): 9 | self.setup_stripe_mocks() 10 | 11 | def tearDown(self): 12 | self.tear_down_stripe_mocks() 13 | 14 | def test_newly_registered_user_can_login(self): 15 | # Register a user using the form 16 | response = self.client.post( 17 | reverse("register"), 18 | { 19 | "full_name": "test person", 20 | "email": "test@example.com", 21 | "password": "k38m1KIhIUzeA^UL", 22 | "birth_date": "1991-01-01", 23 | "constitution_agreed": "on", 24 | "constitutional_post": "on", 25 | "constitutional_email": "on", 26 | }, 27 | follow=True, 28 | ) 29 | self.assertTrue(response.context["user"].is_authenticated) 30 | 31 | # Log them out 32 | response = self.client.get(reverse("memberships_logout"), follow=True) 33 | self.assertFalse(response.context["user"].is_authenticated) 34 | 35 | # Log them back in using the login form 36 | response = self.client.post( 37 | reverse("memberships_login"), 38 | {"username": "test@example.com", "password": "k38m1KIhIUzeA^UL"}, 39 | follow=True, 40 | ) 41 | self.assertTrue(response.context["user"].is_authenticated) 42 | -------------------------------------------------------------------------------- /memberships/templates/memberships/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block content %} 5 | 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /theme/static_src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme", 3 | "version": "2.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "npm run dev", 7 | "build": "npm run build:clean && npm run build:postcss && npm run build:cleancss", 8 | "build:clean": "rimraf ../static/css/dist", 9 | "build:postcss": "cross-env NODE_ENV=production postcss --config . --map false --output ../static/css/dist/styles.css ./src/styles.css", 10 | "build:cleancss": "cleancss -o ../static/css/dist/styles.css ../static/css/dist/styles.css", 11 | "sync": "browser-sync start --config bs.config.js", 12 | "dev:postcss": "cross-env NODE_ENV=development postcss --config . --map true --output ../static/css/dist/styles.css -w ./src/styles.css", 13 | "dev:sync": "run-p dev:postcss sync", 14 | "dev": "nodemon -x \"npm run dev:sync\" -w tailwind.config.js -w postcss.config.js -w bs.config.js -e js" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@tailwindcss/aspect-ratio": "^0.2.0", 21 | "@tailwindcss/forms": "^0.3.2", 22 | "@tailwindcss/line-clamp": "^0.2.0", 23 | "@tailwindcss/typography": "^0.4.0", 24 | "autoprefixer": "^10.2.5", 25 | "browser-sync": "^2.26.14", 26 | "clean-css-cli": "^5.2.2", 27 | "cross-env": "^7.0.3", 28 | "nodemon": "^2.0.7", 29 | "npm-run-all": "^4.1.5", 30 | "postcss": "^8.2.9", 31 | "postcss-cli": "^8.3.1", 32 | "postcss-import": "^14.0.1", 33 | "postcss-nested": "^5.0.5", 34 | "postcss-simple-vars": "^6.0.3", 35 | "rimraf": "^3.0.2", 36 | "tailwindcss": "^2.1.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /memberships/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-07-08 21:12 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Member", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("full_name", models.CharField(max_length=255)), 29 | ("preferred_name", models.CharField(max_length=255)), 30 | ("email", models.EmailField(max_length=254)), 31 | ("birth_date", models.DateField()), 32 | ("constitution_agreed", models.BooleanField()), 33 | ( 34 | "user", 35 | models.OneToOneField( 36 | on_delete=django.db.models.deletion.CASCADE, 37 | to=settings.AUTH_USER_MODEL, 38 | ), 39 | ), 40 | ], 41 | options={ 42 | "verbose_name": "member", 43 | "verbose_name_plural": "members", 44 | }, 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /funky_time.py: -------------------------------------------------------------------------------- 1 | # handy functions for doing the heavy lifting in the nightmare that is the global datetime system 2 | 3 | from datetime import datetime 4 | from django.utils.timezone import make_aware 5 | from time import gmtime 6 | from web.settings import TIME_ZONE 7 | 8 | from django.utils.timezone import make_aware 9 | 10 | 11 | # what date is x years from a given date in the past? 12 | def years_ago(years: int, from_date: datetime = None): 13 | if from_date is None: 14 | from_date = datetime.now() 15 | try: 16 | return from_date.replace(year=from_date.year - years) 17 | except ValueError: 18 | # Must be 2/29! 19 | assert from_date.month == 2 and from_date.day == 29 # can be removed 20 | return from_date.replace(month=2, day=28, year=from_date.year - years) 21 | 22 | 23 | # what date is x years from a given date in the future? 24 | def years_from(years: int, from_date: datetime): 25 | try: 26 | new_date = from_date.replace(year=from_date.year + years) 27 | return make_aware(new_date) 28 | except ValueError: 29 | # Must be 2/29! 30 | new_date = from_date.replace(month=3, day=1, year=from_date.year + years) 31 | return make_aware(new_date) 32 | 33 | 34 | def is_younger_than(age: int, birth_date: datetime = None): 35 | return years_ago(age, datetime.now()) <= birth_date 36 | 37 | 38 | def is_older_than(age: int, birth_date: datetime = None): 39 | return years_ago(age, datetime.now()) >= birth_date 40 | 41 | 42 | def date_to_datetime(date): 43 | return datetime.combine(date, datetime.min.time()) 44 | 45 | 46 | def epoch_to_datetime(time): 47 | return make_aware(datetime.fromtimestamp(time)) 48 | -------------------------------------------------------------------------------- /memberships/migrations/0007_auto_20210125_0057.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-25 00:57 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("memberships", "0006_failedpayment"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="member", 15 | options={ 16 | "permissions": (("has_membership", "Member has paid"),), 17 | "verbose_name": "member", 18 | "verbose_name_plural": "members", 19 | }, 20 | ), 21 | migrations.RenameField( 22 | model_name="failedpayment", 23 | old_name="stripe_user_id", 24 | new_name="stripe_customer_id", 25 | ), 26 | migrations.AddField( 27 | model_name="membership", 28 | name="last_payment_time", 29 | field=models.DateTimeField(null=True), 30 | ), 31 | migrations.CreateModel( 32 | name="Payment", 33 | fields=[ 34 | ( 35 | "id", 36 | models.AutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name="ID", 41 | ), 42 | ), 43 | ("stripe_subscription_id", models.CharField(max_length=255)), 44 | ( 45 | "member", 46 | models.ForeignKey( 47 | on_delete=django.db.models.deletion.CASCADE, 48 | to="memberships.member", 49 | ), 50 | ), 51 | ], 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /memberships/tests/test_member_model.py: -------------------------------------------------------------------------------- 1 | from memberships.models import Member 2 | from .utils import StripeTestCase 3 | from web.settings import TEST_USER_PASSWORD 4 | 5 | 6 | class MemberModelTestCase(StripeTestCase): 7 | def setUp(self): 8 | self.setup_stripe_mocks() 9 | 10 | def tearDown(self): 11 | self.tear_down_stripe_mocks() 12 | 13 | def test_a_member_gets_a_django_auth_account(self): 14 | member = Member.create( 15 | full_name="test person", 16 | preferred_name="test", 17 | email="test@example.com", 18 | password=TEST_USER_PASSWORD, 19 | birth_date="1991-01-01", 20 | ) 21 | self.assertIsNotNone(member.user) 22 | self.assertEqual(member.email, member.user.username) 23 | 24 | def test_preferred_name_defaults_to_full_name(self): 25 | member = Member.create( 26 | full_name="test person", 27 | email="test@example.com", 28 | password=TEST_USER_PASSWORD, 29 | birth_date="1991-01-01", 30 | ) 31 | self.assertEqual(member.full_name, member.preferred_name) 32 | 33 | def test_preferred_name_can_be_specified(self): 34 | member = Member.create( 35 | full_name="test person", 36 | preferred_name="test", 37 | email="test@example.com", 38 | password=TEST_USER_PASSWORD, 39 | birth_date="1991-01-01", 40 | ) 41 | self.assertEqual("test", member.preferred_name) 42 | 43 | def test_a_member_is_created_as_a_stripe_customer(self): 44 | member = Member.create( 45 | full_name="test person", 46 | preferred_name="test", 47 | email="test@example.com", 48 | password=TEST_USER_PASSWORD, 49 | birth_date="1991-01-01", 50 | ) 51 | self.assertEqual("example_stripe_customer", member.stripe_customer_id) 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | description: Got a new idea? We're all ears! 4 | labels: [ "enhancement", "triage" ] 5 | assignees: 6 | - q 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this feature request! This is for requesting new functionality, not for fixing existing stuff that has broken. 12 | - type: textarea 13 | id: feature-description 14 | attributes: 15 | label: What's your idea? 16 | description: What is the problem that you are experiencing? What is your proposed solution? Feel free to include any screenshots or videos that help to explain. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: user-story 21 | attributes: 22 | label: User Story 23 | description: As a `persona`, I want to `do a thing` so that `I get a benifit`. 24 | placeholder: As a cat, I want to sit on a comfy bed so that I can sleep all day. 25 | validations: 26 | required: true 27 | - type: dropdown 28 | id: impact 29 | attributes: 30 | label: Impact 31 | description: How important is this? For example, how many people would it help? Is it a legal requirement? 32 | options: 33 | - High 34 | - Low 35 | validations: 36 | required: true 37 | - type: dropdown 38 | id: urgency 39 | attributes: 40 | label: Urgency 41 | description: Should this be implemented now or can it be done later? 42 | options: 43 | - Now 44 | - Later 45 | validations: 46 | required: true 47 | - type: checkboxes 48 | id: terms 49 | attributes: 50 | label: Code of Conduct 51 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://geek.zone/code-of-conduct/) 52 | options: 53 | - label: I agree to follow this project's Code of Conduct 54 | required: true -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | 7 | 8 | 9 | ## Related Issue 10 | 11 | 12 | 13 | 14 | 15 | ## Motivation and Context 16 | 17 | 18 | ## How Has This Been Tested? 19 | 20 | 21 | 22 | 23 | ## Types of changes 24 | 25 | - [ ] Bug fix (non-breaking change which fixes an issue) 26 | - [ ] New feature (non-breaking change which adds functionality) 27 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 28 | 29 | ## Checklist: 30 | 31 | 32 | - [ ] My code follows the code style of this project. 33 | - [ ] My change requires a change to the documentation. 34 | - [ ] I have updated the documentation accordingly. 35 | - [ ] I have read the **[CONTRIBUTING](https://github.com/GeekZoneHQ/contributing)** document. 36 | - [ ] I have added tests to cover my changes. 37 | - [ ] All new and existing tests passed. 38 | -------------------------------------------------------------------------------- /memberships/services.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | import stripe 3 | 4 | 5 | class StripeGateway: 6 | def __init__(self, membership_price_id=None, donation_product_id=None, test=False): 7 | stripe.api_key = settings.STRIPE_SECRET_KEY if not test else None 8 | self.membership_price_id = ( 9 | membership_price_id 10 | if not settings.MEMBERSHIP_PRICE_ID 11 | else settings.MEMBERSHIP_PRICE_ID 12 | ) 13 | self.donation_product_id = ( 14 | donation_product_id 15 | if not settings.DONATION_PRODUCT_ID 16 | else settings.DONATION_PRODUCT_ID 17 | ) 18 | 19 | def upload_member(self, email): 20 | customer = stripe.Customer.create(email=email) 21 | return customer.id 22 | 23 | def create_checkout_session(self, member, success_url, cancel_url): 24 | session = stripe.checkout.Session.create( 25 | payment_method_types=["bacs_debit"], 26 | mode="setup", 27 | customer=member.stripe_customer_id, 28 | success_url=success_url, 29 | cancel_url=cancel_url, 30 | ) 31 | return session.id 32 | 33 | def create_subscription(self, setup_intent, donation=None): 34 | intent = stripe.SetupIntent.retrieve(setup_intent) 35 | customer = stripe.Customer.retrieve(intent.customer) 36 | 37 | items = [{"price": self.membership_price_id}] 38 | if donation: 39 | price = stripe.Price.create( 40 | unit_amount=donation * int(100), 41 | currency="gbp", 42 | recurring={"interval": "year"}, 43 | product=self.donation_product_id, 44 | ) 45 | items.append({"price": price.id}) 46 | 47 | subscription = stripe.Subscription.create( 48 | customer=intent.customer, 49 | default_payment_method=intent.payment_method, 50 | items=items, 51 | ) 52 | return {"id": subscription.id, "email": customer.email} 53 | -------------------------------------------------------------------------------- /memberships/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from celery.utils.log import get_task_logger 3 | from datetime import datetime, timedelta 4 | from django.contrib.auth.models import Permission, User 5 | from memberships.models import Member 6 | 7 | from .email import send_email 8 | 9 | logger = get_task_logger(__name__) 10 | 11 | 12 | @shared_task 13 | def task_send_email( 14 | to_name, to_email, subject, body 15 | ): # where possible, to_name should be preferred name 16 | logger.info("Email on its way!") 17 | return send_email(to_name, to_email, subject, body) 18 | 19 | 20 | @shared_task 21 | def task_payment_check(id): 22 | try: 23 | member = Member.objects.get(id=id) 24 | user = User.objects.get(id=member.user_id) 25 | if not user.has_perm("memberships.has_membership"): 26 | if user.has_perm("memberships.reminder_email_72hr"): 27 | logger.info("New user has not paid in 120 hours.") 28 | subject = "120hr email subject" 29 | body = "120hr email body" 30 | # todo: delete user 31 | elif user.has_perm("memberships.reminder_email_24hr"): 32 | logger.info("New user has not paid in 72 hours.") 33 | perm = Permission.objects.get(codename="reminder_email_72hr") 34 | user.user_permissions.add(perm) 35 | exec_time = datetime.utcnow() + timedelta(hours=46) 36 | task_payment_check.apply_async(args=(member.id,), eta=exec_time) 37 | subject = "72hr email subject" 38 | body = "72hr email body" 39 | else: 40 | logger.info("New user has not paid in 24 hours.") 41 | perm = Permission.objects.get(codename="reminder_email_24hr") 42 | user.user_permissions.add(perm) 43 | exec_time = datetime.utcnow() + timedelta(hours=48) 44 | task_payment_check.apply_async(args=(member.id,), eta=exec_time) 45 | subject = "24hr email subject" 46 | body = "24hr email body" 47 | 48 | task_send_email(member.preferred_name, member.email, subject, body) 49 | 50 | except Member.DoesNotExist: 51 | # todo: should anything be done if the user is not found? 52 | logger.info(f"User with id: {id} not found.") 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | web/.env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | *.xml 131 | 132 | # PyCharm workspace settings 133 | .idea/ 134 | .idea/workspace.xml 135 | .idea/workspace.xml 136 | 137 | # Exception for Tailwind CSS 138 | !theme/static/css/dist/ -------------------------------------------------------------------------------- /memberships/migrations/0011_auto_20210330_2019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-30 20:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0010_remove_member_profile_image"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="member", 14 | name="constitutional_email", 15 | field=models.BooleanField( 16 | default=False, 17 | help_text="I am happy to receive emails that relate to constitutional matters", 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="member", 22 | name="constitutional_post", 23 | field=models.BooleanField( 24 | default=False, 25 | help_text="I am happy to receive letters that relate to constitutional matters", 26 | ), 27 | ), 28 | migrations.AddField( 29 | model_name="member", 30 | name="profile_image", 31 | field=models.ImageField( 32 | blank=True, 33 | help_text="Strike a geek pose and give us your best shot! This will be used on your GZID card", 34 | upload_to="images/", 35 | verbose_name="Selfie", 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="member", 40 | name="constitution_agreed", 41 | field=models.BooleanField( 42 | default=False, 43 | help_text='I have read and agree to abide by the Geek.Zone/Constitution. ', 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="member", 48 | name="gift_aid", 49 | field=models.BooleanField( 50 | default=False, 51 | help_text="I would like The UK Government to increase the value of my donation by as much as 25% at no cost to me!

I want to Gift Aid my donation, and any donations I make in the future or have made in the past 4 years, to Geek.Zone. I am a UK taxpayer and understand that if I pay less Income Tax and/or Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that tax year it is my responsibility to pay any difference.

I will notify Geek.Zone if I: ", 52 | verbose_name="Gift aid", 53 | ), 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /memberships/static/js/main.js: -------------------------------------------------------------------------------- 1 | const hello = () => console.log("hello"); // for debugging 2 | 3 | // check user's light/dark mode preference 4 | function checkDarkMode() { 5 | switch (localStorage.theme) { 6 | case "light": 7 | document.documentElement.classList.remove("dark"); 8 | break; 9 | case "dark": 10 | document.documentElement.classList.add("dark"); 11 | break; 12 | default: 13 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) 14 | document.documentElement.classList.add("dark"); 15 | else 16 | document.documentElement.classList.remove("dark"); 17 | } 18 | } 19 | 20 | // change between light and dark modes 21 | function toggleDarkMode() { 22 | document.documentElement.classList.toggle("dark"); 23 | 24 | if (document.documentElement.classList.contains("dark")) 25 | localStorage.theme = "dark"; 26 | else 27 | localStorage.theme = "light"; 28 | } 29 | 30 | // toggle visibility of header menu on smaller screens 31 | function toggleHeaderMenu() { 32 | document.getElementById("header-nav").classList.toggle("hidden"); 33 | } 34 | 35 | // show popover help text 36 | function showHelpText(popover) { 37 | let classList = popover.classList; 38 | 39 | classList.remove("hidden"); 40 | 41 | correctOffscreenRight(popover.firstElementChild); 42 | 43 | classList.remove("opacity-0", "animate-fade-out"); 44 | classList.add("animate-fade-in"); 45 | } 46 | 47 | // hide popover help text 48 | function hideHelpText(popover) { 49 | let classList = popover.classList; 50 | 51 | classList.remove("animate-fade-in"); 52 | classList.add("opacity-0", "animate-fade-out"); 53 | } 54 | 55 | // stop displaying popover if opacity is 0 56 | function hideIfTransparent(popover) { 57 | let classList = popover.classList; 58 | 59 | if (classList.contains('opacity-0')) 60 | classList.add('hidden'); 61 | } 62 | 63 | // check if an element is offscreen to the right and translate if necessary 64 | function correctOffscreenRight(element) { 65 | let rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); 66 | let classList = element.classList; 67 | 68 | // remove any existing translation 69 | classList.remove(element.className.split(" ").find((a) => a.startsWith("-translate-x-"))); 70 | 71 | // get offscreen distance of element in rem units 72 | let paddingRight = parseInt(window.getComputedStyle(element, null).getPropertyValue('padding-right')); 73 | let offscreenRight = (element.getBoundingClientRect().right + paddingRight - window.screen.width) / rootFontSize + 0.5; 74 | 75 | // translate element if offscreen 76 | if (offscreenRight > 0) 77 | classList.add("-translate-x-" + (Math.floor(offscreenRight) * 4)); 78 | } 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG-REPORT.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: Has something done broke? Tell us about it! 4 | labels: ["bug", "triage"] 5 | assignees: 6 | - q 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: textarea 13 | id: expected-behaviour 14 | attributes: 15 | label: What did you expect? 16 | description: What did you expect to happen? 17 | placeholder: Tell us what you were expecting! 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: actual-behaviour 22 | attributes: 23 | label: What actually happened? 24 | description: Tell us happened! Please share a full description along with screenshots or a screen reccording video that shows us the steps you took to encounter the bug. Including your narration in the video is helpful. 25 | placeholder: Tell us what you actually see! 26 | validations: 27 | required: true 28 | - type: dropdown 29 | id: impact 30 | attributes: 31 | label: Impact 32 | description: How important is this? 33 | options: 34 | - High 35 | - Low 36 | validations: 37 | required: true 38 | - type: dropdown 39 | id: urgency 40 | attributes: 41 | label: Urgency 42 | description: Should this be fixed now or can it be done later? 43 | options: 44 | - Now 45 | - Later 46 | validations: 47 | required: true 48 | - type: dropdown 49 | id: browsers 50 | attributes: 51 | label: What browsers are you seeing the problem on? 52 | multiple: true 53 | options: 54 | - Firefox 55 | - Chrome 56 | - Safari 57 | - Microsoft Edge 58 | - Opera 59 | - UC 60 | - Samsung Internet 61 | - Chromium & Derivitives 62 | - type: dropdown 63 | id: os 64 | attributes: 65 | label: What operating system are you using? 66 | multiple: true 67 | options: 68 | - Windows 69 | - Mac 70 | - Linux 71 | - iOS 72 | - Android 73 | - type: textarea 74 | id: logs 75 | attributes: 76 | label: Relevant log output 77 | description: If possible, please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 78 | render: shell 79 | - type: checkboxes 80 | id: terms 81 | attributes: 82 | label: Code of Conduct 83 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://geek.zone/code-of-conduct) 84 | options: 85 | - label: I agree to follow this project's Code of Conduct 86 | required: true -------------------------------------------------------------------------------- /.circleci/config.yml.only-ci: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build-test-publish: 4 | machine: 5 | image: ubuntu-2004:202107-02 6 | steps: 7 | - checkout 8 | - run: 9 | name: Set and persist variable 10 | command: | 11 | echo 'export TAG="0.1.${CIRCLE_BUILD_NUM}"' >> /home/circleci/.bashrc 12 | source /home/circleci/.bashrc 13 | echo $TAG 14 | - run: 15 | name: Download and configure Snyk CLI 16 | command: | 17 | curl https://static.snyk.io/cli/latest/snyk-linux -o snyk 18 | chmod +x ./snyk 19 | sudo mv ./snyk /usr/local/bin/ 20 | snyk config set disableSuggestions=true 21 | snyk auth ${SNYK_TOKEN} 22 | - run: 23 | name: Build frontend image 24 | background: true 25 | command: | 26 | docker build -t geekzone/frontend:$TAG -f docker/proxy/Dockerfile . 27 | - run: 28 | name: "Build backend image in docker-compose" 29 | command: | 30 | docker-compose up -d 31 | - run: 32 | name: Run Testy McTestface tests 33 | command: | 34 | docker-compose run web python3 manage.py test 2>&1 | tee -a test-results/test-output 35 | - store_test_results: 36 | path: test-results/test-output 37 | - run: 38 | name: Run Snyk scan on frontend image 39 | command: | 40 | snyk test --docker geekzone/frontend:$TAG --severity-threshold=high --fail-on=all 41 | - run: 42 | name: Run Snyk scan on backend image 43 | command: | 44 | snyk test --docker geekzone/backend:$TAG --severity-threshold=high --fail-on=all 45 | - deploy: 46 | name: Push frontend image to Docker Hub 47 | background: true 48 | command: | 49 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 50 | docker push geekzone/frontend:$TAG 51 | - deploy: 52 | name: Push backend image to Docker Hub 53 | command: | 54 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 55 | docker push geekzone/backend:$TAG 56 | just-fail: 57 | machine: 58 | image: ubuntu-2004:202107-02 59 | steps: 60 | - run: 61 | name: This branch is not allowed to merge 62 | command: | 63 | echo "This branch is not allowed to merge" 64 | exit 1 65 | 66 | workflows: 67 | version: 3 68 | main-web: 69 | jobs: 70 | - build-test-publish: 71 | filters: 72 | branches: 73 | ignore: 74 | - /^doc-.*/ 75 | context: 76 | - org-global 77 | - just-fail: 78 | filters: 79 | branches: 80 | only: 81 | - /^junk-.*/ 82 | -------------------------------------------------------------------------------- /memberships/templates/memberships/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block content %} 5 | 74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /memberships/payments.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Permission 2 | from django.http import HttpResponse 3 | from urllib.parse import parse_qs, urlparse 4 | 5 | from datetime import datetime 6 | 7 | from memberships.models import Member, Membership, FailedPayment, Payment 8 | from funky_time import epoch_to_datetime, years_from 9 | from .services import StripeGateway 10 | from .tasks import task_send_email 11 | 12 | 13 | def handle_stripe_payment(event): 14 | if event["type"] == "checkout.session.completed": 15 | return session_completed(event) 16 | if event["type"] == "invoice.payment_failed": 17 | member = Member.objects.get(email=event["data"]["object"]["customer_email"]) 18 | FailedPayment.objects.create( 19 | member=member, 20 | stripe_subscription_id=event["data"]["object"]["subscription"], 21 | stripe_event_type=event["type"], 22 | ) 23 | update_payment_status(event["type"], member) 24 | failed_payment_email(member) 25 | return HttpResponse(200) 26 | if event["type"] == "invoice.paid": 27 | member = Member.objects.get(email=event["data"]["object"]["customer_email"]) 28 | update_payment_status(event["type"], member) 29 | log_successful_payment(event, member) 30 | update_last_payment(event, member) 31 | add_user_membership_permission(member) 32 | set_membership_renewal_date(member) 33 | return HttpResponse(200) 34 | 35 | return HttpResponse(200) 36 | 37 | 38 | def session_completed(event): 39 | client = StripeGateway() 40 | try: 41 | donation = donation_from_url(event.data.object.success_url) 42 | subscription = client.create_subscription( 43 | event.data.object.setup_intent, donation=donation 44 | ) 45 | # todo: member not found? 46 | # todo: unable to create membership? delete from stripe? alert someone? 47 | member = Member.objects.get(email=subscription["email"]) 48 | membership = Membership.objects.create( 49 | member=member, stripe_subscription_id=subscription["id"] 50 | ) 51 | update_payment_status(event["type"], membership) 52 | return HttpResponse(200) 53 | except Exception as e: 54 | # todo: should this be a 5xx? 55 | return HttpResponse(e, status=500) 56 | 57 | 58 | def donation_from_url(url): 59 | parsed = urlparse(url) 60 | query = parse_qs(parsed.query) 61 | 62 | if "donation" in query: 63 | return int(query["donation"][0]) 64 | else: 65 | return None 66 | 67 | 68 | def log_successful_payment(event, member): 69 | # Log payment for a member in the database 70 | Payment.objects.create( 71 | member=member, 72 | stripe_subscription_id=event["data"]["object"]["subscription"], 73 | ) 74 | 75 | 76 | def update_last_payment(event, member): 77 | # Store payment DateTime in membership model 78 | membership = Membership.objects.get(member=member) 79 | membership.last_payment_time = epoch_to_datetime(event["created"]) 80 | membership.save() 81 | 82 | 83 | def update_payment_status(event, membership): 84 | membership.payment_status = event 85 | membership.save() 86 | 87 | 88 | def add_user_membership_permission(member): 89 | # Give user 'has_membership' permission 90 | perm = Permission.objects.get(codename="has_membership") 91 | member.user.user_permissions.add(perm) 92 | 93 | 94 | def set_membership_renewal_date(member): 95 | if member.renewal_date: 96 | member.renewal_date = years_from(1, member.renewal_date.replace(tzinfo=None)) 97 | else: 98 | member.renewal_date = years_from(1, datetime.utcnow()) 99 | member.save() 100 | 101 | 102 | def failed_payment_email(member): 103 | subject = "Your payment failed!" 104 | body = "Something seems to have gone wrong with your payment." 105 | task_send_email(member.preferred_name, member.email, subject, body) 106 | -------------------------------------------------------------------------------- /memberships/tests/test_stripe_gateway.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from django.test import TestCase 3 | from unittest import mock 4 | 5 | from memberships.services import StripeGateway 6 | 7 | Member = namedtuple("Member", "stripe_customer_id") 8 | SetupIntent = namedtuple("SetupIntent", "customer payment_method") 9 | Customer = namedtuple("Customer", "id email") 10 | Session = namedtuple("Session", "id") 11 | Subscription = namedtuple("Subscription", "id") 12 | Price = namedtuple("Price", "id") 13 | 14 | 15 | class StripeGatewayTestCase(TestCase): 16 | @mock.patch("stripe.Customer.create", autospec=True) 17 | def test_upload_member_creates_a_stripe_customer_record(self, create_customer): 18 | create_customer.return_value = Customer( 19 | "example_customer_id", "test@example.com" 20 | ) 21 | 22 | stripe_gateway = StripeGateway() 23 | customer_id = stripe_gateway.upload_member(email="test@example.com") 24 | 25 | create_customer.assert_called_with(email="test@example.com") 26 | self.assertEqual("example_customer_id", customer_id) 27 | 28 | @mock.patch("stripe.checkout.Session.create", autospec=True) 29 | def test_create_checkout_session_creates_a_stripe_bacs_session( 30 | self, create_session 31 | ): 32 | create_session.return_value = Session("example_session_id") 33 | 34 | stripe_gateway = StripeGateway() 35 | session_id = stripe_gateway.create_checkout_session( 36 | Member("example_customer_id"), "success-url", "cancel-url" 37 | ) 38 | 39 | create_session.assert_called_with( 40 | payment_method_types=["bacs_debit"], 41 | mode="setup", 42 | customer="example_customer_id", 43 | success_url="success-url", 44 | cancel_url="cancel-url", 45 | ) 46 | self.assertEqual("example_session_id", session_id) 47 | 48 | @mock.patch("stripe.Customer.retrieve", autospec=True) 49 | @mock.patch("stripe.SetupIntent.retrieve", autospec=True) 50 | @mock.patch("stripe.Subscription.create", autospec=True) 51 | def test_create_subscription_creates_a_membership_in_stripe( 52 | self, create_subscription, get_intent, get_customer 53 | ): 54 | get_intent.return_value = SetupIntent("example_customer", "a_payment_method") 55 | get_customer.return_value = Customer("customer_id", "test@example.com") 56 | create_subscription.return_value = Subscription("stripe_subscription_id") 57 | 58 | stripe_gateway = StripeGateway("example_membership_price") 59 | result = stripe_gateway.create_subscription("example_setup_intent_id") 60 | 61 | create_subscription.assert_called_with( 62 | customer="example_customer", 63 | default_payment_method="a_payment_method", 64 | items=[{"price": stripe_gateway.membership_price_id}], 65 | ) 66 | self.assertEqual( 67 | {"id": "stripe_subscription_id", "email": "test@example.com"}, 68 | result, 69 | ) 70 | 71 | @mock.patch("stripe.Price.create", autospec=True) 72 | @mock.patch("stripe.Customer.retrieve", autospec=True) 73 | @mock.patch("stripe.SetupIntent.retrieve", autospec=True) 74 | @mock.patch("stripe.Subscription.create", autospec=True) 75 | def test_create_subscription_with_a_donation_includes_a_donation_price( 76 | self, create_subscription, get_intent, get_customer, create_price 77 | ): 78 | get_intent.return_value = SetupIntent("example_customer", "a_payment_method") 79 | get_customer.return_value = Customer("customer_id", "test@example.com") 80 | create_subscription.return_value = Subscription("stripe_subscription_id") 81 | create_price.return_value = Price("donation_price_id") 82 | 83 | stripe_gateway = StripeGateway( 84 | membership_price_id="membership_price_id", 85 | donation_product_id="donation_product_id", 86 | ) 87 | result = stripe_gateway.create_subscription( 88 | "example_setup_intent_id", donation=10 89 | ) 90 | 91 | create_price.assert_called_with( 92 | unit_amount=10 * 100, 93 | currency="gbp", 94 | recurring={"interval": "year"}, 95 | product=stripe_gateway.donation_product_id, 96 | ) 97 | _, kwargs = create_subscription.call_args 98 | self.assertIn({"price": "donation_price_id"}, kwargs["items"]) 99 | -------------------------------------------------------------------------------- /theme/static_src/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a minimal config. 3 | * 4 | * If you need the full config, get it from here: 5 | * https://unpkg.com/browse/tailwindcss@latest/stubs/defaultConfig.stub.js 6 | */ 7 | 8 | module.exports = { 9 | /** 10 | * Stylesheet generation mode. 11 | * 12 | * Set mode to "jit" if you want to generate your styles on-demand as you author your templates; 13 | * Set mode to "aot" if you want to generate the stylesheet in advance and purge later (aka legacy mode). 14 | */ 15 | mode: "aot", 16 | 17 | purge: { 18 | content: [ 19 | /** 20 | * HTML. Paths to Django template files that will contain Tailwind CSS classes. 21 | */ 22 | /* Templates within theme app (e.g. base.html) */ 23 | '../templates/**/*.html', 24 | 25 | /* Templates in other apps. Adjust the following line so that it matches 26 | * your project structure. 27 | */ 28 | '../../memberships/templates/**/*.html', 29 | 30 | /** 31 | * JS: If you use Tailwind CSS in JavaScript, uncomment the following lines and make sure 32 | * patterns match your project structure. 33 | */ 34 | /* JS 1: Ignore any JavaScript in node_modules folder. */ 35 | '!../../**/node_modules', 36 | /* JS 2: Process all JavaScript files in the project. */ 37 | '../../**/*.js', 38 | 39 | /** 40 | * Python: If you use Tailwind CSS classes in Python, uncomment the following line 41 | * and make sure the pattern below matches your project structure. 42 | */ 43 | // '../../**/*.py' 44 | ], 45 | safelist: [ 46 | 'dark', 47 | 'animate-fade-in', 48 | 'animate-fade-out', 49 | ...(new Array(21)).fill('-translate-x-') 50 | .map((str, i) => str + (i * 4).toString()), 51 | /* keeping the following until 'jit' mode works with regular expressions */ 52 | // /^animate-fade-/, 53 | // /^-translate-x-/, 54 | ], 55 | }, 56 | darkMode: 'class', // can be 'media', 'class' or false [sic] 57 | theme: { 58 | extend: { 59 | animation: { 60 | 'fade-in': 'fadeIn 0.5s', 61 | 'fade-out': 'fadeOut 0.5s', 62 | }, 63 | colors: { 64 | 'red-true': '#ff0000', 65 | 'yellow-true': '#ffff00', 66 | }, 67 | fontFamily: { 68 | impact: [ 69 | 'Impact', 70 | 'Haettenschweiler', 71 | 'Franklin Gothic Bold', 72 | 'Charcoal', 73 | 'Helvetica Inserat', 74 | 'Bitstream Vera Sans Bold', 75 | 'Arial Black', 76 | 'sans serif', 77 | ], 78 | }, 79 | keyframes: { 80 | fadeIn: { 81 | '0%': { opacity: '0' }, 82 | '100%': { opacity: '1' }, 83 | }, 84 | fadeOut: { 85 | '0%': { opacity: '1' }, 86 | '100%': { opacity: '0' }, 87 | } 88 | }, 89 | maxWidth: { 90 | '2xs': '18rem', 91 | }, 92 | scale: { 93 | flip: '-1', 94 | }, 95 | spacing: { 96 | '-68': '-17rem', 97 | '-76': '-19rem', 98 | }, 99 | }, 100 | }, 101 | variants: { 102 | extend: { 103 | boxShadow: ['active'], 104 | display: ['dark'], 105 | inset: ['active'], 106 | margin: ['last'], 107 | position: ['active'], 108 | }, 109 | }, 110 | plugins: [ 111 | /** 112 | * '@tailwindcss/forms' is the forms plugin that provides a minimal styling 113 | * for forms. If you don't like it or have own styling for forms, 114 | * comment the line below to disable '@tailwindcss/forms'. 115 | */ 116 | require('@tailwindcss/forms'), 117 | require('@tailwindcss/typography'), 118 | require('@tailwindcss/line-clamp'), 119 | require('@tailwindcss/aspect-ratio'), 120 | ], 121 | } 122 | -------------------------------------------------------------------------------- /memberships/forms.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from django import forms 3 | from django.forms import ModelForm, DateField 4 | from django.contrib.auth import password_validation, get_user_model 5 | from django.utils.safestring import mark_safe 6 | from .models import Member 7 | 8 | 9 | class DateInput(forms.DateInput): 10 | input_type = "date" 11 | 12 | 13 | class RegistrationForm(forms.Form): 14 | full_name = forms.CharField( 15 | required=True, 16 | max_length=255, 17 | ) 18 | preferred_name = forms.CharField( 19 | required=False, 20 | label="Preferred name (optional)", 21 | max_length=255, 22 | ) 23 | email = forms.EmailField( 24 | required=True, 25 | label="Email address", 26 | ) 27 | password = forms.CharField( 28 | required=True, 29 | widget=forms.PasswordInput, 30 | help_text=password_validation.password_validators_help_text_html(), 31 | max_length=255, 32 | ) 33 | birth_date = forms.DateField( 34 | required=True, 35 | widget=DateInput, 36 | label="Date of birth", 37 | ) 38 | donation = forms.DecimalField( 39 | required=False, 40 | label="Donation (optional)", 41 | min_value=0, 42 | decimal_places=2, 43 | initial=30, 44 | ) 45 | constitution_agreed = forms.BooleanField( 46 | required=True, 47 | label=mark_safe( 48 | 'Constitution agreed' 49 | ), 50 | ) 51 | constitutional_email = forms.BooleanField( 52 | required=True, 53 | ) 54 | constitutional_post = forms.BooleanField( 55 | required=True, 56 | ) 57 | 58 | def clean_birth_date(self, *args, **kwargs): 59 | from funky_time import is_younger_than, is_older_than, date_to_datetime 60 | 61 | birth_date = date_to_datetime(self.cleaned_data.get("birth_date")) 62 | 63 | if is_younger_than(0, birth_date): 64 | raise forms.ValidationError( 65 | "Unless you are a Time Lord, please enter a date in the past." 66 | ) 67 | 68 | elif is_younger_than(18, birth_date): 69 | raise forms.ValidationError( 70 | "Thanks for your interest in joining Geek.Zone! We're pumped that you" 71 | " want to become an official epic Geek, however, as you are under 18 we" 72 | " need to speak to your parent or guardian. Please ask them to email" 73 | " trustees@geek.zone to request membership on your behalf. Thanks!" 74 | ) 75 | 76 | elif is_older_than(130, birth_date): 77 | raise forms.ValidationError( 78 | "Nobody has ever lived that long! Please check your birthdate." 79 | ) 80 | 81 | # FIXME JDG in the future, messages and limits like these should be admin user configurable 82 | 83 | return birth_date 84 | 85 | def clean_password(self): 86 | password = self.cleaned_data.get("password") 87 | password_validation.validate_password(password, None) 88 | 89 | return self.cleaned_data["password"] 90 | 91 | def clean_email(self): 92 | if Member.objects.filter(email=self.cleaned_data["email"]).exists(): 93 | raise forms.ValidationError("You've already registered! Please login") 94 | return self.cleaned_data["email"] 95 | 96 | 97 | class MemberSettingsForm(ModelForm): 98 | class Meta: 99 | model = Member 100 | fields = "__all__" 101 | exclude = [ 102 | "stripe_customer_id", 103 | "email", 104 | "user", 105 | "constitution_agreed", 106 | "constitutional_email", 107 | "constitutional_post", 108 | "renewal_date", 109 | "profile_image", 110 | "email_verified", 111 | ] # JDG Should also exclude renewal date once we have it 112 | widgets = {"birth_date": DateInput()} 113 | 114 | 115 | class MemberDetailsForm(ModelForm): 116 | class Meta: 117 | model = Member 118 | fields = "__all__" 119 | exclude = [ 120 | "stripe_customer_id", 121 | "email", 122 | "user", 123 | "constitution_agreed", 124 | "constitutional_email", 125 | "constitutional_post", 126 | "profile_image", 127 | ] 128 | -------------------------------------------------------------------------------- /memberships/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | Geek.Zone Membership Management 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% if recaptcha_enabled %} 18 | 19 | {% endif %} 20 | 21 |
22 |
23 | 29 | 30 |
31 | 38 |
39 | 40 | 68 |
69 |
70 | 71 |
72 | {% block content %} 73 | {% endblock %} 74 |
75 | 76 |
77 |
78 |
79 | 80 | Copyleft 81 | Geek.Zone 82 |
83 | 89 |
90 |
91 |
92 |

93 | The Geek.Zone membership system is currently in beta. Please email your feedback to 94 | dev@geek.zone 95 | so that we can improve it! 96 |

97 |

98 | We do use some functional cookies to deliver functionality to you. 99 | Don't worry, we won't spy on you! 100 |

101 |
102 |
103 |
104 | 105 | 106 | -------------------------------------------------------------------------------- /memberships/templates/inc/logo.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /.circleci/config.yml.aws: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build-test-publish: 4 | machine: 5 | image: ubuntu-2004:202107-02 6 | steps: 7 | - checkout 8 | - run: 9 | name: Set and persist variable 10 | command: | 11 | echo 'export TAG="0.1.${CIRCLE_BUILD_NUM}"' >> /home/circleci/.bashrc 12 | source /home/circleci/.bashrc 13 | echo $TAG 14 | - run: 15 | name: Download and configure Snyk CLI 16 | command: | 17 | curl https://static.snyk.io/cli/latest/snyk-linux -o snyk 18 | chmod +x ./snyk 19 | sudo mv ./snyk /usr/local/bin/ 20 | snyk config set disableSuggestions=true 21 | snyk auth ${SNYK_TOKEN} 22 | - run: 23 | name: Build frontend image 24 | background: true 25 | command: | 26 | docker build -t geekzone/frontend:$TAG -f docker/proxy/Dockerfile . 27 | - run: 28 | name: "Build backend image in docker-compose" 29 | command: | 30 | docker-compose up -d 31 | - run: 32 | name: Run Testy McTestface tests 33 | command: | 34 | docker-compose run web python3 manage.py test 2>&1 | tee -a test-results/test-output 35 | - store_test_results: 36 | path: test-results/test-output 37 | - run: 38 | name: Run Snyk scan on frontend image 39 | command: | 40 | snyk test --docker geekzone/frontend:$TAG --severity-threshold=high --fail-on=all 41 | - run: 42 | name: Run Snyk scan on backend image 43 | command: | 44 | snyk test --docker geekzone/backend:$TAG --severity-threshold=high --fail-on=all 45 | - deploy: 46 | name: Push frontend image to Docker Hub 47 | background: true 48 | command: | 49 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 50 | docker push geekzone/frontend:$TAG 51 | - deploy: 52 | name: Push backend image to Docker Hub 53 | command: | 54 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 55 | docker push geekzone/backend:$TAG 56 | just-fail: 57 | machine: 58 | image: ubuntu-2004:202107-02 59 | steps: 60 | - run: 61 | name: This branch is not allowed to merge 62 | command: | 63 | echo "This branch is not allowed to merge" 64 | exit 1 65 | 66 | deploy-test: 67 | docker: 68 | - image: 'geekzone/infra' 69 | steps: 70 | - checkout 71 | - run: 72 | name: Create .terraformrc file locally 73 | command: >- 74 | echo "credentials \"app.terraform.io\" {token = 75 | \"$TERRAFORM_TOKEN\"}" > $HOME/.terraformrc 76 | - run: 77 | name: Connect to or create k8s cluster 78 | command: | 79 | cd /usr/src/infra 80 | { az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 81 | } && 82 | { az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 83 | } || 84 | { create-aws-infra 85 | } 86 | - run: 87 | name: Deploy k8s resources 88 | command: | 89 | cd /usr/src/infra 90 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 91 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 92 | deploy-test-in-aws 93 | - run: 94 | name: Plan cluster destruction 95 | command: | 96 | cd /usr/src/infra 97 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 98 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 99 | kubectl apply -f aws/k8s/cronjobs/destroy-infra-test.yaml 100 | 101 | deploy-prod: 102 | docker: 103 | - image: 'geekzone/infra' 104 | steps: 105 | - checkout 106 | - run: 107 | name: Create .terraformrc file locally 108 | command: >- 109 | echo "credentials \"app.terraform.io\" {token = 110 | \"$TERRAFORM_TOKEN\"}" > $HOME/.terraformrc 111 | - run: 112 | name: Connect to or create k8s cluster 113 | command: | 114 | cd /usr/src/infra 115 | { az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 116 | } && 117 | { az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 118 | } || 119 | { create-aws-infra 120 | } 121 | - run: 122 | name: Deploy k8s resources 123 | command: | 124 | cd /usr/src/infra 125 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 126 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 127 | deploy-prod-in-aws 128 | - run: 129 | name: Plan cluster destruction 130 | command: | 131 | cd /usr/src/infra 132 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 133 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 134 | kubectl apply -f aws/k8s/cronjobs/destroy-infra-prod.yaml 135 | 136 | 137 | workflows: 138 | version: 2 139 | main-web: 140 | jobs: 141 | - build-test-publish: 142 | filters: 143 | branches: 144 | ignore: 145 | - /^doc-.*/ 146 | context: 147 | - org-global 148 | - just-fail: 149 | filters: 150 | branches: 151 | only: 152 | - /^junk-.*/ 153 | - deploy-test: 154 | requires: 155 | - build-test-publish 156 | filters: 157 | branches: 158 | only: /feature-.*/ 159 | context: 160 | - org-global 161 | - deploy-prod: 162 | requires: 163 | - build-test-publish 164 | filters: 165 | branches: 166 | only: main 167 | context: 168 | - org-global 169 | -------------------------------------------------------------------------------- /web/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for web project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | import environ 15 | 16 | env = environ.Env( 17 | DEBUG=(bool, True), 18 | ) 19 | environ.Env.read_env() 20 | 21 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 22 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 23 | 24 | 25 | # Quick-start development settings - unsuitable for production 26 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 27 | 28 | # SECURITY WARNING: keep the secret key used in production secret! 29 | SECRET_KEY = ")i@@^(m2b0jalyaa)r$2wg6o&mjb*rm_+cm9g03hyt=j61i2u(" 30 | 31 | # SECURITY WARNING: don't run with debug turned on in production! 32 | DEBUG = env("DEBUG") 33 | 34 | ALLOWED_HOSTS = [env("ALLOWED_HOSTS", default="localhost"), "127.0.0.1"] 35 | 36 | # Application definition 37 | 38 | # QUEUE 39 | # DEADLETTER QUEUE 40 | 41 | INSTALLED_APPS = [ 42 | "django.contrib.admin", 43 | "django.contrib.auth", 44 | "django.contrib.contenttypes", 45 | "django.contrib.sessions", 46 | "django.contrib.messages", 47 | "livereload", 48 | "django.contrib.staticfiles", 49 | "tailwind", 50 | "theme", 51 | "widget_tweaks", 52 | # Included at the end so that we can configure 53 | # built-in django admin features 54 | "memberships", 55 | "django_extensions", 56 | "django_probes", 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | "django.middleware.security.SecurityMiddleware", 61 | "django.contrib.sessions.middleware.SessionMiddleware", 62 | "django.middleware.common.CommonMiddleware", 63 | "django.middleware.csrf.CsrfViewMiddleware", 64 | "django.contrib.auth.middleware.AuthenticationMiddleware", 65 | "django.contrib.messages.middleware.MessageMiddleware", 66 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 67 | "livereload.middleware.LiveReloadScript", 68 | "clacks.middleware.ClacksMiddleware", 69 | ] 70 | 71 | 72 | CLACKS_NAMES = [ 73 | "Terry Pratchett", 74 | "Joe Armstrong", 75 | "Chris Giancola", 76 | ] 77 | 78 | # To silence the DEFAULT_AUTO_FIELD warning when running 'python3 manage.py test' 79 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 80 | 81 | ROOT_URLCONF = "web.urls" 82 | 83 | TEMPLATES = [ 84 | { 85 | "BACKEND": "django.template.backends.django.DjangoTemplates", 86 | "DIRS": [], 87 | "APP_DIRS": True, 88 | "OPTIONS": { 89 | "context_processors": [ 90 | "django.template.context_processors.debug", 91 | "django.template.context_processors.request", 92 | "django.contrib.auth.context_processors.auth", 93 | "django.contrib.messages.context_processors.messages", 94 | "memberships.context_processors.recaptcha_enabled", 95 | ], 96 | }, 97 | }, 98 | ] 99 | 100 | WSGI_APPLICATION = "web.wsgi.application" 101 | 102 | 103 | # Database 104 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 105 | 106 | # DATABASES = {"default": env.db(default="sqlite:/db.sqlite3")} 107 | 108 | # Use for production 109 | DATABASES = { 110 | "default": { 111 | "ENGINE": "django.db.backends.postgresql", 112 | "NAME": os.environ.get("DATABASE_NAME"), 113 | "USER": os.environ.get("DATABASE_USER"), 114 | "PASSWORD": os.environ.get("DATABASE_PASSWORD"), 115 | "HOST": os.environ.get("DATABASE_HOST"), 116 | "PORT": os.environ.get("DATABASE_PORT"), 117 | } 118 | } 119 | 120 | # Password validation 121 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 122 | 123 | AUTH_PASSWORD_VALIDATORS = [ 124 | { 125 | "NAME": ( 126 | "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 127 | ), 128 | "OPTIONS": { 129 | "user_attributes": ( 130 | "username", 131 | "email", 132 | "first_name", 133 | "last_name", 134 | ), 135 | "max_similarity": 0.5, 136 | }, 137 | }, 138 | { 139 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 140 | "OPTIONS": { 141 | "min_length": 10, 142 | }, 143 | }, 144 | { 145 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 146 | }, 147 | { 148 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 149 | }, 150 | ] 151 | 152 | 153 | # Internationalization 154 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 155 | 156 | LANGUAGE_CODE = "en-us" 157 | 158 | TIME_ZONE = "UTC" 159 | 160 | USE_I18N = True 161 | 162 | USE_L10N = True 163 | 164 | USE_TZ = True 165 | 166 | 167 | # Static files (CSS, JavaScript, Images) 168 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 169 | 170 | STATIC_URL = "/static/" 171 | STATIC_ROOT = "static/" 172 | 173 | STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default=None) 174 | STRIPE_PUBLIC_KEY = env("STRIPE_PUBLIC_KEY", default=None) 175 | MEMBERSHIP_PRICE_ID = env("MEMBERSHIP_PRICE_ID", default=None) 176 | DONATION_PRODUCT_ID = env("DONATION_PRODUCT_ID", default=None) 177 | RECAPTCHA_SITE_KEY = env("RECAPTCHA_SITE_KEY", default=None) 178 | RECAPTCHA_SECRET_KEY = env("RECAPTCHA_SECRET_KEY", default=None) 179 | 180 | LOGIN_URL = "memberships_login" 181 | LOGIN_REDIRECT_URL = "memberships_details" 182 | LOGOUT_REDIRECT_URL = "memberships_login" 183 | 184 | # Tailwind options 185 | TAILWIND_APP_NAME = "theme" 186 | INTERNAL_IPS = [ 187 | "127.0.0.1", 188 | ] 189 | 190 | # Celery Configuration Options 191 | CELERY_TIMEZONE = "UTC" 192 | CELERY_TASK_TRACK_STARTED = True 193 | CELERY_TASK_TIME_LIMIT = 30 * 60 194 | BROKER_URL = "django://" 195 | 196 | # Email config 197 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 198 | EMAIL_HOST = "smtp.gmail.com" 199 | EMAIL_HOST_USER = "support@geek.zone" 200 | EMAIL_HOST_PASSWORD = env("GMAIL_APP_PASSWORD", default=None) 201 | EMAIL_PORT = 587 202 | EMAIL_USE_TLS = True 203 | DEFAULT_FROM_EMAIL = "Geek.Zone " 204 | 205 | # Testing config 206 | TEST_USER_PASSWORD = env("TEST_USER_PASSWORD", default=None) 207 | TEST_USER_PASSWORD_BAD = env("TEST_USER_PASSWORD_BAD", default=None) 208 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build-test-publish: 4 | docker: 5 | - image: cimg/base:2022.04 6 | environment: 7 | TAG: 0.1.<< pipeline.number >> 8 | steps: 9 | - checkout 10 | - setup_remote_docker: 11 | version: default 12 | - run: 13 | name: Skip ci 14 | command: | 15 | if [[ "$CIRCLE_BRANCH" =~ ^doc.*$ ]]; then 16 | echo 'Just updating the documentation, no need to run the pipeline' 17 | circleci-agent step halt 18 | else 19 | echo 'Running the pipeline to build a new image' 20 | fi 21 | - run: 22 | name: Download and configure Snyk CLI 23 | command: | 24 | curl https://static.snyk.io/cli/latest/snyk-linux -o snyk 25 | chmod +x ./snyk 26 | sudo mv ./snyk /usr/local/bin/ 27 | snyk config set disableSuggestions=true 28 | snyk auth ${SNYK_TOKEN} 29 | - run: 30 | name: Build frontend image 31 | background: true 32 | command: | 33 | docker build -t geekzone/frontend:$TAG -f docker/proxy/Dockerfile . 34 | - run: 35 | name: "Build backend image in docker-compose" 36 | command: | 37 | docker-compose up -d 38 | - run: 39 | name: Run Testy McTestface tests 40 | command: | 41 | docker-compose run web python3 manage.py test 2>&1 | tee -a test-results/test-output 42 | - store_test_results: 43 | path: test-results/test-output 44 | - run: 45 | name: Run Snyk scan on frontend image 46 | command: | 47 | snyk test --docker geekzone/frontend:$TAG --severity-threshold=high --fail-on=all 48 | - run: 49 | name: Run Snyk scan on backend image 50 | command: | 51 | snyk test --docker geekzone/backend:$TAG --severity-threshold=high --fail-on=all 52 | - run: 53 | name: Push frontend image to Docker Hub 54 | background: true 55 | command: | 56 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 57 | docker push geekzone/frontend:$TAG 58 | - run: 59 | name: Push backend image to Docker Hub 60 | command: | 61 | docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 62 | docker push geekzone/backend:$TAG 63 | just-fail: 64 | machine: 65 | image: ubuntu-2204:2023.04.2 66 | steps: 67 | - run: 68 | name: This branch is not allowed to merge 69 | command: | 70 | echo "This branch is not allowed to merge" 71 | exit 1 72 | 73 | deploy-test: 74 | docker: 75 | - image: "geekzone/infra:0.1.421" 76 | environment: 77 | TAG: 0.1.<< pipeline.number >> 78 | steps: 79 | - checkout 80 | - run: 81 | name: Create .terraformrc file locally 82 | command: >- 83 | echo "credentials \"app.terraform.io\" {token = 84 | \"$TERRAFORM_TOKEN\"}" > $HOME/.terraformrc 85 | - run: 86 | name: Connect to or create k8s cluster 87 | command: | 88 | cd /usr/src/infra 89 | { az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 90 | } && 91 | { az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 92 | } || 93 | { create-azure-infra 94 | } 95 | - run: 96 | name: Deploy k8s resources 97 | command: | 98 | cd /usr/src/infra 99 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 100 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 101 | deploy-test-in-azure 102 | - run: 103 | name: Plan cluster destruction 104 | command: | 105 | cd /usr/src/infra 106 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 107 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 108 | kubectl apply -f azure/k8s/cronjobs/destroy-infra-test.yaml 109 | 110 | deploy-prod: 111 | docker: 112 | - image: "geekzone/infra:0.1.421" 113 | environment: 114 | TAG: 0.1.<< pipeline.number >> 115 | steps: 116 | - checkout 117 | - run: 118 | name: Create .terraformrc file locally 119 | command: >- 120 | echo "credentials \"app.terraform.io\" {token = 121 | \"$TERRAFORM_TOKEN\"}" > $HOME/.terraformrc 122 | - run: 123 | name: Connect to or create k8s cluster 124 | command: | 125 | cd /usr/src/infra 126 | { az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 127 | } && 128 | { az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 129 | } || 130 | { create-azure-infra 131 | } 132 | - run: 133 | name: Deploy k8s resources 134 | command: | 135 | cd /usr/src/infra 136 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 137 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 138 | deploy-prod-in-azure 139 | - run: 140 | name: Plan cluster destruction 141 | command: | 142 | cd /usr/src/infra 143 | az login --service-principal -u $CLIENT_ID -p $CLIENT_PASSWORD --tenant $TENANT_ID 144 | az aks get-credentials --resource-group $RESOURCE_GROUP --name $CLUSTER_NAME --admin 145 | kubectl apply -f azure/k8s/cronjobs/destroy-infra-prod.yaml 146 | 147 | workflows: 148 | version: 2 149 | main-web: 150 | jobs: 151 | - build-test-publish: 152 | context: 153 | - org-global 154 | - just-fail: 155 | filters: 156 | branches: 157 | only: 158 | - /^junk-.*/ 159 | - deploy-test: 160 | requires: 161 | - build-test-publish 162 | filters: 163 | branches: 164 | only: /feature-.*/ 165 | context: 166 | - org-global 167 | - deploy-prod: 168 | requires: 169 | - build-test-publish 170 | filters: 171 | branches: 172 | only: main 173 | context: 174 | - org-global 175 | -------------------------------------------------------------------------------- /memberships/tests/test_stripe_webhooks.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.contrib.auth.models import User, Permission 3 | 4 | from .utils import StripeTestCase 5 | from memberships.models import Member, Membership, FailedPayment, Payment 6 | 7 | from datetime import datetime 8 | from django.utils.timezone import make_aware 9 | from funky_time import epoch_to_datetime, years_from 10 | from web.settings import TEST_USER_PASSWORD 11 | 12 | 13 | class CheckoutCompletedWebhookTestCase(StripeTestCase): 14 | def setUp(self): 15 | self.setup_stripe_mocks() 16 | self.member = Member.create( 17 | full_name="test person", 18 | preferred_name="test", 19 | email="test@example.com", 20 | password=TEST_USER_PASSWORD, 21 | birth_date="1991-01-01", 22 | ) 23 | self.membership = Membership.objects.create( 24 | member=self.member, stripe_subscription_id=self.member.email 25 | ) 26 | 27 | def tearDown(self): 28 | self.tear_down_stripe_mocks() 29 | 30 | def test_a_stripe_membership_subscription_is_created_for_the_member(self): 31 | response = self.client.post( 32 | reverse("stripe_webhook"), 33 | { 34 | "type": "checkout.session.completed", 35 | "data": { 36 | "object": { 37 | "setup_intent": "example_setup_intent", 38 | "success_url": "https://example.com/success?donation=10", 39 | } 40 | }, 41 | }, 42 | content_type="application/json", 43 | ) 44 | 45 | self.assertEqual(200, response.status_code) 46 | self.create_subscription.assert_called() 47 | 48 | def test_membership_subscriptions_can_include_donations(self): 49 | response = self.client.post( 50 | reverse("stripe_webhook"), 51 | { 52 | "type": "checkout.session.completed", 53 | "data": { 54 | "object": { 55 | "setup_intent": "example_setup_intent", 56 | "success_url": "https://example.com/success?donation=10", 57 | } 58 | }, 59 | }, 60 | content_type="application/json", 61 | ) 62 | _, kwargs = self.create_subscription.call_args 63 | 64 | self.assertEqual(200, response.status_code) 65 | self.assertEqual(10, kwargs["donation"]) 66 | 67 | def test_a_membership_is_created_for_the_member_in_the_database(self): 68 | response = self.client.post( 69 | reverse("stripe_webhook"), 70 | { 71 | "type": "checkout.session.completed", 72 | "data": { 73 | "object": { 74 | "setup_intent": "example_setup_intent", 75 | "success_url": "https://example.com/success?donation=10", 76 | } 77 | }, 78 | }, 79 | content_type="application/json", 80 | ) 81 | memberships = Membership.objects.filter(member=self.member) 82 | 83 | self.assertEqual(200, response.status_code) 84 | self.assertEqual(2, memberships.count()) 85 | 86 | def test_a_failed_payment_for_membership_gets_logged_to_db(self): 87 | self.member.stripe_customer_id = "cus_12345" 88 | self.member.save() 89 | response = self.client.post( 90 | reverse("stripe_webhook"), 91 | { 92 | "type": "invoice.payment_failed", 93 | "data": { 94 | "object": { 95 | "customer": "cus_12345", 96 | "customer_email": "test@example.com", 97 | "subscription": "sub_12345", 98 | } 99 | }, 100 | }, 101 | content_type="application/json", 102 | ) 103 | f_payments = FailedPayment.objects.all() 104 | 105 | self.assertEqual(1, f_payments.count()) 106 | 107 | def test_a_successful_payment_for_membership_gets_logged_in_db(self): 108 | response = self.client.post( 109 | reverse("stripe_webhook"), 110 | { 111 | "type": "invoice.paid", 112 | "data": { 113 | "object": { 114 | "customer_email": "test@example.com", 115 | "subscription": "sub_12345", 116 | } 117 | }, 118 | "created": 1611620481, 119 | }, 120 | content_type="application/json", 121 | ) 122 | payments = Payment.objects.all() 123 | 124 | self.assertEqual(1, payments.count()) 125 | 126 | def test_new_member_is_given_a_membership_renewal_date_upon_payment(self): 127 | response = self.client.post( 128 | reverse("stripe_webhook"), 129 | { 130 | "type": "invoice.paid", 131 | "data": { 132 | "object": { 133 | "customer_email": "test@example.com", 134 | "subscription": "sub_12345", 135 | } 136 | }, 137 | "created": 1611620481, 138 | }, 139 | content_type="application/json", 140 | ) 141 | member = Member.objects.get(id=self.member.id) 142 | 143 | self.assertEqual(datetime, type(member.renewal_date)) 144 | 145 | def test_existing_membership_renewal_date_updated_upon_payment(self): 146 | original = datetime(2020, 1, 1, 12, 55, 59, 123456) 147 | self.member.renewal_date = make_aware(original) 148 | self.member.save() 149 | response = self.client.post( 150 | reverse("stripe_webhook"), 151 | { 152 | "type": "invoice.paid", 153 | "data": { 154 | "object": { 155 | "customer_email": "test@example.com", 156 | "subscription": "sub_12345", 157 | } 158 | }, 159 | "created": 1611620481, 160 | }, 161 | content_type="application/json", 162 | ) 163 | new_datetime = years_from(1, original) 164 | member = Member.objects.get(id=self.member.id) 165 | 166 | self.assertEqual(datetime, type(member.renewal_date)) 167 | self.assertEqual(new_datetime, member.renewal_date) 168 | 169 | def test_new_member_is_given_user_permission_on_payment(self): 170 | response = self.client.post( 171 | reverse("stripe_webhook"), 172 | { 173 | "type": "invoice.paid", 174 | "data": { 175 | "object": { 176 | "customer_email": "test@example.com", 177 | "subscription": "sub_12345", 178 | } 179 | }, 180 | "created": 1611620481, 181 | }, 182 | content_type="application/json", 183 | ) 184 | user = User.objects.get(id=self.member.user_id) 185 | 186 | self.assertEqual(True, user.has_perm("memberships.has_membership")) 187 | -------------------------------------------------------------------------------- /memberships/migrations/0005_auto_20201120_1445.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-11-20 14:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0004_auto_20200807_1947"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="member", 14 | name="address_1", 15 | field=models.CharField( 16 | blank=True, max_length=255, verbose_name="Address line one" 17 | ), 18 | ), 19 | migrations.AddField( 20 | model_name="member", 21 | name="address_postcode", 22 | field=models.CharField(blank=True, max_length=10, verbose_name="Postcode"), 23 | ), 24 | migrations.AddField( 25 | model_name="member", 26 | name="gdpr_email_notifications", 27 | field=models.BooleanField( 28 | default=False, 29 | help_text="May we send you system notifications by email? This is required to participate in Geek.Zone Elections", 30 | verbose_name="Email notifications", 31 | ), 32 | ), 33 | migrations.AddField( 34 | model_name="member", 35 | name="gdpr_email_updates", 36 | field=models.BooleanField( 37 | default=False, 38 | help_text="May we send you updates, like event information, via email?", 39 | verbose_name="Email updates", 40 | ), 41 | ), 42 | migrations.AddField( 43 | model_name="member", 44 | name="gdpr_likeness", 45 | field=models.BooleanField( 46 | default=False, 47 | help_text="May we use photos, videos or voice recordings of you in our publications?", 48 | verbose_name="Likeness", 49 | ), 50 | ), 51 | migrations.AddField( 52 | model_name="member", 53 | name="gdpr_post_notifications", 54 | field=models.BooleanField( 55 | default=False, 56 | help_text="May we send you system notifications by post? This includes 2FA and voting notifications", 57 | verbose_name="Post notifications", 58 | ), 59 | ), 60 | migrations.AddField( 61 | model_name="member", 62 | name="gdpr_post_updates", 63 | field=models.BooleanField( 64 | default=False, 65 | help_text="May we send you updates, like event information, via post?", 66 | verbose_name="Post updates", 67 | ), 68 | ), 69 | migrations.AddField( 70 | model_name="member", 71 | name="gdpr_sms_notifications", 72 | field=models.BooleanField( 73 | default=False, 74 | help_text="May we send you system notifications by SMS? This includes 2FA notifications", 75 | verbose_name="SMS notifications", 76 | ), 77 | ), 78 | migrations.AddField( 79 | model_name="member", 80 | name="gdpr_sms_updates", 81 | field=models.BooleanField( 82 | default=False, 83 | help_text="May we sent you updates, like event information, via SMS?", 84 | verbose_name="SMS updates", 85 | ), 86 | ), 87 | migrations.AddField( 88 | model_name="member", 89 | name="gdpr_telephone_notifications", 90 | field=models.BooleanField( 91 | default=False, 92 | help_text="May we call you to notify you of system messages? This includes 2FA", 93 | verbose_name="Phone notifications", 94 | ), 95 | ), 96 | migrations.AddField( 97 | model_name="member", 98 | name="gdpr_telephone_updates", 99 | field=models.BooleanField( 100 | default=False, 101 | help_text="May we call you with updates, like event information?", 102 | verbose_name="Phone updates", 103 | ), 104 | ), 105 | migrations.AddField( 106 | model_name="member", 107 | name="gift_aid", 108 | field=models.BooleanField( 109 | default=False, 110 | help_text="I would like The UK Government to increase the value of my donation by as much as 25% at no cost to me!

I want to Gift Aid my donation, and any donations I make in the future or have made in the past 4 years, to Geek.Zone. I am a UK taxpayer and understand that if I pay less Income Tax and/or Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that tax year it is my responsibility to pay any difference.

I will notify Geek.Zone if I:
  • want to cancel this declaration
  • change my name or home address
  • no longer pay sufficient tax on my income and/or capital gains
", 111 | verbose_name="Gift aid", 112 | ), 113 | ), 114 | migrations.AddField( 115 | model_name="member", 116 | name="minecraft_username", 117 | field=models.CharField( 118 | blank=True, 119 | help_text="What is your Minecraft Java Edition username? Let us know so that you can join us on Geek.Zone/Minecraft!", 120 | max_length=255, 121 | verbose_name="Minecraft username", 122 | ), 123 | ), 124 | migrations.AddField( 125 | model_name="member", 126 | name="profile_image", 127 | field=models.ImageField( 128 | blank=True, 129 | help_text="Strike a geek pose and give us your best shot! This will be used on your GZID card", 130 | upload_to="images/", 131 | verbose_name="Selfie", 132 | ), 133 | ), 134 | migrations.AddField( 135 | model_name="member", 136 | name="telephone", 137 | field=models.CharField( 138 | blank=True, max_length=255, verbose_name="Phone number" 139 | ), 140 | ), 141 | migrations.AlterField( 142 | model_name="member", 143 | name="birth_date", 144 | field=models.DateField( 145 | help_text="When did you begin your glorious adventure around or local star?", 146 | verbose_name="Date of birth", 147 | ), 148 | ), 149 | migrations.AlterField( 150 | model_name="member", 151 | name="constitution_agreed", 152 | field=models.BooleanField( 153 | help_text='I have read and agree to abide by the Geek.Zone/Constitution.' 154 | ), 155 | ), 156 | migrations.AlterField( 157 | model_name="member", 158 | name="full_name", 159 | field=models.CharField(max_length=255, verbose_name="Full name"), 160 | ), 161 | migrations.AlterField( 162 | model_name="member", 163 | name="preferred_name", 164 | field=models.CharField( 165 | help_text="What should we call you?", 166 | max_length=255, 167 | verbose_name="Preferred name", 168 | ), 169 | ), 170 | ] 171 | -------------------------------------------------------------------------------- /memberships/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.http import HttpResponse 3 | from django.contrib.auth.models import User 4 | from django.db import transaction 5 | from django.conf import settings 6 | from django.utils import timezone 7 | from .services import StripeGateway 8 | from funky_time import years_from 9 | from datetime import datetime 10 | 11 | 12 | class Member(models.Model): 13 | gift_aid_help_text = ( 14 | "I would like The UK Government to increase the value of my donation by as much as 25% at no " 15 | "cost to me!

I want to Gift Aid my donation, and any donations I make in the " 16 | "future or have made in the past 4 years, to Geek.Zone. I am a UK taxpayer and understand " 17 | "that if I pay less Income Tax and/or Capital Gains Tax than the amount of Gift Aid claimed " 18 | "on all my donations in that tax year it is my responsibility to pay any difference.

I will notify Geek.Zone if I:
  • want to cancel this declaration
  • change " 20 | "my name or home address
  • no longer pay sufficient tax on my income and/or capital " 21 | "gains
" 22 | ) 23 | 24 | full_name = models.CharField(max_length=255, verbose_name="Full name") 25 | preferred_name = models.CharField( 26 | max_length=255, 27 | verbose_name="Preferred name", 28 | help_text="What should we call you?", 29 | ) 30 | email = models.EmailField(max_length=254) 31 | birth_date = models.DateField( 32 | verbose_name="Date of birth", 33 | help_text="When did you begin your glorious adventure around or local star?", 34 | ) 35 | constitution_agreed = models.BooleanField( 36 | help_text="I have read and agree to abide by the Geek.Zone/Constitution. ', 38 | default=False, 39 | ) 40 | constitutional_email = models.BooleanField( 41 | help_text="I am happy to receive emails that relate to constitutional matters", 42 | default=False, 43 | ) 44 | constitutional_post = models.BooleanField( 45 | help_text="I am happy to receive letters that relate to constitutional matters", 46 | default=False, 47 | ) 48 | 49 | stripe_customer_id = models.CharField(max_length=255, unique=True) 50 | user = models.OneToOneField(User, on_delete=models.CASCADE) 51 | profile_image = models.ImageField( 52 | upload_to="images/", 53 | blank=True, 54 | verbose_name="Selfie", 55 | help_text="Strike a geek pose and give us your best shot! This will be used on your GZID card", 56 | ) 57 | telephone = models.CharField( 58 | max_length=255, blank=True, verbose_name="Phone number" 59 | ) 60 | minecraft_username = models.CharField( 61 | max_length=255, 62 | blank=True, 63 | verbose_name="Minecraft username", 64 | help_text="What is your Minecraft Java Edition username? Let us know so that you can join us on Geek.Zone/Minecraft!", 65 | ) 66 | address_1 = models.CharField( 67 | max_length=255, blank=True, verbose_name="Address line one" 68 | ) 69 | address_postcode = models.CharField( 70 | max_length=10, blank=True, verbose_name="Postcode" 71 | ) 72 | gift_aid = models.BooleanField( 73 | default=False, verbose_name="Gift aid", help_text=gift_aid_help_text 74 | ) 75 | gdpr_likeness = models.BooleanField( 76 | default=False, 77 | verbose_name="Likeness", 78 | help_text="May we use photos, videos or voice recordings of you in our publications?", 79 | ) 80 | # JDG NB: updates = news (event info etc), notifications = system messages (2FA etc) 81 | gdpr_sms_updates = models.BooleanField( 82 | default=False, 83 | verbose_name="SMS updates", 84 | help_text="May we sent you updates, like event information, via SMS?", 85 | ) 86 | gdpr_sms_notifications = models.BooleanField( 87 | default=False, 88 | verbose_name="SMS notifications", 89 | help_text="May we send you system notifications by SMS? This includes 2FA notifications", 90 | ) 91 | gdpr_email_updates = models.BooleanField( 92 | default=False, 93 | verbose_name="Email updates", 94 | help_text="May we send you updates, like event information, via email?", 95 | ) 96 | gdpr_email_notifications = models.BooleanField( 97 | default=False, 98 | verbose_name="Email notifications", 99 | help_text="May we send you system notifications by email? " 100 | "This is required to participate in Geek.Zone Elections", 101 | ) 102 | gdpr_telephone_updates = models.BooleanField( 103 | default=False, 104 | verbose_name="Phone updates", 105 | help_text="May we call you with updates, like event information?", 106 | ) 107 | gdpr_telephone_notifications = models.BooleanField( 108 | default=False, 109 | verbose_name="Phone notifications", 110 | help_text="May we call you to notify you of system messages? This includes 2FA", 111 | ) 112 | gdpr_post_updates = models.BooleanField( 113 | default=False, 114 | verbose_name="Post updates", 115 | help_text="May we send you updates, like event information, via post?", 116 | ) 117 | gdpr_post_notifications = models.BooleanField( 118 | default=False, 119 | verbose_name="Post notifications", 120 | help_text="May we send you system notifications by post? This includes 2FA and voting notifications", 121 | ) 122 | # MVP only 123 | renewal_date = models.DateTimeField( 124 | null=True, 125 | verbose_name="Membership renewal date", 126 | ) 127 | email_verified = models.BooleanField( 128 | default=False, 129 | verbose_name="Email verified", 130 | ) 131 | 132 | class Meta: 133 | verbose_name = "member" 134 | verbose_name_plural = "members" 135 | permissions = ( 136 | ("has_membership", "Member has paid"), 137 | ("reminder_email_24hr", "New member sent 24hr payment email"), 138 | ("reminder_email_72hr", "New member sent 72hr payment email"), 139 | ) 140 | 141 | @staticmethod 142 | def create(full_name, email, password, birth_date, preferred_name=None): 143 | stripe_gateway = StripeGateway() 144 | stripe_customer_id = stripe_gateway.upload_member(email) 145 | 146 | preferred_name = preferred_name if preferred_name else full_name 147 | with transaction.atomic(): 148 | user = User.objects.create_user( 149 | username=email, password=password, email=email 150 | ) 151 | return Member.objects.create( 152 | user=user, 153 | full_name=full_name, 154 | email=email, 155 | birth_date=birth_date, 156 | preferred_name=preferred_name, 157 | constitution_agreed=True, 158 | constitutional_email=True, 159 | constitutional_post=True, 160 | stripe_customer_id=stripe_customer_id, 161 | ) 162 | 163 | def __str__(self): 164 | return self.full_name 165 | 166 | 167 | class Membership(models.Model): 168 | # todo: What should the on_delete be? 169 | member = models.ForeignKey(Member, on_delete=models.CASCADE) 170 | stripe_subscription_id = models.CharField(max_length=255) 171 | start_date = models.DateTimeField(default=timezone.now) 172 | end_date = models.DateTimeField(null=True) 173 | last_payment_time = models.DateTimeField(null=True) 174 | payment_status = models.CharField(max_length=255, null=True) 175 | 176 | 177 | class FailedPayment(models.Model): 178 | member = models.ForeignKey(Member, on_delete=models.CASCADE) 179 | stripe_subscription_id = models.CharField(max_length=255) 180 | stripe_event_type = models.CharField(max_length=255) 181 | 182 | 183 | class Payment(models.Model): 184 | member = models.ForeignKey(Member, on_delete=models.CASCADE) 185 | stripe_subscription_id = models.CharField(max_length=255) 186 | -------------------------------------------------------------------------------- /erd.xml: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /memberships/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.contrib.auth.models import User 3 | from django.http import HttpResponse, HttpResponseRedirect 4 | from django.template.loader import render_to_string 5 | from django.urls import reverse 6 | from django.contrib.sites.shortcuts import get_current_site 7 | from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode 8 | from django.utils.encoding import force_bytes, force_str 9 | 10 | from django.views.decorators.csrf import csrf_exempt 11 | from django.conf import settings 12 | from django.contrib.auth import authenticate, login, logout 13 | 14 | import urllib.request 15 | import json 16 | import stripe 17 | from django.shortcuts import redirect 18 | from django.contrib.auth.decorators import login_required 19 | from django.utils.decorators import method_decorator 20 | from datetime import datetime, timedelta 21 | 22 | from .payments import handle_stripe_payment 23 | from .forms import * 24 | from .models import Member, Membership 25 | from .services import StripeGateway 26 | from .tokens import email_verification_token 27 | from .tasks import task_payment_check, task_send_email 28 | 29 | 30 | def validate_recaptcha(response): 31 | url = "https://www.google.com/recaptcha/api/siteverify" 32 | secret = settings.RECAPTCHA_SECRET_KEY 33 | payload = {"secret": secret, "response": response} 34 | data = urllib.parse.urlencode(payload).encode("utf-8") 35 | request = urllib.request.Request(url, data=data) 36 | response = urllib.request.urlopen(request) 37 | result = json.loads(response.read().decode()) 38 | success = result.get("success") 39 | 40 | if (not result.get("success")) or (float(result.get("score")) < 0.5): 41 | return "fail" 42 | 43 | return result 44 | 45 | 46 | def form_valid(self, form): 47 | # identify the token from the submitted form 48 | recaptchaV3_response = self.request.POST.get("recaptchaV3-response") 49 | url = "https://www.google.com/recaptcha/api/siteverify" 50 | payload = { 51 | "secret_key": settings.RECAPTCHA_SECRET_KEY, 52 | "response": recaptchaV3_response, 53 | } 54 | 55 | # encode the payload in the url and send 56 | data = urllib.parse.urlencode(payload).encode() 57 | request = urllib.request.Request(url, data=data) 58 | 59 | # verify that the token is valid 60 | response = urllib.request.urlopen(request) 61 | result = json.loads(response.read().decode()) 62 | 63 | # verify the two elements in the returned dictionary 64 | if (not result["register"]) or (not result["action"] == ""): 65 | messages.error(self.request, "Invalid reCAPTCHA response. Please try again.") 66 | return super().form_invalid(form) 67 | 68 | 69 | def register(request): 70 | if request.user.is_authenticated: 71 | return redirect(reverse("memberships_details")) 72 | 73 | if not request.method == "POST": 74 | return render( 75 | request, 76 | "memberships/register.html", 77 | { 78 | "form": RegistrationForm(label_suffix=""), 79 | "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY, 80 | }, 81 | ) 82 | 83 | form = RegistrationForm(request.POST) 84 | 85 | if not form.is_valid(): 86 | return render( 87 | request, 88 | "memberships/register.html", 89 | { 90 | "form": form, 91 | "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY, 92 | }, 93 | ) 94 | 95 | if not form.cleaned_data["preferred_name"]: 96 | form.cleaned_data["preferred_name"] = form.cleaned_data["full_name"] 97 | 98 | if settings.RECAPTCHA_SECRET_KEY and settings.RECAPTCHA_SITE_KEY: 99 | recaptchaV3_response = request.POST.get("recaptchaV3-response") 100 | success = validate_recaptcha(recaptchaV3_response) 101 | if success == "fail": 102 | return HttpResponse( 103 | "Invalid reCAPTCHA response. Please try again.", status=403 104 | ) 105 | 106 | member = Member.create( 107 | full_name=form.cleaned_data["full_name"], 108 | preferred_name=form.cleaned_data["preferred_name"], 109 | email=form.cleaned_data["email"], 110 | password=form.cleaned_data["password"], 111 | birth_date=form.cleaned_data["birth_date"], 112 | ) 113 | 114 | exec_time = datetime.utcnow() + timedelta(hours=24) 115 | task_payment_check.apply_async(args=(member.id,), eta=exec_time) 116 | 117 | login(request, member.user) 118 | 119 | user = request.user 120 | message = render_to_string( 121 | "memberships/welcome_email.html", 122 | { 123 | "user": user.member.preferred_name, 124 | }, 125 | ) 126 | task_send_email.delay( 127 | user.member.preferred_name, user.member.email, "Welcome", message 128 | ) 129 | 130 | donation = request.POST.get("donation") 131 | 132 | if donation: 133 | confirmation_url = "{}?donation={}".format(reverse("confirm"), donation) 134 | return HttpResponseRedirect(confirmation_url) 135 | 136 | return HttpResponseRedirect(reverse("confirm")) 137 | 138 | 139 | def confirm(request): 140 | if not request.user.is_authenticated: 141 | return HttpResponseRedirect(reverse("register")) 142 | 143 | if request.GET.get("donation"): 144 | donation = "{0:.2F}".format(float(request.GET.get("donation"))) 145 | total = "{0:.2F}".format(1 if not donation else float(donation) + 1) 146 | else: 147 | donation = request.GET.get("donation") 148 | total = "{0:.2F}".format(1) 149 | 150 | cancel_url = ( 151 | "{}?donation={}".format(reverse("confirm"), donation) 152 | if donation 153 | else reverse("confirm") 154 | ) 155 | success_url = ( 156 | "{}?donation={}".format(reverse("memberships_settings"), donation) 157 | if donation 158 | else reverse("memberships_settings") 159 | ) 160 | stripe_gateway = StripeGateway() 161 | session_id = stripe_gateway.create_checkout_session( 162 | member=request.user.member, 163 | success_url=request.build_absolute_uri(success_url), 164 | cancel_url=request.build_absolute_uri(cancel_url), 165 | ) 166 | 167 | return render( 168 | request, 169 | "memberships/confirm.html", 170 | { 171 | "donation": donation, 172 | "total": total, 173 | "stripe_public_key": settings.STRIPE_PUBLIC_KEY, 174 | "stripe_session_id": session_id, 175 | "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY, 176 | }, 177 | ) 178 | 179 | 180 | def thanks(request): 181 | if not request.user.is_authenticated: 182 | return HttpResponseRedirect(reverse("register")) 183 | return HttpResponse("Registration successful.") 184 | 185 | 186 | @csrf_exempt 187 | def stripe_webhook(request): 188 | event = None 189 | try: 190 | event = stripe.Event.construct_from( 191 | json.loads(request.body), settings.STRIPE_SECRET_KEY 192 | ) 193 | return handle_stripe_payment(event) 194 | except ValueError as e: 195 | return HttpResponse("Failed to parse stripe payload", status=400) 196 | 197 | 198 | @login_required() 199 | def details_view(request): 200 | user = request.user 201 | verified = False 202 | if user.member.email_verified: 203 | verified = True 204 | 205 | return render( 206 | request, 207 | "memberships/member_details.html", 208 | { 209 | "form": MemberDetailsForm(instance=request.user.member, label_suffix=""), 210 | "verified": verified, 211 | }, 212 | ) 213 | 214 | 215 | @login_required() 216 | def settings_view(request): 217 | if not request.method == "POST": 218 | return render( 219 | request, 220 | "memberships/member_settings.html", 221 | {"form": MemberSettingsForm(instance=request.user.member, label_suffix="")}, 222 | ) 223 | 224 | form = MemberSettingsForm(request.POST, instance=request.user.member) 225 | if not form.is_valid(): 226 | return render(request, "memberships/member_settings.html", form) 227 | 228 | form.save() 229 | return redirect(reverse("memberships_details")) 230 | 231 | 232 | def sendVerification(request): 233 | user = request.user 234 | token = email_verification_token.make_token(user) 235 | message = render_to_string( 236 | "memberships/verify_email.html", 237 | { 238 | "user": user.member.preferred_name, 239 | "domain": get_current_site(request), 240 | "uid": urlsafe_base64_encode(force_bytes(request.user.pk)) 241 | .encode() 242 | .decode(), 243 | "token": token, 244 | }, 245 | ) 246 | task_send_email.delay( 247 | user.member.preferred_name, user.member.email, "Verfy Email", message 248 | ) 249 | return render(request, "memberships/verify_sent.html") 250 | 251 | 252 | def verify(request, uidb64, token): 253 | try: 254 | uid = force_str(urlsafe_base64_decode(uidb64)) 255 | user = User.objects.get(pk=uid) 256 | m = request.user 257 | except (TypeError, ValueError, User.DoesNotExist, OverflowError): 258 | user = None 259 | if user is not None and email_verification_token.check_token(user, token): 260 | m.member.email_verified = True 261 | user.save() 262 | m.member.save(update_fields=["email_verified"]) 263 | login(request, user) 264 | return render(request, "memberships/verify_confirmation.html") 265 | else: 266 | return HttpResponse( 267 | "Error, the verification link is invalid, please use a new link" 268 | ) 269 | -------------------------------------------------------------------------------- /memberships/tests/test_registration_form.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.test import TestCase, override_settings 3 | from unittest import mock 4 | 5 | from .utils import StripeTestCase 6 | from memberships.models import Member, Membership 7 | from web.settings import TEST_USER_PASSWORD, TEST_USER_PASSWORD_BAD 8 | 9 | 10 | @override_settings(RECAPTCHA_SECRET_KEY=None, RECAPTCHA_SITE_KEY=None) 11 | class RegisterFormTestCase(StripeTestCase): 12 | def setUp(self): 13 | self.setup_stripe_mocks() 14 | 15 | def tearDown(self): 16 | self.tear_down_stripe_mocks() 17 | 18 | def test_signed_in_users_cannot_register(self): 19 | member = Member.create( 20 | full_name="test person", 21 | preferred_name="test", 22 | email="test@example.com", 23 | password=TEST_USER_PASSWORD, 24 | birth_date="1991-01-01", 25 | ) 26 | 27 | self.client.login(username="test@example.com", password=TEST_USER_PASSWORD) 28 | 29 | response = self.client.get(reverse("register")) 30 | self.assertEqual(response.status_code, 302) 31 | 32 | def test_correct_fields_are_required(self): 33 | required_string = "This field is required." 34 | response = self.client.post(reverse("register")) 35 | self.assertFormError(response, "form", "full_name", required_string) 36 | self.assertFormError(response, "form", "email", required_string) 37 | self.assertFormError(response, "form", "password", required_string) 38 | self.assertFormError(response, "form", "birth_date", required_string) 39 | self.assertFormError(response, "form", "constitution_agreed", required_string) 40 | self.assertFormError(response, "form", "constitutional_post", required_string) 41 | self.assertFormError(response, "form", "constitutional_email", required_string) 42 | 43 | def test_donation_is_required_to_be_a_number(self): 44 | response = self.client.post( 45 | reverse("register"), 46 | { 47 | "full_name": "test person", 48 | "email": "test@example.com", 49 | "password": TEST_USER_PASSWORD, 50 | "birth_date": "1991-01-01", 51 | "constitution_agreed": "on", 52 | "constitutional_post": "on", 53 | "constitutional_email": "on", 54 | "donation": "example_stripe_key", 55 | }, 56 | ) 57 | self.assertFormError(response, "form", "donation", "Enter a number.") 58 | 59 | def test_member_is_logged_in_after_registration(self): 60 | response = self.client.post( 61 | reverse("register"), 62 | { 63 | "full_name": "test person", 64 | "email": "test@example.com", 65 | "password": TEST_USER_PASSWORD, 66 | "birth_date": "1991-01-01", 67 | "constitution_agreed": "on", 68 | "constitutional_post": "on", 69 | "constitutional_email": "on", 70 | }, 71 | follow=True, 72 | ) 73 | self.assertTrue(response.context["user"].is_authenticated) 74 | 75 | def test_member_is_redirected_to_confirm_page_with_donation_when_provided( 76 | self, 77 | ): 78 | response = self.client.post( 79 | reverse("register"), 80 | { 81 | "full_name": "test person", 82 | "email": "test@example.com", 83 | "password": TEST_USER_PASSWORD, 84 | "birth_date": "1991-01-01", 85 | "constitution_agreed": "on", 86 | "constitutional_post": "on", 87 | "constitutional_email": "on", 88 | "donation": 10, 89 | }, 90 | ) 91 | self.assertRedirects(response, "{}?donation=10".format(reverse("confirm"))) 92 | 93 | def test_member_is_redirected_to_confirm_page_without_donation_when_not_provided( 94 | self, 95 | ): 96 | response = self.client.post( 97 | reverse("register"), 98 | { 99 | "full_name": "test person", 100 | "email": "test@example.com", 101 | "password": TEST_USER_PASSWORD, 102 | "birth_date": "1991-01-01", 103 | "constitution_agreed": "on", 104 | "constitutional_post": "on", 105 | "constitutional_email": "on", 106 | }, 107 | ) 108 | self.assertRedirects(response, reverse("confirm")) 109 | 110 | def test_registration_rejected_on_short_common_passwords(self): 111 | response = self.client.post( 112 | reverse("register"), 113 | { 114 | "full_name": "test person", 115 | "email": "test@example.com", 116 | "password": TEST_USER_PASSWORD_BAD, 117 | "birth_date": "1991-01-01", 118 | "constitution_agreed": "on", 119 | "constitutional_post": "on", 120 | "constitutional_email": "on", 121 | }, 122 | ) 123 | self.assertFormError( 124 | response, 125 | "form", 126 | "password", 127 | [ 128 | "This password is too short. It must contain at least 10 characters.", 129 | "This password is too common.", 130 | ], 131 | ) 132 | 133 | def test_existing_member_cannot_reregister(self): 134 | Member.create( 135 | full_name="test person", 136 | email="test@example.com", 137 | password=TEST_USER_PASSWORD, 138 | birth_date="1991-01-01", 139 | ) 140 | 141 | response = self.client.post( 142 | reverse("register"), 143 | { 144 | "full_name": "test person", 145 | "email": "test@example.com", 146 | "password": TEST_USER_PASSWORD, 147 | "birth_date": "1991-01-01", 148 | "constitution_agreed": "on", 149 | "constitutional_post": "on", 150 | "constitutional_email": "on", 151 | }, 152 | ) 153 | 154 | self.assertFormError( 155 | response, 156 | "form", 157 | "email", 158 | "You've already registered! Please login", 159 | ) 160 | 161 | 162 | class DonationConfirmPageTestCase(StripeTestCase): 163 | def setUp(self): 164 | self.setup_stripe_mocks() 165 | 166 | member = Member.create( 167 | full_name="test person", 168 | preferred_name="test", 169 | email="test@example.com", 170 | password=TEST_USER_PASSWORD, 171 | birth_date="1991-01-01", 172 | ) 173 | self.client.force_login(member.user) 174 | 175 | def tearDown(self): 176 | self.tear_down_stripe_mocks() 177 | 178 | def test_page_requires_authenticated_user(self): 179 | self.client.logout() 180 | response = self.client.get(reverse("confirm")) 181 | self.assertRedirects(response, reverse("register")) 182 | 183 | def test_total_with_donation_shows_correct_amount(self): 184 | response = self.client.get("{}?donation={}".format(reverse("confirm"), 10.00)) 185 | self.assertContains(response, "Your membership will cost £11.00 a year") 186 | self.assertContains( 187 | response, 188 | "This is made up of a £1 membership charge and a £10.00 donation", 189 | ) 190 | 191 | def test_total_without_donation_shows_correct_amount(self): 192 | response = self.client.get(reverse("confirm")) 193 | 194 | self.assertContains(response, "Your membership will cost £1.00 a year") 195 | self.assertContains( 196 | response, 197 | "This is made up of a £1 membership charge with no donation", 198 | ) 199 | 200 | @mock.patch("django.conf.settings.STRIPE_PUBLIC_KEY", "example_stripe_key") 201 | def test_view_has_stripe_public_key(self): 202 | response = self.client.get(reverse("confirm")) 203 | self.assertEqual(response.context["stripe_public_key"], "example_stripe_key") 204 | 205 | def test_view_has_stripe_session_id(self): 206 | response = self.client.get(reverse("confirm")) 207 | self.assertEqual(response.context["stripe_session_id"], "example_session_id") 208 | 209 | def test_users_without_a_donation_are_sent_to_the_correct_cancel_and_success_urls( 210 | self, 211 | ): 212 | response = self.client.get(reverse("confirm")) 213 | _, kwargs = self.create_checkout_session.call_args 214 | self.assertEqual( 215 | kwargs["cancel_url"], 216 | "http://testserver{}".format(reverse("confirm")), 217 | ) 218 | self.assertEqual( 219 | kwargs["success_url"], 220 | "http://testserver{}".format(reverse("memberships_settings")), 221 | ) 222 | 223 | def test_users_with_a_donation_are_sent_to_the_correct_cancel_and_success_urls( 224 | self, 225 | ): 226 | self.client.get("{}?donation=10.00".format(reverse("confirm"))) 227 | _, kwargs = self.create_checkout_session.call_args 228 | self.assertEqual( 229 | kwargs["cancel_url"], 230 | "http://testserver{}?donation=10.00".format(reverse("confirm")), 231 | ) 232 | self.assertEqual( 233 | kwargs["success_url"], 234 | "http://testserver{}?donation=10.00".format( 235 | reverse("memberships_settings") 236 | ), 237 | ) 238 | 239 | 240 | class ThanksPageTestCase(TestCase): 241 | def test_page_requires_authenticated_user(self): 242 | response = self.client.get(reverse("thanks"), follow=True) 243 | self.assertRedirects(response, reverse("register")) 244 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Code style: black](https://img.shields.io/badge/code%20style-black-black)](https://black.readthedocs.io/en/stable) 2 | [![License: GPLv3](https://img.shields.io/github/license/geekzonehq/web)](https://www.gnu.org/licenses/gpl-3.0.en.html) 3 | 4 | 5 | # Geek.Zone Web App 6 | This application is currently intended as the minimum viable product for Geek.Zone members and n00bs to be able to manage their Geek.Zone membership. We will build it from there, but that's our target right now! We currently use a third party to do this, and while they are not a bad service per se, they do charge us for their services and do not do all the things we need them to do. Building it ourselves will not only mean that we get the system that we need, but also that those involved will learn new, transferrable skills and have some fun doing so. 7 | 8 | Take a look at the original [spec doc](https://docs.google.com/document/d/1c43e1wYHZhDdyiafeqodQPPd9sXDHv3pEtyxxVa64OI/edit?usp=sharing). 9 | 10 | # Progress so far 11 | Here's what the front page looks like in [light mode](/screencapture-gzweb-light-2021-03-22-20_23_50.png) and in 12 | [dark mode](/screencapture-gzweb-dark-2021-03-22-20_24_03.png). 13 | 14 | ## Running the project locally 15 | The easiest and fastest way to run the project without cluttering your machine is by using docker containers. However you should be able to setup this project on any operating system that supports Django. We have instructions for Ubuntu based linux distributions and for Windows 10. Both can be found below. 16 | 17 | ### 1. Install Docker 18 | 19 | ##### Linux/Ubuntu 20 | ```sh 21 | # Install Docker 22 | sudo apt-get update 23 | sudo apt-get install -y docker.io 24 | 25 | # Configure docker to start on boot 26 | sudo systemctl enable docker.service 27 | 28 | # Manage Docker as a non-root user 29 | sudo groupadd docker 30 | sudo usermod -aG docker $USER 31 | ``` 32 | Log out of your session completely and then log back in 33 | 34 | ```sh 35 | # Install docker-compose 36 | sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 37 | sudo chmod +x /usr/local/bin/docker-compose 38 | ``` 39 | ```sh 40 | # Install command completion 41 | sudo curl \ 42 | -L https://raw.githubusercontent.com/docker/compose/1.29.2/contrib/completion/bash/docker-compose \ 43 | -o /etc/bash_completion.d/docker-compose 44 | source ~/.bashrc 45 | ``` 46 | ##### Windows 10 47 | 48 | 1. Follow the instructions in the [Docker documentation](https://docs.docker.com/desktop/windows/install/); the installation varies depending on the Windows 10 edition. 49 | 2. Right-click on the Docker icon in the system tray, and `Switch to Linux Containers` if Docker Desktop is set to Windows Containers. 50 | 51 | ### 2. Run the containers 52 | 53 | An `.env.dev` file under the `web` folder is already existing and provides environment variables to docker-compose. There are 2 docker-compose files in the project folder: `docker-compose.yml`, to be used in the ci/cd or to just run the project, and `docker-compose.dev.yml`, to be used for development purposes instead (see the `Local Development` section). 54 | 55 | 1. Make sure Docker is running (Ubuntu: `sudo systemctl restart docker.service` or `service docker.service start`; Windows 10: run Powershell as administrator `Start-Service 'Docker Desktop Service'`) 56 | 2. `docker-compose up` (to run containers when the images are already present in the machine; if not existing they will be created) 57 | 3. `docker-compose --build` (to build images for each service outlined in the docker-compose.dev.yml file) 58 | 4. `docker-compose up --build` (to force to re-build images and run containers out of these images) 59 | 5. `docker-compose ps` (from another terminal window, to check the status of each container created by docker-compose) 60 | 6. If you navigate to `http://localhost:8000/memberships/register` in your browser you should see the app main page. You can press control-c in the terminal to exit docker-compose. 61 | 62 | 7. `docker-compose down` (to delete the network and containers that docker-compose created) 63 | 64 | ### Ubuntu based Linux (or WSL on Microsoft Windows) 65 | 66 | > This guide assumes that you can execute basic terminal commands. It also assumes that you have setup github with SSH keys. 67 | 68 | Ubuntu 20.04 and above should come with a recent enough version of Python 3 for you to follow along with this guide. As of writing I am using Python 3.8.5. 69 | 70 | First follow the instructions below for initial setup. 71 | 72 | 1. Install the Python package manager `pip` by running the command `sudo apt install python3-pip` 73 | 2. Install virtualenv using the command `python3 -m pip install virtualenv`. This tool allows us to install dependencies local to a project and not clutter your system. 74 | 3. Clone this repository to your desired location `git clone git@github.com:geekzonehq/web.git` and change into that directory `cd web`. 75 | 4. Create a virtual environment `python3 -m virtualenv env`. This will create a folder in the project called `env` that will contain all of the project dependencies. 76 | 5. Activate the virtual environment `source env/bin/activate` 77 | 6. Install libpq-dev package required by psycopg2 `sudo apt-get install libpq-dev` 78 | 7. Install the project dependencies `pip install -r requirements.txt` 79 | 8. Install Postgres database `sudo apt-get -y install postgresql` 80 | 9. Configure Postgres to start on boot `sudo systemctl enable postgresql` or `service postgresql start` 81 | 10. Switch user environment to postgres user `sudo su postgres` 82 | 11. Run the Postgres interactive terminal `psql` 83 | 12. Change/assign password to postgres user `\password postgres` 84 | 13. Type a new password, (e.g. 'postgres'). This password has to match whatever is configured in step 16 85 | 14. Exit from postgres database terminal `exit` 86 | 15. Exit from postgres user environment `exit` 87 | 16. Create an .env file with parameters for local development. Add any extra parameters as needed: 88 | ```sh 89 | cat < web/.env 90 | DEBUG=1 91 | DATABASE_USER=postgres 92 | DATABASE_NAME=postgres 93 | DATABASE_HOST=localhost 94 | DATABASE_PASSWORD=postgres 95 | DATABASE_PORT=5432 96 | EOF 97 | ``` 98 | 17. Run the database migrations `python3 manage.py migrate` 99 | 18. Install RabbitMQ `sudo apt-get install rabbitmq-server` 100 | 19. Configure RabbitMQ to start on boot `sudo systemctl enable rabbitmq-server` or `service rabbitmq-server start` 101 | 20. Run the celery worker `celery -A web worker --loglevel=info` 102 | 21. Open another terminal and run the local server `python3 manage.py runserver`. If you navigate to `http://localhost:8000/memberships/register` in your browser you should now see the app. You can press control-c in the terminal to exit the server. 103 | 104 | After you have done the above subsequent setup is a lot simpler. 105 | ```sh 106 | source env/bin/activate # You only need to do this if your virtual env is not already active 107 | python manage.py runserver 108 | ``` 109 | 110 | If there are new changes to the database the runserver output will run you through the process of updating and running the migrations. 111 | 112 | ### Microsoft Windows (Without WSL) 113 | 114 | > This guide assumes that you can execute basic terminal/Powershell commands. It also assumes that you have setup github with SSH keys. 115 | Currently the project needs some adjustments to run in Windows. Specifically the USER and PASSWORD variables for Postgres need either to be hard-coded in settings.py or passed through cli when running database migrations. 116 | 117 | 1. Install Git for windows by downloading a copy from https://git-scm.com/download/win 118 | 2. Install Python from the Microsoft store. Typing `python` into a command prompt will open the correct page on the Microsoft store. This will also install the `pip` package manager. 119 | 3. Install virtualenv using the command `pip install virtualenv`. This tool allows us to install dependencies local to a project and not clutter your system. 120 | 4. Clone this repository to your desired location `git clone git@github.com:geekzonehq/web.git` and change into that directory `cd web`. 121 | 5. Create a virtual environment `python -m virtualenv env`. This will create a folder in the project called `env` that will contain all of the project dependencies. 122 | 6. Activate the virtual environment `env\Scripts\activate.bat` 123 | 7. Install Postgresql from this link: https://www.enterprisedb.com/downloads/postgres-postgresql-downloads 124 | 8. Run the installation wizard, choose a password for the database superuser (postgres) and accept all subsequent defaults, click on "Finish" 125 | 9. Press Win+R and type `services.msc`: scroll down to the postgres-service=name and start it if it is not already running. If the option to start the service is greyed out, configure Postgres to start on boot: right-click on the postgres-service-name, click on `Properties` and set the `Startup type` to `Automatic`. 126 | The same can be achieved by running as administrator a couple of Powershell commands: 127 | ```PS 128 | Install-Module PostgreSQLCmdlets 129 | Set-Service -Name "<>" -Status running -StartupType automatic 130 | ``` 131 | 10. Create an .env file with parameters for local development. Add any extra parameters as needed: 132 | ```PS 133 | echo "DEBUG=1 134 | DATABASE_USER=postgres 135 | DATABASE_NAME=postgres 136 | DATABASE_HOST=localhost 137 | DATABASE_PASSWORD=postgres 138 | DATABASE_PORT=5432" | tee web/.env 139 | ``` 140 | 11. Install the project dependencies `pip install -r requirements.txt` 141 | 12. Run the database migrations `python manage.py migrate` 142 | 13. Install Erlang for Windows using an administrative account from this link: https://erlang.org/download/otp_versions_tree.html 143 | 14. Download and run the latest Rabbitmq installer from this page: https://github.com/rabbitmq/rabbitmq-server/releases. Rabbitmq service should already be running, otherwise start it from the start menu 144 | 12. Run the celery worker `celery -A web worker --loglevel=info` 145 | 13. Open another terminal and run the local server `python manage.py runserver`. If you navigate to `http://localhost:8000/memberships/register` in your browser you should now see the app. You can press control-c in the terminal to exit the server. 146 | 147 | After you have done the above subsequent setup is a lot simpler. 148 | ```PS 149 | env\Scripts\activate.bat # You only need to do this if your virtual env is not already active 150 | python manage.py runserver 151 | ``` 152 | 153 | If there are new changes to the database the runserver output will run you through the process of updating and running the migrations. 154 | 155 | #### Running RabbitMQ & Celery independently (same configuration for Ubuntu and Windows 10) 156 | RabbitMQ & Celery have been purposefully implemented in a way that allows them to be used in any part of the project. 157 | Equally, this also allows them to be used interactively in the Django Python shell. 158 | 1. Make sure RabbitMQ is running (Ubuntu: `sudo systemctl start rabbitmq-server`; Windows 10: run Powershell as administrator `Start-Service RabbitMQ`) 159 | 1. Run the celery worker `celery -A web worker --loglevel=info` 160 | 1. `python manage.py shell` 161 | 1. `from memberships import tasks, email` 162 | 1. `import celery` 163 | 1. Run a task function from `tasks.py`, such as 164 | `tasks.task_send_email("Bob", "weoifjefij@mailinator.com", "Hello world", "Just a test")` 165 | 166 | You will need the password if you want to send from an @geek.zone email address. Please contact 167 | @JamesGeddes for this or configure your own testing email address in `settings.py`. 168 | 169 | ## Local Development 170 | 171 | ### Working on the front-end code 172 | 173 | > All commands in this section can be run either in Docker containers or in the virtual environment. 174 | 175 | The website currently uses Tailwind CSS to style the front end. Tailwind works by generating a stylesheet at `theme/static/css/dist/styles.css`, using settings located in `theme/static_src` (with base styles at `theme/static_src/src/styles.scss`). 176 | 177 | A development build of `styles.css` already exists in the repository, containing all possible Tailwind base styles. Therefore, only install and run Tailwind if you plan on making changes to settings or base styles at `theme/static_src` (or you want to generate a production build of `styles.css`). You do not need to install and run Tailwind to make simple styling changes. 178 | 179 | ### 1. Docker 180 | 181 | To test any changes in the code: 182 | 1. Run the project in docker-compose from `docker-compose.dev.yml`: 183 | ```sh 184 | docker-compose -f docker-compose.dev.yml up --build 185 | ``` 186 | 2. From another terminal window, open a shell into the `web` container: 187 | ```sh 188 | docker exec -it web sh 189 | ``` 190 | 3. Run the following commands to install and start tailwind or generate a production build of `styles.css`: 191 | ```sh 192 | python3 manage.py tailwind install 193 | ``` 194 | ```sh 195 | python3 manage.py tailwind start 196 | ``` 197 | ```sh 198 | python3 manage.py tailwind build 199 | ``` 200 | 201 | 4. To leave the container's shell, type: 202 | ```sh 203 | exit 204 | ``` 205 | ### 2. Virtual environment 206 | 207 | ###### Installing Tailwind 208 | 209 | You will need to ensure Node.js and NPM are installed on your system first - Node.js must be version 12.13.0 or higher. 210 | 211 | Once that's done, run: 212 | ```sh 213 | python manage.py tailwind install 214 | ``` 215 | 216 | >You will need to run this command again if you ever upgrade Node.js. 217 | 218 | ###### Running Tailwind alongside the local server 219 | 220 | When running the local server, run the following in a second terminal/command prompt: 221 | ```sh 222 | python manage.py tailwind start 223 | ``` 224 | 225 | This will re-generate the development build of `styles.css`, then watch for any changes made to files in `theme/static_src`. 226 | 227 | >A production build of `styles.css` can be generated using the command `python manage.py tailwind build` - this reduces the file to only the base styles that are actually being used. 228 | 229 | If you want to use LiveReload to automatically refresh your web browser in response to file changes, run the following in another terminal/command prompt: 230 | ```sh 231 | python manage.py livereload 232 | ``` 233 | 234 | #### Suggested tools 235 | 236 | Clearly, you can and should use which ever development tools you prefer. If you don't have a preference, we suggest trying, 237 | 238 | #### General coding 239 | * [Visual Studio Code](https://code.visualstudio.com/) 240 | #### Python 241 | * [PyCharm](https://www.jetbrains.com/pycharm/) 242 | #### SQL 243 | * [DBeaver Community Edition](https://dbeaver.io/) 244 | #### Diagrams 245 | * [DrawIO Desktop](https://github.com/jgraph/drawio-desktop/releases/tag/v13.3.1) 246 | 247 | Also, do join us on our [Discord](https://geek.zone/discord)! 248 | 249 | ### Running the Tests 250 | 251 | Simply run `python manage.py test`. 252 | 253 | ### Changing the CircleCI Build 254 | 255 | We have found the [circleci local cli tool](https://circleci.com/docs/2.0/local-cli/) to be very useful when making changes to the circle build locally. The errors can be a bit cryptic, but it's easier than debugging basic syntax issues from within the circleci console. 256 | 257 | ## Contributing 258 | 259 | We try to be super informal, and we welcome all PRs. For full details, see [CONTRIBUTING](CONTRIBUTING.md). 260 | 261 | ## License 262 | 263 | Geek.Zone is a member of the [Open Source Initiative](https://opensource.org/osi-affiliate-membership), so all our 264 | projects are published under GPLv3. Any contributions you make will be published under these provisions. See 265 | [LICENSE](LICENSE). 266 | --------------------------------------------------------------------------------