diff --git a/apps/authentication/migrations/0044_organization_free_visibility_by_scope.py b/apps/authentication/migrations/0044_organization_free_visibility_by_scope.py new file mode 100644 index 0000000..ecde191 --- /dev/null +++ b/apps/authentication/migrations/0044_organization_free_visibility_by_scope.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0 on 2025-11-02 07:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0043_alter_user_options_alter_user_managers'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='free_visibility_by_scope', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/authentication/models.py b/apps/authentication/models.py index e2a6c41..461bed1 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -116,6 +116,7 @@ class Organization(BaseModel): ('CI', 'City') ) field_of_activity = models.CharField(max_length=2, choices=activity_fields, default="") + free_visibility_by_scope = models.BooleanField(default=False) company_code = models.CharField(max_length=30, default="") province = models.ForeignKey( Province, diff --git a/apps/authentication/services/visibility_services.py b/apps/authentication/services/visibility_services.py new file mode 100644 index 0000000..d5ccc0f --- /dev/null +++ b/apps/authentication/services/visibility_services.py @@ -0,0 +1,44 @@ +import typing + +from django.db.models import Q + +from apps.authentication.models import Organization +from apps.core.visibility_registry import VISIBILITY_MAP + + +def get_visible_organizations(org: Organization) -> typing.Any: + """ + get visible organizations + """ + if org.free_visibility_by_scope: + + if org.field_of_activity == 'CO': + return Organization.objects.all() + + elif org.field_of_activity == 'CI': + return Organization.objects.filter(city=org.city) + + elif org.field_of_activity == 'PR': + return Organization.objects.filter(province=org.province) + + return Organization.objects.filter(id=org.id) + + +def apply_visibility_filter(queryset, org): + model_name = queryset.model.__name__.lower() + org_fields = VISIBILITY_MAP.get(model_name) + + if not org_fields: + return queryset + + visible_orgs = get_visible_organizations(org) + org_ids = visible_orgs.values_list('id', flat=True) + + if isinstance(org_fields, str): + return queryset.filter(**{f"{org_fields}__in": org_ids}) + + q_obj = Q() + for field in org_fields: + q_obj |= Q(**{f"{field}__in": org_ids}) + + return queryset.filter(q_obj) diff --git a/apps/core/api.py b/apps/core/api.py index d673e39..889de96 100644 --- a/apps/core/api.py +++ b/apps/core/api.py @@ -2,6 +2,7 @@ from rest_framework import viewsets from apps.authentication.mixins.region_filter import RegionFilterMixin, get_organization_by_user from apps.authentication.services.service import get_all_org_child +from apps.authentication.services.visibility_services import apply_visibility_filter from apps.authorization.services.role_child import get_all_role_child from apps.core.models import MobileTest, SystemConfig from apps.core.serializers import MobileTestSerializer, SystemConfigSerializer @@ -13,7 +14,7 @@ class BaseViewSet(RegionFilterMixin, viewsets.ModelViewSet): It applies region-based filtering automatically to GET (list) requests. """ - def get_queryset(self, show_my_org: bool = None): + def get_queryset(self, show_my_org: bool = None, visibility_by_org_scope: bool = None): queryset = super().get_queryset() request = self.request user = request.user @@ -23,6 +24,11 @@ class BaseViewSet(RegionFilterMixin, viewsets.ModelViewSet): if self.request.method.lower() == 'get' and not self.kwargs.get('pk'): queryset = self.filter_by_region(queryset, org=True) + if visibility_by_org_scope: + """ if organization has free visibility by scope, apply visibility filter """ + queryset = apply_visibility_filter(queryset, org) + return queryset + if user_relation.exists(): user_relation = user_relation.first() if not user_relation.role.type.key == 'ADM': diff --git a/apps/core/visibility_registry.py b/apps/core/visibility_registry.py new file mode 100644 index 0000000..47feeea --- /dev/null +++ b/apps/core/visibility_registry.py @@ -0,0 +1,16 @@ +VISIBILITY_MAP = { + 'userrelations': 'organization', + 'organization': 'id', + 'quota': ['registerer_organization', 'assigned_organizations'], + 'quotadistribution': ['assigner_organization', 'assigned_organization'], + 'inventoryentry': 'organization', + 'inventoryquotasaletransaction': 'organization', + 'device': 'organization', + # 'deviceactivationcode': 'organization', + # 'deviceversion': 'organization', + # 'posclient': 'organization', + # 'deviceassignment': 'organization', + # 'stakeholders': 'organization', + # 'stakeholdershareamount': 'registering_organization', + # 'posfreeproducts': 'organization', +} diff --git a/apps/product/exceptions.py b/apps/product/exceptions.py index 8ffef6c..6a7a919 100644 --- a/apps/product/exceptions.py +++ b/apps/product/exceptions.py @@ -38,7 +38,15 @@ class QuotaExpiredTimeException(APIException): """if quota allowed time for distribute, sale, etc. is expired""" status_code = status.HTTP_400_BAD_REQUEST - default_detail = "زمان مجوز این سهمیه به پایان رسیده است" # noqa + default_detail = "این سهمیه دارای محدودیت توزیع میباشد" # noqa + default_code = 'error' + + +class QuotaSaleTimeException(APIException): + """if quota allowed time for distribute, sale, etc. is expired""" + + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "اين سهمیه دارای محدودیت مجوز فروش میباشد" # noqa default_code = 'error' diff --git a/apps/product/models.py b/apps/product/models.py index 59d2bbb..84ebfbc 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -446,6 +446,13 @@ class Quota(BaseModel): now = datetime.now() persian_date = jdatetime.datetime.fromgregorian(datetime=now) + return persian_date.month in self.distribution_mode + + def is_in_sale_licence_time(self): + """ check if quota allowed time for sale and... is expired """ + now = datetime.now() + persian_date = jdatetime.datetime.fromgregorian(datetime=now) + return persian_date.month in self.sale_license def soft_delete(self): diff --git a/apps/product/web/api/v1/viewsets/quota_api.py b/apps/product/web/api/v1/viewsets/quota_api.py index bccf40c..3767a7c 100644 --- a/apps/product/web/api/v1/viewsets/quota_api.py +++ b/apps/product/web/api/v1/viewsets/quota_api.py @@ -1,22 +1,24 @@ -from apps.product.web.api.v1.serializers import quota_distribution_serializers -from apps.product.services.services import get_products_in_warehouse -from apps.product.web.api.v1.serializers import quota_serializers -from apps.core.mixins.soft_delete_mixin import SoftDeleteMixin -from apps.product.exceptions import QuotaExpiredTimeException -from apps.core.mixins.search_mixin import DynamicSearchMixin -from apps.core.pagination import CustomPageNumberPagination -from apps.product.web.api.v1.viewsets import product_api -from common.helpers import get_organization_by_user -from rest_framework.exceptions import APIException -from apps.product import models as product_models -from rest_framework.response import Response -from rest_framework.decorators import action -from common.tools import CustomOperations -from rest_framework import viewsets, filters -from rest_framework import status +from datetime import datetime + from django.db import transaction from django.db.models import Q -from datetime import datetime +from rest_framework import status +from rest_framework import viewsets, filters +from rest_framework.decorators import action +from rest_framework.exceptions import APIException +from rest_framework.response import Response + +from apps.core.api import BaseViewSet +from apps.core.mixins.search_mixin import DynamicSearchMixin +from apps.core.mixins.soft_delete_mixin import SoftDeleteMixin +from apps.core.pagination import CustomPageNumberPagination +from apps.product import models as product_models +from apps.product.exceptions import QuotaExpiredTimeException +from apps.product.web.api.v1.serializers import quota_distribution_serializers +from apps.product.web.api.v1.serializers import quota_serializers +from apps.product.web.api.v1.viewsets import product_api +from common.helpers import get_organization_by_user +from common.tools import CustomOperations def trash(queryset, pk): # noqa @@ -32,7 +34,7 @@ def delete(queryset, pk): obj.delete() -class QuotaViewSet(SoftDeleteMixin, viewsets.ModelViewSet, DynamicSearchMixin): # noqa +class QuotaViewSet(BaseViewSet, SoftDeleteMixin, viewsets.ModelViewSet, DynamicSearchMixin): # noqa """ apis for product quota """ queryset = product_models.Quota.objects.all() @@ -344,18 +346,14 @@ class QuotaViewSet(SoftDeleteMixin, viewsets.ModelViewSet, DynamicSearchMixin): def active_quotas(self, request): """ list of organization active quotas """ - queryset = self.filter_query(self.queryset) # return by search param or all objects - - organization = get_organization_by_user(request.user) + queryset = self.filter_query( + self.get_queryset(visibility_by_org_scope=True)) # return by search param or all objects # paginate queryset page = self.paginate_queryset( - queryset.filter( - Q(registerer_organization=organization), - Q(is_closed=False) - ).order_by('-modify_date') + queryset.order_by('-modify_date') ) - if page is not None: + if page is not None: # noqa serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) @@ -370,18 +368,14 @@ class QuotaViewSet(SoftDeleteMixin, viewsets.ModelViewSet, DynamicSearchMixin): def closed_quotas(self, request): """ list of organization closed quotas """ - queryset = self.filter_query(self.queryset) # return by search param or all objects - - organization = get_organization_by_user(request.user) + queryset = self.filter_query( + self.get_queryset(visibility_by_org_scope=True)) # return by search param or all objects # paginate queryset page = self.paginate_queryset( - queryset.filter( - Q(registerer_organization=organization), - Q(is_closed=True) - ).order_by('-modify_date') + queryset.order_by('-modify_date') ) - if page is not None: + if page is not None: # noqa serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) diff --git a/apps/warehouse/pos/api/v1/serializers.py b/apps/warehouse/pos/api/v1/serializers.py index 4e09cb2..8d1c9d0 100644 --- a/apps/warehouse/pos/api/v1/serializers.py +++ b/apps/warehouse/pos/api/v1/serializers.py @@ -9,7 +9,7 @@ from apps.herd.services.services import get_rancher_statistics, rancher_quota_we from apps.pos_device.models import POSFreeProducts from apps.pos_device.pos.api.v1.serializers.device import DeviceSerializer from apps.pos_device.services.services import pos_organizations_sharing_information -from apps.product.exceptions import DistributionWeightException +from apps.product.exceptions import DistributionWeightException, QuotaSaleTimeException from apps.product.models import ( QuotaDistribution, Product @@ -251,6 +251,10 @@ class InventoryQuotaSaleTransactionSerializer(serializers.ModelSerializer): for item in items: if 'quota_distribution' in item.keys(): distribution = QuotaDistribution.objects.get(id=item.get('quota_distribution')) + + # if quota has not been in sale time + if not distribution.quota.is_in_sale_licence_time(): + raise QuotaSaleTimeException() total_sale_weight = distribution.sale_items.aggregate( total=models.Sum('weight') )['total'] or 0 diff --git a/logs/django_requests.log b/logs/django_requests.log index add8fa0..b7593b2 100644 --- a/logs/django_requests.log +++ b/logs/django_requests.log @@ -633,3 +633,28 @@ AssertionError: .validate() should return the validated data [2025-11-01 14:19:59,373] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\core\api.py changed, reloading. [2025-11-01 14:20:01,908] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader [2025-11-01 14:34:29,660] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\core\api.py changed, reloading. +[2025-11-01 14:34:31,755] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 08:33:15,283] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 08:33:35,818] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 08:34:44,035] INFO django.server | IP: - | Path: - | "POST /captcha/ HTTP/1.1" 200 690 +[2025-11-02 08:35:17,275] INFO django.server | IP: - | Path: - | "POST /auth/api/v1/login/ HTTP/1.1" 200 681 +[2025-11-02 08:43:15,098] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\product\models.py changed, reloading. +[2025-11-02 08:43:25,159] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 08:57:18,625] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\warehouse\pos\api\v1\serializers.py changed, reloading. +[2025-11-02 08:57:22,331] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 08:57:34,431] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\product\exceptions.py changed, reloading. +[2025-11-02 08:57:38,298] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 09:04:23,459] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 09:55:15,104] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\authentication\models.py changed, reloading. +[2025-11-02 09:55:17,569] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:10:25,957] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\authentication\services\service.py changed, reloading. +[2025-11-02 10:10:27,762] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:10:35,301] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\authentication\services\service.py changed, reloading. +[2025-11-02 10:10:39,073] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:17:07,590] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\authentication\services\service.py changed, reloading. +[2025-11-02 10:17:09,581] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:19:36,116] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\authentication\services\service.py changed, reloading. +[2025-11-02 10:19:38,189] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:48:20,522] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\core\api.py changed, reloading. +[2025-11-02 10:48:27,576] INFO django.utils.autoreload | IP: - | Path: - | Watching for file changes with StatReloader +[2025-11-02 10:56:40,429] INFO django.utils.autoreload | IP: - | Path: - | D:\Project\Rasaddam_Backend\apps\product\web\api\v1\viewsets\quota_api.py changed, reloading.