Merge branch 'master' into storefront-nuxt
This commit is contained in:
commit
114204b105
6 changed files with 105 additions and 19 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
from contextlib import suppress
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
|
@ -18,6 +19,9 @@ from graphene import (
|
|||
String,
|
||||
relay,
|
||||
)
|
||||
from graphene import (
|
||||
Decimal as GrapheneDecimal,
|
||||
)
|
||||
from graphene.types.generic import GenericScalar
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.filter import (
|
||||
|
|
@ -536,7 +540,7 @@ class ProductType(DjangoObjectType):
|
|||
attribute_groups = DjangoFilterConnectionField(
|
||||
AttributeGroupType, description=_("attribute groups")
|
||||
)
|
||||
price = Float(description=_("price"))
|
||||
price = GrapheneDecimal(description=_("price"))
|
||||
quantity = Float(description=_("quantity"))
|
||||
feedbacks_count = Int(description=_("number of feedbacks"))
|
||||
personal_orders_only = Boolean(description=_("only available for personal orders"))
|
||||
|
|
@ -544,7 +548,7 @@ class ProductType(DjangoObjectType):
|
|||
rating = Float(
|
||||
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:
|
||||
model = Product
|
||||
|
|
@ -574,8 +578,8 @@ class ProductType(DjangoObjectType):
|
|||
def resolve_description(self: Product, _info) -> str:
|
||||
return render_markdown(self.description or "")
|
||||
|
||||
def resolve_price(self: Product, _info) -> float:
|
||||
return self.price or 0.0
|
||||
def resolve_price(self: Product, _info) -> Decimal:
|
||||
return self.price or Decimal("0.0")
|
||||
|
||||
def resolve_rating(self: Product, _info) -> float:
|
||||
return self.rating or 0.0
|
||||
|
|
@ -655,7 +659,7 @@ class ProductType(DjangoObjectType):
|
|||
def resolve_video(self: Product, info) -> str:
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -770,9 +770,13 @@ class Product(NiceModel):
|
|||
).order_by("-discount_percent")
|
||||
|
||||
@cached_property
|
||||
def discount_price(self) -> Decimal | None:
|
||||
def discount_price(self) -> Decimal:
|
||||
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
|
||||
def rating(self) -> float:
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import os
|
|||
import traceback
|
||||
from contextlib import suppress
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from os import getenv
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
from constance import config
|
||||
from django.conf import settings
|
||||
|
|
@ -135,11 +137,7 @@ class CustomGraphQLView(FileUploadGraphQLView):
|
|||
def get_context(self, request):
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def json_encode(request, d, pretty=False):
|
||||
from decimal import Decimal
|
||||
|
||||
import orjson
|
||||
def json_encode(self, request, d, pretty=False):
|
||||
|
||||
def _default(obj):
|
||||
if isinstance(obj, Decimal):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
|
|
@ -7,9 +8,11 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||
from django.shortcuts import render
|
||||
from django.urls import path, reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from health_check.exceptions import HealthCheckException
|
||||
from unfold.sites import UnfoldAdminSite
|
||||
|
||||
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__)
|
||||
|
||||
|
|
@ -31,6 +34,18 @@ class OTPVerifyForm(forms.Form):
|
|||
|
||||
|
||||
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:
|
||||
if request.method == "POST":
|
||||
email = request.POST.get("username", "")
|
||||
|
|
@ -38,13 +53,23 @@ class SchonAdminSite(UnfoldAdminSite):
|
|||
user = authenticate(request, username=email, password=password)
|
||||
|
||||
if user is not None and user.is_staff: # ty: ignore[unresolved-attribute]
|
||||
code = generate_otp_code(user)
|
||||
send_admin_otp_email_task.delay(user_pk=str(user.pk), code=code)
|
||||
request.session["_2fa_user_id"] = str(user.pk)
|
||||
messages.info(
|
||||
request, _("A verification code has been sent to your email.")
|
||||
if self._email_backend_configured():
|
||||
code = generate_otp_code(user)
|
||||
send_admin_otp_email_task.delay(user_pk=str(user.pk), code=code)
|
||||
request.session["_2fa_user_id"] = str(user.pk)
|
||||
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)
|
||||
|
||||
|
|
|
|||
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.DNS",
|
||||
"health_check.Database",
|
||||
"health_check.Mail",
|
||||
"schon.health_checks.DynamicMail",
|
||||
"health_check.Storage",
|
||||
"health_check.contrib.psutil.Disk",
|
||||
"health_check.contrib.psutil.Memory",
|
||||
|
|
|
|||
Loading…
Reference in a new issue