diff --git a/api/admin.py b/api/admin.py index 01d4f2d5..0ca8e536 100644 --- a/api/admin.py +++ b/api/admin.py @@ -210,6 +210,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): f"Dispute of order {order.id} solved successfully on favor of the maker", messages.SUCCESS, ) + send_notification.delay(order_id=order.id, message="dispute_closed") else: self.message_user( @@ -248,6 +249,7 @@ class OrderAdmin(AdminChangeLinksMixin, admin.ModelAdmin): f"Dispute of order {order.id} solved successfully on favor of the taker", messages.SUCCESS, ) + send_notification.delay(order_id=order.id, message="dispute_closed") else: self.message_user( diff --git a/api/migrations/0048_alter_order_reference.py b/api/migrations/0048_alter_order_reference.py new file mode 100644 index 00000000..cf8fa076 --- /dev/null +++ b/api/migrations/0048_alter_order_reference.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-06-29 14:07 + +import api.models.order +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0047_notification'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='reference', + field=models.UUIDField(default=api.models.order.custom_uuid, editable=False), + ), + ] diff --git a/api/models/order.py b/api/models/order.py index 161f6d3e..f9a72ecf 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -10,6 +10,7 @@ from django.db import models from django.db.models.signals import pre_delete from django.dispatch import receiver from django.utils import timezone +from api.tasks import send_notification if config("TESTING", cast=bool, default=False): import random @@ -91,10 +92,7 @@ class Order(models.Model): decimal_places=2, default=0, null=True, - validators=[ - MinValueValidator(Decimal(-100)), - MaxValueValidator(Decimal(999)) - ], + validators=[MinValueValidator(Decimal(-100)), MaxValueValidator(Decimal(999))], blank=True, ) # explicit @@ -352,6 +350,8 @@ class Order(models.Model): self.log( f"Order state went from {old_status}: {Order.Status(old_status).label} to {new_status}: {Order.Status(new_status).label}" ) + if new_status == Order.Status.FAI: + send_notification.delay(order_id=self.id, message="lightning_failed") @receiver(pre_delete, sender=Order) diff --git a/api/models/robot.py b/api/models/robot.py index 9efed1c1..c838d354 100644 --- a/api/models/robot.py +++ b/api/models/robot.py @@ -1,12 +1,8 @@ -from pathlib import Path - -from django.conf import settings from django.contrib.auth.models import User from django.core.validators import validate_comma_separated_integer_list from django.db import models -from django.db.models.signals import post_save, pre_delete +from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils.html import mark_safe class Robot(models.Model): diff --git a/api/notifications.py b/api/notifications.py index 480c6d47..7adc9f60 100644 --- a/api/notifications.py +++ b/api/notifications.py @@ -219,3 +219,47 @@ class Notifications: title = f"๐Ÿ› ๏ธ Your order with ID {order.id} has been cancelled by the coordinator {config('COORDINATOR_ALIAS', cast=str, default='NoAlias')} for the upcoming maintenance stop." self.send_message(order, order.maker.robot, title) return + + def dispute_closed(self, order): + lang = order.maker.robot.telegram_lang_code + if order.status == Order.Status.MLD: + # Maker lost dispute + looser = order.maker + winner = order.taker + elif order.status == Order.Status.TLD: + # Taker lost dispute + looser = order.taker + winner = order.maker + + lang = looser.robot.telegram_lang_code + if lang == "es": + title = f"โš–๏ธ Hey {looser.username}, has perdido la disputa en la orden con ID {str(order.id)}." + else: + title = f"โš–๏ธ Hey {looser.username}, you lost the dispute on your order with ID {str(order.id)}." + self.send_message(order, looser.robot, title) + + lang = winner.robot.telegram_lang_code + if lang == "es": + title = f"โš–๏ธ Hey {winner.username}, has ganado la disputa en la orden con ID {str(order.id)}." + else: + title = f"โš–๏ธ Hey {winner.username}, you won the dispute on your order with ID {str(order.id)}." + self.send_message(order, winner.robot, title) + + return + + def lightning_failed(self, order): + lang = order.maker.robot.telegram_lang_code + if order.type == Order.Types.BUY: + buyer = order.maker + else: + buyer = order.taker + + if lang == "es": + title = f"โšกโŒ Hey {buyer.username}, el pago lightning en la order con ID {str(order.id)} ha fallado." + description = "Intentalo de nuevo con una nueva factura o con otra wallet." + else: + title = f"โšกโŒ Hey {buyer.username}, the lightning payment on your order with ID {str(order.id)} failed." + description = "Try again with a new invoice or from another wallet." + + self.send_message(order, buyer.robot, title, description) + return diff --git a/api/serializers.py b/api/serializers.py index 2f7b2310..46fb1c58 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -491,9 +491,16 @@ class OrderDetailSerializer(serializers.ModelSerializer): class ListNotificationSerializer(serializers.ModelSerializer): + status = serializers.SerializerMethodField( + help_text="The `status` of the order when the notification was trigered", + ) + class Meta: model = Notification - fields = ("title", "description", "order_id") + fields = ("title", "description", "order_id", "status", "created_at") + + def get_status(self, notification) -> int: + return notification.order.status class OrderPublicSerializer(serializers.ModelSerializer): diff --git a/api/tasks.py b/api/tasks.py index 88e924a8..656feb89 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -303,4 +303,10 @@ def send_notification(order_id=None, chat_message_id=None, message=None): elif message == "coordinator_cancelled": notifications.coordinator_cancelled(order) + elif message == "dispute_closed": + notifications.dispute_closed(order) + + elif message == "lightning_failed": + notifications.lightning_failed(order) + return diff --git a/api/views.py b/api/views.py index 0caa7407..a03dbd33 100644 --- a/api/views.py +++ b/api/views.py @@ -761,10 +761,7 @@ class NotificationsView(ListAPIView): notification_data = [] for notification in queryset: data = self.serializer_class(notification).data - data["title"] = str(notification.title) - data["description"] = str(notification.description) data["order_id"] = notification.order.id - notification_data.append(data) return Response(notification_data, status=status.HTTP_200_OK) diff --git a/docker-tests.yml b/docker-tests.yml index 0a4af45e..975747e8 100644 --- a/docker-tests.yml +++ b/docker-tests.yml @@ -15,7 +15,7 @@ version: '3.9' services: bitcoind: image: ruimarinho/bitcoin-core:${BITCOIND_VERSION:-24.0.1}-alpine - container_name: btc + container_name: test-btc restart: always ports: - "8000:8000" @@ -50,7 +50,7 @@ services: coordinator-LND: image: lightninglabs/lnd:${LND_VERSION:-v0.17.0-beta} - container_name: coordinator-LND + container_name: test-coordinator-LND restart: always volumes: - bitcoin:/root/.bitcoin/ @@ -83,7 +83,7 @@ services: coordinator-CLN: image: elementsproject/lightningd:${CLN_VERSION:-v24.05} restart: always - container_name: coordinator-CLN + container_name: test-coordinator-CLN environment: LIGHTNINGD_NETWORK: 'regtest' volumes: @@ -97,7 +97,7 @@ services: robot-LND: image: lightninglabs/lnd:${LND_VERSION:-v0.17.0-beta} - container_name: robot-LND + container_name: test-robot-LND restart: always volumes: - bitcoin:/root/.bitcoin/ @@ -129,7 +129,7 @@ services: redis: image: redis:${REDIS_VERSION:-7.2.1}-alpine - container_name: redis + container_name: test-redis restart: always volumes: - redisdata:/data @@ -141,7 +141,7 @@ services: args: DEVELOPMENT: True image: backend-image - container_name: coordinator + container_name: test-coordinator restart: always environment: DEVELOPMENT: True @@ -171,7 +171,7 @@ services: postgres: image: postgres:${POSTGRES_VERSION:-14.2}-alpine - container_name: sql + container_name: test-sql restart: always environment: POSTGRES_PASSWORD: 'example' diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index 60c5c933..490c35ae 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -1106,8 +1106,16 @@ components: order_id: type: integer readOnly: true + status: + type: integer + readOnly: true + description: The `status` of the order when the notification was trigered + created_at: + type: string + format: date-time required: - order_id + - status ListOrder: type: object properties: diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index bf438a48..c226b5c0 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -1163,6 +1163,60 @@ class TradeTest(BaseAPITestCase): f"โš–๏ธ Hey {data['taker_nick']}, a dispute has been opened on your order with ID {str(trade.order_id)}.", ) + # def test_dispute_closed_maker_wins(self): + # trade = Trade(self.client) + # trade.publish_order() + # trade.take_order() + # trade.lock_taker_bond() + # trade.lock_escrow(trade.taker_index) + # trade.submit_payout_invoice(trade.maker_index) + + # # Admin resolves dispute + + # trade.clean_orders() + + # maker_headers = trade.get_robot_auth(trade.maker_index) + # response = self.client.get(reverse("notifications"), **maker_headers) + # self.assertResponse(response) + # notifications_data = list(response.json()) + # self.assertEqual(notifications_data[0]["order_id"], trade.order_id) + # self.assertEqual( + # notifications_data[0]["title"], + # f"โš–๏ธ Hey {data['maker_nick']}, you won the dispute on your order with ID {str(trade.order_id)}." + # ) + # taker_headers = trade.get_robot_auth(trade.taker_index) + # response = self.client.get(reverse("notifications"), **taker_headers) + # self.assertResponse(response) + # notifications_data = list(response.json()) + # self.assertEqual(notifications_data[0]["order_id"], trade.order_id) + # self.assertEqual( + # notifications_data[0]["title"], + # f"โš–๏ธ Hey {data['taker_nick']}, you lost the dispute on your order with ID {str(trade.order_id)}." + # ) + + def test_lightning_payment_failed(self): + trade = Trade(self.client) + trade.publish_order() + trade.take_order() + trade.lock_taker_bond() + trade.lock_escrow(trade.taker_index) + trade.submit_payout_invoice(trade.maker_index) + + trade.change_order_status(Order.Status.FAI) + + trade.clean_orders() + + maker_headers = trade.get_robot_auth(trade.maker_index) + maker_nick = read_file(f"tests/robots/{trade.maker_index}/nickname") + response = self.client.get(reverse("notifications"), **maker_headers) + self.assertResponse(response) + notifications_data = list(response.json()) + self.assertEqual(notifications_data[0]["order_id"], trade.order_id) + self.assertEqual( + notifications_data[0]["title"], + f"โšกโŒ Hey {maker_nick}, the lightning payment on your order with ID {str(trade.order_id)} failed.", + ) + def test_withdraw_reward_after_unilateral_cancel(self): """ Tests withdraw rewards as taker after maker cancels order unilaterally diff --git a/tests/utils/trade.py b/tests/utils/trade.py index c00b8757..39bcda0a 100644 --- a/tests/utils/trade.py +++ b/tests/utils/trade.py @@ -271,3 +271,9 @@ class Trade: order = Order.objects.get(id=self.order_id) order.expires_at = datetime.now() order.save() + + @patch("api.tasks.send_notification.delay", send_notification) + def change_order_status(self, status): + # Change order expiry to now + order = Order.objects.get(id=self.order_id) + order.update_status(status)