diff --git a/setup.py b/setup.py index 74fadcadb63509edd8c7ff2a89dfefba385e7056..06f29abe1f8ccd42d0fa74f54d17608b46d07361 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="stripe-checkout", - version="0.1", + version="0.2", author="GEANT", author_email="swd@geant.org", description="Stripe custom checkout support service", diff --git a/stripe_checkout/stripe_checkout/management/commands/processevents.py b/stripe_checkout/stripe_checkout/management/commands/processevents.py index 207d291c91ad38d281bd66faf21bdbead578e071..fb0a0b5f0fabc4a86a25d04346ac7718930dda28 100644 --- a/stripe_checkout/stripe_checkout/management/commands/processevents.py +++ b/stripe_checkout/stripe_checkout/management/commands/processevents.py @@ -3,7 +3,6 @@ from django.core.management.base import BaseCommand from django.db import transaction from stripe_checkout.stripe_checkout.models import Event, ItemKind, Order from stripe_checkout.stripe_checkout.visit import VisitorAPI -from stripe_checkout.stripe_checkout.shopping_cart import get_answers SUCCESS_EVENTS = [ "invoice.paid", @@ -11,8 +10,6 @@ SUCCESS_EVENTS = [ "checkout.session.completed", ] -PAID_TAG = "PAID" - RAISE_EXCEPTIONS = True @@ -57,23 +54,15 @@ class Command(BaseCommand): def update_visit_paid(self, order: Order): api = VisitorAPI() visitor = api.get_visitor(order.visitor_id) - current_tags = visitor["tags"] - answers = {} - payload = {} for item in order.items.all(): if item.kind == ItemKind.REGISTRATION_TYPE: - if PAID_TAG not in current_tags: - payload["tags"] = current_tags + [PAID_TAG] + visitor.paid = True elif item.kind == ItemKind.EXTRA: question_id = item.visit_paid_question_id answer_id = item.visit_paid_answer_id + visitor.add_answer(question_id, answer_id) - if question_id not in answers: - answers[question_id] = get_answers(visitor, question_id) - answers[question_id].add(answer_id) - - payload["questions"] = self.prepare_answers(answers) - api.update_visitor(order.visitor_id, payload) + api.update_visitor(visitor) @staticmethod def prepare_answers(answers: dict): diff --git a/stripe_checkout/stripe_checkout/management_views.py b/stripe_checkout/stripe_checkout/management_views.py index 42a674559429e6f26416e7365d94b19f5d15d9d6..5d68fcbc4f48f02a2ea71ada5dfac62c11f355e4 100644 --- a/stripe_checkout/stripe_checkout/management_views.py +++ b/stripe_checkout/stripe_checkout/management_views.py @@ -1,9 +1,8 @@ from typing import Sequence -from django.http import HttpResponse from django.shortcuts import redirect, render from django.contrib.auth.decorators import login_required -from stripe_checkout.stripe_checkout.models import ItemKind, PricedItem +from stripe_checkout.stripe_checkout.models import ItemKind, Order, PricedItem from . import visit from django.views.decorators.http import require_GET, require_POST @@ -18,6 +17,8 @@ def index(request): def list_visitors(request): api = visit.VisitorAPI() visitors = api.list_visitors() + visitors.sort(key=lambda v: v.last_name) + extra_items = PricedItem.objects.enabled().filter(kind=ItemKind.EXTRA).all() registration_types = PricedItem.objects.filter( kind=ItemKind.REGISTRATION_TYPE @@ -27,7 +28,7 @@ def list_visitors(request): request, "visitors.html", context={ - "visitors": prepare_visitors(visitors), + "visitors": visitors, "extra_items": extra_items, "registration_types": registration_types, }, @@ -37,45 +38,33 @@ def list_visitors(request): @login_required @require_POST def update_visitor(request, visitor_id): - data = request.POST - kind = request.GET.get("kind") - - all_items: Sequence[PricedItem] = ( + all_extras: Sequence[PricedItem] = ( PricedItem.objects.enabled().filter(kind=ItemKind.EXTRA).all() ) - - payload = {} - if kind == "paid": - pass - - elif kind == "checkout": - - selected_extras = {key for key, value in data.items() if value == "on"} - all_answers = {item.visit_checkout_question_id: [] for item in all_items} - for extra in selected_extras: - question, answer = extra.split(":") - all_answers[question].append(answer) - - payload = { - "registrationType": {"id": data["registration_type"]}, - "questions": [ - { - "id": question, - "answers": [{"id": a} for a in answers], - } - for question, answers in all_answers.items() - ], - } - else: - return HttpResponse(status=400) - api = visit.VisitorAPI() - api.update_visitor(visitor_id, payload) + visitor = api.get_visitor(visitor_id) + + data = request.POST + all_answers = {} + for extra in all_extras: + for question, answer in [ + (extra.visit_checkout_question_id, extra.visit_checkout_answer_id), + (extra.visit_paid_question_id, extra.visit_paid_answer_id), + ]: + all_answers.setdefault(question, set()) + if data.get(f"{question}:{answer}"): + all_answers[question].add(answer) + for question, answers in all_answers.items(): + visitor.set_answers(question, answers) + if registration_type := data.get("registration_type"): + visitor.set_registration_type(registration_type) + visitor.paid = data.get("paid") + api.update_visitor(visitor) return redirect("stripe_checkout:list-visitors") -def prepare_visitors(visitors): - return sorted( - filter(lambda v: not v["deleted"], visitors), - key=lambda v: v.get("contact", {}).get("lastName", ""), - ) +@login_required +@require_POST +def delete_orders(request, visitor_id): + Order.objects.filter(visitor_id=visitor_id).delete() + return redirect("stripe_checkout:list-visitors") diff --git a/stripe_checkout/stripe_checkout/stripe.py b/stripe_checkout/stripe_checkout/stripe.py index 7ed65a667cc1859b4ff2a9f6daa8f15a483f9836..b81e5f39f7369f963636842a3caa958542410519 100644 --- a/stripe_checkout/stripe_checkout/stripe.py +++ b/stripe_checkout/stripe_checkout/stripe.py @@ -9,6 +9,8 @@ import stripe.error from stripe.error import StripeError # noqa F401 from django.conf import settings +from stripe_checkout.stripe_checkout.visit import Visitor + if TYPE_CHECKING: from stripe_checkout.stripe_checkout.shopping_cart import ShoppingCart @@ -21,22 +23,17 @@ TAX_RATE_ID = "txr_1QeddlDSpyjzuj5pPwUcMwTd" TAX_RATE = 20 # % -def get_or_create_customer(visitor) -> Optional[str]: +def get_or_create_customer(visitor: Visitor) -> Optional[str]: """Returns a stripe customer based on email address :return: customer_id """ - contact = visitor["contact"] - email = contact["email"] - if not email: - return None - stripe.api_key = settings.STRIPE_API_KEY - result = stripe.Customer.search(query=f"email:'{email}'")["data"] + result = stripe.Customer.search(query=f"email:'{visitor.email}'")["data"] if len(result) == 0: customer = stripe.Customer.create( - name=f"{contact['firstName']} {contact['lastName']}", - email=email, - country=contact["country"], + name=visitor.full_name, + email=visitor.email, + country=visitor["contact"].get("country"), ) return customer["id"] return result[0]["id"] @@ -55,6 +52,7 @@ def create_invoice( invoice = stripe.Invoice.create( customer=customer_id, collection_method="send_invoice", + payment_settings={"payment_method_types": ["card", "customer_balance"]}, days_until_due=30, custom_fields=custom_fields, rendering={"template": settings.STRIPE_INVOICE_TEMPLATE_ID}, @@ -66,7 +64,7 @@ def create_invoice( for item in shopping_cart ], ) - stripe.Invoice.send_invoice(invoice["id"]) + invoice = stripe.Invoice.send_invoice(invoice["id"]) return invoice diff --git a/stripe_checkout/stripe_checkout/templates/visitors.html b/stripe_checkout/stripe_checkout/templates/visitors.html index 3cda17ad25741bb235b1aef8b3decda907392c70..2376d6a6d1fb4f013b858646cbc104a57799257d 100644 --- a/stripe_checkout/stripe_checkout/templates/visitors.html +++ b/stripe_checkout/stripe_checkout/templates/visitors.html @@ -5,21 +5,26 @@ {% endblock title %} {% block body %} <div> - <h1>Checked-out items</h1> + <h1>Manage visitors</h1> + <h2 style="color: red">Warning: Be careful, you may break things here</h2> <table> <thead> <tr> <th>ID</th> <th>Name</th> <th>RegistrationType</th> + <th>Paid</th> {% for item in extra_items %}<th>{{ item.display_name }}</th>{% endfor %} </tr> </thead> <tbody> {% for visitor in visitors %} <tr> - <td>{{ visitor.id }}</td> - <td>{{ visitor.contact.firstName }} {{visitor.contact.lastName }}</td> + <td> + <a href="{% url 'stripe_checkout:checkout-entrypoint' visitor.id %}" + target="_blank">{{ visitor.id }}</a> + </td> + <td>{{ visitor.contact.firstName }} {{ visitor.contact.lastName }}</td> <td> <select form="{{ visitor.id }}_checkout_update_form" name=registration_type> {% for item in registration_types %} @@ -30,6 +35,13 @@ {% endfor %} </select> </td> + <td> + <input type="checkbox" + form="{{ visitor.id }}_checkout_update_form" + name="paid" + autocomplete="off" + {% if visitor.paid %}checked{% endif %}> + </td> {% for item in extra_items %} <td> <input type="checkbox" @@ -37,16 +49,31 @@ name="{{ item.visit_checkout_question_id }}:{{ item.visit_checkout_answer_id }}" autocomplete="off" {% if visitor|has_answer:item %}checked{% endif %}> + <span>(paid <span> + <input type="checkbox" + form="{{ visitor.id }}_checkout_update_form" + name="{{ item.visit_paid_question_id }}:{{ item.visit_paid_answer_id }}" + autocomplete="off" + {% if visitor|has_paid:item %}checked{% endif %}> + </span>)</span> </td> {% endfor %} <td> <form id="{{ visitor.id }}_checkout_update_form" method="post" - action="{% url 'stripe_checkout:update-visitor' visitor_id=visitor.id %}?kind=checkout"> + action="{% url 'stripe_checkout:update-visitor' visitor_id=visitor.id %}"> {% csrf_token %} <button>Update</button> </form> </td> + <td> + <form id="{{ visitor.id }}_delete_orders" + method="post" + action="{% url 'stripe_checkout:delete-orders' visitor_id=visitor.id %}"> + {% csrf_token %} + <button>Delete orders</button> + </form> + </td> </tr> {% endfor %} </tbody> diff --git a/stripe_checkout/stripe_checkout/templatetags/visitor_helpers.py b/stripe_checkout/stripe_checkout/templatetags/visitor_helpers.py index a1d4980c6ec60e0a092999514938e9d1c7797875..4d61d07ac34ac83542fa97caa1768afb87db6e69 100644 --- a/stripe_checkout/stripe_checkout/templatetags/visitor_helpers.py +++ b/stripe_checkout/stripe_checkout/templatetags/visitor_helpers.py @@ -13,3 +13,12 @@ def has_answer(visitor: dict, item: PricedItem): question_id=item.visit_checkout_question_id, answer_id=item.visit_checkout_answer_id, ) + + +@register.filter +def has_paid(visitor: dict, item: PricedItem): + return shopping_cart.has_answer( + visitor, + question_id=item.visit_paid_question_id, + answer_id=item.visit_paid_answer_id, + ) diff --git a/stripe_checkout/stripe_checkout/urls.py b/stripe_checkout/stripe_checkout/urls.py index 24b8482a4bfebf627db435684b0b3a77c0f5a257..04b5000ce914c9066f839381c1c094e67d8236c5 100644 --- a/stripe_checkout/stripe_checkout/urls.py +++ b/stripe_checkout/stripe_checkout/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path, re_path -from .management_views import update_visitor, list_visitors, index +from .management_views import delete_orders, update_visitor, list_visitors, index from .visit_views import stripe_event, checkout, checkout_success visitor_id = r"(?P<visitor_id>[a-z0-9]+)" @@ -11,6 +11,9 @@ urlpatterns = [ "visitors/", include( [ + re_path( + f"{visitor_id}/delete_orders/", delete_orders, name="delete-orders" + ), re_path(f"{visitor_id}/", update_visitor, name="update-visitor"), path("", list_visitors, name="list-visitors"), ] diff --git a/stripe_checkout/stripe_checkout/visit.py b/stripe_checkout/stripe_checkout/visit.py index 5d771bba49e05c25773c6be6ba9052cabc97bf77..3fbdd320983b8648ca746f8db25f47fbb04e7856 100644 --- a/stripe_checkout/stripe_checkout/visit.py +++ b/stripe_checkout/stripe_checkout/visit.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Optional, Union import requests from requests.auth import HTTPBasicAuth from django.conf import settings @@ -21,12 +23,94 @@ class VisitorAPI: return response.json() def list_visitors(self): - return self._request("get", f"{BASE_URL}/visitors/{self.expo_id}") + all_visitors = self._request("get", f"{BASE_URL}/visitors/{self.expo_id}") + return [ + Visitor.from_api(data) + for data in filter(lambda v: not v["deleted"], all_visitors) + ] def get_visitor(self, visitor_id: str): - return self._request("get", f"{BASE_URL}/visitors/{self.expo_id}/{visitor_id}") + return Visitor.from_api( + self._request("get", f"{BASE_URL}/visitors/{self.expo_id}/{visitor_id}") + ) - def update_visitor(self, visitor_id: str, payload: dict): + def update_visitor( + self, visitor: Union[str, Visitor], payload: Optional[dict] = None + ): + if isinstance(visitor, dict) and payload is None: + payload = visitor + visitor = visitor["id"] return self._request( - "put", f"{BASE_URL}/visitors/{self.expo_id}/{visitor_id}", json=payload + "put", f"{BASE_URL}/visitors/{self.expo_id}/{visitor}", json=payload ) + + +class Visitor(dict): + PAID_TAG = "PAID" + + @classmethod + def from_api(cls, data): + return cls(**data) + + @property + def first_name(self): + return self["contact"].get("firstName", "") + + @property + def last_name(self): + return self["contact"].get("lastName", "") + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}".strip() + + @property + def email(self): + return self["contact"].get("email") + + @property + def paid(self): + return self.PAID_TAG in self.get("tags") + + @paid.setter + def paid(self, value: bool): + current_tags = set(self.get("tags", [])) + if value: + self["tags"] = list(current_tags | {self.PAID_TAG}) + else: + self["tags"] = list(current_tags - {self.PAID_TAG}) + + def set_registration_type(self, registration_type_id): + self["registrationType"] = {"id": registration_type_id} + + def get_all_questions(self) -> dict[str, set[str]]: + return { + question["id"]: {a["id"] for a in question["answers"]} + for question in self.get("questions", []) + } + + def update_questions(self, questions: dict[str, set[str]]): + self["questions"] = [ + { + "id": question, + "answers": [{"id": a} for a in answers], + } + for question, answers in questions.items() + ] + + def add_answer(self, question_id, answer_id): + all_questions = self.get_all_questions() + all_questions.setdefault(question_id, set()).add(answer_id) + self.update_questions(all_questions) + + def remove_answer(self, question_id, answer_id): + all_questions = self.get_all_questions() + if question_id not in all_questions: + return + all_questions[question_id].remove(answer_id) + self.update_questions(all_questions) + + def set_answers(self, question_id, answers_ids): + all_questions = self.get_all_questions() + all_questions[question_id] = set(answers_ids) + self.update_questions(all_questions) diff --git a/stripe_checkout/stripe_checkout/visit_views.py b/stripe_checkout/stripe_checkout/visit_views.py index 848caaf9825ecf929cbef27488b7047959b71be5..eb9f70059e31d0eb7a2e04c51e0a20ded168af35 100644 --- a/stripe_checkout/stripe_checkout/visit_views.py +++ b/stripe_checkout/stripe_checkout/visit_views.py @@ -37,8 +37,9 @@ def checkout(request, visitor_id): data = form.cleaned_data obj = create_invoice(visitor, data) shopping_cart.create_order(visitor, stripe_obj=obj) - - return redirect("stripe_checkout:checkout-success", visitor_id=visitor_id) + response = redirect(obj["hosted_invoice_url"]) + response.status_code = 303 + return response # form is invalid status_code = 400 diff --git a/test/conftest.py b/test/conftest.py index 39681e007e6b23c61ed364a059e750e940b10081..9d4833720bc8a09970a7b06f7d75d4c79a5d61cd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -175,7 +175,13 @@ def mock_stripe(item_data): ), patch("stripe.Invoice.create", return_value={"id": "stripe-inv-id"}), patch("stripe.InvoiceItem.create", return_value={"id": "stripe-invitem-id"}), - patch("stripe.Invoice.send_invoice"), + patch( + "stripe.Invoice.send_invoice", + return_value={ + "id": "stripe-invitem-id", + "hosted_invoice_url": "http://boguspath", + }, + ), patch("stripe.Invoice.add_lines"), patch("stripe.Price.list", return_value={"data": price_list}), patch("stripe.Product.list", return_value={"data": product_list}), diff --git a/test/test_visit.py b/test/test_visit.py index 4710578b9229629b93adee6ae2278208065c188a..454629c6df9d1bd4616155756e4e8728a284c27b 100644 --- a/test/test_visit.py +++ b/test/test_visit.py @@ -18,7 +18,7 @@ def test_list_visitors(client, user): @pytest.mark.django_db def test_update_visitor(client, user, visitor_id): client.force_login(user) - rv = client.post(f"/visitors/{visitor_id}/?kind=paid") + rv = client.post(f"/visitors/{visitor_id}/") assert rv.status_code == 302 @@ -31,7 +31,7 @@ def test_create_invoice(client, visitor_id): assert not Order.objects.count() rv = client.post(f"/checkout/{visitor_id}/", data={"payment_method": "invoice"}) - assert rv.status_code == 302 + assert rv.status_code == 303 assert Order.objects.count() == 1