feat(graphene): replace Float with Decimal for price and discount_price
Switched Float fields to Decimal in Graphene for increased precision in monetary values. Updated related queries and resolvers accordingly. Additionally: - Added custom DynamicMail health check for runtime-based email configuration. - Enhanced admin login flow to handle unconfigured email backends by logging users directly.
This commit is contained in:
parent
31d9ccb82a
commit
b914b5fc91
6 changed files with 105 additions and 19 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -18,6 +19,9 @@ from graphene import (
|
||||||
String,
|
String,
|
||||||
relay,
|
relay,
|
||||||
)
|
)
|
||||||
|
from graphene import (
|
||||||
|
Decimal as GrapheneDecimal,
|
||||||
|
)
|
||||||
from graphene.types.generic import GenericScalar
|
from graphene.types.generic import GenericScalar
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
from graphene_django.filter import (
|
from graphene_django.filter import (
|
||||||
|
|
@ -536,7 +540,7 @@ class ProductType(DjangoObjectType):
|
||||||
attribute_groups = DjangoFilterConnectionField(
|
attribute_groups = DjangoFilterConnectionField(
|
||||||
AttributeGroupType, description=_("attribute groups")
|
AttributeGroupType, description=_("attribute groups")
|
||||||
)
|
)
|
||||||
price = Float(description=_("price"))
|
price = GrapheneDecimal(description=_("price"))
|
||||||
quantity = Float(description=_("quantity"))
|
quantity = Float(description=_("quantity"))
|
||||||
feedbacks_count = Int(description=_("number of feedbacks"))
|
feedbacks_count = Int(description=_("number of feedbacks"))
|
||||||
personal_orders_only = Boolean(description=_("only available for personal orders"))
|
personal_orders_only = Boolean(description=_("only available for personal orders"))
|
||||||
|
|
@ -544,7 +548,7 @@ class ProductType(DjangoObjectType):
|
||||||
rating = Float(
|
rating = Float(
|
||||||
description=_("rating value from 1 to 10, inclusive, or 0 if not set.")
|
description=_("rating value from 1 to 10, inclusive, or 0 if not set.")
|
||||||
)
|
)
|
||||||
discount_price = Float(description=_("discount price"))
|
discount_price = GrapheneDecimal(description=_("discount price"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
|
|
@ -574,8 +578,8 @@ class ProductType(DjangoObjectType):
|
||||||
def resolve_description(self: Product, _info) -> str:
|
def resolve_description(self: Product, _info) -> str:
|
||||||
return render_markdown(self.description or "")
|
return render_markdown(self.description or "")
|
||||||
|
|
||||||
def resolve_price(self: Product, _info) -> float:
|
def resolve_price(self: Product, _info) -> Decimal:
|
||||||
return self.price or 0.0
|
return self.price or Decimal("0.0")
|
||||||
|
|
||||||
def resolve_rating(self: Product, _info) -> float:
|
def resolve_rating(self: Product, _info) -> float:
|
||||||
return self.rating or 0.0
|
return self.rating or 0.0
|
||||||
|
|
@ -655,7 +659,7 @@ class ProductType(DjangoObjectType):
|
||||||
def resolve_video(self: Product, info) -> str:
|
def resolve_video(self: Product, info) -> str:
|
||||||
return info.context.build_absolute_uri(self.video.url) if self.video else ""
|
return info.context.build_absolute_uri(self.video.url) if self.video else ""
|
||||||
|
|
||||||
def resolve_discount_price(self: Product, _info) -> float | None:
|
def resolve_discount_price(self: Product, _info) -> Decimal:
|
||||||
return self.discount_price
|
return self.discount_price
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -770,9 +770,13 @@ class Product(NiceModel):
|
||||||
).order_by("-discount_percent")
|
).order_by("-discount_percent")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def discount_price(self) -> Decimal | None:
|
def discount_price(self) -> Decimal:
|
||||||
promo = self.promos.first()
|
promo = self.promos.first()
|
||||||
return (self.price / 100) * promo.discount_percent if promo else None
|
return (
|
||||||
|
Decimal((self.price / 100) * promo.discount_percent)
|
||||||
|
if promo
|
||||||
|
else Decimal("0.0")
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rating(self) -> float:
|
def rating(self) -> float:
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import os
|
||||||
import traceback
|
import traceback
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
from os import getenv
|
from os import getenv
|
||||||
|
|
||||||
|
import orjson
|
||||||
import requests
|
import requests
|
||||||
from constance import config
|
from constance import config
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -135,11 +137,7 @@ class CustomGraphQLView(FileUploadGraphQLView):
|
||||||
def get_context(self, request):
|
def get_context(self, request):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
@staticmethod
|
def json_encode(self, request, d, pretty=False):
|
||||||
def json_encode(request, d, pretty=False):
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import orjson
|
|
||||||
|
|
||||||
def _default(obj):
|
def _default(obj):
|
||||||
if isinstance(obj, Decimal):
|
if isinstance(obj, Decimal):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
@ -7,9 +8,11 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from health_check.exceptions import HealthCheckException
|
||||||
from unfold.sites import UnfoldAdminSite
|
from unfold.sites import UnfoldAdminSite
|
||||||
|
|
||||||
from engine.vibes_auth.utils.otp import generate_otp_code, send_admin_otp_email_task
|
from engine.vibes_auth.utils.otp import generate_otp_code, send_admin_otp_email_task
|
||||||
|
from schon.health_checks import DynamicMail
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -31,6 +34,18 @@ class OTPVerifyForm(forms.Form):
|
||||||
|
|
||||||
|
|
||||||
class SchonAdminSite(UnfoldAdminSite):
|
class SchonAdminSite(UnfoldAdminSite):
|
||||||
|
@staticmethod
|
||||||
|
def _email_backend_configured() -> bool:
|
||||||
|
"""Use DynamicMail health check to verify the SMTP backend is reachable."""
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
|
try:
|
||||||
|
check = DynamicMail(timeout=datetime.timedelta(seconds=5))
|
||||||
|
async_to_sync(check.run)()
|
||||||
|
except (HealthCheckException, Exception):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def login(self, request: HttpRequest, extra_context=None) -> HttpResponse:
|
def login(self, request: HttpRequest, extra_context=None) -> HttpResponse:
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
email = request.POST.get("username", "")
|
email = request.POST.get("username", "")
|
||||||
|
|
@ -38,13 +53,23 @@ class SchonAdminSite(UnfoldAdminSite):
|
||||||
user = authenticate(request, username=email, password=password)
|
user = authenticate(request, username=email, password=password)
|
||||||
|
|
||||||
if user is not None and user.is_staff: # ty: ignore[unresolved-attribute]
|
if user is not None and user.is_staff: # ty: ignore[unresolved-attribute]
|
||||||
code = generate_otp_code(user)
|
if self._email_backend_configured():
|
||||||
send_admin_otp_email_task.delay(user_pk=str(user.pk), code=code)
|
code = generate_otp_code(user)
|
||||||
request.session["_2fa_user_id"] = str(user.pk)
|
send_admin_otp_email_task.delay(user_pk=str(user.pk), code=code)
|
||||||
messages.info(
|
request.session["_2fa_user_id"] = str(user.pk)
|
||||||
request, _("A verification code has been sent to your email.")
|
messages.info(
|
||||||
|
request,
|
||||||
|
_("A verification code has been sent to your email."),
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse("admin:verify-otp"))
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Email backend is not configured — "
|
||||||
|
"skipping OTP, logging in user %s directly.",
|
||||||
|
user.pk,
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(reverse("admin:verify-otp"))
|
login(request, user)
|
||||||
|
return HttpResponseRedirect(reverse("admin:index"))
|
||||||
|
|
||||||
return super().login(request, extra_context)
|
return super().login(request, extra_context)
|
||||||
|
|
||||||
|
|
|
||||||
55
schon/health_checks.py
Normal file
55
schon/health_checks.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Custom health checks that read runtime config from constance."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
from health_check.base import HealthCheck
|
||||||
|
from health_check.exceptions import ServiceUnavailable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class DynamicMail(HealthCheck):
|
||||||
|
"""
|
||||||
|
Check that the SMTP backend configured via constance can open a connection.
|
||||||
|
|
||||||
|
Unlike the built-in ``health_check.Mail``, this check reads host, port,
|
||||||
|
credentials, and TLS/SSL flags from ``constance.config`` so it always
|
||||||
|
reflects whatever an administrator has set at runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Timeout for the SMTP connection attempt.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
timeout: datetime.timedelta = dataclasses.field(
|
||||||
|
default=datetime.timedelta(seconds=15), repr=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_connection(self) -> None:
|
||||||
|
"""Blocking SMTP probe — runs inside a thread."""
|
||||||
|
from engine.core.utils import get_dynamic_email_connection
|
||||||
|
|
||||||
|
connection = get_dynamic_email_connection()
|
||||||
|
connection.timeout = int(self.timeout.total_seconds())
|
||||||
|
logger.debug("Trying to open dynamic mail connection.")
|
||||||
|
try:
|
||||||
|
connection.open()
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
"Failed to open connection with SMTP server"
|
||||||
|
) from e
|
||||||
|
except ConnectionRefusedError as e:
|
||||||
|
raise ServiceUnavailable("Connection refused") from e
|
||||||
|
except OSError as e:
|
||||||
|
raise ServiceUnavailable("Could not connect to mail server") from e
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
logger.debug("Dynamic mail connection established successfully.")
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
await asyncio.to_thread(self._check_connection)
|
||||||
|
|
@ -29,7 +29,7 @@ urlpatterns = [
|
||||||
"health_check.Cache",
|
"health_check.Cache",
|
||||||
"health_check.DNS",
|
"health_check.DNS",
|
||||||
"health_check.Database",
|
"health_check.Database",
|
||||||
"health_check.Mail",
|
"schon.health_checks.DynamicMail",
|
||||||
"health_check.Storage",
|
"health_check.Storage",
|
||||||
"health_check.contrib.psutil.Disk",
|
"health_check.contrib.psutil.Disk",
|
||||||
"health_check.contrib.psutil.Memory",
|
"health_check.contrib.psutil.Memory",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue