from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.contrib import messages from django.urls import reverse from django.utils import timezone from django.core.exceptions import ValidationError from accounts.models import Profile from common.consts import UserRoles from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval from accounts.models import Role from invoices.models import Item, Quote, QuoteItem from wells.models import WaterMeterManufacturer from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange from .forms import InstallationReportForm from decimal import Decimal, InvalidOperation from processes.utils import get_scoped_instance_or_404 @login_required def installation_assign_step(request, instance_id, step_id): instance = get_scoped_instance_or_404(request, instance_id) step = get_object_or_404(instance.process.steps, id=step_id) previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() # Installers list (profiles that have installer role) installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value, county=instance.well.county).select_related('user').all() assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance) # Role flags profile = getattr(request.user, 'profile', None) is_manager = False try: is_manager = bool(profile and profile.has_role(UserRoles.MANAGER)) except Exception: is_manager = False if request.method == 'POST': if not is_manager: messages.error(request, 'شما اجازه تعیین نصاب را ندارید') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) installer_id = request.POST.get('installer_id') scheduled_date = (request.POST.get('scheduled_date') or '').strip() assignment.installer_id = installer_id or None if scheduled_date: assignment.scheduled_date = scheduled_date.replace('/', '-') assignment.assigned_by = request.user assignment.save() # complete step StepInstance.objects.update_or_create( process_instance=instance, step=step, defaults={'status': 'completed', 'completed_at': timezone.now()} ) if next_step: instance.current_step = next_step instance.save() return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) return redirect('processes:request_list') # Read-only logic for non-managers read_only = not is_manager show_denied_msg = (not is_manager) and (assignment.installer_id is None) return render(request, 'installations/installation_assign_step.html', { 'instance': instance, 'step': step, 'assignment': assignment, 'installers': installers, 'previous_step': previous_step, 'next_step': next_step, 'is_manager': is_manager, 'read_only': read_only, 'show_denied_msg': show_denied_msg, }) def create_item_changes_for_report(report, remove_map, add_map, quote_price_map): """Helper function to create item changes for a report""" # Create remove changes for item_id, qty in remove_map.items(): up = quote_price_map.get(item_id) total = (up * qty) if up is not None else None InstallationItemChange.objects.create( report=report, item_id=item_id, change_type='remove', quantity=qty, unit_price=up, total_price=total, ) # Create add changes for item_id, data in add_map.items(): unit_price = data.get('price') qty = data.get('qty') or 1 total = (unit_price * qty) if (unit_price is not None) else None InstallationItemChange.objects.create( report=report, item_id=item_id, change_type='add', quantity=qty, unit_price=unit_price, total_price=total, ) @login_required def installation_report_step(request, instance_id, step_id): instance = get_scoped_instance_or_404(request, instance_id) step = get_object_or_404(instance.process.steps, id=step_id) previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() assignment = InstallationAssignment.objects.filter(process_instance=instance).first() existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first() # Only the assigned installer can create/edit the report try: has_installer_role = bool(getattr(request.user, 'profile', None) and request.user.profile.has_role(UserRoles.INSTALLER)) except Exception: has_installer_role = False is_assigned_installer = bool(assignment and assignment.installer_id == request.user.id) user_is_installer = bool(has_installer_role and is_assigned_installer) edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False # Prevent edit mode if an approved report exists if existing_report and existing_report.approved: edit_mode = False # Ensure a StepInstance exists for this step step_instance, _ = StepInstance.objects.get_or_create( process_instance=instance, step=step, defaults={'status': 'in_progress'} ) # current quote items baseline quote = Quote.objects.filter(process_instance=instance).first() quote_items = list(quote.items.select_related('item').all()) if quote else [] quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items} items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') manufacturers = WaterMeterManufacturer.objects.all().order_by('name') # Initialize the form form = None if request.method == 'POST' and request.POST.get('action') not in ['approve', 'reject']: # Handle form submission for report creation/editing if not user_is_installer: messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) # Block editing approved reports if existing_report and existing_report.approved: messages.error(request, 'این گزارش قبلا تایید شده و قابل ویرایش نیست') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) form = InstallationReportForm( request.POST, instance=existing_report if edit_mode else None, user_is_installer=user_is_installer, instance_well=instance.well ) if form.is_valid(): # Validate photos photo_validation_passed = False try: deleted_photo_ids = [] for key, val in request.POST.items(): if key.startswith('del_photo_') and val == '1': try: pid = key.split('_')[-1] deleted_photo_ids.append(pid) except Exception: continue existing_photos = existing_report.photos.all() if existing_report else None form.validate_photos(request.FILES, existing_photos, deleted_photo_ids) photo_validation_passed = True except ValidationError as e: form.add_error(None, str(e)) # Re-render form with photo validation error photo_validation_passed = False # Always clear approvals/rejections when form is submitted (even if photo validation fails) # Reset step status and clear approvals/rejections step_instance.status = 'in_progress' step_instance.completed_at = None step_instance.save() try: for appr in list(step_instance.approvals.all()): appr.delete() except Exception: pass try: for rej in list(step_instance.rejections.all()): rej.delete() except Exception: pass # Reopen subsequent steps try: subsequent_steps = instance.process.steps.filter(order__gt=step.order) for subsequent_step in subsequent_steps: subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first() if subsequent_step_instance and subsequent_step_instance.status == 'completed': instance.step_instances.filter(step=subsequent_step).update( status='in_progress', completed_at=None ) try: for appr in list(subsequent_step_instance.approvals.all()): appr.delete() except Exception: pass except Exception: pass # Reset current step if needed 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 # Only save the report if photo validation passed if photo_validation_passed: # Save the form report = form.save(commit=False) if not existing_report: report.assignment = assignment report.created_by = request.user report.approved = False # Reset approval status report.save() # Handle photo uploads and deletions if existing_report and edit_mode: # Delete selected existing photos for key, val in request.POST.items(): if key.startswith('del_photo_') and val == '1': try: pid = int(key.split('_')[-1]) InstallationPhoto.objects.filter(id=pid, report=report).delete() except Exception: continue # Add new photos for f in request.FILES.getlist('photos'): InstallationPhoto.objects.create(report=report, image=f) # Handle item changes (this logic remains the same) remove_map = {} add_map = {} for key in request.POST.keys(): if key.startswith('rem_') and key.endswith('_type'): try: item_id = int(key.split('_')[1]) except Exception: continue if request.POST.get(key) != 'remove': continue qty_val = request.POST.get(f'rem_{item_id}_qty') or '1' try: qty = int(qty_val) except Exception: qty = 1 remove_map[item_id] = qty if key.startswith('add_') and key.endswith('_type'): try: item_id = int(key.split('_')[1]) except Exception: continue if request.POST.get(key) != 'add': continue qty_val = request.POST.get(f'add_{item_id}_qty') or '1' price_val = request.POST.get(f'add_{item_id}_price') try: qty = int(qty_val) except Exception: qty = 1 # resolve unit price unit_price = None if price_val: try: unit_price = Decimal(price_val) except InvalidOperation: unit_price = None if unit_price is None: item_obj = Item.objects.filter(id=item_id).first() unit_price = item_obj.unit_price if item_obj else None add_map[item_id] = {'qty': qty, 'price': unit_price} # Replace item changes with new submission if existing_report and edit_mode: report.item_changes.all().delete() create_item_changes_for_report(report, remove_map, add_map, quote_price_map) messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) else: # GET request or approval/rejection actions - initialize form for display form = InstallationReportForm( instance=existing_report if existing_report else None, user_is_installer=user_is_installer, instance_well=instance.well ) # Build approver requirements/status for UI 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 [] # Align permission check with invoices flow (role id intersection) try: req_role_ids = {r.role_id for r in reqs} user_role_ids = {ur.id for ur in user_roles} can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0 except Exception: can_approve_reject = False user_can_approve = can_approve_reject approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False)) 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 ] # Determine if current user has already approved/rejected (to disable buttons) current_user_has_decided = False try: user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists() user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists() current_user_has_decided = bool(user_has_approval or user_has_rejection) except Exception: current_user_has_decided = False # Manager approval/rejection actions if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: action = request.POST.get('action') # find a matching approver role based on step requirements req_roles = [req.role for req in step.approver_requirements.select_related('role').all()] user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all()) matching_role = next((r for r in user_roles if r in req_roles), None) if matching_role is None: messages.error(request, 'شما دسترسی لازم برای این عملیات را ندارید.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) if not existing_report: messages.error(request, 'گزارش برای تایید/رد وجود ندارد.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) if action == 'approve': # Record this user's approval for their role StepApproval.objects.update_or_create( step_instance=step_instance, role=matching_role, defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} ) # Only mark report approved when ALL required roles have approved if step_instance.is_fully_approved(): existing_report.approved = True existing_report.save() step_instance.status = 'completed' step_instance.completed_at = timezone.now() step_instance.save() if next_step: instance.current_step = next_step instance.save() return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) return redirect('processes:request_list') else: # Not fully approved yet; keep report as not approved if existing_report.approved: existing_report.approved = False existing_report.save(update_fields=['approved']) messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) if action == 'reject': reason = (request.POST.get('reject_reason') or '').strip() if not reason: messages.error(request, 'لطفاً علت رد شدن را وارد کنید.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) StepApproval.objects.update_or_create( step_instance=step_instance, role=matching_role, defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} ) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) existing_report.approved = False existing_report.save() # If current step moved ahead of this step, reset it back for correction (align with invoices) 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, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) # Build prefill maps from existing report changes removed_ids = set() removed_qty = {} added_map = {} if existing_report: for ch in existing_report.item_changes.all(): if ch.change_type == 'remove': removed_ids.add(ch.item_id) removed_qty[ch.item_id] = ch.quantity elif ch.change_type == 'add': added_map[ch.item_id] = {'qty': ch.quantity, 'price': ch.unit_price} return render(request, 'installations/installation_report_step.html', { 'instance': instance, 'step': step, 'assignment': assignment, 'report': existing_report, 'form': form, 'edit_mode': edit_mode, 'user_is_installer': user_is_installer, 'quote': quote, 'quote_items': quote_items, 'all_items': items, 'manufacturers': manufacturers, 'removed_ids': removed_ids, 'removed_qty': removed_qty, 'added_map': added_map, 'previous_step': previous_step, 'next_step': next_step, 'step_instance': step_instance, 'approver_statuses': approver_statuses, 'user_can_approve': user_can_approve, 'can_approve_reject': can_approve_reject, 'current_user_has_decided': current_user_has_decided, })