diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml new file mode 100644 index 0000000..c66e7cd --- /dev/null +++ b/.github/workflows/runtests.yml @@ -0,0 +1,22 @@ +name: CI +on: [push] + +jobs: + ci: + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11"] + os: [ubuntu-20.04] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Setup dependencies + run: pip install -r requirements.test.txt + - name: Run tests + env: + DJANGO_SETTINGS_MODULE: config.settings.develop + run: pytest --cov && coverage xml diff --git a/README.md b/README.md index 051174c..c969f97 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,69 @@ -# Django Template +# Shop -## How to use the template? +## Desarrollo -Just run this, changing the project name: +### Establecer variables de entorno + +A partir del archivo `.env.example`, creamos un archivo `.env` en el que podemos establecer las +[variables de entorno](#variables-de-entorno). + +### Instalar dependencias ```bash -django-admin startproject --template=https://github.com/pablo-moreno/django-template/archive/refs/heads/main.zip +pip install -r requirements.test.txt ``` -- Django's extensions already installed (such as the beautiful shell plus) +### Lanzar migraciones + +```bash +python manage.py migrate +``` + +### Arrancar servidor + +```bash +python manage.py runserver +``` + +### Tareas de Celery + +```bash +celery -A config worker -B -l debug +``` + +### Pruebas con pytest + +```bash +pytest --cov +``` + + + +## Variables de entorno + +Puedes crear un archivo `.env` con las siguientes variables de entorno. + +| Variable | Default value | Comments | +|-------------------------|----------------------------------|--------------------------------------------| +| SECRET_KEY | secret key | Clave de encriptación | +| DEBUG | TRUE | Modo de desarrollo activo | +| DATABASE_URL | sqlite:///db.sqlite3 | URL de conexión a base de datos | +| STATIC_ROOT | static | Carpeta de estáticos | +| MEDIA_ROOT | media | Carpeta de archivos media | +| REDIS_HOST | redis | Dirección de servidor Redis | +| REDIS_PORT | 6379 | Puerto de Redis | +| PAGE_SIZE | 20 | Tamaño de paginación | +| ALLOWED_HOSTS | * | Hosts permitidos (CORS) | +| CELERY_BROKER_URL | redis://172.17.0.1:6379/0 | Url de conexión para celery | +| S3_ENABLED | FALSE | Activado protocolo S3 | +| S3_HOST | | URL de servidor S3 | +| AWS_ACCESS_KEY_ID | | Clave de acceso de S3 | +| AWS_SECRET_ACCESS_KEY | | Clave secreta de S3 | +| AWS_STORAGE_BUCKET_NAME | | Nombre del bucket de S3 | +| AWS_S3_ENDPOINT_URL | | Endpoint de S3 | +| REDSYS_SHARED_SECRET | sq7HjrUOBfKmC576ILgskD5srU870gJ7 | Clave privada de Redsys | +| REDSYS_MERCHANT_CODE | 999008881 | Código de negocio de Redsys | +| REDSYS_TERMINAL | 001 | Terminal de Redsys | +| REDSYS_CURRENCY_CODE | 978 | Código de moneda de Redsys | +| REDSYS_TPV_DOMAIN | | Dominio del TPV para redirección de Redsys | + diff --git a/config/settings/base.py b/config/settings/base.py index d439037..f378380 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -177,14 +177,3 @@ SPECTACULAR_SETTINGS = { CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", default="redis://172.17.0.1:6379/0") CELERY_TIME_ZONE = TIME_ZONE CELERY_ALWAYS_EAGER = DEBUG - -# Redsys -# https://pagosonline.redsys.es/conexion-redireccion.html - -REDSYS_SHARED_SECRET = env.str( - "REDSYS_SHARED_SECRET", "sq7HjrUOBfKmC576ILgskD5srU870gJ7" -) -REDSYS_MERCHANT_CODE = env.str("REDSYS_MERCHANT_CODE", "999008881") -REDSYS_TERMINAL = env.str("REDSYS_TERMINAL", "001") -REDSYS_CURRENCY_CODE = env.str("REDSYS_CURRENCY_CODE", "978") -REDSYS_TPV_DOMAIN = env.str("REDSYS_TPV_DOMAIN", "") diff --git a/config/settings/environ.py b/config/settings/environ.py index 52264a5..6aa4388 100644 --- a/config/settings/environ.py +++ b/config/settings/environ.py @@ -25,6 +25,8 @@ REDIS_PORT = env.str("REDIS_PORT", 6379) PAGE_SIZE = env.int("PAGE_SIZE", 20) ALLOWED_HOSTS = env.str("ALLOWED_HOSTS", "*").split(",") +CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", default="redis://172.17.0.1:6379/0") + S3_ENABLED = env.bool("S3_ENABLED", False) AWS_S3_HOST = env.str("S3_HOST", "") @@ -32,3 +34,14 @@ AWS_ACCESS_KEY_ID = env.str("S3_ACCESS_KEY_ID", "") AWS_SECRET_ACCESS_KEY = env.str("S3_SECRET_ACCESS_KEY", "") AWS_STORAGE_BUCKET_NAME = env.str("S3_STORAGE_BUCKET_NAME", "") AWS_S3_ENDPOINT_URL = env.str("S3_ENDPOINT_URL", "") + +# Redsys +# https://pagosonline.redsys.es/conexion-redireccion.html + +REDSYS_SHARED_SECRET = env.str( + "REDSYS_SHARED_SECRET", "sq7HjrUOBfKmC576ILgskD5srU870gJ7" +) +REDSYS_MERCHANT_CODE = env.str("REDSYS_MERCHANT_CODE", "999008881") +REDSYS_TERMINAL = env.str("REDSYS_TERMINAL", "001") +REDSYS_CURRENCY_CODE = env.str("REDSYS_CURRENCY_CODE", "978") +REDSYS_TPV_DOMAIN = env.str("REDSYS_TPV_DOMAIN", "") diff --git a/requirements.test.txt b/requirements.test.txt index 4bd4a20..63ab765 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -2,4 +2,4 @@ pytest==7.3.0 pytest-django==4.8.0 coverage==7.2.3 -pytest-cov==4.0.0 \ No newline at end of file +pytest-cov==4.0.0 diff --git a/shop/migrations/0001_initial.py b/shop/migrations/0001_initial.py index 3448c4f..a8fe408 100644 --- a/shop/migrations/0001_initial.py +++ b/shop/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-24 12:51 +# Generated by Django 5.0.3 on 2024-03-24 18:23 import django.db.models.deletion import django.utils.timezone @@ -13,6 +13,43 @@ class Migration(migrations.Migration): dependencies = [] operations = [ + migrations.CreateModel( + name="Customer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "vat_id", + models.CharField( + max_length=32, + unique=True, + verbose_name="Documento de identidad", + ), + ), + ("first_name", models.CharField(max_length=64, verbose_name="Nombre")), + ( + "last_name", + models.CharField(max_length=64, verbose_name="Apellidos"), + ), + ("email", models.EmailField(max_length=254, verbose_name="E-mail")), + ("address", models.CharField(max_length=255, verbose_name="Dirección")), + ("city", models.CharField(max_length=64, verbose_name="Ciudad")), + ("state", models.CharField(max_length=64, verbose_name="Región")), + ("country", models.CharField(max_length=64, verbose_name="País")), + ("zip", models.CharField(max_length=32, verbose_name="Código postal")), + ], + options={ + "verbose_name": "Cliente", + "verbose_name_plural": "Clientes", + }, + ), migrations.CreateModel( name="OrderLine", fields=[ @@ -189,6 +226,68 @@ class Migration(migrations.Migration): verbose_name="Total", ), ), + ( + "billing_address", + models.CharField( + max_length=255, verbose_name="Dirección de facturación" + ), + ), + ( + "billing_city", + models.CharField( + max_length=64, verbose_name="Ciudad de facturación" + ), + ), + ( + "billing_state", + models.CharField( + max_length=64, verbose_name="Región de facturación" + ), + ), + ( + "billing_country", + models.CharField(max_length=64, verbose_name="País de facturación"), + ), + ( + "billing_zip", + models.CharField( + max_length=32, verbose_name="Código postal de facturación" + ), + ), + ( + "shipping_address", + models.CharField(max_length=255, verbose_name="Dirección"), + ), + ( + "shipping_city", + models.CharField(max_length=64, verbose_name="Ciudad"), + ), + ( + "shipping_state", + models.CharField(max_length=64, verbose_name="Región"), + ), + ( + "shipping_country", + models.CharField(max_length=64, verbose_name="País"), + ), + ( + "shipping_zip", + models.CharField(max_length=32, verbose_name="Código postal"), + ), + ( + "contact_phone", + models.CharField( + blank=True, max_length=32, verbose_name="Teléfono de contacto" + ), + ), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="shop.customer", + verbose_name="Cliente", + ), + ), ( "lines", models.ManyToManyField(to="shop.orderline", verbose_name="Líneas"), diff --git a/shop/models.py b/shop/models.py index 6f45a96..58d058b 100644 --- a/shop/models.py +++ b/shop/models.py @@ -80,6 +80,33 @@ class Tax(models.Model): verbose_name_plural = _("Impuestos") +class Customer(models.Model): + vat_id = models.CharField( + max_length=32, + blank=False, + unique=True, + verbose_name=_("Documento de identidad"), + ) + first_name = models.CharField(max_length=64, blank=False, verbose_name=_("Nombre")) + last_name = models.CharField( + max_length=64, blank=False, verbose_name=_("Apellidos") + ) + email = models.EmailField(blank=False, verbose_name=_("E-mail")) + + address = models.CharField(max_length=255, blank=False, verbose_name=_("Dirección")) + city = models.CharField(max_length=64, blank=False, verbose_name=_("Ciudad")) + state = models.CharField(max_length=64, blank=False, verbose_name=_("Región")) + country = models.CharField(max_length=64, blank=False, verbose_name=_("País")) + zip = models.CharField(max_length=32, blank=False, verbose_name=_("Código postal")) + + def __str__(self): + return f"{self.vat_id} - {self.first_name} {self.last_name}" + + class Meta: + verbose_name = _("Cliente") + verbose_name_plural = _("Clientes") + + class OrderLine(models.Model): product = models.ForeignKey( "shop.Product", @@ -154,6 +181,48 @@ class Order(models.Model): default=Decimal("0"), max_digits=13, decimal_places=2, verbose_name=_("Total") ) + # Customer reference + customer = models.ForeignKey( + "shop.Customer", on_delete=models.PROTECT, verbose_name=_("Cliente") + ) + + # Billing information + billing_address = models.CharField( + max_length=255, blank=False, verbose_name=_("Dirección de facturación") + ) + billing_city = models.CharField( + max_length=64, blank=False, verbose_name=_("Ciudad de facturación") + ) + billing_state = models.CharField( + max_length=64, blank=False, verbose_name=_("Región de facturación") + ) + billing_country = models.CharField( + max_length=64, blank=False, verbose_name=_("País de facturación") + ) + billing_zip = models.CharField( + max_length=32, blank=False, verbose_name=_("Código postal de facturación") + ) + + # Shipping information + shipping_address = models.CharField( + max_length=255, blank=False, verbose_name=_("Dirección") + ) + shipping_city = models.CharField( + max_length=64, blank=False, verbose_name=_("Ciudad") + ) + shipping_state = models.CharField( + max_length=64, blank=False, verbose_name=_("Región") + ) + shipping_country = models.CharField( + max_length=64, blank=False, verbose_name=_("País") + ) + shipping_zip = models.CharField( + max_length=32, blank=False, verbose_name=_("Código postal") + ) + contact_phone = models.CharField( + max_length=32, blank=True, verbose_name=_("Teléfono de contacto") + ) + def calculate_total_from_lines(self): self.base_total = self.lines.aggregate(base_total=models.Sum("base_total")).get( "base_total" diff --git a/shop/tests/test_shop_models.py b/shop/tests/test_shop_models.py index 81255bf..c27a260 100644 --- a/shop/tests/test_shop_models.py +++ b/shop/tests/test_shop_models.py @@ -1,6 +1,6 @@ from decimal import Decimal from rest_framework.test import APITestCase as TestCase -from shop.models import Product, ProductPrice, OrderLine, Tax +from shop.models import Product, ProductPrice, OrderLine, Tax, Customer from shop.utils import create_order_line_for_product, create_order @@ -34,6 +34,17 @@ class ShopModelsTest(TestCase): ProductPrice.objects.create(product=self.usb_c, price=Decimal("9.95")) def test_create_order(self): + self.customer = Customer.objects.create( + first_name='Luke', + last_name='Skywalker', + vat_id='11111111H', + address='Farm Skywalker', + city='Desert', + state='Mos-Eisley', + zip='00001', + country='Tatooine', + ) + l1 = create_order_line_for_product( self.potatoes, quantity=Decimal("1.5"), @@ -50,7 +61,15 @@ class ShopModelsTest(TestCase): tax=self.tax, ) - order = create_order(OrderLine.objects.all()) + order = create_order( + customer=self.customer, + lines=OrderLine.objects.all(), + billing_address=self.customer.address, + billing_city=self.customer.city, + billing_state=self.customer.state, + billing_zip=self.customer.zip, + billing_country=self.customer.country, + ) assert order.total == l1.total + l2.total + l3.total assert order.base_total == l1.base_total + l2.base_total + l3.base_total diff --git a/shop/utils.py b/shop/utils.py index 35f409c..e629ffc 100644 --- a/shop/utils.py +++ b/shop/utils.py @@ -1,5 +1,7 @@ from decimal import Decimal -from shop.models import OrderLine, Tax, Product, Order +from typing import Iterable + +from shop.models import OrderLine, Tax, Product, Order, Customer from django.db.models import QuerySet @@ -19,10 +21,51 @@ def create_order_line_for_product(product: Product, quantity: Decimal, tax: Tax) ) -def create_order(lines: QuerySet): - order = Order.objects.create() +def create_order( + customer: Customer, + lines: Iterable, - for line in lines.all(): + billing_address: str, + billing_city: str, + billing_state: str, + billing_country: str, + billing_zip: str, + + shipping_address: str = '', + shipping_city: str = '', + shipping_state: str = '', + shipping_country: str = '', + shipping_zip: str = '', +) -> Order: + billing_address = billing_address if billing_address else customer.address + billing_city = billing_city if billing_city else customer.city + billing_state = billing_state if billing_state else customer.state + billing_country = billing_country if billing_country else customer.country + billing_zip = billing_zip if billing_zip else customer.zip + + shipping_address = shipping_address if shipping_address else billing_address + shipping_city = shipping_city if shipping_city else billing_city + shipping_state = shipping_state if shipping_state else billing_state + shipping_country = shipping_country if shipping_country else billing_country + shipping_zip = shipping_zip if shipping_zip else billing_zip + + order = Order.objects.create( + customer=customer, + + billing_address=billing_address, + billing_city=billing_city, + billing_state=billing_state, + billing_country=billing_country, + billing_zip=billing_zip, + + shipping_address=shipping_address, + shipping_city=shipping_city, + shipping_state=shipping_state, + shipping_country=shipping_country, + shipping_zip=shipping_zip, + ) + + for line in lines: order.lines.add(line) order.calculate_total_from_lines() diff --git a/tpv/tests/test_redsys.py b/tpv/tests/test_redsys.py index a372413..64b760d 100644 --- a/tpv/tests/test_redsys.py +++ b/tpv/tests/test_redsys.py @@ -104,6 +104,11 @@ class TestRedsysTPV(APITestCase): assert response.status_code == status.HTTP_200_OK + response = self.client.get( + reverse("tpv:ok", kwargs={"order": order.hash}) + ) + assert response.status_code == status.HTTP_200_OK + def test_redsys_webhook_payment_error(self): amount_to_pay = Decimal("10.00") order = PaymentTPV.objects.create(amount=amount_to_pay) @@ -167,3 +172,8 @@ class TestRedsysTPV(APITestCase): order.refresh_from_db() assert str(ERROR_CODES.get("0184")) in order.observations assert order.status == PaymentTPV.StatusChoices.ERROR + + response = self.client.get( + reverse("tpv:ko", kwargs={"order": order.hash}) + ) + assert response.status_code == status.HTTP_200_OK diff --git a/tpv/views.py b/tpv/views.py index e5bf9d0..c2e5795 100644 --- a/tpv/views.py +++ b/tpv/views.py @@ -13,10 +13,6 @@ from tpv.signals import redsys_payment_accepted, redsys_payment_rejected def payment_accepted(request, order): order = get_object_or_404(PaymentTPV, hash=order) - # Ñapa histórica - if request.GET: - return redirect(reverse("tpv:ok", kwargs={"order": order.hash})) - return render( request, template_name="tpv/order_created.html", @@ -29,13 +25,9 @@ def payment_accepted(request, order): def payment_rejected(request, order): order = get_object_or_404(PaymentTPV, hash=order) - # Ñapa histórica - if request.GET: - return redirect(reverse("tpv:ko", kwargs={"order": order.hash})) - return render( request, - template_name="tpv/order.html", + template_name="tpv/order_created.html", context={ "order": order, }, @@ -67,45 +59,16 @@ def order_created(request, order): client = RedsysClient() parameters = client.get_body_for_order(order) - if request.method == "GET": - return render( - request, - template_name="tpv/order_created.html", - context={ - "form": UpdateEmailForm({"contact_email": order.contact_email}), - "action": reverse("tpv:order_created", kwargs={"order": order.hash}), - "order": order, - "signature_version": parameters.get("Ds_SignatureVersion"), - "merchant_parameters": parameters.get("Ds_MerchantParameters"), - "signature": parameters.get("Ds_Signature"), - "redsys_target_url": client.get_target_url(), - }, - ) - - elif request.method == "POST": - form = UpdateEmailForm(request.POST) - - if form.is_valid(): - order.contact_email = form.cleaned_data["contact_email"] - order.save() - - return redirect(reverse("tpv:order_created", kwargs={"order": order.hash})) - else: - return render( - request, - template_name="tpv/order_created.html", - context={ - "form": UpdateEmailForm({"contact_email": ""}), - "errors": form.errors, - "action": reverse( - "tpv:order_created", kwargs={"order": order.hash} - ), - "order": order, - "signature_version": parameters.get("Ds_SignatureVersion"), - "merchant_parameters": parameters.get("Ds_MerchantParameters"), - "signature": parameters.get("Ds_Signature"), - "redsys_target_url": client.get_target_url(), - }, - ) - - return HttpResponse(status=405) + return render( + request, + template_name="tpv/order_created.html", + context={ + "form": UpdateEmailForm({"contact_email": order.contact_email}), + "action": reverse("tpv:order_created", kwargs={"order": order.hash}), + "order": order, + "signature_version": parameters.get("Ds_SignatureVersion"), + "merchant_parameters": parameters.get("Ds_MerchantParameters"), + "signature": parameters.get("Ds_Signature"), + "redsys_target_url": client.get_target_url(), + }, + )