forked from data.coop/membersystem
Compare commits
6 commits
298a453e19
...
4c810efe6c
Author | SHA1 | Date | |
---|---|---|---|
Benjamin Bach | 4c810efe6c | ||
Benjamin Bach | 44c0156890 | ||
Víðir Valberg Guðmundsson | 0cf579c5f6 | ||
Víðir Valberg Guðmundsson | 7a3a629d6f | ||
Benjamin Bach | f6d8f82065 | ||
Benjamin Bach | 2c99799d4d |
33
Dockerfile
33
Dockerfile
|
@ -1,20 +1,31 @@
|
||||||
FROM python:3.12-slim-bullseye
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# PYTHONFAULTHANDLER: Propagate tracebacks from all threads.
|
||||||
|
# PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose).
|
||||||
|
# PYTHONDONTWRITEBYTECODE: Dont write *pyc files at all, making it possible for a 100% read-only container.
|
||||||
|
# PIP_NO_CACHE_DIR: Disable PIP cache, we don't need pip's cache after building the image.
|
||||||
|
# PIP_DISABLE_PIP_VERSION_CHECK: Build the image with the available pip, do not check for updates (faster!)
|
||||||
|
# PIP_DEFAULT_TIMEOUT: Allow for longer timeouts.
|
||||||
ENV PYTHONFAULTHANDLER=1 \
|
ENV PYTHONFAULTHANDLER=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONHASHSEED=random \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
PIP_NO_CACHE_DIR=off \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
||||||
PIP_DEFAULT_TIMEOUT=100
|
PIP_DEFAULT_TIMEOUT=100
|
||||||
ARG BUILD
|
ARG BUILD
|
||||||
ENV BUILD ${BUILD}
|
ENV BUILD=${BUILD}
|
||||||
ARG REQUIREMENTS_FILE=requirements.txt
|
ARG REQUIREMENTS_FILE=requirements.txt
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
||||||
RUN apt-get update && \
|
|
||||||
|
# Only copy the requirements file first to leverage Docker cache
|
||||||
|
COPY $REQUIREMENTS_FILE .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/src/static && \
|
||||||
|
chown www:www /app/src/static && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
binutils \
|
binutils \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
@ -26,15 +37,13 @@ RUN apt-get update && \
|
||||||
libgdk-pixbuf2.0-0 \
|
libgdk-pixbuf2.0-0 \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
shared-mime-info \
|
shared-mime-info \
|
||||||
gettext
|
gettext && \
|
||||||
|
pip install --no-cache-dir -r $REQUIREMENTS_FILE
|
||||||
|
|
||||||
COPY --chown=www:www . .
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
RUN mkdir /app/src/static && \
|
RUN django-admin compilemessages
|
||||||
chown www:www /app/src/static
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r $REQUIREMENTS_FILE
|
|
||||||
RUN django-admin compilemessages
|
|
||||||
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
||||||
|
|
10
README.md
10
README.md
|
@ -98,3 +98,13 @@ hatch-pip-compile
|
||||||
# Update requirements/requirements-dev.txt:
|
# Update requirements/requirements-dev.txt:
|
||||||
hatch-pip-compile dev
|
hatch-pip-compile dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Updating requirements
|
||||||
|
|
||||||
|
If you want to update the requirements, you can run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hatch run requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses [hatch-pip-compile](https://juftin.com/hatch-pip-compile/) to update the requirements.
|
||||||
|
|
|
@ -13,16 +13,16 @@ authors = [
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Django>=5.1b1,<5.2",
|
"Django>=5.1b1,<5.2",
|
||||||
"django-money==3.5.2",
|
"django-money~=3.5",
|
||||||
"django-allauth==0.63.6",
|
"django-allauth~=0.63",
|
||||||
"psycopg[binary]==3.2.1",
|
"psycopg[binary]~=3.2",
|
||||||
"environs[django]==11.0.0",
|
"environs[django]>=11,<12",
|
||||||
"uvicorn==0.30.1",
|
"uvicorn~=0.30",
|
||||||
"whitenoise==6.7.0",
|
"whitenoise~=6.7",
|
||||||
"django-zen-queries==2.1.0",
|
"django-zen-queries~=2.1",
|
||||||
"django-registries==0.0.3",
|
"django-registries==0.0.3",
|
||||||
"django-view-decorator==0.0.4",
|
"django-view-decorator==0.0.4",
|
||||||
"django-oauth-toolkit==2.4.0",
|
"django-oauth-toolkit~=2.4",
|
||||||
"django_stubs_ext~=5.0",
|
"django_stubs_ext~=5.0",
|
||||||
"stripe~=10.5",
|
"stripe~=10.5",
|
||||||
]
|
]
|
||||||
|
@ -64,7 +64,7 @@ matrix.python.dependencies = [
|
||||||
{ value = "typing_extensions==4.5.0", if = ["3.10"]},
|
{ value = "typing_extensions==4.5.0", if = ["3.10"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.envs.dev.scripts]
|
[tool.hatch.envs.default.scripts]
|
||||||
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}"
|
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}"
|
||||||
no-cov = "cov --no-cov {args}"
|
no-cov = "cov --no-cov {args}"
|
||||||
typecheck = "mypy --config-file=pyproject.toml ."
|
typecheck = "mypy --config-file=pyproject.toml ."
|
||||||
|
@ -74,6 +74,8 @@ migrate = "./src/manage.py migrate"
|
||||||
makemigrations = "./src/manage.py makemigrations"
|
makemigrations = "./src/manage.py makemigrations"
|
||||||
createsuperuser = "./src/manage.py createsuperuser"
|
createsuperuser = "./src/manage.py createsuperuser"
|
||||||
shell = "./src/manage.py shell"
|
shell = "./src/manage.py shell"
|
||||||
|
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
|
||||||
|
stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE="tests.settings"
|
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||||
|
|
|
@ -16,7 +16,7 @@ charset-normalizer==3.3.2
|
||||||
# via requests
|
# via requests
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
cryptography==42.0.8
|
cryptography==43.0.0
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
dj-database-url==2.2.0
|
dj-database-url==2.2.0
|
||||||
# via environs
|
# via environs
|
||||||
|
@ -97,7 +97,7 @@ typing-extensions==4.12.2
|
||||||
# stripe
|
# stripe
|
||||||
urllib3==2.2.2
|
urllib3==2.2.2
|
||||||
# via requests
|
# via requests
|
||||||
uvicorn==0.30.1
|
uvicorn==0.30.4
|
||||||
# via membersystem (pyproject.toml)
|
# via membersystem (pyproject.toml)
|
||||||
whitenoise==6.7.0
|
whitenoise==6.7.0
|
||||||
# via membersystem (pyproject.toml)
|
# via membersystem (pyproject.toml)
|
||||||
|
|
|
@ -11,19 +11,19 @@
|
||||||
# - django-debug-toolbar==4.2.0
|
# - django-debug-toolbar==4.2.0
|
||||||
# - django-browser-reload==1.7.0
|
# - django-browser-reload==1.7.0
|
||||||
# - model-bakery==1.17.0
|
# - model-bakery==1.17.0
|
||||||
# - django-allauth==0.63.6
|
# - django-allauth~=0.63
|
||||||
# - django-money==3.5.2
|
# - django-money~=3.5
|
||||||
# - django-oauth-toolkit==2.4.0
|
# - django-oauth-toolkit~=2.4
|
||||||
# - django-registries==0.0.3
|
# - django-registries==0.0.3
|
||||||
# - django-stubs-ext~=5.0
|
# - django-stubs-ext~=5.0
|
||||||
# - django-view-decorator==0.0.4
|
# - django-view-decorator==0.0.4
|
||||||
# - django-zen-queries==2.1.0
|
# - django-zen-queries~=2.1
|
||||||
# - django<5.2,>=5.1b1
|
# - django<5.2,>=5.1b1
|
||||||
# - environs[django]==11.0.0
|
# - environs[django]<12,>=11
|
||||||
# - psycopg[binary]==3.2.1
|
# - psycopg[binary]~=3.2
|
||||||
# - stripe~=10.5
|
# - stripe~=10.5
|
||||||
# - uvicorn==0.30.1
|
# - uvicorn~=0.30
|
||||||
# - whitenoise==6.7.0
|
# - whitenoise~=6.7
|
||||||
#
|
#
|
||||||
|
|
||||||
asgiref==3.8.1
|
asgiref==3.8.1
|
||||||
|
@ -49,7 +49,7 @@ coverage==7.3.0
|
||||||
# hatch.envs.dev
|
# hatch.envs.dev
|
||||||
# coverage
|
# coverage
|
||||||
# pytest-cov
|
# pytest-cov
|
||||||
cryptography==42.0.8
|
cryptography==43.0.0
|
||||||
# via jwcrypto
|
# via jwcrypto
|
||||||
dj-database-url==2.2.0
|
dj-database-url==2.2.0
|
||||||
# via environs
|
# via environs
|
||||||
|
@ -86,7 +86,7 @@ django-registries==0.0.3
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-stubs==1.16.0
|
django-stubs==1.16.0
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-stubs-ext==5.0.2
|
django-stubs-ext==5.0.4
|
||||||
# via
|
# via
|
||||||
# hatch.envs.dev
|
# hatch.envs.dev
|
||||||
# django-stubs
|
# django-stubs
|
||||||
|
@ -166,7 +166,7 @@ tomli==2.0.1
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
types-pytz==2024.1.0.20240417
|
types-pytz==2024.1.0.20240417
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
types-pyyaml==6.0.12.20240311
|
types-pyyaml==6.0.12.20240724
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
typing-extensions==4.12.2
|
typing-extensions==4.12.2
|
||||||
# via
|
# via
|
||||||
|
@ -180,7 +180,7 @@ typing-extensions==4.12.2
|
||||||
# stripe
|
# stripe
|
||||||
urllib3==2.2.2
|
urllib3==2.2.2
|
||||||
# via requests
|
# via requests
|
||||||
uvicorn==0.30.1
|
uvicorn==0.30.3
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
wheel==0.43.0
|
wheel==0.43.0
|
||||||
# via pip-tools
|
# via pip-tools
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 5.1b1 on 2024-07-31 22:02
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0009_alter_orderproduct_order_alter_orderproduct_product'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='orderproduct',
|
||||||
|
options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='payment',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
),
|
||||||
|
]
|
|
@ -163,8 +163,6 @@ class Payment(CreatedModifiedAbstract):
|
||||||
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
|
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
|
||||||
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
|
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
|
||||||
|
|
||||||
stripe_charge_id = models.CharField(max_length=255, default="", blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("payment")
|
verbose_name = _("payment")
|
||||||
verbose_name_plural = _("payments")
|
verbose_name_plural = _("payments")
|
||||||
|
|
|
@ -30,7 +30,7 @@ def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs:
|
||||||
instance.order.save()
|
instance.order.save()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=models.Payment)
|
@receiver(post_save, sender=models.Order)
|
||||||
def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001
|
def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001
|
||||||
"""Mark a membership as activated when its order is marked as paid."""
|
"""Mark a membership as activated when its order is marked as paid."""
|
||||||
if instance.is_paid:
|
if instance.is_paid:
|
||||||
|
|
|
@ -40,9 +40,10 @@
|
||||||
|
|
||||||
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
|
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
|
||||||
|
|
||||||
|
{% if not order.is_paid %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
|
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -7,11 +7,13 @@ from django.core.mail import send_mail
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django_view_decorator import namespaced_decorator_factory
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -52,6 +54,8 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
order = models.Order.objects.get(pk=order_id, member=user)
|
order = models.Order.objects.get(pk=order_id, member=user)
|
||||||
current_site = Site.objects.get_current(request)
|
current_site = Site.objects.get_current(request)
|
||||||
base_domain = f"https://{current_site.domain}"
|
base_domain = f"https://{current_site.domain}"
|
||||||
|
if settings.DEBUG:
|
||||||
|
f"http://{current_site.domain}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
line_items = []
|
line_items = []
|
||||||
|
@ -70,6 +74,7 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
)
|
)
|
||||||
checkout_session = stripe.checkout.Session.create(
|
checkout_session = stripe.checkout.Session.create(
|
||||||
line_items=line_items,
|
line_items=line_items,
|
||||||
|
metadata={"order_id": order.id},
|
||||||
mode="payment",
|
mode="payment",
|
||||||
success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}),
|
success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}),
|
||||||
cancel_url=base_domain + "/cancel",
|
cancel_url=base_domain + "/cancel",
|
||||||
|
@ -132,12 +137,12 @@ def cancel(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@transaction.atomic
|
||||||
@order_view(
|
@order_view(
|
||||||
paths="stripe/webhook/",
|
paths="stripe/webhook/",
|
||||||
name="webhook",
|
name="webhook",
|
||||||
login_required=True,
|
|
||||||
)
|
)
|
||||||
|
@csrf_exempt
|
||||||
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
"""Handle Stripe webhook.
|
"""Handle Stripe webhook.
|
||||||
|
|
||||||
|
@ -157,7 +162,16 @@ def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded":
|
if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded":
|
||||||
# TODO: Implement the payment
|
# Order is marked paid via signals, Membership is activated via signals.
|
||||||
pass
|
order_id = event["data"]["object"]["metadata"]["order_id"]
|
||||||
|
order = get_object_or_404(models.Order, pk=order_id)
|
||||||
|
if not models.Payment.objects.filter(order=order).exists():
|
||||||
|
models.Payment.objects.create(
|
||||||
|
order=order,
|
||||||
|
amount=Money(event["data"]["object"]["amount_total"], event["data"]["object"]["currency"]),
|
||||||
|
description="Paid via Stripe",
|
||||||
|
payment_type=models.PaymentType.objects.get_or_create(name="Stripe")[0],
|
||||||
|
external_transaction_id=event["id"],
|
||||||
|
)
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|
|
@ -66,7 +66,7 @@ def ensure_membership_type_exists(
|
||||||
# Get the default account of the member. We don't really know what to do if a person owns multiple accounts.
|
# Get the default account of the member. We don't really know what to do if a person owns multiple accounts.
|
||||||
account, __ = Account.objects.get_or_create(owner=member)
|
account, __ = Account.objects.get_or_create(owner=member)
|
||||||
# Create an Order for the products in the membership
|
# Create an Order for the products in the membership
|
||||||
order = Order.objects.create(member=member, account=account)
|
order = Order.objects.create(member=member, account=account, description=membership_type.name)
|
||||||
# Add stuff to the order
|
# Add stuff to the order
|
||||||
for product in membership_type.products.all():
|
for product in membership_type.products.all():
|
||||||
OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat)
|
OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat)
|
||||||
|
|
Loading…
Reference in a new issue