diff --git a/engine/core/graphene/object_types.py b/engine/core/graphene/object_types.py index 4076d082..0ad62635 100644 --- a/engine/core/graphene/object_types.py +++ b/engine/core/graphene/object_types.py @@ -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 diff --git a/engine/core/models.py b/engine/core/models.py index 06f15426..ee490b77 100644 --- a/engine/core/models.py +++ b/engine/core/models.py @@ -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: diff --git a/engine/core/views.py b/engine/core/views.py index 85782849..948f1673 100644 --- a/engine/core/views.py +++ b/engine/core/views.py @@ -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): diff --git a/engine/vibes_auth/admin_site.py b/engine/vibes_auth/admin_site.py index 7e833ca4..ab24f9e5 100644 --- a/engine/vibes_auth/admin_site.py +++ b/engine/vibes_auth/admin_site.py @@ -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) diff --git a/schon/health_checks.py b/schon/health_checks.py new file mode 100644 index 00000000..4efc2541 --- /dev/null +++ b/schon/health_checks.py @@ -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) diff --git a/schon/urls.py b/schon/urls.py index 5be556fe..3f1913c2 100644 --- a/schon/urls.py +++ b/schon/urls.py @@ -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",