diff --git a/README.rst b/README.rst index 980a407509f9611440bf784f8c8a5afb27762781..f5598bb695ae306143d31639f53d742ff18ddd22 100644 --- a/README.rst +++ b/README.rst @@ -17,6 +17,7 @@ Licence :: Copyright © 2022 Dominik George <dominik.george@teckids.org> + Copyright © 2022 Tom Teichler <tom.teichler@teckids.org> Licenced under the EUPL, version 1.2 or later diff --git a/aleksis/apps/tezor/apps.py b/aleksis/apps/tezor/apps.py index 91be7bb899eae1e9d1a56bd40651423b41fe37e8..194ffad797675f5a7d9708c4d1c0df1c72281e7d 100644 --- a/aleksis/apps/tezor/apps.py +++ b/aleksis/apps/tezor/apps.py @@ -1,4 +1,7 @@ +from django.apps import apps + from aleksis.core.util.apps import AppConfig +from aleksis.core.util.core_helpers import get_site_preferences class DefaultConfig(AppConfig): @@ -10,4 +13,48 @@ class DefaultConfig(AppConfig): "Repository": "https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Tezor", } licence = "EUPL-1.2+" - copyright_info = (([2022], "Dominik George", "dominik.george@teckids.org"),) + copyright_info = (([2022], "Dominik George", "dominik.george@teckids.org"), ([2022], "Tom Teichler", "tom.teichler@teckids.org"),) + + def ready(self): + from django.conf import settings # noqa + settings.PAYMENT_VARIANTS = {} + + for app_config in apps.app_configs.values(): + if hasattr(app_config, "get_payment_variants"): + variants = app_config.get_payment_variants() + for name, config in variants.items(): + if name not in settings.PAYMENT_VARIANTS: + settings.PAYMENT_VARIANTS[name] = config + + def get_payment_variants(self): + prefs = get_site_preferences() + variants = {} + + if prefs["payments__sofort_api_id"]: + variants["sofort"] = ("payments.sofort.SofortProvider", { + "id": prefs["payments__sofort_api_id"], + "key": prefs["payments__sofort_api_key"], + "project_id": prefs["payments__sofort_project_id"], + "endpoint": "https://api.sofort.com/api/xml", + }) + + if prefs["payments__paypal_client_id"]: + variants["paypal"] = ("payments.paypal.PaypalProvider", { + "client_id": prefs["payments__paypal_client_id"], + "secret": prefs["payments__paypal_secret"], + "capture": not prefs["payments__paypal_capture"], + "endpoint": "https://api.paypal.com", + }) + + if prefs["payments__pledge_enabled"]: + variants["pledge"] = ("djp_sepa.providers.PaymentPledgeProvider", { + }) + + + if prefs["payments__sdd_creditor_identifier"]: + variants["sdd"] = ("djp_sepa.providers.DirectDebitProvider", { + "creditor": prefs["payments__sdd_creditor"], + "creditor_identifier": prefs["payments__sdd_creditor_identifier"], + }) + + return variants diff --git a/aleksis/apps/tezor/menus.py b/aleksis/apps/tezor/menus.py index 49c9644d489921913cd07a84aeaf217008bfa661..1355051be6de23342f18e317f4382aa68a96452e 100644 --- a/aleksis/apps/tezor/menus.py +++ b/aleksis/apps/tezor/menus.py @@ -3,19 +3,19 @@ from django.utils.translation import gettext_lazy as _ MENUS = { "NAV_MENU_CORE": [ { - "name": _("Payments"), + "name": _("Payments and Money"), "url": "#", "root": True, - "icon": "price_check", + "svg_icon": "mdi:piggy_bank", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", ], "submenu": [ { - "name": _("Clients"), + "name": _("Manage clients"), "url": "clients", - "icon": "account_balance", + "svg_icon": "mdi:domain", "validators": [ ( "aleksis.core.util.predicates.permission_validator", diff --git a/aleksis/apps/tezor/migrations/0001_initial.py b/aleksis/apps/tezor/migrations/0001_initial.py index 16716174e4829ed019de800d4d83e803f95bee6d..98802cabe1a043e935049e80df04c07b1ce497e7 100644 --- a/aleksis/apps/tezor/migrations/0001_initial.py +++ b/aleksis/apps/tezor/migrations/0001_initial.py @@ -85,7 +85,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='invoice', - constraint=models.UniqueConstraint(fields=('transaction_id', 'group'), name='number_uniq_per_group'), + constraint=models.UniqueConstraint(fields=('number', 'group'), name='number_uniq_per_group'), ), migrations.AddConstraint( model_name='client', diff --git a/aleksis/apps/tezor/migrations/0002_invoice_due_date.py b/aleksis/apps/tezor/migrations/0002_invoice_due_date.py new file mode 100644 index 0000000000000000000000000000000000000000..373f9dc61f94d613cce82d789b85d50d2c1154bd --- /dev/null +++ b/aleksis/apps/tezor/migrations/0002_invoice_due_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-03-12 21:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tezor', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='due_date', + field=models.DateField(null=True, verbose_name='Payment due date'), + ), + ] diff --git a/aleksis/apps/tezor/migrations/0003_manual_invoicing.py b/aleksis/apps/tezor/migrations/0003_manual_invoicing.py new file mode 100644 index 0000000000000000000000000000000000000000..efff727e2f683d26d8b2872211a14da2924f353a --- /dev/null +++ b/aleksis/apps/tezor/migrations/0003_manual_invoicing.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.12 on 2022-03-12 21:41 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0038_notification_send_at'), + ('sites', '0002_alter_domain_unique'), + ('tezor', '0002_invoice_due_date'), + ] + + operations = [ + migrations.CreateModel( + name='InvoiceItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('sku', models.CharField(blank=True, max_length=255, verbose_name='Article no.')), + ('description', models.CharField(max_length=255, verbose_name='Purchased item')), + ('price', models.DecimalField(decimal_places=2, default='0.0', max_digits=9, verbose_name='Item gross price')), + ('currency', models.CharField(max_length=10, verbose_name='Currency')), + ('tax_rate', models.DecimalField(decimal_places=1, default='0.0', max_digits=4, verbose_name='Tax rate')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddField( + model_name='invoice', + name='person', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.person', verbose_name='Invoice recipient (person)'), + ), + migrations.AddConstraint( + model_name='invoice', + constraint=models.CheckConstraint(check=models.Q(('for_object_id__isnull', True), ('person__isnull', True), _connector='OR'), name='object_or_person'), + ), + migrations.AddField( + model_name='invoiceitem', + name='site', + field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site'), + ), + migrations.AddField( + model_name='invoice', + name='items', + field=models.ManyToManyField(to='tezor.InvoiceItem', verbose_name='Invoice items'), + ), + ] diff --git a/aleksis/apps/tezor/models/invoice.py b/aleksis/apps/tezor/models/invoice.py index 7dcad881b85266d1eb099b49d5ab7a318b200932..d8ddc3f0d7d270e5519176f8283a2a8e70efa48b 100644 --- a/aleksis/apps/tezor/models/invoice.py +++ b/aleksis/apps/tezor/models/invoice.py @@ -1,16 +1,19 @@ +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q +from django.shortcuts import reverse from django.utils.translation import gettext_lazy as _ -from djmoney.models.fields import CurrencyField, MoneyField -from payments import PurchasedItem +from payments import PaymentStatus, PurchasedItem from payments.models import BasePayment from aleksis.core.mixins import ExtensibleModel, PureDjangoModel +from aleksis.core.models import Person -from .base import Client from ..tables import PurchasedItemsTable, TotalsTable +from .base import Client class InvoiceGroup(ExtensibleModel): @@ -32,22 +35,73 @@ class InvoiceGroup(ExtensibleModel): class Invoice(BasePayment, PureDjangoModel): + VARIANT_DISPLAY = { + "paypal": (_("PayPal"), "logos:paypal"), + "sofort": (_("Klarna / Sofort"), "simple-icons:klarna"), + "pledge": (_("Payment pledge / manual payment"), "mdi:hand-coin"), + "sdd": (_("SEPA Direct Debit"), "mdi:bank-transfer"), + } + STATUS_ICONS = { + PaymentStatus.WAITING: "mdi:cash-lock-open", + PaymentStatus.INPUT: "mdi:cash-lock-open", + PaymentStatus.PREAUTH: "mdi:cash-lock", + PaymentStatus.CONFIRMED: "mdi:cash-check", + PaymentStatus.REFUNDED: "mdi:cash-refund", + PaymentStatus.REJECTED: "mdi:cash-remove", + PaymentStatus.ERROR: "mdi:cash-remove", + } + group = models.ForeignKey( InvoiceGroup, verbose_name=_("Invoice group"), related_name="invoices", on_delete=models.SET_NULL, null=True ) number = models.CharField(verbose_name=_("Invoice number"), max_length=255) + due_date = models.DateField(verbose_name=_("Payment due date"), null=True) for_content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True) for_object_id = models.PositiveIntegerField() for_object = GenericForeignKey("for_content_type", "for_object_id") + # For manual invoicing + person = models.ForeignKey(Person, on_delete=models.SET_NULL, verbose_name=_("Invoice recipient (person)"), blank=True, null=True) + items = models.ManyToManyField("InvoiceItem", verbose_name=_("Invoice items")) + + @classmethod + def get_variant_choices(cls): + choices = [] + for variant in settings.PAYMENT_VARIANTS.keys(): + choices.append((variant, cls.VARIANT_DISPLAY[variant][0])) + return choices + + def get_variant_name(self): + return self.__class__.VARIANT_DISPLAY[self.variant][0] + + def get_variant_icon(self): + return self.__class__.VARIANT_DISPLAY[self.variant][1] + + def get_status_icon(self): + return self.__class__.STATUS_ICONS[self.status] + def get_purchased_items(self): - return self.for_object.get_purchased_items() + for item in self.items.all(): + yield item.as_purchased_item() + else: + return self.for_object.get_purchased_items() + + def get_person(self): + if self.person: + return self.person + elif hasattr(self.for_object, "person"): + return self.for_object.person + elif hasattr(self.for_object, "get_person"): + return self.for_object.get_person() + + return None class Meta: constraints = [ - models.UniqueConstraint(fields=["transaction_id", "group"], name="number_uniq_per_group") + models.UniqueConstraint(fields=["number", "group"], name="number_uniq_per_group"), + models.CheckConstraint(check=(Q(for_object_id__isnull=True) | Q(person__isnull=True)), name="object_or_person"), ] def get_billing_email_recipients(self): @@ -83,3 +137,20 @@ class Invoice(BasePayment, PureDjangoModel): }) return TotalsTable(values) + + def get_success_url(self): + return reverse("invoice_by_token", kwargs={"slug": self.token}) + + def get_failure_url(self): + return reverse("invoice_by_token", kwargs={"slug": self.token}) + + +class InvoiceItem(ExtensibleModel): + sku = models.CharField(max_length=255, verbose_name=_("Article no."), blank=True) + description = models.CharField(max_length=255, verbose_name=_("Purchased item")) + price = models.DecimalField(verbose_name=_("Item gross price"), max_digits=9, decimal_places=2, default="0.0") + currency = models.CharField(max_length=10, verbose_name=_("Currency")) + tax_rate = models.DecimalField(verbose_name=_("Tax rate"), max_digits=4, decimal_places=1, default="0.0") + + def as_purchased_item(self): + yield PurchasedItem(name=self.description, quantity=1, price=self.price, currency=self.currency, sku=self.sku, tax_rate=self.tax_rate) diff --git a/aleksis/apps/tezor/predicates.py b/aleksis/apps/tezor/predicates.py new file mode 100644 index 0000000000000000000000000000000000000000..e9fb75deeb3b67e7063152b13105afa77cd7d81d --- /dev/null +++ b/aleksis/apps/tezor/predicates.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model + +from rules import predicate + +from .models.invoice import Invoice + +User = get_user_model() + +@predicate +def is_own_invoice(user: User, obj: Invoice): + """Predicate which checks if the invoice is linked to the current user.""" + return obj.get_person() == user.person + +@predicate +def has_no_payment_variant(user: User, obj: Invoice): + """Predicate which checks that the invoice has no payment variant.""" + return not obj.variant + +def is_in_payment_status(status: str): + """Predicate which checks whether the invoice is in a specific state.""" + + @predicate + def _predicate(user: User, obj: Invoice): + return obj.status == status + + return _predicate diff --git a/aleksis/apps/tezor/preferences.py b/aleksis/apps/tezor/preferences.py new file mode 100644 index 0000000000000000000000000000000000000000..8a25f34519a9b34394ec9b126aa6d67651a12049 --- /dev/null +++ b/aleksis/apps/tezor/preferences.py @@ -0,0 +1,119 @@ +from django.utils.translation import gettext_lazy as _ + +from dynamic_preferences.preferences import Section +from dynamic_preferences.types import BooleanPreference, StringPreference + +from aleksis.core.registries import site_preferences_registry + +payments = Section("payments", verbose_name=_("Payments")) + + +@site_preferences_registry.register +class EnablePledge(BooleanPreference): + """Allow payments to be made by anyone, not only invoice recipient.""" + + section = payments + name = "public_payments" + verbose_name = _("Public payments") + help_text = _("Allow anyone (including guests) to make payments. Basic invoice information will be visible to anyone who knows the invoice token.") + default = True + required = False + + +@site_preferences_registry.register +class SofortAPIID(StringPreference): + """Sofort payment backend - API ID.""" + + section = payments + name = "sofort_api_id" + verbose_name = _("Sofort / Klarna - API ID") + default = "" + required = False + + +@site_preferences_registry.register +class SofortAPIKey(StringPreference): + """Sofort payment backend - API key.""" + + section = payments + name = "sofort_api_key" + verbose_name = _("Sofort / Klarna - API Key") + default = "" + required = False + + +@site_preferences_registry.register +class SofortProjectID(StringPreference): + """Sofort payment backend - project ID.""" + + section = payments + name = "sofort_project_id" + verbose_name = _("Sofort / Klarna - Project ID") + default = "" + required = False + + +@site_preferences_registry.register +class PaypalClientID(StringPreference): + """PayPal payment backend - client ID.""" + + section = payments + name = "paypal_client_id" + verbose_name = _("PayPal - Client ID") + default = "" + required = False + + +@site_preferences_registry.register +class PaypalSecret(StringPreference): + """PayPal payment backend - secret.""" + + section = payments + name = "paypal_secret" + verbose_name = _("PayPal - Secret") + default = "" + required = False + + +@site_preferences_registry.register +class PaypalCapture(BooleanPreference): + """PayPal payment backend - use Authorize & Capture.""" + + section = payments + name = "paypal_capture" + verbose_name = _("PayPal - Use Authorize & Capture") + default = False + required = False + + +@site_preferences_registry.register +class EnablePledge(BooleanPreference): + """Payment pledge payment backend - enable or not.""" + + section = payments + name = "pledge_enabled" + verbose_name = _("Enabledp ledged payments") + default = False + required = False + + +@site_preferences_registry.register +class SDDCreditor(StringPreference): + """SEPA direct debit backend - creditor name.""" + + section = payments + name = "sdd_creditor" + verbose_name = _("SEPA Direct Debit - Creditor name") + default = "" + required = False + + +@site_preferences_registry.register +class SDDCreditorIdentifier(StringPreference): + """SEPA direct debit backend - creditor identifier.""" + + section = payments + name = "sdd_creditor_identifier" + verbose_name = _("SEPA Direct Debit - Creditor identifier") + default = "" + required = False diff --git a/aleksis/apps/tezor/rules.py b/aleksis/apps/tezor/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..12a27e3b55a38bbff4d7af450d0b9b757e0a01b6 --- /dev/null +++ b/aleksis/apps/tezor/rules.py @@ -0,0 +1,91 @@ +import rules +from payments import PaymentStatus + +from aleksis.core.util.predicates import has_person, has_global_perm, has_any_object, has_object_perm, is_site_preference_set + +from .models.base import Client +from .models.invoice import Invoice, InvoiceGroup +from .predicates import has_no_payment_variant, is_own_invoice, is_in_payment_status + +# View clients +view_clients_predicate = has_person & ( + has_global_perm("tezor.view_client") | has_any_object("tezor.view_client", Client) +) +rules.add_perm("tezor.view_clients_rule", view_clients_predicate) + +# View client +view_client_predicate = has_person & ( + has_global_perm("tezor.view_client") | has_object_perm("tezor.view_client") +) +rules.add_perm("tezor.view_client_rule", view_client_predicate) + +# Edit clients +edit_client_predicate = has_person & ( + has_global_perm("tezor.edit_client") | has_object_perm("tezor.edit_client") +) +rules.add_perm("tezor.edit_client_rule", edit_client_predicate) + +# Create clients +create_client_predicate = has_person & ( + has_global_perm("tezor.create_client") | has_any_object("tezor.create_client", Client) +) +rules.add_perm("tezor.create_client_rule", create_client_predicate) + +# Delete clients +delete_client_predicate = has_person & ( + has_global_perm("tezor.delete_client") | has_object_perm("tezor.delete_client") +) +rules.add_perm("tezor.delete_client_rule", delete_client_predicate) + +# View invoice groups +view_invoice_groups_predicate = has_person & ( + has_global_perm("tezor.view_invoice_group") | has_any_object("tezor.view_invoice_group", InvoiceGroup) +) +rules.add_perm("tezor.view_invoice_groups_rule", view_invoice_groups_predicate) + +# View invoice_group +view_invoice_group_predicate = has_person & ( + has_global_perm("tezor.view_invoice_group") | has_object_perm("tezor.view_invoice_group") +) +rules.add_perm("tezor.view_invoice_group_rule", view_invoice_group_predicate) + +# Edit invoice groups +edit_invoice_group_predicate = has_person & ( + has_global_perm("tezor.edit_invoice_group") | has_object_perm("tezor.edit_invoice_group") +) +rules.add_perm("tezor.edit_invoice_group_rule", edit_invoice_group_predicate) + +# Create invoice groups +create_invoice_groups_predicate = has_person & ( + has_global_perm("tezor.create_invoice_group") | has_any_object("tezor.create_invoice_group", InvoiceGroup) +) +rules.add_perm("tezor.create_invoice_groups_rule", create_invoice_groups_predicate) + +# Delete invoice groups +delete_invoice_groups_predicate = has_person & ( + has_global_perm("tezor.delete_invoice_group") | has_any_object("tezor.delete_invoice_group", InvoiceGroup) +) +rules.add_perm("tezor.delete_invoice_groups_rule", delete_invoice_groups_predicate) + +# Display invoice billing information +display_billing_predicate = has_person & (is_own_invoice | has_global_perm("tezor.display_billing") | has_object_perm("tezor.display_billing")) +rules.add_perm("tezor.display_billing_rule", display_billing_predicate) + +# Display invoice purchased items +display_purchased_items_predicate = has_person & (is_own_invoice | has_global_perm("tezor.display_purchased_items") | has_object_perm("tezor.display_purchased_items")) +rules.add_perm("tezor.display_purchased_items_rule", display_purchased_items_predicate) + +# Change payment variant +change_payment_variant_predicate = has_person & is_in_payment_status(PaymentStatus.WAITING) & ((is_own_invoice & has_no_payment_variant) | has_global_perm("tezor.change_payment_variant") | has_object_perm("tezor.change_payment_variant")) +rules.add_perm("tezor.change_payment_variant", change_payment_variant_predicate) + +# Start payment +do_payment_predicate = has_person & (is_in_payment_status(PaymentStatus.WAITING) | is_in_payment_status(PaymentStatus.INPUT) | is_in_payment_status(PaymentStatus.ERROR) | is_in_payment_status(PaymentStatus.REJECTED)) & ((is_own_invoice | is_site_preference_set("payments", "public_payments")) | has_global_perm("tezor.do_payment") | has_object_perm("tezor.do_payment")) +rules.add_perm("tezor.do_payment", do_payment_predicate) + +# View invoice +view_invoice_predicate = has_person & is_own_invoice | is_site_preference_set("payments", "public_payments") | has_global_perm("tezor.view_invoice") | has_object_perm("tezor.view_invoice") +rules.add_perm("tezor.view_invoice_rule", view_invoice_predicate) + +print_invoice_predicate = (view_invoice_predicate & display_billing_predicate & display_purchased_items_predicate) +rules.add_perm("tezor.print_invoice_rule", print_invoice_predicate) diff --git a/aleksis/apps/tezor/settings.py b/aleksis/apps/tezor/settings.py index dabd92baa378f72fb1d665e416b87d09f9d6c18d..e7277712f384d2170c04e46ce10634a01ac71785 100644 --- a/aleksis/apps/tezor/settings.py +++ b/aleksis/apps/tezor/settings.py @@ -1 +1,5 @@ -INSTALLED_APPS = ["payments"] +INSTALLED_APPS = ["payments", "djp_sepa"] + +PAYMENT_MODEL = "tezor.Invoice" + +overrides = ["PAYMENT_MODEL"] diff --git a/aleksis/apps/tezor/tables.py b/aleksis/apps/tezor/tables.py index d9001895cd8d4232f3ff24b25ea847b2747077db..16ed8a0ed491b7eb064c432cfd06950239ba620d 100644 --- a/aleksis/apps/tezor/tables.py +++ b/aleksis/apps/tezor/tables.py @@ -85,21 +85,21 @@ class InvoiceGroupsTable(tables.Table): class InvoicesTable(tables.Table): - transaction_id = tables.Column() + number = tables.Column() status = tables.Column() created = tables.DateColumn() billing_first_name = tables.Column() billing_last_name = tables.Column() total = tables.Column() view = tables.LinkColumn( - "invoice_by_pk", - args=[A("id")], + "invoice_by_token", + args=[A("token")], verbose_name=_("View"), text=_("View"), ) print = tables.LinkColumn( - "get_invoice_by_pk", - args=[A("id")], + "print_invoice", + args=[A("token")], verbose_name=_("Print"), text=_("Print"), ) diff --git a/aleksis/apps/tezor/templates/tezor/invoice/full.html b/aleksis/apps/tezor/templates/tezor/invoice/full.html index add38a90fa88f33578f98b3282ae1f315d05a031..345aed939f0ef2ad1e2e8a15b58d18286c0c9eae 100644 --- a/aleksis/apps/tezor/templates/tezor/invoice/full.html +++ b/aleksis/apps/tezor/templates/tezor/invoice/full.html @@ -1,16 +1,108 @@ {% extends "core/base.html" %} -{% load material_form i18n %} +{% load material_form i18n rules %} {% load render_table from django_tables2 %} -{% block browser_title %}{{ object.transaction_id }}{% endblock %} +{% block browser_title %}{{ object.number }}{% endblock %} {% block content %} - <h1>{% trans "Invoice" %} {{ object.transaction_id }} — {{ object.created.date }}</h1> - <a class="btn colour-primary waves-effect waves-light" href="{% url 'invoice_group_by_pk' object.group.pk %}">{% trans "Back" %}</a> + {% has_perm 'tezor.do_payment' user object as can_do_payment %} + {% has_perm 'tezor.view_invoice_group_rule' user object.group as can_view_invoice_group %} + {% has_perm 'tezor.display_purchased_items_rule' user object as can_view_purchased_items %} + {% has_perm 'tezor.display_billing_rule' user object as can_view_billing_information %} + {% has_perm 'tezor.print_invoice_rule' user object as can_print_invoice %} - {% render_table object.purchased_items_table %} - {% render_table object.totals_table %} + <h1>{% trans "Invoice" %} {{ object.number }} — {{ object.created.date }}</h1> + + {% if can_view_invoice_group %} + <a class="btn colour-primary waves-effect waves-light" href="{% url 'invoice_group_by_pk' object.group.pk %}">{% trans "Back" %}</a> + {% endif %} + {% if can_print_invoice %} + <a class="btn colour-primary waves-effect waves-light" href="{% url 'print_invoice' object.token %}">{% trans "Print" %}</a> + {% endif %} + + <div class="row"> + {% if can_view_billing_information %} + <div class="col s12 m6"> + <div class="card"> + <div class="card-content"> + <span class="card-title">{% trans "Billing information" %}</span> + <table class="highlight"> + <tr> + <td> + <i class="material-icons small iconify" data-icon="mdi:account-outline"></i> + </td> + <td>{{ object.billing_first_name }} {{object.billing_last_name }}</td> + </tr> + <tr> + <td rowspan="2"> + <i class="material-icons small iconify" data-icon="mdi:map-marker-outline"></i> + </td> + <td>{{ object.billing_address_1 }} {{ object.billing_address_2 }}</td> + </tr> + <tr> + <td>{{ object.billing_postcode }} {{ object.billing_city}}</td> + </tr> + <tr> + <td> + <i class="material-icons small iconify" data-icon="mdi:email-outline"></i> + </td> + <td> + <a href="mailto:{{ object.billing_email }}">{{ object.billing_email }}</a> + </td> + </tr> + </table> + </div> + </div> + </div> + {% endif %} + <div class="col s12 m6"> + <div class="card"> + <div class="card-content"> + <span class="card-title">{% trans "Payment" %}</span> + <table class="highlight"> + <tr> + <td> + <i class="material-icons iconify" data-icon="{{ object.get_variant_icon }}"></i> + </td> + <td> + {{ object.get_variant_name }} + </td> + </tr> + <tr> + <td> + <i class="material-icons iconify" data-icon="{{ object.get_status_icon }}"></i> + </td> + <td> + {{ object.get_status_display }} + </td> + </tr> + <tr> + <td> + <i class="material-icons iconify" data-icon="mdi:calendar-end"></i> + </td> + <td> + {{ object.due_date }} + </td> + </tr> + </table> + </div> + {% if object.status == "waiting" or object.status == "rejected" or object.status == "input" and can_do_payment %} + <div class="card-action"> + <a class="btn waves-effect waves-light green" href="{% url 'do_payment' object.token %}"> + <i class="material-icons left iconify" data-icon="mdi:cash-fast"></i> + {% trans "Pay now" %} + </a> + </div> + {% endif %} + </div> + </div> + </div> + + {% if can_view_purchased_items %} + {% render_table object.purchased_items_table %} + {% render_table object.totals_table %} + {% endif %} {% endblock %} diff --git a/aleksis/apps/tezor/templates/tezor/invoice/payment.html b/aleksis/apps/tezor/templates/tezor/invoice/payment.html new file mode 100644 index 0000000000000000000000000000000000000000..838fa26a54bda50be6865880c7fd21d8c7cabab3 --- /dev/null +++ b/aleksis/apps/tezor/templates/tezor/invoice/payment.html @@ -0,0 +1,16 @@ +{% extends 'core/base.html' %} + +{% load i18n %} +{% load material_form %} + +{% block page_title %}{% blocktrans %}Make payment for{% endblocktrans %} {{ payment.number }}{% endblock %} +{% block browser_title %}{% blocktrans %}Make payment for{% endblocktrans %} {{ payment.number }}{% endblock %} + +{% block content %} +<form action="{{ form.action }}" method="{{ form.method }}"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% trans "Confirm payment" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="shopping_cart_checkout" %} +</form> +{% endblock %} diff --git a/aleksis/apps/tezor/urls.py b/aleksis/apps/tezor/urls.py index baf77d09ab2a7254074da33d2403cf273e776294..237a7f37b34ea87a7fcf000532024e552e8afd51 100644 --- a/aleksis/apps/tezor/urls.py +++ b/aleksis/apps/tezor/urls.py @@ -4,56 +4,57 @@ from . import views urlpatterns = [ path("payments/", include("payments.urls")), - path("invoice/<int:pk>/print", views.GetInvoicePDF.as_view(), name="get_invoice_by_pk"), + path("invoice/<str:token>/print/", views.GetInvoicePDF.as_view(), name="print_invoice"), + path("invoice/<str:token>/pay", views.DoPaymentView.as_view(), name="do_payment"), path( - "clients/list", + "clients/", views.ClientListView.as_view(), name="clients", ), path( - "clients/create", + "client/create/", views.ClientCreateView.as_view(), name="create_client", ), path( - "clients/<int:pk>/edit", + "client/<int:pk>/edit/", views.ClientEditView.as_view(), name="edit_client_by_pk", ), path( - "clients/<int:pk>/delete", + "client/<int:pk>/delete/", views.ClientDeleteView.as_view(), name="delete_client_by_pk", ), path( - "clients/<int:pk>/", + "client/<int:pk>/", views.ClientDetailView.as_view(), name="client_by_pk", ), path( - "client/<int:pk>/invoice_groups/create", + "client/<int:pk>/invoice_groups/create/", views.InvoiceGroupCreateView.as_view(), name="create_invoice_group", ), path( - "invoice_groups/<int:pk>/edit", + "invoice_group/<int:pk>/edit/", views.InvoiceGroupEditView.as_view(), name="edit_invoice_group_by_pk", ), path( - "invoice_groups/<int:pk>/", + "invoice_group/<int:pk>/", views.InvoiceGroupDetailView.as_view(), name="invoice_group_by_pk", ), path( - "invoice_groups/<int:pk>/delete", + "invoice_group/<int:pk>/delete/", views.InvoiceGroupDeleteView.as_view(), name="delete_invoice_group_by_pk", ), path( - "invoice/<int:pk>/", + "invoice/<str:slug>/", views.InvoiceDetailView.as_view(), - name="invoice_by_pk", + name="invoice_by_token", ), path( "invoice/<int:pk>/send/", diff --git a/aleksis/apps/tezor/views.py b/aleksis/apps/tezor/views.py index f6618033972bf245e12faf564412aabcedf083be..448fe651a811ffe28c193015fd3a4b6b1b353fc1 100644 --- a/aleksis/apps/tezor/views.py +++ b/aleksis/apps/tezor/views.py @@ -1,12 +1,12 @@ -from django.views.generic import View -from django.shortcuts import render +from django.shortcuts import redirect, render, get_object_or_404 from django.views.decorators.cache import never_cache +from django.views.generic import FormView, TemplateView, View +from django.views.generic.detail import DetailView +from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import FormView, TemplateView from django.utils.translation import ugettext as _ -from django.urls import reverse, reverse_lazy -from django.views.generic.detail import DetailView +from payments import get_payment_model, PaymentStatus, RedirectNeeded from rules.contrib.views import PermissionRequiredMixin from django_tables2.views import SingleTableView, RequestConfig from templated_email import InlineImage, send_templated_mail @@ -23,23 +23,49 @@ from .models.invoice import Invoice, InvoiceGroup class GetInvoicePDF(PermissionRequiredMixin, RenderPDFView): - permission_required = "tezor.can_print_invoice" + permission_required = "tezor.print_invoice_rule" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - invoice = Invoice.objects.get(id=self.kwargs["pk"]) + invoice = Invoice.objects.get(token=self.kwargs["token"]) self.template_name = invoice.group.template_name context["invoice"] = invoice print(invoice.group.__dict__) return context + +class DoPaymentView(PermissionRequiredMixin, View): + + model = Invoice + permission_required = "tezor.do_payment_rule" + template_name = "tezor/invoice/payment.html" + + def dispatch(self, request, token): + self.object = get_object_or_404(self.model, token=token) + + if self.object.status not in [PaymentStatus.WAITING, PaymentStatus.INPUT, PaymentStatus.REJECTED]: + return redirect(self.object.get_success_url()) + + try: + form = self.object.get_form(data=request.POST or None) + except RedirectNeeded as redirect_to: + return redirect(str(redirect_to)) + + context = { + "form": form, + "payment": self.object, + } + + return render(request, self.template_name, context) + + class ClientListView(PermissionRequiredMixin, SingleTableView): """Table of all clients.""" model = Client table_class = ClientsTable - permission_required = "tezor.view_clients" + permission_required = "tezor.view_clients_rule" template_name = "tezor/client/list.html" @@ -49,7 +75,7 @@ class ClientCreateView(PermissionRequiredMixin, AdvancedCreateView): model = Client form_class = EditClientForm - permission_required = "tezor.add_clients" + permission_required = "tezor.create_client_rule" template_name = "tezor/client/create.html" success_url = reverse_lazy("clients") success_message = _("The client has been created.") @@ -61,7 +87,7 @@ class ClientEditView(PermissionRequiredMixin, AdvancedEditView): model = Client form_class = EditClientForm - permission_required = "tezor.edit_clients" + permission_required = "tezor.edit_client_rule" template_name = "tezor/client/edit.html" success_url = reverse_lazy("clients") success_message = _("The client has been saved.") @@ -71,7 +97,7 @@ class ClientDeleteView(PermissionRequiredMixin, AdvancedDeleteView): """Delete view for client.""" model = Client - permission_required = "tezor.delete_client" + permission_required = "tezor.delete_client_rule" template_name = "core/pages/delete.html" success_url = reverse_lazy("clients") success_message = _("The client has been deleted.") @@ -80,7 +106,7 @@ class ClientDeleteView(PermissionRequiredMixin, AdvancedDeleteView): class ClientDetailView(PermissionRequiredMixin, DetailView): model = Client - permission_required = "tezor.view_client" + permission_required = "tezor.view_client_rule" template_name = "tezor/client/full.html" def get_context_data(self, object): @@ -96,7 +122,7 @@ class ClientDetailView(PermissionRequiredMixin, DetailView): class InvoiceGroupDetailView(PermissionRequiredMixin, DetailView): model = InvoiceGroup - permission_required = "tezor.view_invoice_group" + permission_required = "tezor.view_invoice_group_rule" template_name = "tezor/invoice_group/full.html" def get_context_data(self, object): @@ -116,7 +142,7 @@ class InvoiceGroupCreateView(PermissionRequiredMixin, AdvancedCreateView): model = InvoiceGroup form_class = EditInvoiceGroupForm - permission_required = "tezor.add_invoice_groups" + permission_required = "tezor.create_invoice_groups_rule" template_name = "tezor/invoice_group/create.html" success_url = reverse_lazy("clients") success_message = _("The invoice_group has been created.") @@ -134,7 +160,7 @@ class InvoiceGroupEditView(PermissionRequiredMixin, AdvancedEditView): model = InvoiceGroup form_class = EditInvoiceGroupForm - permission_required = "tezor.edit_invoice_groups" + permission_required = "tezor.edit_invoice_group_rule" template_name = "tezor/invoice_group/edit.html" success_url = reverse_lazy("invoice_groups") success_message = _("The invoice_group has been saved.") @@ -144,7 +170,7 @@ class InvoiceGroupDeleteView(PermissionRequiredMixin, AdvancedDeleteView): """Delete view for invoice_group.""" model = InvoiceGroup - permission_required = "tezor.delete_invoice_group" + permission_required = "tezor.delete_invoice_group_rule" template_name = "core/pages/delete.html" success_url = reverse_lazy("invoice_groups") success_message = _("The invoice_group has been deleted.") @@ -153,7 +179,8 @@ class InvoiceGroupDeleteView(PermissionRequiredMixin, AdvancedDeleteView): class InvoiceDetailView(PermissionRequiredMixin, DetailView): model = Invoice - permission_required = "tezor.view_invoice" + slug_field = "token" + permission_required = "tezor.view_invoice_rule" template_name = "tezor/invoice/full.html" diff --git a/pyproject.toml b/pyproject.toml index f3f3a24fe11000781e67d9ac34a4398c92c20b9b..cdbd8becfc928118b2f2098f0ff40db2f7516e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ include = [ ] description = "AlekSIS (School Information System) — App Tezor (account and payment system)" -authors = ["Dominik George <dominik.george@teckids.org>"] +authors = ["Dominik George <dominik.george@teckids.org>", "Tom Teichler <tom.teichler@teckids.org>"] license = "EUPL-1.2-or-later" homepage = "https://aleksis.org" repository = "https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Tezor" @@ -30,8 +30,9 @@ secondary = true [tool.poetry.dependencies] python = "^3.9" -aleksis-core = "^2.7" -django-payments = "^0.15.0" +aleksis-core = "^2.8.1.dev0" +django-payments = "^0.15.1" +django-payments-sepa = "^1.0.dev0" [tool.poetry.dev-dependencies] aleksis-builddeps = "*"