From ed4c21a5de2ff28e74190e4118e32779ed99b2c1 Mon Sep 17 00:00:00 2001
From: Rodrigo Castro <rodrigondec@gmail.com>
Date: Tue, 24 Dec 2019 00:22:08 -0300
Subject: [PATCH] Show current ticket price on UpdateMessage (#33)

* utils exceptions

* utils with dcrdata request wrapper

* ObserverMessage expire after 7 days

* TicketPrice created

* test TicketPrice

* UpdateMessage with ticket price

* minor fix
---
 bot/commands/callback.py      |  4 +-
 bot/commands/exchange.py      |  4 +-
 bot/commands/subscription.py  |  2 +-
 bot/exceptions.py             |  3 --
 db/observer.py                |  2 +-
 db/subject.py                 |  4 +-
 db/ticket.py                  | 62 +++++++++++++++++++++++++
 db/update_message.py          |  6 ++-
 tests/db/test_ticket_price.py | 87 +++++++++++++++++++++++++++++++++++
 utils/__init__.py             |  0
 utils/dcrdata.py              | 16 +++++++
 {db => utils}/exceptions.py   |  3 ++
 {bot => utils}/utils.py       | 16 ++-----
 13 files changed, 184 insertions(+), 25 deletions(-)
 delete mode 100644 bot/exceptions.py
 create mode 100644 db/ticket.py
 create mode 100644 tests/db/test_ticket_price.py
 create mode 100644 utils/__init__.py
 create mode 100644 utils/dcrdata.py
 rename {db => utils}/exceptions.py (72%)
 rename {bot => utils}/utils.py (53%)

diff --git a/bot/commands/callback.py b/bot/commands/callback.py
index ec9319b..ce9ffad 100644
--- a/bot/commands/callback.py
+++ b/bot/commands/callback.py
@@ -7,8 +7,8 @@
 from bot.core import BotTelegramCore
 from db.subject import Subject
 from db.observer import Observer
-from db.exceptions import (ObserverAlreadyRegisteredError,
-                           ObserverNotRegisteredError)
+from utils.exceptions import (ObserverAlreadyRegisteredError,
+                              ObserverNotRegisteredError)
 
 
 logging.basicConfig(
diff --git a/bot/commands/exchange.py b/bot/commands/exchange.py
index 3eca3ae..168c150 100644
--- a/bot/commands/exchange.py
+++ b/bot/commands/exchange.py
@@ -4,8 +4,8 @@
 from telegram.ext import CommandHandler, CallbackContext
 
 from bot.core import BotTelegramCore
-from bot.utils import convert_dcr
-from bot.exceptions import DcrDataAPIError
+from utils.utils import convert_dcr
+from utils.exceptions import DcrDataAPIError
 
 
 logging.basicConfig(
diff --git a/bot/commands/subscription.py b/bot/commands/subscription.py
index fe96526..8851c50 100644
--- a/bot/commands/subscription.py
+++ b/bot/commands/subscription.py
@@ -6,7 +6,7 @@
 from telegram.ext import CommandHandler, CallbackContext
 
 from bot.core import BotTelegramCore
-from bot.utils import build_menu
+from utils.utils import build_menu
 from db.subject import Subject
 from db.observer import UserObserver
 
diff --git a/bot/exceptions.py b/bot/exceptions.py
deleted file mode 100644
index 934e95d..0000000
--- a/bot/exceptions.py
+++ /dev/null
@@ -1,3 +0,0 @@
-
-class DcrDataAPIError(Exception):
-    pass
diff --git a/db/observer.py b/db/observer.py
index d6ea58d..3ad8e2b 100644
--- a/db/observer.py
+++ b/db/observer.py
@@ -26,7 +26,7 @@ class ObserverMessage(Document):
     meta = {
         'ordering': ['datetime'],
         'indexes': [
-            {'fields': ['datetime'], 'expireAfterSeconds': 1*24*60*60}
+            {'fields': ['datetime'], 'expireAfterSeconds': 7*24*60*60}
         ]
     }
 
diff --git a/db/subject.py b/db/subject.py
index b723895..e4803f8 100644
--- a/db/subject.py
+++ b/db/subject.py
@@ -5,8 +5,8 @@
     StringField, ListField,
     ReferenceField, NULLIFY)
 
-from db.exceptions import (ObserverNotRegisteredError,
-                           ObserverAlreadyRegisteredError)
+from utils.exceptions import (ObserverNotRegisteredError,
+                              ObserverAlreadyRegisteredError)
 from db.observer import Observer, UserObserver
 
 
diff --git a/db/ticket.py b/db/ticket.py
new file mode 100644
index 0000000..30c861c
--- /dev/null
+++ b/db/ticket.py
@@ -0,0 +1,62 @@
+import logging
+
+import pendulum
+from mongoengine import (
+    Document,
+    FloatField, DateTimeField)
+
+from utils.dcrdata import request_dcr_data
+
+
+logging.basicConfig(
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    level=logging.INFO)
+
+logger = logging.getLogger(__name__)
+
+
+class TicketPrice(Document):
+    endpoint = "stake/diff"
+
+    price = FloatField(required=True)
+    datetime = DateTimeField(default=pendulum.now, required=True)
+
+    meta = {
+        'ordering': ['datetime'],
+        'indexes': [
+            {'fields': ['datetime'], 'expireAfterSeconds': 7*24*60*60}
+        ]
+    }
+
+    def __str__(self):
+        return f"{self.price:.2f} DCR"
+
+    @property
+    def pendulum_datetime(self):
+        return pendulum.instance(self.datetime).in_tz('America/Sao_Paulo')
+
+    def is_past_expire(self):
+        now = pendulum.now()
+        last = self.pendulum_datetime
+        diff = now - last
+        return diff.in_seconds() >= 2*60*60
+
+    @classmethod
+    def _fetch_new_ticket_price(cls):
+        price = request_dcr_data(cls.endpoint)
+        price = price.get('current')
+        return cls(price)
+
+    @classmethod
+    def get_last(cls):
+        last_ticket_price = cls.objects.order_by('-datetime').first()
+
+        try:
+            if last_ticket_price.is_past_expire():
+                last_ticket_price = cls._fetch_new_ticket_price()
+        except AttributeError:
+            last_ticket_price = cls._fetch_new_ticket_price()
+        finally:
+            last_ticket_price.save()
+
+        return last_ticket_price
diff --git a/db/update_message.py b/db/update_message.py
index 05672d4..2f9a52b 100644
--- a/db/update_message.py
+++ b/db/update_message.py
@@ -7,6 +7,7 @@
     EmbeddedDocumentListField, ReferenceField)
 
 from db.subject import Subject
+from db.ticket import TicketPrice
 
 
 class Amount(EmbeddedDocument):
@@ -56,8 +57,9 @@ class UpdateMessage(Document):
     datetime = DateTimeField(default=pendulum.now, required=True)
 
     def __str__(self):
-        string = f"<b>{self.subject.header}</b>\n"
-        string += f"<i>default session: {self.subject.default_session}</i>\n\n"
+        string = f"<b>{self.subject.header}</b>\n\n"
+        string += f"<i>Default session: {self.subject.default_session}</i>\n\n"
+        string += f"Ticket price: {TicketPrice.get_last()}\n\n"
         for index, msg in enumerate(self.sessions):
             string += f"<code>{msg}</code>"
             string += "\n\n" if index != len(self.sessions) - 1 else ""
diff --git a/tests/db/test_ticket_price.py b/tests/db/test_ticket_price.py
new file mode 100644
index 0000000..44f7823
--- /dev/null
+++ b/tests/db/test_ticket_price.py
@@ -0,0 +1,87 @@
+from unittest import TestCase, mock
+
+import pendulum
+import pytest
+
+from tests.fixtures import mongo  # noqa F401
+from db.ticket import TicketPrice
+
+
+@pytest.mark.usefixtures('mongo')
+class TicketPriceTestCase(TestCase):
+    def test_create(self):
+        self.assertEqual(TicketPrice.objects.count(), 0)
+
+        instance = TicketPrice(150.5).save()
+        self.assertEqual(TicketPrice.objects.count(), 1)
+
+        self.assertEqual(instance.price, 150.5)
+        self.assertTrue(instance.datetime)
+
+    def test_is_past_expire(self):
+        self.assertEqual(TicketPrice.objects.count(), 0)
+        TicketPrice(150.5).save()
+        self.assertEqual(TicketPrice.objects.count(), 1)
+
+        instance = TicketPrice.objects.first()
+
+        self.assertFalse(instance.is_past_expire())
+
+        instance.datetime = pendulum.yesterday()
+        self.assertTrue(instance.is_past_expire())
+
+    @mock.patch('db.ticket.request_dcr_data')
+    def test_fetch_new_ticket_price(self, mocked_request_dcr_data):
+        self.assertIsInstance(mocked_request_dcr_data, mock.MagicMock)
+        mocked_request_dcr_data.return_value = mock.MagicMock(
+            get=mock.MagicMock(
+                return_value=150.5
+            )
+        )
+
+        instance = TicketPrice._fetch_new_ticket_price()
+        self.assertEqual(instance.price, 150.5)
+        self.assertTrue(instance.datetime)
+
+    @mock.patch('db.ticket.request_dcr_data')
+    def test_get_last_price_exception(self, mocked_request_dcr_data):
+        self.assertIsInstance(mocked_request_dcr_data, mock.MagicMock)
+        mocked_request_dcr_data.return_value = mock.MagicMock(
+            get=mock.MagicMock(
+                return_value=150.5
+            )
+        )
+
+        self.assertEqual(TicketPrice.objects.count(), 0)
+
+        instance = TicketPrice.get_last()
+        self.assertEqual(TicketPrice.objects.count(), 1)
+        self.assertEqual(instance.price, 150.5)
+        self.assertTrue(instance.datetime)
+
+    def test_get_last_price_existing(self):
+        TicketPrice(130).save()
+
+        self.assertEqual(TicketPrice.objects.count(), 1)
+
+        instance = TicketPrice.get_last()
+        self.assertEqual(TicketPrice.objects.count(), 1)
+        self.assertEqual(instance.price, 130)
+        self.assertTrue(instance.datetime)
+
+    @mock.patch('db.ticket.request_dcr_data')
+    def test_get_last_price_existing_expired(self, mocked_request_dcr_data):
+        self.assertIsInstance(mocked_request_dcr_data, mock.MagicMock)
+        mocked_request_dcr_data.return_value = mock.MagicMock(
+            get=mock.MagicMock(
+                return_value=150.5
+            )
+        )
+
+        TicketPrice(130, pendulum.yesterday()).save()
+        self.assertEqual(TicketPrice.objects.count(), 1)
+
+        instance = TicketPrice.get_last()
+        self.assertEqual(TicketPrice.objects.count(), 2)
+        self.assertEqual(instance.price, 150.5)
+        self.assertTrue(instance.datetime)
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/utils/dcrdata.py b/utils/dcrdata.py
new file mode 100644
index 0000000..31e929b
--- /dev/null
+++ b/utils/dcrdata.py
@@ -0,0 +1,16 @@
+import json
+
+import requests
+
+from utils.exceptions import DcrDataAPIError
+
+
+DCRDATA_API_URL = "https://dcrdata.decred.org/api"
+
+
+def request_dcr_data(endpoint):
+    dcrdata_response = requests.get(f"{DCRDATA_API_URL}/{endpoint}")
+    if dcrdata_response.status_code != 200:
+        raise DcrDataAPIError(dcrdata_response.content)
+
+    return json.loads(dcrdata_response.content)
diff --git a/db/exceptions.py b/utils/exceptions.py
similarity index 72%
rename from db/exceptions.py
rename to utils/exceptions.py
index d3d0f02..8e0f978 100644
--- a/db/exceptions.py
+++ b/utils/exceptions.py
@@ -1,4 +1,7 @@
 
+class DcrDataAPIError(Exception):
+    pass
+
 
 class ObserverNotRegisteredError(Exception):
     pass
diff --git a/bot/utils.py b/utils/utils.py
similarity index 53%
rename from bot/utils.py
rename to utils/utils.py
index a37429b..313cf33 100644
--- a/bot/utils.py
+++ b/utils/utils.py
@@ -1,8 +1,4 @@
-import json
-
-import requests
-
-from bot.exceptions import DcrDataAPIError
+from utils.dcrdata import request_dcr_data
 
 
 def build_menu(buttons,
@@ -18,13 +14,9 @@ def build_menu(buttons,
 
 
 def convert_dcr(dcr_amount: float, target_currency: str):
-    dcrdata_response = requests.get(f"https://dcrdata.decred.org/api/exchanges?"
-                                    f"code={target_currency}")
-    if dcrdata_response.status_code != 200:
-        raise DcrDataAPIError(dcrdata_response.content)
-
-    dcr_to_usd_value = json.loads(dcrdata_response.content)
+    endpoint = "exchanges"
+    dcr_to_usd_value = request_dcr_data(endpoint)
     dcr_to_usd_value = dcr_to_usd_value.get("price")
 
     if target_currency == 'USD':
-        return dcr_amount*dcr_to_usd_value
+        return dcr_amount * dcr_to_usd_value