add scope to filter data

This commit is contained in:
aminhashemi92 2025-09-13 12:08:50 +03:30
parent 394546dc67
commit e9dec3292c
13 changed files with 386 additions and 36 deletions

View file

@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static %}
{% load accounts_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
@ -43,10 +44,12 @@
</span>
</span>
</button>
{% if request.user|is_broker %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus me-1"></i>
درخواست جدید
</button>
{% endif %}
</div>
</div>
</div>
@ -132,6 +135,91 @@
</div>
</div>
{% if access_denied %}
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
<i class="bx bx-info-circle me-2"></i>
<div>شما به این بخش دسترسی ندارید.</div>
</div>
{% endif %}
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-2 align-items-end">
<div class="col-sm-6 col-md-3">
<label class="form-label">وضعیت درخواست</label>
<select class="form-select" name="status">
<option value="">همه</option>
{% for val, label in status_choices %}
<option value="{{ val }}" {% if filter_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}@require_POST
@login_required
def delete_request(request, instance_id):
"""حذف درخواست"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
# Only BROKER can delete requests and only within their scope
try:
profile = getattr(request.user, 'profile', None)
if not (profile and profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
# Enforce ownership by broker (prevent deleting others' requests)
if instance.broker_id and profile.broker and instance.broker_id != profile.broker.id:
return JsonResponse({'success': False, 'message': 'شما مجاز به حذف این درخواست نیستید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
code = instance.code
if instance.status == 'completed':
return JsonResponse({
'success': False,
'message': 'درخواست تکمیل شده نمی‌تواند حذف شود'
})
instance.delete()
return JsonResponse({
'success': True,
'message': f'درخواست {code} با موفقیت حذف شد'
})
</select>
</div>
<div class="col-sm-6 col-md-3">
<label class="form-label">امور</label>
<select class="form-select" name="affairs">
<option value="">همه</option>
{% for a in affairs_list %}
<option value="{{ a.id }}" {% if filter_affairs|default:''|stringformat:'s' == a.id|stringformat:'s' %}selected{% endif %}>{{ a.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-md-3">
<label class="form-label">کارگزار</label>
<select class="form-select" name="broker">
<option value="">همه</option>
{% for b in brokers_list %}
<option value="{{ b.id }}" {% if filter_broker|default:''|stringformat:'s' == b.id|stringformat:'s' %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-md-3">
<label class="form-label">مرحله فعلی</label>
<select class="form-select" name="step">
<option value="">همه</option>
{% for s in steps_list %}
<option value="{{ s.id }}" {% if filter_step|default:''|stringformat:'s' == s.id|stringformat:'s' %}selected{% endif %}>{{ s.process.name }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 d-flex gap-2 justify-content-end mt-3">
<button type="submit" class="btn btn-primary">
<i class="bx bx-filter-alt me-1"></i>
اعمال فیلتر
</button>
<a href="?" class="btn btn-outline-secondary">
<i class="bx bx-x me-1"></i>
حذف فیلتر
</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-datatable table-responsive">
<table id="requests-table" class="datatables-basic table border-top">
@ -178,7 +266,7 @@
</div>
</td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>{{ item.instance.jcreated }}</td>
<td>{{ item.instance.jcreated_date }}</td>
<td>
<div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
@ -196,19 +284,31 @@
</a>
{% endif %}
</li>
{% if request.user|is_broker %}
<div class="dropdown-divider"></div>
<li>
<a href="#" class="dropdown-item text-danger" data-instance-id="{{ item.instance.id }}" data-instance-code="{{ item.instance.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))">
<i class="bx bx-trash me-1"></i>حذف
</a>
</li>
{% endif %}
</ul>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="11" class="text-center text-muted">موردی ثبت نشده است</td>
<td class="text-center text-muted">موردی ثبت نشده است</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endfor %}
</tbody>

View file

@ -1,6 +1,7 @@
from django import template
from django.utils.safestring import mark_safe
from ..models import ProcessInstance, StepInstance
from ..utils import count_incomplete_instances
register = template.Library()
@ -104,3 +105,8 @@ def instance_info(instance, modal_id=None):
title="اطلاعات کامل چاه و نماینده"></i>
'''
return mark_safe(html)
@register.simple_tag
def incomplete_requests_count(user):
return count_incomplete_instances(user)

118
processes/utils.py Normal file
View file

@ -0,0 +1,118 @@
from django.shortcuts import get_object_or_404
from .models import ProcessInstance
from common.consts import UserRoles
def scope_instances_queryset(user, queryset=None):
"""Return a queryset of ProcessInstance scoped by the user's role.
If no profile/role, returns an empty queryset.
"""
qs = queryset if queryset is not None else ProcessInstance.objects.all()
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
try:
if profile.has_role(UserRoles.INSTALLER):
# Only instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
return qs.filter(id__in=assign_ids)
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.ADMIN):
return qs
# if profile.has_role(UserRoles.WATER_RESOURCE_MANAGER) or profile.has_role(UserRoles.HEADQUARTER):
# return qs.filter(well__county=profile.county)
# Fallback: no special scope
# return qs
except Exception:
return qs.none()
def count_incomplete_instances(user):
"""Count non-completed, non-deleted requests within the user's scope."""
base = ProcessInstance.objects.select_related('well').filter(is_deleted=False).exclude(status='completed')
return scope_instances_queryset(user, base).count()
def user_can_access_instance(user, instance: ProcessInstance) -> bool:
"""Check if user can access a specific instance based on scoping rules."""
try:
scoped = scope_instances_queryset(user, ProcessInstance.objects.filter(id=instance.id))
return scoped.exists()
except Exception:
return False
def get_scoped_instance_or_404(request, instance_id: int) -> ProcessInstance:
"""Return instance only if it's within the user's scope; otherwise 404.
Use this in any view receiving instance_id from URL to prevent URL tampering.
"""
base = ProcessInstance.objects.filter(is_deleted=False)
qs = scope_instances_queryset(request.user, base)
return get_object_or_404(qs, id=instance_id)
def scope_wells_queryset(user, queryset=None):
"""Return a queryset of Well scoped by the user's role (parity with instances)."""
try:
from wells.models import Well
qs = queryset if queryset is not None else Well.objects.all()
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
if profile.has_role(UserRoles.ADMIN):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Wells that have instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
inst_qs = ProcessInstance.objects.filter(id__in=assign_ids)
return qs.filter(process_instances__in=inst_qs).distinct()
# Fallback
return qs.none()
except Exception:
return qs.none() if 'qs' in locals() else []
def scope_customers_queryset(user, queryset=None):
"""Return a queryset of customer Profiles scoped by user's role.
Assumes queryset is Profiles already filtered to customers, otherwise we filter here.
"""
try:
from accounts.models import Profile
qs = queryset if queryset is not None else Profile.objects.all()
# Ensure we're only looking at customer profiles
from common.consts import UserRoles as UR
qs = qs.filter(roles__slug=UR.CUSTOMER.value, is_deleted=False)
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
if profile.has_role(UserRoles.ADMIN):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Customers that are representatives of instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
rep_ids = ProcessInstance.objects.filter(id__in=assign_ids).values_list('representative', flat=True)
return qs.filter(user_id__in=rep_ids)
# Fallback
return qs.none()
except Exception:
return qs.none() if 'qs' in locals() else []

View file

@ -7,19 +7,62 @@ from django.http import JsonResponse
from django.views.decorators.http import require_POST, require_GET
from django.db import transaction
from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance
from .models import Process, ProcessInstance, StepInstance, ProcessStep
from .utils import scope_instances_queryset, get_scoped_instance_or_404
from installations.models import InstallationAssignment
from wells.models import Well
from accounts.models import Profile
from accounts.models import Profile, Broker
from locations.models import Affairs
from accounts.forms import CustomerForm
from wells.forms import WellForm
from wells.models import WaterMeterManufacturer
from common.consts import UserRoles
@login_required
def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created')
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester', 'broker', 'current_step', 'process').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created')
access_denied = False
# filter by roles (scoped queryset)
try:
instances = scope_instances_queryset(request.user, instances)
if not instances.exists() and not getattr(request.user, 'profile', None):
access_denied = True
instances = instances.none()
except Exception:
access_denied = True
instances = instances.none()
# Filters
status_q = (request.GET.get('status') or '').strip()
affairs_q = (request.GET.get('affairs') or '').strip()
broker_q = (request.GET.get('broker') or '').strip()
step_q = (request.GET.get('step') or '').strip()
if status_q:
instances = instances.filter(status=status_q)
if affairs_q:
try:
instances = instances.filter(well__affairs_id=int(affairs_q))
except Exception:
pass
if broker_q:
try:
instances = instances.filter(broker_id=int(broker_q))
except Exception:
pass
if step_q:
try:
instances = instances.filter(current_step_id=int(step_q))
except Exception:
pass
processes = Process.objects.filter(is_active=True)
status_choices = list(ProcessInstance.STATUS_CHOICES)
affairs_list = Affairs.objects.all().order_by('name')
brokers_list = Broker.objects.all().order_by('name')
steps_list = ProcessStep.objects.select_related('process').all().order_by('process__name', 'order')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Calculate progress for each instance
@ -52,6 +95,16 @@ def request_list(request):
'completed_count': completed_count,
'in_progress_count': in_progress_count,
'pending_count': pending_count,
# filter context
'status_choices': status_choices,
'affairs_list': affairs_list,
'brokers_list': brokers_list,
'steps_list': steps_list,
'filter_status': status_q,
'filter_affairs': affairs_q,
'filter_broker': broker_q,
'filter_step': step_q,
'access_denied': access_denied,
})
@ -125,6 +178,13 @@ def lookup_representative_by_national_code(request):
def create_request_with_entities(request):
"""ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست"""
User = get_user_model()
# Only BROKER can create requests
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
except Exception:
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
process_id = request.POST.get('process')
process = Process.objects.get(id=process_id)
description = request.POST.get('description', '')
@ -230,6 +290,14 @@ def create_request_with_entities(request):
well.broker = current_profile.broker
well.save()
# Ensure no active (non-deleted, non-completed) request exists for this well
try:
active_exists = ProcessInstance.objects.filter(well=well, is_deleted=False).exclude(status='completed').exists()
if active_exists:
return JsonResponse({'ok': False, 'error': 'برای این چاه یک درخواست جاری وجود دارد. ابتدا آن را تکمیل یا حذف کنید.'}, status=400)
except Exception:
return JsonResponse({'ok': False, 'error': 'خطا در بررسی وضعیت درخواست‌های قبلی این چاه'}, status=400)
# Create request instance
instance = ProcessInstance.objects.create(
process=process,
@ -261,7 +329,17 @@ def create_request_with_entities(request):
@login_required
def delete_request(request, instance_id):
"""حذف درخواست"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
instance = get_scoped_instance_or_404(request, instance_id)
# Only BROKER can delete requests and only within their scope
try:
profile = getattr(request.user, 'profile', None)
if not (profile and profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
# Enforce ownership by broker (prevent deleting others' requests)
if instance.broker_id and profile.broker and instance.broker_id != profile.broker.id:
return JsonResponse({'success': False, 'message': 'شما مجاز به حذف این درخواست نیستید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
code = instance.code
if instance.status == 'completed':
return JsonResponse({
@ -278,10 +356,10 @@ def delete_request(request, instance_id):
@login_required
def step_detail(request, instance_id, step_id):
"""نمایش جزئیات مرحله خاص"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
# Prefetch for performance
instance = ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile').get(id=instance.id)
step = get_object_or_404(instance.process.steps, id=step_id)
# If the request is already completed, redirect to read-only summary page
if instance.status == 'completed':
@ -339,7 +417,8 @@ def step_detail(request, instance_id, step_id):
@login_required
def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
if not instance.current_step:
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
@ -361,6 +440,9 @@ def instance_steps(request, instance_id):
@login_required
def instance_summary(request, instance_id):
"""نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده."""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id)
# Only show for completed requests; otherwise route to steps
if instance.status != 'completed':