diff --git a/core/api_urls.py b/core/api_urls.py index fa2d3fd5..51bc983d 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -20,6 +20,7 @@ from core.viewsets import ( CategoryViewSet, FeedbackViewSet, OrderViewSet, + ProductTagViewSet, ProductViewSet, PromoCodeViewSet, PromotionViewSet, @@ -41,6 +42,7 @@ core_router.register(r"stocks", StockViewSet, basename="stocks") core_router.register(r"promo_codes", PromoCodeViewSet, basename="promo_codes") core_router.register(r"promotions", PromotionViewSet, basename="promotions") core_router.register(r"addresses", AddressViewSet, basename="addresses") +core_router.register(r"product_tags", ProductTagViewSet, basename="product_tags") sitemaps = { "products": ProductSitemap, diff --git a/core/docs/images/evibes-big-simple.png b/core/docs/images/evibes-big-simple.png new file mode 100644 index 00000000..08ba5f8c Binary files /dev/null and b/core/docs/images/evibes-big-simple.png differ diff --git a/core/filters.py b/core/filters.py index 8f250640..044eb905 100644 --- a/core/filters.py +++ b/core/filters.py @@ -1,5 +1,6 @@ import json import logging +import uuid from django.db.models import Avg, FloatField, OuterRef, Q, Subquery, Value from django.db.models.functions import Coalesce @@ -251,7 +252,7 @@ class WishlistFilter(FilterSet): class CategoryFilter(FilterSet): uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") name = CharFilter(field_name="name", lookup_expr="icontains") - parent_uuid = UUIDFilter(field_name="parent__uuid", lookup_expr="exact") + parent_uuid = CharFilter(method="filter_parent_uuid") slug = CharFilter(field_name="slug", lookup_expr="exact") order_by = OrderingFilter( @@ -264,7 +265,22 @@ class CategoryFilter(FilterSet): class Meta: model = Category - fields = ["uuid", "name"] + fields = ["uuid", "name", "parent_uuid", "slug"] + + def filter_parent_uuid(self, queryset, name, value): + """ + If ?parent_uuid= or ?parent_uuid=null, return items with parent=None. + Otherwise treat `value` as a real UUID and filter parent__uuid=value. + """ + if value in ("", "null", "None"): + return queryset.filter(parent=None) + + try: + uuid_val = uuid.UUID(value) + except (ValueError, TypeError): + return queryset + + return queryset.filter(parent__uuid=uuid_val) class BrandFilter(FilterSet): diff --git a/core/graphene/object_types.py b/core/graphene/object_types.py index ae06b024..2465ce0a 100644 --- a/core/graphene/object_types.py +++ b/core/graphene/object_types.py @@ -21,6 +21,7 @@ from core.models import ( OrderProduct, Product, ProductImage, + ProductTag, PromoCode, Promotion, Stock, @@ -140,7 +141,7 @@ class CategoryType(DjangoObjectType): if depth <= 0: return Category.objects.none() - categories = Category.objects.language(info.context.locale).filter(parent=self) + categories = Category.objects.filter(parent=self) if info.context.user.has_perm("core.view_category"): return categories return categories.filter(is_active=True) @@ -455,6 +456,17 @@ class WishlistType(DjangoObjectType): description = _("wishlists") +class ProductTagType(DjangoObjectType): + product_set = DjangoFilterConnectionField(ProductType, description=_("tagged products")) + + class Meta: + model = ProductTag + interfaces = (relay.Node,) + fields = ("uuid", "tag_name", "name", "product_set") + filter_fields = ["uuid", "tag_name", "name"] + description = _("product tags") + + class ConfigType(ObjectType): project_name = String(description=_("project name")) base_domain = String(description=_("company email")) @@ -511,5 +523,5 @@ class SearchResultsType(ObjectType): class BulkActionOrderProductInput(InputObjectType): - id = UUID(required=True) + uuid = UUID(required=True) attributes = GenericScalar(required=False) diff --git a/core/graphene/schema.py b/core/graphene/schema.py index 223731fc..f8f2520e 100644 --- a/core/graphene/schema.py +++ b/core/graphene/schema.py @@ -48,6 +48,7 @@ from core.graphene.object_types import ( OrderProductType, OrderType, ProductImageType, + ProductTagType, ProductType, PromoCodeType, PromotionType, @@ -64,6 +65,7 @@ from core.models import ( OrderProduct, Product, ProductImage, + ProductTag, PromoCode, Promotion, Stock, @@ -108,6 +110,7 @@ class Query(ObjectType): product_images = DjangoFilterConnectionField(ProductImageType) stocks = DjangoFilterConnectionField(StockType) wishlists = DjangoFilterConnectionField(WishlistType, filterset_class=WishlistFilter) + product_tags = DjangoFilterConnectionField(ProductTagType) promotions = DjangoFilterConnectionField(PromotionType) promocodes = DjangoFilterConnectionField(PromoCodeType) brands = DjangoFilterConnectionField(BrandType, filterset_class=BrandFilter) @@ -184,7 +187,7 @@ class Query(ObjectType): @staticmethod def resolve_categories(_parent, info, **kwargs): - categories = Category.objects.filter(parent=None) + categories = Category.objects.all() if info.context.user.has_perm("core.view_category"): return categories return categories.filter(is_active=True) @@ -280,6 +283,12 @@ class Query(ObjectType): return promocodes.filter(user__uuid=kwargs.get("user_uuid")) or promocodes.all() return promocodes.filter(is_active=True, user=info.context.user) + @staticmethod + def resolve_product_tags(_parent, info, **kwargs): + if info.context.user.has_perm("core.view_producttag"): + return ProductTag.objects.all() + return ProductTag.objects.filter(is_active=True) + class Mutation(ObjectType): search = Search.Field() diff --git a/core/managers.py b/core/managers.py index 19d78527..8b502c53 100644 --- a/core/managers.py +++ b/core/managers.py @@ -22,7 +22,7 @@ class AddressManager(models.Manager): "addressdetails": 1, "q": raw_data, } - resp = requests.get(config.NOMINATIM_URL, params=params) + resp = requests.get(config.NOMINATIM_URL.rstrip("/") + "/search", params=params) resp.raise_for_status() results = resp.json() if not results: @@ -31,7 +31,7 @@ class AddressManager(models.Manager): # Parse address components addr = data.get("address", {}) - street = addr.get("road") or addr.get("pedestrian") or "" + street = f"{addr.get('road', '') or addr.get('pedestrian', '')}, {addr.get('house_number', '')}" district = addr.get("city_district") or addr.get("suburb") or "" city = addr.get("city") or addr.get("town") or addr.get("village") or "" region = addr.get("state") or addr.get("region") or "" @@ -49,6 +49,7 @@ class AddressManager(models.Manager): # Create the model instance, storing both the input string and full API response return super().create( raw_data=raw_data, + address_line=f"{kwargs.get('address_line_1')}, {kwargs.get('address_line_2')}", street=street, district=district, city=city, diff --git a/core/migrations/0023_address_address_line.py b/core/migrations/0023_address_address_line.py new file mode 100644 index 00000000..55654c18 --- /dev/null +++ b/core/migrations/0023_address_address_line.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-28 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0022_category_slug'), + ] + + operations = [ + migrations.AddField( + model_name='address', + name='address_line', + field=models.TextField(blank=True, help_text='address line for the customer', null=True, + verbose_name='address line'), + ), + ] diff --git a/core/models.py b/core/models.py index 3b1b64e9..e36436d8 100644 --- a/core/models.py +++ b/core/models.py @@ -1259,6 +1259,12 @@ class Documentary(NiceModel): class Address(NiceModel): is_publicly_visible = False + address_line = TextField( # noqa: DJ001 + blank=True, + null=True, + help_text=_("address line for the customer"), + verbose_name=_("address line"), + ) street = CharField(_("street"), max_length=255, null=True) # noqa: DJ001 district = CharField(_("district"), max_length=255, null=True) # noqa: DJ001 city = CharField(_("city"), max_length=100, null=True) # noqa: DJ001 diff --git a/core/permissions.py b/core/permissions.py index 2a906c36..c033af30 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -22,6 +22,8 @@ class EvibesPermission(permissions.BasePermission): - Standard model perms ('add', 'view', 'change', 'delete') are enforced for all other actions, including for staff users. - Publicly visible models allow anonymous list/retrieve. + - If an instance or queryset has a "user" attribute, ensure that the request.user is the same, + unless the user is an admin with the required django permission. """ ACTION_PERM_MAP = { @@ -34,6 +36,8 @@ class EvibesPermission(permissions.BasePermission): } USER_SCOPED_ACTIONS = { + "list", + "retrieve", "buy", "buy_unregistered", "current", @@ -64,19 +68,56 @@ class EvibesPermission(permissions.BasePermission): if request.user.has_perm(f"{app_label}.{codename}"): return True - return bool(action in ("list", "retrieve") and getattr(model, "is_publicly_visible", False)) + return bool( + action in ("list", "retrieve") + and getattr(model, "is_publicly_visible", False) + ) + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + + if hasattr(obj, "user"): + if obj.user == request.user: + return True + # Allow admins who hold the required model permission + app_label = obj._meta.app_label + model_name = obj._meta.model_name + action = getattr(view, "action", None) + perm_prefix = self.ACTION_PERM_MAP.get(action) + return bool(perm_prefix and request.user.has_perm(f"{app_label}.{perm_prefix}_{model_name}")) + + model = view.queryset.model + app_label = model._meta.app_label + model_name = model._meta.model_name + action = getattr(view, "action", None) + perm_prefix = self.ACTION_PERM_MAP.get(action) + return bool(perm_prefix and request.user.has_perm(f"{app_label}.{perm_prefix}_{model_name}")) def has_queryset_permission(self, request, view, queryset): """ Filter the base queryset according to the action and user. - Staff users still require view permissions to see records. + For models with a "user" field, restrict access to records belonging to the request user + unless the admin holds the needed permissions. """ model = view.queryset.model app_label = model._meta.app_label model_name = model._meta.model_name - if view.action in self.USER_SCOPED_ACTIONS: - return queryset.filter(user=request.user) + if hasattr(model, "user"): + if view.action in self.USER_SCOPED_ACTIONS: + return queryset.filter(user=request.user) + if view.action in ("list", "retrieve"): + if request.user.has_perm(f"{app_label}.view_{model_name}"): + return queryset + return queryset.none() + + base = queryset.filter(is_active=True, user=request.user) + if request.user.is_staff and request.user.has_perm( + f"{app_label}.{self.ACTION_PERM_MAP.get(view.action)}_{model_name}" + ): + return queryset.filter(is_active=True) + return base if view.action in ("list", "retrieve"): if request.user.has_perm(f"{app_label}.view_{model_name}"): @@ -87,10 +128,7 @@ class EvibesPermission(permissions.BasePermission): base = queryset.filter(is_active=True) match view.action: - case "update": - if request.user.has_perm(f"{app_label}.change_{model_name}"): - return base - case "partial_update": + case "update" | "partial_update": if request.user.has_perm(f"{app_label}.change_{model_name}"): return base case "destroy": diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py index 95e372bf..96f2fe80 100644 --- a/core/serializers/__init__.py +++ b/core/serializers/__init__.py @@ -158,10 +158,24 @@ class AddressCreateSerializer(ModelSerializer): # noqa: F405 write_only=True, max_length=512, ) + address_line_1 = CharField( + write_only=True, + max_length=128, + required=False + ) + address_line_2 = CharField( + write_only=True, + max_length=128, + required=False + ) class Meta: model = Address - fields = ["raw_data"] + fields = [ + "raw_data", + "address_line_1", + "address_line_2" + ] def create(self, validated_data): raw = validated_data.pop("raw_data") diff --git a/core/serializers/detail.py b/core/serializers/detail.py index 0ef62c6f..b4ecc9a3 100644 --- a/core/serializers/detail.py +++ b/core/serializers/detail.py @@ -89,20 +89,19 @@ class CategoryDetailSerializer(ModelSerializer): .distinct() ) - distinct_vals_list = list(distinct_vals) + distinct_vals_list = list(distinct_vals)[0:128] if len(list(distinct_vals)) > 128 else list(distinct_vals) - if len(distinct_vals_list) <= 256: - filterable_results.append( - { - "attribute_name": attr.name, - "possible_values": distinct_vals_list, - "value_type": attr.value_type, - } - ) - else: - continue + filterable_results.append( + { + "attribute_name": attr.name, + "possible_values": distinct_vals_list, + "value_type": attr.value_type, + } + ) + + if not user.has_perm("view_attribute"): + cache.set(f"{obj.uuid}_filterable_results", filterable_results, 86400) - cache.set(f"{obj.uuid}_filterable_results", filterable_results, 86400) return filterable_results def get_children(self, obj) -> list[dict]: @@ -123,7 +122,7 @@ class CategoryDetailSerializer(ModelSerializer): class BrandDetailSerializer(ModelSerializer): - categories = CategoryDetailSerializer(many=True) + categories = CategorySimpleSerializer(many=True) small_logo = SerializerMethodField() big_logo = SerializerMethodField() diff --git a/core/viewsets.py b/core/viewsets.py index 122c7dad..756edd55 100644 --- a/core/viewsets.py +++ b/core/viewsets.py @@ -73,13 +73,18 @@ from core.serializers import ( OrderProductSimpleSerializer, OrderSimpleSerializer, ProductDetailSerializer, + ProductImageDetailSerializer, ProductImageSimpleSerializer, ProductSimpleSerializer, + ProductTagDetailSerializer, ProductTagSimpleSerializer, + PromoCodeDetailSerializer, PromoCodeSimpleSerializer, + PromotionDetailSerializer, PromotionSimpleSerializer, RemoveOrderProductSerializer, RemoveWishlistProductSerializer, + StockDetailSerializer, StockSimpleSerializer, VendorSimpleSerializer, WishlistDetailSerializer, @@ -364,21 +369,11 @@ class OrderProductViewSet(EvibesViewSet): } -class ProductTagViewSet(EvibesViewSet): - queryset = ProductTag.objects.all() - filter_backends = [DjangoFilterBackend] - filterset_fields = ["tag_name", "is_active"] - serializer_class = AttributeGroupDetailSerializer - action_serializer_classes = { - "list": ProductTagSimpleSerializer, - } - - class ProductImageViewSet(EvibesViewSet): queryset = ProductImage.objects.all() filter_backends = [DjangoFilterBackend] filterset_fields = ["product", "priority", "is_active"] - serializer_class = AttributeGroupDetailSerializer + serializer_class = ProductImageDetailSerializer action_serializer_classes = { "list": ProductImageSimpleSerializer, } @@ -388,7 +383,7 @@ class PromoCodeViewSet(EvibesViewSet): queryset = PromoCode.objects.all() filter_backends = [DjangoFilterBackend] filterset_fields = ["code", "discount_amount", "discount_percent", "start_time", "end_time", "used_on", "is_active"] - serializer_class = AttributeGroupDetailSerializer + serializer_class = PromoCodeDetailSerializer action_serializer_classes = { "list": PromoCodeSimpleSerializer, } @@ -398,7 +393,7 @@ class PromotionViewSet(EvibesViewSet): queryset = Promotion.objects.all() filter_backends = [DjangoFilterBackend] filterset_fields = ["name", "discount_percent", "is_active"] - serializer_class = AttributeGroupDetailSerializer + serializer_class = PromotionDetailSerializer action_serializer_classes = { "list": PromotionSimpleSerializer, } @@ -408,7 +403,7 @@ class StockViewSet(EvibesViewSet): queryset = Stock.objects.all() filter_backends = [DjangoFilterBackend] filterset_fields = ["vendor", "product", "sku", "is_active"] - serializer_class = AttributeGroupDetailSerializer + serializer_class = StockDetailSerializer action_serializer_classes = { "list": StockSimpleSerializer, } @@ -419,7 +414,7 @@ class WishlistViewSet(EvibesViewSet): queryset = Wishlist.objects.all() filter_backends = [DjangoFilterBackend] filterset_fields = ["user", "is_active"] - serializer_class = AttributeGroupDetailSerializer + serializer_class = WishlistDetailSerializer action_serializer_classes = { "list": WishlistSimpleSerializer, } @@ -535,3 +530,13 @@ class AddressViewSet(EvibesViewSet): ) return Response(suggestions, status=status.HTTP_200_OK) + + +class ProductTagViewSet(EvibesViewSet): + queryset = ProductTag.objects.all() + filter_backends = [DjangoFilterBackend] + filterset_fields = ["tag_name", "is_active"] + serializer_class = ProductTagDetailSerializer + action_serializer_classes = { + "list": ProductTagSimpleSerializer, + } diff --git a/evibes/settings/base.py b/evibes/settings/base.py index cb00076f..5e93a7fc 100644 --- a/evibes/settings/base.py +++ b/evibes/settings/base.py @@ -2,7 +2,7 @@ import logging from os import getenv from pathlib import Path -EVIBES_VERSION = "2.7.0" +EVIBES_VERSION = "2.7.1" BASE_DIR = Path(__file__).resolve().parent.parent.parent diff --git a/pyproject.toml b/pyproject.toml index 551ae696..535e2eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "eVibes" -version = "2.7.0" +version = "2.7.1" description = "eVibes is an open-source eCommerce backend service built with Django. It’s designed for flexibility, making it ideal for various use cases and learning Django skills. The project is easy to customize, allowing for straightforward editing and extension." authors = ["fureunoir "] readme = "README.md" diff --git a/vibes_auth/graphene/mutations.py b/vibes_auth/graphene/mutations.py index 28750979..fae1bb69 100644 --- a/vibes_auth/graphene/mutations.py +++ b/vibes_auth/graphene/mutations.py @@ -3,7 +3,7 @@ from hmac import compare_digest from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.core.exceptions import BadRequest, PermissionDenied +from django.core.exceptions import BadRequest, PermissionDenied, ValidationError from django.db import IntegrityError from django.http import Http404 from django.utils.http import urlsafe_base64_decode @@ -11,7 +11,6 @@ from django.utils.translation import gettext_lazy as _ from graphene import UUID, Boolean, Field, List, String from graphene.types.generic import GenericScalar from graphene_file_upload.scalars import Upload -from rest_framework.exceptions import ValidationError from core.graphene import BaseMutation from core.utils.messages import permission_denied_message @@ -123,8 +122,8 @@ class UpdateUser(BaseMutation): password = kwargs.get("password", "") confirm_password = kwargs.get("confirm_password", "") - if compare_digest(password.lower(), email.lower()): - raise BadRequest(_("password too weak")) + if password: + validate_password(password=password, user=user) if not compare_digest(password, "") and compare_digest(password, confirm_password): user.set_password(password) @@ -314,13 +313,15 @@ class ConfirmResetPassword(BaseMutation): if not password_reset_token.check_token(user, token): raise BadRequest(_("token is invalid!")) + validate_password(password=password, user=user) + user.set_password(password) user.save() return ConfirmResetPassword(success=True) - except (TypeError, ValueError, OverflowError, User.DoesNotExist) as e: + except (TypeError, ValueError, OverflowError, ValidationError, User.DoesNotExist) as e: raise BadRequest(_(f"something went wrong: {e!s}")) diff --git a/vibes_auth/viewsets.py b/vibes_auth/viewsets.py index ec4a2620..8c4fae3d 100644 --- a/vibes_auth/viewsets.py +++ b/vibes_auth/viewsets.py @@ -3,7 +3,9 @@ import traceback from contextlib import suppress from secrets import compare_digest +from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.exceptions import ValidationError from django.utils.decorators import method_decorator from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ @@ -20,7 +22,6 @@ from evibes.settings import DEBUG from vibes_auth.docs.drf.viewsets import USER_SCHEMA from vibes_auth.models import User from vibes_auth.serializers import ( - ConfirmPasswordResetSerializer, UserSerializer, ) from vibes_auth.utils.emailing import send_reset_password_email_task @@ -64,29 +65,34 @@ class UserViewSet( @action(detail=False, methods=["post"]) @method_decorator(ratelimit(key="ip", rate="2/h" if not DEBUG else "888/h")) - def confirm_password_reset(self): + def confirm_password_reset(self, request, *args, **kwargs): try: - data = ConfirmPasswordResetSerializer(self.request.data).data - if not compare_digest(data.get("password"), data.get("confirm_password")): + if not compare_digest(request.data.get("password"), request.data.get("confirm_password")): return Response( {"error": _("passwords do not match")}, status=status.HTTP_400_BAD_REQUEST, ) - uuid = urlsafe_base64_decode(data.get("uidb64")).decode() + uuid = urlsafe_base64_decode(request.data.get("uidb64")).decode() user = User.objects.get(pk=uuid) + validate_password(password=request.data.get("password"), user=user) + password_reset_token = PasswordResetTokenGenerator() - if not password_reset_token.check_token(user, data.get("token")): + if not password_reset_token.check_token(user, request.data.get("token")): return Response({"error": _("token is invalid!")}, status=status.HTTP_400_BAD_REQUEST) - user.set_password(data.get("password")) + user.set_password(request.data.get("password")) user.save() return Response({"message": _("password reset successfully")}, status=status.HTTP_200_OK) - except (TypeError, ValueError, OverflowError, User.DoesNotExist) as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except (TypeError, ValueError, OverflowError, ValidationError, User.DoesNotExist) as e: + data = {"error": str(e)} + if DEBUG: + data["detail"] = str(traceback.format_exc()) + data["received"] = str(request.data) + return Response(data, status=status.HTTP_400_BAD_REQUEST) @method_decorator(ratelimit(key="ip", rate="3/h" if not DEBUG else "888/h")) def create(self, request, *args, **kwargs): @@ -142,6 +148,9 @@ class UserViewSet( return Response(serializer.data) def update(self, request, pk=None, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + instance = serializer.update(instance=self.get_object(), validated_data=request.data) return Response( - self.get_serializer(self.get_object()).update(instance=self.get_object(), validated_data=request.data).data + self.get_serializer(instance.data) )