fix until contracts step
This commit is contained in:
		
							parent
							
								
									246a2c0759
								
							
						
					
					
						commit
						af40e169ae
					
				
					 9 changed files with 180 additions and 128 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
					@ -91,6 +91,7 @@ class Quote(NameSlugModel):
 | 
				
			||||||
        verbose_name="ایجاد کننده",
 | 
					        verbose_name="ایجاد کننده",
 | 
				
			||||||
        related_name='created_quotes'
 | 
					        related_name='created_quotes'
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    history = HistoricalRecords()
 | 
					    history = HistoricalRecords()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,15 +18,16 @@
 | 
				
			||||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
<!-- Persian Date Picker CSS -->
 | 
					<!-- Persian Date Picker CSS -->
 | 
				
			||||||
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
 | 
					<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
 | 
				
			||||||
<style>
 | 
					
 | 
				
			||||||
@media print {
 | 
					 | 
				
			||||||
  .no-print { display: none !important; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
</style>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
{% include '_toasts.html' %}
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Instance Info Modal -->
 | 
				
			||||||
 | 
					{% instance_info_modal instance %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% csrf_token %}
 | 
					{% csrf_token %}
 | 
				
			||||||
<div class="container-xxl flex-grow-1 container-p-y">
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
  <div class="row">
 | 
					  <div class="row">
 | 
				
			||||||
| 
						 | 
					@ -35,19 +36,21 @@
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
          <small class="text-muted d-block">
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
					            {% instance_info instance %}
 | 
				
			||||||
            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
					 | 
				
			||||||
          </small>
 | 
					          </small>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="d-flex gap-2">
 | 
					        <div class="d-flex gap-2">
 | 
				
			||||||
          <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
 | 
					          <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
 | 
				
			||||||
            <i class="bx bx-printer"></i> پرینت
 | 
					            <i class="bx bx-printer me-2"></i> پرینت
 | 
				
			||||||
 | 
					          </a>
 | 
				
			||||||
 | 
					          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
 | 
				
			||||||
 | 
					            <i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
 | 
				
			||||||
 | 
					            بازگشت
 | 
				
			||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="bs-stepper wizard-vertical vertical mt-2 no-print">
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2">
 | 
				
			||||||
        {% stepper_header instance step %}
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
        <div class="bs-stepper-content">
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
| 
						 | 
					@ -60,7 +63,7 @@
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
              
 | 
					              
 | 
				
			||||||
              <div class="row g-3">
 | 
					              <div class="row g-3">
 | 
				
			||||||
                {% if can_manage_payments %}
 | 
					                {% if is_broker %}
 | 
				
			||||||
                <div class="col-12 col-lg-5">
 | 
					                <div class="col-12 col-lg-5">
 | 
				
			||||||
                  <div class="card h-100 border">
 | 
					                  <div class="card h-100 border">
 | 
				
			||||||
                    <div class="card-header">
 | 
					                    <div class="card-header">
 | 
				
			||||||
| 
						 | 
					@ -104,7 +107,7 @@
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                {% endif %}
 | 
					                {% endif %}
 | 
				
			||||||
                <div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}">
 | 
					                <div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
 | 
				
			||||||
                  <div class="card mb-3 border">
 | 
					                  <div class="card mb-3 border">
 | 
				
			||||||
                    <div class="card-header d-flex justify-content-between">
 | 
					                    <div class="card-header d-flex justify-content-between">
 | 
				
			||||||
                        <h5 class="card-title mb-0">وضعیت پیشفاکتور</h5>
 | 
					                        <h5 class="card-title mb-0">وضعیت پیشفاکتور</h5>
 | 
				
			||||||
| 
						 | 
					@ -171,7 +174,7 @@
 | 
				
			||||||
                                  <i class="bx bx-show"></i>
 | 
					                                  <i class="bx bx-show"></i>
 | 
				
			||||||
                                </a>
 | 
					                                </a>
 | 
				
			||||||
                                {% endif %}
 | 
					                                {% endif %}
 | 
				
			||||||
                                {% if can_manage_payments %}
 | 
					                                {% if is_broker %}
 | 
				
			||||||
                                <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
 | 
					                                <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
 | 
				
			||||||
                                  <i class="bx bx-trash"></i>
 | 
					                                  <i class="bx bx-trash"></i>
 | 
				
			||||||
                                </button>
 | 
					                                </button>
 | 
				
			||||||
| 
						 | 
					@ -195,7 +198,7 @@
 | 
				
			||||||
                      <h6 class="mb-0">وضعیت تاییدها</h6>
 | 
					                      <h6 class="mb-0">وضعیت تاییدها</h6>
 | 
				
			||||||
                      {% if can_approve_reject %}
 | 
					                      {% if can_approve_reject %}
 | 
				
			||||||
                      <div class="d-flex gap-2">
 | 
					                      <div class="d-flex gap-2">
 | 
				
			||||||
                        <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
 | 
					                        <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
 | 
				
			||||||
                        <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
 | 
					                        <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
 | 
				
			||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
                      {% endif %}
 | 
					                      {% endif %}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,7 +41,7 @@
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="d-flex gap-2">
 | 
					        <div class="d-flex gap-2">
 | 
				
			||||||
          <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
 | 
					          <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
 | 
				
			||||||
            <i class="bx bx-printer"></i> پرینت
 | 
					            <i class="bx bx-printer me-2"></i> پرینت
 | 
				
			||||||
          </a>
 | 
					          </a>
 | 
				
			||||||
          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
 | 
					          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
 | 
				
			||||||
            <i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
 | 
					            <i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,9 +15,7 @@ urlpatterns = [
 | 
				
			||||||
    # Quote payments step (step 3)
 | 
					    # Quote payments step (step 3)
 | 
				
			||||||
    path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
 | 
				
			||||||
    path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
 | 
				
			||||||
    path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
 | 
					 | 
				
			||||||
    path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
 | 
				
			||||||
    path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Quote print
 | 
					    # Quote print
 | 
				
			||||||
    path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
 | 
					    path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -107,7 +107,7 @@ def create_quote(request, instance_id, step_id):
 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
 | 
					        return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Create or reuse quote
 | 
					    # Create or reuse quote
 | 
				
			||||||
    quote, _ = Quote.objects.get_or_create(
 | 
					    quote, created_q = Quote.objects.get_or_create(
 | 
				
			||||||
        process_instance=instance,
 | 
					        process_instance=instance,
 | 
				
			||||||
        defaults={
 | 
					        defaults={
 | 
				
			||||||
            'name': f"پیشفاکتور {instance.code}",
 | 
					            'name': f"پیشفاکتور {instance.code}",
 | 
				
			||||||
| 
						 | 
					@ -117,6 +117,15 @@ def create_quote(request, instance_id, step_id):
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Track whether this step was already completed before this edit
 | 
				
			||||||
 | 
					    step_instance_existing = instance.step_instances.filter(step=step).first()
 | 
				
			||||||
 | 
					    was_already_completed = bool(step_instance_existing and step_instance_existing.status == 'completed')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Snapshot previous items before overwrite for change detection
 | 
				
			||||||
 | 
					    previous_items_map = {}
 | 
				
			||||||
 | 
					    if not created_q:
 | 
				
			||||||
 | 
					        previous_items_map = {qi.item_id: int(qi.quantity) for qi in quote.items.filter(is_deleted=False).all()}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Replace quote items with submitted ones
 | 
					    # Replace quote items with submitted ones
 | 
				
			||||||
    quote.items.all().delete()
 | 
					    quote.items.all().delete()
 | 
				
			||||||
    for entry in items_payload:
 | 
					    for entry in items_payload:
 | 
				
			||||||
| 
						 | 
					@ -140,21 +149,61 @@ def create_quote(request, instance_id, step_id):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    quote.calculate_totals()
 | 
					    quote.calculate_totals()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Detect changes versus previous state and mark audit fields if editing after completion
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        new_items_map = {int(entry.get('id')): int(entry.get('qty') or 1) for entry in items_payload}
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        new_items_map = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if was_already_completed and new_items_map != previous_items_map:
 | 
				
			||||||
 | 
					        # StepInstance-level generic audit (for reuse across steps)
 | 
				
			||||||
 | 
					        if step_instance_existing:
 | 
				
			||||||
 | 
					            step_instance_existing.edited_after_completion = True
 | 
				
			||||||
 | 
					            step_instance_existing.last_edited_at = timezone.now()
 | 
				
			||||||
 | 
					            step_instance_existing.last_edited_by = request.user
 | 
				
			||||||
 | 
					            step_instance_existing.edit_count = (step_instance_existing.edit_count or 0) + 1
 | 
				
			||||||
 | 
					            step_instance_existing.completed_at = timezone.now()
 | 
				
			||||||
 | 
					            step_instance_existing.save(update_fields=['edited_after_completion', 'last_edited_at', 'last_edited_by', 'edit_count', 'completed_at'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if quote.status != 'draft':
 | 
				
			||||||
 | 
					            quote.status = 'draft'
 | 
				
			||||||
 | 
					            quote.save(update_fields=['status'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if next_step:
 | 
				
			||||||
 | 
					            next_step_instance = instance.step_instances.filter(step=next_step).first()
 | 
				
			||||||
 | 
					            if next_step_instance and next_step_instance.status == 'completed':
 | 
				
			||||||
 | 
					                next_step_instance.status = 'in_progress'
 | 
				
			||||||
 | 
					                next_step_instance.completed_at = None
 | 
				
			||||||
 | 
					                next_step_instance.save(update_fields=['status', 'completed_at'])
 | 
				
			||||||
 | 
					                # Clear previous approvals if the step requires re-approval
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    next_step_instance.approvals.all().delete()
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            instance.current_step = next_step
 | 
				
			||||||
 | 
					            instance.save(update_fields=['current_step'])
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    # تکمیل مرحله
 | 
					    # تکمیل مرحله
 | 
				
			||||||
    step_instance, created = StepInstance.objects.get_or_create(
 | 
					    step_instance, created = StepInstance.objects.get_or_create(
 | 
				
			||||||
        process_instance=instance,
 | 
					        process_instance=instance,
 | 
				
			||||||
        step=step
 | 
					        step=step
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    if not was_already_completed:
 | 
				
			||||||
        step_instance.status = 'completed'
 | 
					        step_instance.status = 'completed'
 | 
				
			||||||
        step_instance.completed_at = timezone.now()
 | 
					        step_instance.completed_at = timezone.now()
 | 
				
			||||||
    step_instance.save()
 | 
					        step_instance.save(update_fields=['status', 'completed_at'])
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # انتقال به مرحله بعدی
 | 
					    # انتقال به مرحله بعدی
 | 
				
			||||||
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
					 | 
				
			||||||
    redirect_url = None
 | 
					    redirect_url = None
 | 
				
			||||||
    if next_step:
 | 
					    if next_step:
 | 
				
			||||||
 | 
					        # Only advance current step if we are currently on this step to avoid regressions
 | 
				
			||||||
 | 
					        if instance.current_step_id == step.id:
 | 
				
			||||||
            instance.current_step = next_step
 | 
					            instance.current_step = next_step
 | 
				
			||||||
        instance.save()
 | 
					            instance.save(update_fields=['current_step'])
 | 
				
			||||||
        # هدایت مستقیم به مرحله پیشنمایش پیشفاکتور
 | 
					        # هدایت مستقیم به مرحله پیشنمایش پیشفاکتور
 | 
				
			||||||
        redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
 | 
					        redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -202,6 +251,7 @@ def quote_preview_step(request, instance_id, step_id):
 | 
				
			||||||
        'is_broker': is_broker,
 | 
					        'is_broker': is_broker,
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@login_required
 | 
					@login_required
 | 
				
			||||||
def quote_print(request, instance_id):
 | 
					def quote_print(request, instance_id):
 | 
				
			||||||
    """صفحه پرینت پیشفاکتور"""
 | 
					    """صفحه پرینت پیشفاکتور"""
 | 
				
			||||||
| 
						 | 
					@ -213,6 +263,7 @@ def quote_print(request, instance_id):
 | 
				
			||||||
        'quote': quote,
 | 
					        'quote': quote,
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@require_POST
 | 
					@require_POST
 | 
				
			||||||
@login_required  
 | 
					@login_required  
 | 
				
			||||||
def approve_quote(request, instance_id, step_id):
 | 
					def approve_quote(request, instance_id, step_id):
 | 
				
			||||||
| 
						 | 
					@ -285,6 +336,7 @@ def quote_payment_step(request, instance_id, step_id):
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
 | 
					    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    reqs = list(step.approver_requirements.select_related('role').all())
 | 
					    reqs = list(step.approver_requirements.select_related('role').all())
 | 
				
			||||||
    user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
 | 
					    user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
 | 
				
			||||||
    user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
 | 
					    user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
 | 
				
			||||||
| 
						 | 
					@ -298,6 +350,7 @@ def quote_payment_step(request, instance_id, step_id):
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        for r in reqs
 | 
					        for r in reqs
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # dynamic permission: who can approve/reject this step (based on requirements)
 | 
					    # dynamic permission: who can approve/reject this step (based on requirements)
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        req_role_ids = {r.role_id for r in reqs}
 | 
					        req_role_ids = {r.role_id for r in reqs}
 | 
				
			||||||
| 
						 | 
					@ -305,20 +358,7 @@ def quote_payment_step(request, instance_id, step_id):
 | 
				
			||||||
        can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
 | 
					        can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        can_approve_reject = False
 | 
					        can_approve_reject = False
 | 
				
			||||||
    # approver status map for template
 | 
					        
 | 
				
			||||||
    reqs = list(step.approver_requirements.select_related('role').all())
 | 
					 | 
				
			||||||
    user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
 | 
					 | 
				
			||||||
    user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
 | 
					 | 
				
			||||||
    approvals_list = list(step_instance.approvals.select_related('role').all())
 | 
					 | 
				
			||||||
    approvals_by_role = {a.role_id: a for a in approvals_list}
 | 
					 | 
				
			||||||
    approver_statuses = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            'role': r.role,
 | 
					 | 
				
			||||||
            'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
 | 
					 | 
				
			||||||
            'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        for r in reqs
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Accountant/Admin approval and rejection via POST (multi-role)
 | 
					    # Accountant/Admin approval and rejection via POST (multi-role)
 | 
				
			||||||
    if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
 | 
					    if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
 | 
				
			||||||
| 
						 | 
					@ -362,6 +402,13 @@ def quote_payment_step(request, instance_id, step_id):
 | 
				
			||||||
                defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
 | 
					                defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
 | 
					            StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
 | 
				
			||||||
 | 
					                # If current step is ahead of this step, reset it back to this step
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                if instance.current_step and instance.current_step.order > step.order:
 | 
				
			||||||
 | 
					                    instance.current_step = step
 | 
				
			||||||
 | 
					                    instance.save(update_fields=['current_step'])
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                pass
 | 
				
			||||||
            messages.success(request, 'مرحله پرداختها رد شد و برای اصلاح بازگشت.')
 | 
					            messages.success(request, 'مرحله پرداختها رد شد و برای اصلاح بازگشت.')
 | 
				
			||||||
            return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
					            return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -388,8 +435,6 @@ def quote_payment_step(request, instance_id, step_id):
 | 
				
			||||||
        'approver_statuses': approver_statuses,
 | 
					        'approver_statuses': approver_statuses,
 | 
				
			||||||
        'is_broker': is_broker,
 | 
					        'is_broker': is_broker,
 | 
				
			||||||
        'is_accountant': is_accountant,
 | 
					        'is_accountant': is_accountant,
 | 
				
			||||||
        # dynamic permissions: any role required to approve can also manage payments
 | 
					 | 
				
			||||||
        'can_manage_payments': can_approve_reject,
 | 
					 | 
				
			||||||
        'can_approve_reject': can_approve_reject,
 | 
					        'can_approve_reject': can_approve_reject,
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -412,14 +457,16 @@ def add_quote_payment(request, instance_id, step_id):
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # dynamic permission: users whose roles are among required approvers can add payments
 | 
					    # who can add payments
 | 
				
			||||||
 | 
					    profile = getattr(request.user, 'profile', None)
 | 
				
			||||||
 | 
					    is_broker = False
 | 
				
			||||||
 | 
					    is_accountant = False
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
 | 
					        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
				
			||||||
        user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
 | 
					        is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
 | 
				
			||||||
        user_role_ids = set(user_roles_qs.values_list('id', flat=True))
 | 
					 | 
				
			||||||
        if len(req_role_ids.intersection(user_role_ids)) == 0:
 | 
					 | 
				
			||||||
            return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
					 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        is_broker = False
 | 
				
			||||||
 | 
					        is_accountant = False
 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
					        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    logger = logging.getLogger(__name__)
 | 
					    logger = logging.getLogger(__name__)
 | 
				
			||||||
| 
						 | 
					@ -477,48 +524,11 @@ def add_quote_payment(request, instance_id, step_id):
 | 
				
			||||||
        si.approvals.all().delete()
 | 
					        si.approvals.all().delete()
 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        pass
 | 
					        pass
 | 
				
			||||||
    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
					    # If current step is ahead of this step, reset it back to this step
 | 
				
			||||||
    return JsonResponse({'success': True, 'redirect': redirect_url})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@require_POST
 | 
					 | 
				
			||||||
@login_required
 | 
					 | 
				
			||||||
def update_quote_payment(request, instance_id, step_id, payment_id):
 | 
					 | 
				
			||||||
    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
					 | 
				
			||||||
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
					 | 
				
			||||||
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
					 | 
				
			||||||
    invoice = Invoice.objects.filter(quote=quote).first()
 | 
					 | 
				
			||||||
    if not invoice:
 | 
					 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
 | 
					 | 
				
			||||||
    payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        amount = request.POST.get('amount')
 | 
					        if instance.current_step and instance.current_step.order > step.order:
 | 
				
			||||||
        payment_date = request.POST.get('payment_date') or payment.payment_date
 | 
					            instance.current_step = step
 | 
				
			||||||
        payment_method = request.POST.get('payment_method') or payment.payment_method
 | 
					            instance.save(update_fields=['current_step'])
 | 
				
			||||||
        reference_number = request.POST.get('reference_number') or ''
 | 
					 | 
				
			||||||
        notes = request.POST.get('notes') or ''
 | 
					 | 
				
			||||||
        receipt_image = request.FILES.get('receipt_image')
 | 
					 | 
				
			||||||
        if amount:
 | 
					 | 
				
			||||||
            payment.amount = amount
 | 
					 | 
				
			||||||
        payment.payment_date = payment_date
 | 
					 | 
				
			||||||
        payment.payment_method = payment_method
 | 
					 | 
				
			||||||
        payment.reference_number = reference_number
 | 
					 | 
				
			||||||
        payment.notes = notes
 | 
					 | 
				
			||||||
        # اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
 | 
					 | 
				
			||||||
        if receipt_image:
 | 
					 | 
				
			||||||
            payment.receipt_image = receipt_image
 | 
					 | 
				
			||||||
        payment.save()
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # On update, return to awaiting approval
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
					 | 
				
			||||||
        si.status = 'in_progress'
 | 
					 | 
				
			||||||
        si.completed_at = None
 | 
					 | 
				
			||||||
        si.save()
 | 
					 | 
				
			||||||
        si.approvals.all().delete()
 | 
					 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        pass
 | 
					        pass
 | 
				
			||||||
    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
					    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
				
			||||||
| 
						 | 
					@ -535,15 +545,18 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
 | 
				
			||||||
    if not invoice:
 | 
					    if not invoice:
 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
 | 
					        return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
 | 
				
			||||||
    payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
 | 
					    payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
 | 
				
			||||||
    # dynamic permission: users whose roles are among required approvers can delete payments
 | 
					    
 | 
				
			||||||
 | 
					    # who can delete payments
 | 
				
			||||||
 | 
					    profile = getattr(request.user, 'profile', None)
 | 
				
			||||||
 | 
					    is_broker = False
 | 
				
			||||||
 | 
					    is_accountant = False
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
 | 
					        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
				
			||||||
        user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
 | 
					        is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
 | 
				
			||||||
        user_role_ids = set(user_roles_qs.values_list('id', flat=True))
 | 
					 | 
				
			||||||
        if len(req_role_ids.intersection(user_role_ids)) == 0:
 | 
					 | 
				
			||||||
            return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
 | 
					 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
 | 
					        is_broker = False
 | 
				
			||||||
 | 
					        is_accountant = False
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        # soft delete using project's BaseModel delete override
 | 
					        # soft delete using project's BaseModel delete override
 | 
				
			||||||
| 
						 | 
					@ -559,43 +572,17 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
 | 
				
			||||||
        si.approvals.all().delete()
 | 
					        si.approvals.all().delete()
 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        pass
 | 
					        pass
 | 
				
			||||||
 | 
					    # If current step is ahead of this step, reset it back to this step
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        if instance.current_step and instance.current_step.order > step.order:
 | 
				
			||||||
 | 
					            instance.current_step = step
 | 
				
			||||||
 | 
					            instance.save(update_fields=['current_step'])
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
					    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
				
			||||||
    return JsonResponse({'success': True, 'redirect': redirect_url})
 | 
					    return JsonResponse({'success': True, 'redirect': redirect_url})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@require_POST
 | 
					 | 
				
			||||||
@login_required
 | 
					 | 
				
			||||||
def approve_payments(request, instance_id, step_id):
 | 
					 | 
				
			||||||
    """تایید مرحله پرداخت فیشها و انتقال به مرحله بعد"""
 | 
					 | 
				
			||||||
    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
					 | 
				
			||||||
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
					 | 
				
			||||||
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    is_fully_paid = quote.get_remaining_amount() <= 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # تکمیل مرحله
 | 
					 | 
				
			||||||
    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
					 | 
				
			||||||
    step_instance.status = 'completed'
 | 
					 | 
				
			||||||
    step_instance.completed_at = timezone.now()
 | 
					 | 
				
			||||||
    step_instance.save()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # حرکت به مرحله بعد
 | 
					 | 
				
			||||||
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
					 | 
				
			||||||
    redirect_url = reverse('processes:request_list')
 | 
					 | 
				
			||||||
    if next_step:
 | 
					 | 
				
			||||||
        instance.current_step = next_step
 | 
					 | 
				
			||||||
        instance.save()
 | 
					 | 
				
			||||||
        redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    msg = 'پرداختها تایید شد'
 | 
					 | 
				
			||||||
    if is_fully_paid:
 | 
					 | 
				
			||||||
        msg += ' - مبلغ پیشفاکتور به طور کامل پرداخت شده است.'
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@login_required
 | 
					@login_required
 | 
				
			||||||
def final_invoice_step(request, instance_id, step_id):
 | 
					def final_invoice_step(request, instance_id, step_id):
 | 
				
			||||||
    """تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
 | 
					    """تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -50,6 +50,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
 | 
				
			||||||
    verbose_name_plural = "درخواستها"
 | 
					    verbose_name_plural = "درخواستها"
 | 
				
			||||||
    list_display = [
 | 
					    list_display = [
 | 
				
			||||||
        'code',
 | 
					        'code',
 | 
				
			||||||
 | 
					        'current_step',
 | 
				
			||||||
        'slug', 
 | 
					        'slug', 
 | 
				
			||||||
        'well_display', 
 | 
					        'well_display', 
 | 
				
			||||||
        'representative', 
 | 
					        'representative', 
 | 
				
			||||||
| 
						 | 
					@ -142,7 +143,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@admin.register(StepInstance)
 | 
					@admin.register(StepInstance)
 | 
				
			||||||
class StepInstanceAdmin(SimpleHistoryAdmin):
 | 
					class StepInstanceAdmin(SimpleHistoryAdmin):
 | 
				
			||||||
    list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'started_at', 'completed_at']
 | 
					    list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at']
 | 
				
			||||||
    list_filter = ['status', 'step__process', 'started_at']
 | 
					    list_filter = ['status', 'step__process', 'started_at']
 | 
				
			||||||
    search_fields = ['process_instance__name', 'step__name', 'assigned_to__username']
 | 
					    search_fields = ['process_instance__name', 'step__name', 'assigned_to__username']
 | 
				
			||||||
    readonly_fields = ['started_at', 'completed_at']
 | 
					    readonly_fields = ['started_at', 'completed_at']
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,56 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-09-08 08:18
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('processes', '0002_processinstance_broker'),
 | 
				
			||||||
 | 
					        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalstepinstance',
 | 
				
			||||||
 | 
					            name='edit_count',
 | 
				
			||||||
 | 
					            field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalstepinstance',
 | 
				
			||||||
 | 
					            name='edited_after_completion',
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalstepinstance',
 | 
				
			||||||
 | 
					            name='last_edited_at',
 | 
				
			||||||
 | 
					            field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalstepinstance',
 | 
				
			||||||
 | 
					            name='last_edited_by',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='stepinstance',
 | 
				
			||||||
 | 
					            name='edit_count',
 | 
				
			||||||
 | 
					            field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='stepinstance',
 | 
				
			||||||
 | 
					            name='edited_after_completion',
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='stepinstance',
 | 
				
			||||||
 | 
					            name='last_edited_at',
 | 
				
			||||||
 | 
					            field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='stepinstance',
 | 
				
			||||||
 | 
					            name='last_edited_by',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='step_instances_edited', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -327,6 +327,12 @@ class StepInstance(models.Model):
 | 
				
			||||||
    notes = models.TextField(verbose_name="یادداشتها", blank=True)
 | 
					    notes = models.TextField(verbose_name="یادداشتها", blank=True)
 | 
				
			||||||
    started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
 | 
					    started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
 | 
				
			||||||
    completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
 | 
					    completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
 | 
				
			||||||
 | 
					    # Generic edit-tracking for post-completion modifications
 | 
				
			||||||
 | 
					    edited_after_completion = models.BooleanField(default=False, verbose_name="ویرایش پس از تکمیل")
 | 
				
			||||||
 | 
					    last_edited_at = models.DateTimeField(null=True, blank=True, verbose_name="آخرین زمان ویرایش پس از تکمیل")
 | 
				
			||||||
 | 
					    last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='step_instances_edited', verbose_name="ویرایش توسط")
 | 
				
			||||||
 | 
					    edit_count = models.PositiveIntegerField(default=0, verbose_name="تعداد ویرایش پس از تکمیل")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    history = HistoricalRecords()
 | 
					    history = HistoricalRecords()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue