Merge branch 'master' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-05 14:01:08 +03:00
commit 114204b105
6 changed files with 105 additions and 19 deletions

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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
View 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)

View file

@ -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",