loko/streetup/mobile/views.py

1023 lines
40 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from functools import wraps
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.decorators import login_not_required, login_required
from django.http import JsonResponse, HttpResponse
from django.conf import settings
from django.contrib.staticfiles import finders as staticfiles_finders
from django.views.decorators.cache import never_cache
from django.utils.translation import get_language, gettext as _
from pathlib import Path
from common.models import UserConfig, Thematic, UserThematics
from common.utils import check_thematic_access
from assets.models import AssetCategory, NatureAssetModel
from assets.models.nature import BULB_TYPE_CHOICES
def stock_access_required(view_func):
"""
Decorator to ensure the user has one of the allowed stock roles:
admin, stock_manager, manager, controller, top_manager.
"""
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect('%s?next=%s' % (settings.LOGIN_URL, request.path))
try:
user_config = request.user.config
roles = set(user_config.roles.values_list('name', flat=True))
allowed_roles = {'admin', 'stock_manager', 'manager', 'controller', 'top_manager'}
if request.user.is_superuser or roles.intersection(allowed_roles):
return view_func(request, *args, **kwargs)
except (UserConfig.DoesNotExist, AttributeError):
pass
return render(request, "403.html", status=403)
return _wrapped_view
def index(request):
"""Render the mobile landing page."""
user_config = None
try:
user_config = UserConfig.objects.get(user=request.user)
# Vérifie si l'utilisateur n'a qu'un seul rôle et que c'est 'technician'
roles = list(user_config.roles.values_list('name', flat=True))
if len(roles) == 1 and roles[0] == 'technician':
return redirect('/mobile/interventions/')
# Vérifie si l'utilisateur n'a qu'un seul rôle et que c'est 'observer'
if len(roles) == 1 and roles[0] == 'observer':
return redirect('/mobile/observations/')
except UserConfig.DoesNotExist:
# If no user config exists, return unauthorized (403)
return render(request, "403.html", status=403)
# Check if user has permission to edit green surfaces (user config thematics)
can_edit_green_surfaces = False
try:
nature_thematic = Thematic.objects.get(code='nature')
can_edit_green_surfaces = user_config.is_intern and UserThematics.objects.filter(
user_config=user_config,
thematic=nature_thematic,
can_edit_assets=True
).exists()
except Thematic.DoesNotExist:
pass
# Check if user has access to at least one warehouse (view permission)
from stock.permissions import get_user_accessible_warehouses
has_stock_role = request.user.is_superuser or any(r in roles for r in ['admin', 'stock_manager', 'manager', 'controller', 'top_manager'])
can_access_stock = has_stock_role and get_user_accessible_warehouses(request.user).exists()
# Check if user can add repair interventions for structures thematic (technicians)
can_add_structures_repair = False
default_thematic = user_config.default_thematic
if default_thematic and default_thematic.code == 'structures':
is_technician = 'technician' in roles
if is_technician:
can_add_structures_repair = UserThematics.objects.filter(
user_config=user_config,
thematic=default_thematic,
can_edit_interventions=True
).exists()
# Check if user (internal admin/manager/controller) can add interventions across all thematics
can_add_intervention_mobile = user_config.is_intern and any(r in roles for r in ['admin', 'manager', 'controller'])
# Check if user is an inspector and get inspection configurations
is_inspector = 'inspector' in roles
inspection_configs = []
if is_inspector:
from interventions.models import InspectionConfiguration
from interventions.permissions import can_user_create_inspection_for_contract
configs = list(
InspectionConfiguration.objects.select_related('thematic')
.prefetch_related('contracts_providers__contract', 'operation_templates')
)
is_admin = 'admin' in roles
for config in configs:
associated_contracts = [cp.contract for cp in config.contracts_providers.all()]
if not associated_contracts:
inspection_configs.append(config)
elif is_admin:
inspection_configs.append(config)
else:
for contract in associated_contracts:
if can_user_create_inspection_for_contract(request.user, contract):
inspection_configs.append(config)
break
can_inspect = user_config.get_inspectable_thematics().exists()
return render(request, "mobile/mobile_index.html", {
'can_edit_green_surfaces': can_edit_green_surfaces,
'can_access_stock': can_access_stock,
'can_add_structures_repair': can_add_structures_repair,
'can_add_intervention_mobile': can_add_intervention_mobile,
'is_inspector': is_inspector,
'inspection_configs': inspection_configs,
'can_inspect': can_inspect,
})
@login_not_required
def install(request):
"""Render installation instructions for the mobile app."""
return render(request, "mobile/mobile_install.html")
@never_cache
def mobile_controls_service_worker(request):
"""Sert le service worker des contrôles depuis /mobile/controls/service-worker.js.
Ce chemin donne au SW un scope naturel sur /mobile/controls/ qui couvre
toutes les pages mobiles de détail et de liste des contrôles.
"""
rel = 'controls/service-worker.js'
# 1. Finders (développement et production avec STATICFILES_DIRS)
abs_path = staticfiles_finders.find(rel)
if abs_path:
try:
content = Path(abs_path).read_text(encoding='utf-8')
return HttpResponse(content, content_type='application/javascript; charset=utf-8')
except (FileNotFoundError, IOError):
pass
# 2. STATIC_ROOT (après collectstatic en production)
try:
content = (Path(settings.STATIC_ROOT) / rel).read_text(encoding='utf-8')
return HttpResponse(content, content_type='application/javascript; charset=utf-8')
except (FileNotFoundError, IOError, TypeError):
pass
# 3. Chemin source app (fallback ultime)
try:
content = (settings.BASE_DIR / 'controls' / 'static' / rel).read_text(encoding='utf-8')
except (FileNotFoundError, IOError):
content = 'console.error("Controls service worker introuvable");'
return HttpResponse(content, content_type='application/javascript; charset=utf-8')
@check_thematic_access('nature')
def green_surfaces_edit_mobile(request):
"""Mobile interface for editing green surfaces (nature assets)."""
# Get categories and models for nature thematic
categories = list(AssetCategory.objects.filter(thematic__code='nature'))
categories.sort(key=lambda m: m.get_name().lower())
models = list(NatureAssetModel.objects.filter(category__code='NAT_GREEN_AREAS'))
models.sort(key=lambda m: (m.get_name() or "").lower())
context = {
'categories': categories,
'models': models,
'bulb_type_choices': sorted(BULB_TYPE_CHOICES, key=lambda x: x[1].lower()),
}
return render(request, "mobile/green_surfaces_edit_v2.html", context)
@check_thematic_access('nature')
def green_surfaces_edit_custom_draw(request):
"""Mobile interface for editing green surfaces with custom draw module (test page)."""
# Get categories and models for nature thematic
categories = list(AssetCategory.objects.filter(thematic__code='nature'))
categories.sort(key=lambda m: m.get_name().lower())
models = list(NatureAssetModel.objects.filter(category__code='NAT_GREEN_AREAS'))
models.sort(key=lambda m: (m.get_name() or "").lower())
context = {
'categories': categories,
'models': models,
}
return render(request, "mobile/green_surfaces_edit_custom_draw.html", context)
@login_required
@stock_access_required
def stock_index_mobile(request):
"""Mobile landing page for the stock module."""
from stock.models import PreparationOrder, ProductionOrder
from stock.permissions import get_user_accessible_warehouses
preparation_orders_count = PreparationOrder.objects.filter(
status__in=['to_process', 'pending']
).count()
accessible_warehouses = get_user_accessible_warehouses(request.user)
production_orders_count = ProductionOrder.objects.filter(
warehouse__id__in=accessible_warehouses,
status__in=['to_process', 'in_production'],
).count()
return render(request, "stock/mobile/stock_index_mobile.html", {
'preparation_orders_count': preparation_orders_count,
'production_orders_count': production_orders_count,
})
@login_required
@stock_access_required
def stock_products_mobile(request):
"""Mobile list of stock products."""
from stock.models import Product, ProductCategory
from django.db.models import Q
q = request.GET.get('q', '').strip()
category_id = request.GET.get('category', '').strip()
products = Product.objects.select_related('category').order_by('name')
if q:
products = products.filter(Q(name__icontains=q) | Q(code__icontains=q))
if category_id:
products = products.filter(category_id=category_id)
categories = ProductCategory.objects.order_by('name_fr')
return render(request, "stock/mobile/stock_products_mobile.html", {
'products': products[:200],
'q': q,
'category_id': category_id,
'categories': categories,
})
@login_required
@stock_access_required
def qr_scanner(request):
"""Page du scanner QR Code mobile."""
return render(request, 'mobile/qr_scanner.html')
@login_required
@stock_access_required
def qr_resolve(request):
"""
Résout le contenu d'un QR code scanné et retourne l'URL de redirection en JSON.
Format attendu: STREETUP:<type>:<identifiant>
Types supportés : product, order
"""
code = request.GET.get('code', '').strip()
if not code.startswith('STREETUP:'):
return JsonResponse({'error': _('QR Code non reconnu.')}, status=400)
parts = code.split(':', 2)
if len(parts) != 3:
return JsonResponse({'error': _('Format de QR Code invalide.')}, status=400)
_, qr_type, identifier = parts
if qr_type == 'product':
from stock.models import Product
try:
product = Product.objects.get(code=identifier)
return JsonResponse({
'url': f'/mobile/stock/products/{product.pk}/',
'label': f"▣ {product.name} ({product.code})",
})
except Product.DoesNotExist:
return JsonResponse({'error': _(f'Produit « {identifier} » introuvable.')}, status=404)
if qr_type == 'order':
from stock.models import PurchaseOrder
try:
order = PurchaseOrder.objects.get(uuid=identifier)
return JsonResponse({
'url': f'/stock/purchase-orders/{order.pk}/',
'label': f'📦 {order.code or "#" + str(order.pk)}',
})
except (PurchaseOrder.DoesNotExist, Exception):
return JsonResponse({'error': _(f'Commande introuvable.')}, status=404)
if qr_type == 'serializeditem':
from stock.models import SerializedItem
try:
si = SerializedItem.objects.select_related('product').get(serial_number=identifier)
return JsonResponse({
'url': f'/mobile/stock/serialized/{si.pk}/',
'label': f'🔖 {si.product.name} — {si.serial_number}',
})
except SerializedItem.DoesNotExist:
return JsonResponse({'error': _(f'Article tracé « {identifier} » introuvable.')}, status=404)
if qr_type == 'stocklocation':
from stock.models import WarehouseLocation
try:
loc = WarehouseLocation.objects.select_related('warehouse').get(code=identifier)
return JsonResponse({
'url': f'/stock/warehouse-locations/{loc.pk}/',
'label': f'📍 {loc.warehouse.name} — {loc.name}',
})
except WarehouseLocation.DoesNotExist:
return JsonResponse({'error': _(f'Emplacement « {identifier} » introuvable.')}, status=404)
if qr_type == 'preparation':
from stock.models import PreparationOrder
try:
order = PreparationOrder.objects.get(order_number=identifier)
return JsonResponse({
'url': f'/mobile/stock/preparation-orders/{order.pk}/',
'label': f'📋 Bon de préparation {order.order_number}',
})
except PreparationOrder.DoesNotExist:
return JsonResponse({'error': _(f'Bon de préparation « {identifier} » introuvable.')}, status=404)
return JsonResponse({'error': _(f'Type de QR Code inconnu : {qr_type}.')}, status=400)
@login_required
@stock_access_required
def stock_product_detail_mobile(request, product_id):
"""Mobile detail page for a stock product."""
from stock.models import Product, Stock, PurchaseOrderItem, StockMovement
from common.models import UserConfig
from django.shortcuts import get_object_or_404
try:
user_config = request.user.config
is_stock_manager = user_config.has_role('stock_manager') or user_config.has_role('admin')
except (UserConfig.DoesNotExist, AttributeError):
is_stock_manager = False
product = get_object_or_404(Product.objects.select_related('category'), id=product_id)
stocks = list(Stock.objects.filter(product=product).select_related(
'location__warehouse'
).order_by('location__warehouse__name', 'location__name'))
from stock.permissions import user_can_administer_warehouse
for stock in stocks:
stock.can_administer = user_can_administer_warehouse(request.user, stock.location.warehouse)
pending_orders = PurchaseOrderItem.objects.filter(
product=product,
purchase_order__status__in=['pending', 'ordered']
).select_related('purchase_order__supplier', 'purchase_order__warehouse', 'location')
recent_movements = StockMovement.objects.filter(
product=product
).select_related(
'source_location__warehouse', 'destination_location__warehouse',
'batch', 'batch__preparation_order', 'batch__purchase_order',
).order_by('-date')[:20]
order_history = PurchaseOrderItem.objects.filter(
product=product,
purchase_order__status__in=['received', 'cancelled']
).select_related(
'purchase_order__supplier', 'purchase_order__warehouse'
).order_by('-purchase_order__date_ordered')[:30]
serialized_items = None
if product.is_serialized:
from stock.models import SerializedItem
serialized_items = SerializedItem.objects.filter(product=product).select_related(
'current_holder', 'current_location__warehouse'
).order_by('serial_number')
return render(request, 'stock/mobile/product_detail_mobile.html', {
'product': product,
'stocks': stocks,
'pending_orders': pending_orders,
'recent_movements': recent_movements,
'order_history': order_history,
'is_stock_manager': is_stock_manager,
'serialized_items': serialized_items,
})
@login_required
@stock_access_required
def stock_serialized_item_detail_mobile(request, pk):
"""Mobile detail page for a serialized (tracked) item."""
from stock.models import SerializedItem, WarehouseLocation
from stock.permissions import user_can_manage_products
from contracts.models import CompanyMember
from django.shortcuts import get_object_or_404
item = get_object_or_404(
SerializedItem.objects.select_related(
'product__category', 'current_location__warehouse', 'current_holder', 'created_by'
).prefetch_related('current_holder__teams'),
pk=pk,
)
events = item.events.select_related(
'person', 'from_location__warehouse', 'to_location__warehouse', 'created_by'
)
locations = WarehouseLocation.objects.select_related('warehouse').order_by('warehouse__name', 'name')
members = CompanyMember.objects.prefetch_related('teams').order_by('name')
return render(request, 'stock/mobile/serialized_item_detail_mobile.html', {
'item': item,
'events': events,
'locations': locations,
'members': members,
'can_manage': user_can_manage_products(request.user),
})
@login_required
@stock_access_required
def stock_preparation_orders_mobile(request):
from stock.models import PreparationOrder
selected_status = request.GET.get('status', '').strip()
selected_priority = request.GET.get('priority', '').strip()
preparation_orders = PreparationOrder.objects.all()
if selected_status:
preparation_orders = preparation_orders.filter(status=selected_status)
if selected_priority:
preparation_orders = preparation_orders.filter(priority=selected_priority)
preparation_orders = preparation_orders.prefetch_related(
'preparationorderitem_set__product'
).order_by('-date')[:200]
return render(request, "stock/mobile/stock_preparation_orders_mobile.html", {
'preparation_orders': preparation_orders,
'selected_status': selected_status,
'selected_priority': selected_priority,
'preparation_order_status_choices': [
('to_process', _("A traiter")),
('pending', _("En attente")),
('processed', _("Traité")),
('cancelled', _("Annulé")),
],
'preparation_order_priority_choices': [
('1', _("Urgent")),
('2', _("Elevé")),
('3', _("Normal")),
('4', _("Bas")),
],
})
@login_required
@stock_access_required
def stock_production_orders_mobile(request):
"""Mobile list of production orders (bons de confection)."""
from stock.models import ProductionOrder
from stock.permissions import get_user_accessible_warehouses
accessible_warehouses = get_user_accessible_warehouses(request.user)
production_orders = ProductionOrder.objects.filter(
warehouse__id__in=accessible_warehouses
).select_related('warehouse').order_by('-created_at')[:200]
return render(request, "stock/mobile/stock_production_orders_mobile.html", {
'production_orders': production_orders,
})
@login_required
@stock_access_required
def stock_production_order_detail_mobile(request, pk):
"""Mobile detail page for a production order (bon de confection)."""
from django.shortcuts import get_object_or_404
from stock.models import ProductionOrder
from stock.permissions import get_user_accessible_warehouses
from common.models import UserConfig
accessible_warehouses = get_user_accessible_warehouses(request.user)
order = get_object_or_404(
ProductionOrder.objects.select_related(
'warehouse', 'preparation_order', 'output_product', 'output_location',
).prefetch_related('documents').filter(warehouse__id__in=accessible_warehouses),
pk=pk,
)
try:
user_config = request.user.config
can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin')
except UserConfig.DoesNotExist:
can_manage = False
components_status = order.get_components_status()
can_start = order.can_start()
# Extraire la description libre (depuis le FK ou depuis les notes pré-remplies)
other_item_description = None
if order.preparation_order_other_item_id:
other_item_description = order.preparation_order_other_item.description
else:
notes = order.notes or ''
if 'Issu de' in notes and ': ' in notes:
other_item_description = notes.split(': ', 1)[1]
return render(request, "stock/mobile/stock_production_order_detail_mobile.html", {
'order': order,
'components_status': components_status,
'can_start': can_start,
'can_manage': can_manage,
'other_item_description': other_item_description,
})
@login_required
@stock_access_required
def stock_movements_mobile(request):
"""Mobile list of stock movements (entrées / sorties)."""
from stock.models import StockMovementBatch, StockMovement
from stock.permissions import get_user_accessible_warehouses
from django.db.models import Q, Prefetch
accessible_warehouses = get_user_accessible_warehouses(request.user)
batches = StockMovementBatch.objects.filter(
Q(movements__source_location__warehouse__id__in=accessible_warehouses) |
Q(movements__destination_location__warehouse__id__in=accessible_warehouses)
).distinct().select_related('person', 'company', 'created_by').prefetch_related(
Prefetch(
'movements',
queryset=StockMovement.objects.select_related(
'product', 'source_location', 'destination_location'
)
)
).order_by('-date')[:100]
return render(request, "stock/mobile/stock_movements_mobile.html", {
'batches': batches,
})
@login_required
@stock_access_required
def new_stock_entry_mobile(request):
"""Crée une nouvelle entrée de stock depuis l'interface mobile."""
from stock.views import _new_stock_batch_view
return _new_stock_batch_view(
request, 'in',
template_name="stock/mobile/new_stock_batch_mobile.html",
cancel_url_name="mobile:stock_movements_mobile",
success_redirect_fn=lambda req, batch: redirect(
'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk
),
)
@login_required
@stock_access_required
def new_stock_exit_mobile(request):
"""Crée une nouvelle sortie de stock depuis l'interface mobile."""
from stock.views import _new_stock_batch_view
return _new_stock_batch_view(
request, 'out',
template_name="stock/mobile/new_stock_batch_mobile.html",
cancel_url_name="mobile:stock_movements_mobile",
success_redirect_fn=lambda req, batch: redirect(
'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk
),
)
@login_required
@stock_access_required
def new_stock_transfer_mobile(request):
"""Crée un nouveau transfert de stock depuis l'interface mobile."""
from stock.views import _new_stock_batch_view
return _new_stock_batch_view(
request, 'transfer',
template_name="stock/mobile/new_stock_batch_mobile.html",
cancel_url_name="mobile:stock_movements_mobile",
success_redirect_fn=lambda req, batch: redirect(
'mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk
),
)
@login_required
@stock_access_required
def production_order_create_mobile(request):
"""Crée un nouveau bon de confection depuis l'interface mobile."""
from stock.models import ProductionOrder, PreparationOrderItem, PreparationOrderOtherItem
from stock.forms import ProductionOrderForm, ProductionOrderComponentFormSet
from common.models import UserConfig
try:
user_config = request.user.config
is_stock_manager = user_config.has_role('stock_manager') or user_config.has_role('admin')
except UserConfig.DoesNotExist:
is_stock_manager = False
if not is_stock_manager:
messages.error(request, _("Vous n'avez pas la permission d'accéder à cette page."))
return redirect('mobile:stock_production_orders_mobile')
initial = {}
other_item_context = None
prep_item_context = None
prep_id = request.GET.get('preparation_order')
other_item_id = request.GET.get('other_item')
prep_item_id = request.GET.get('prep_item')
if prep_id:
initial['preparation_order'] = prep_id
try:
default_warehouse = request.user.config.get_default_warehouse()
if default_warehouse:
initial['warehouse'] = default_warehouse
except Exception:
pass
if prep_item_id:
try:
prep_item = PreparationOrderItem.objects.select_related(
'preparation_order', 'product__category'
).get(pk=prep_item_id)
prep_item_context = prep_item
if not prep_id:
initial['preparation_order'] = prep_item.preparation_order_id
initial['quantity'] = prep_item.quantity
initial['notes'] = _(
"Issu de l'article {product} du bon de préparation #{order_number}"
).format(
product=prep_item.product.name,
order_number=prep_item.preparation_order.order_number,
)
except PreparationOrderItem.DoesNotExist:
pass
if other_item_id:
try:
other_item = PreparationOrderOtherItem.objects.select_related(
'preparation_order', 'sign_panel_type', 'category'
).get(pk=other_item_id)
other_item_context = other_item
if not prep_id:
initial['preparation_order'] = other_item.preparation_order_id
initial['quantity'] = other_item.quantity
initial['output_product_name'] = other_item.description[:300]
initial['notes'] = _(
"Issu de l'item #{id} du bon de préparation #{order_number}: {desc}"
).format(
id=other_item.id,
order_number=other_item.preparation_order.order_number,
desc=other_item.description,
)
except PreparationOrderOtherItem.DoesNotExist:
pass
if request.method == 'POST':
form = ProductionOrderForm(request.POST, request.FILES)
formset = ProductionOrderComponentFormSet(request.POST, prefix='components')
if form.is_valid() and formset.is_valid():
order = form.save(commit=False)
if prep_item_context:
order.preparation_order_item = prep_item_context
if other_item_context:
order.preparation_order_other_item = other_item_context
order.save()
form.save_m2m()
formset.instance = order
formset.save()
from stock.models import ProductionOrderDocument
for f in request.FILES.getlist('new_documents'):
ProductionOrderDocument.objects.create(
production_order=order,
file=f,
name=f.name,
)
messages.success(request, _("Bon de confection créé avec succès."))
return redirect('mobile:stock_production_order_detail_mobile', pk=order.pk)
else:
form = ProductionOrderForm(initial=initial)
formset = ProductionOrderComponentFormSet(prefix='components')
return render(request, 'stock/mobile/production_order_form_mobile.html', {
'form': form,
'formset': formset,
'action': 'create',
'other_item_context': other_item_context,
'prep_item_context': prep_item_context,
})
@login_required
@stock_access_required
def stock_movement_batch_detail_mobile(request, batch_id):
"""Mobile detail page for a stock movement batch."""
from stock.models import StockMovementBatch, StockBatchSignature
from stock.permissions import get_user_accessible_warehouses
from django.shortcuts import get_object_or_404
from django.db.models import Q
import base64
from django.core.files.base import ContentFile
batch = get_object_or_404(
StockMovementBatch.objects.select_related(
'person',
'company',
'contract', 'contract__company',
'created_by',
'preparation_order',
'purchase_order', 'purchase_order__supplier',
).prefetch_related(
'person__teams',
'movements__product',
'movements__source_location__warehouse',
'movements__destination_location__warehouse',
'signatures__signed_by',
'documents',
),
pk=batch_id,
)
# Vérifier si l'utilisateur peut gérer au moins un entrepôt impliqué dans ce lot
manageable_warehouses = get_user_accessible_warehouses(request.user, can_manage=True)
batch_warehouse_ids = set(
batch.movements.filter(source_location__warehouse__isnull=False)
.values_list('source_location__warehouse_id', flat=True)
) | set(
batch.movements.filter(destination_location__warehouse__isnull=False)
.values_list('destination_location__warehouse_id', flat=True)
)
can_add_signature = bool(
manageable_warehouses.filter(id__in=batch_warehouse_ids).exists()
)
if request.method == 'POST':
if not can_add_signature:
messages.error(request, _("Vous n'avez pas la permission d'ajouter une signature à ce lot."))
return redirect('mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk)
signature_data = request.POST.get('signature_data', '').strip()
signature_name = request.POST.get('signature_name', '').strip() or None
image_file = None
if signature_data and signature_data.startswith('data:image/'):
header, encoded = signature_data.split(',', 1)
image_bytes = base64.b64decode(encoded)
image_file = ContentFile(image_bytes, name=f'signature_batch_{batch.pk}_{batch.signatures.count() + 1}.png')
elif request.FILES.get('signature'):
image_file = request.FILES['signature']
if image_file:
sig = StockBatchSignature(batch=batch, signed_by=request.user, signature_name=signature_name)
sig.image.save(image_file.name if hasattr(image_file, 'name') else f'sig_{batch.pk}.png', image_file, save=True)
messages.success(request, _('Signature enregistrée avec succès.'))
return redirect('mobile:stock_movement_batch_detail_mobile', batch_id=batch.pk)
return render(request, 'stock/mobile/stock_movement_batch_detail_mobile.html', {
'batch': batch,
'can_add_signature': can_add_signature,
})
@login_required
@stock_access_required
def stock_inventories_mobile(request):
"""Mobile list of inventories."""
from stock.models import Inventory
from stock.permissions import get_user_accessible_warehouses
accessible_warehouses = get_user_accessible_warehouses(request.user)
inventories = Inventory.objects.filter(
warehouse_location__warehouse__id__in=accessible_warehouses
).select_related('warehouse_location__warehouse').order_by('-created_at')[:200]
return render(request, "stock/mobile/stock_inventories_mobile.html", {
'inventories': inventories,
})
@login_required
@stock_access_required
def stock_inventory_detail_mobile(request, pk):
"""Mobile detail page for an inventory (saisie des quantités comptées)."""
from django.shortcuts import get_object_or_404
from stock.models import Inventory
from stock.permissions import get_user_accessible_warehouses, user_can_validate_inventory
accessible_warehouses = get_user_accessible_warehouses(request.user)
inventory = get_object_or_404(
Inventory.objects.filter(
warehouse_location__warehouse__id__in=accessible_warehouses
).select_related("warehouse_location__warehouse"),
pk=pk,
)
items = inventory.items.select_related("product", "validated_by").order_by("product__name")
warehouse = inventory.warehouse_location.warehouse
can_validate = user_can_validate_inventory(request.user, warehouse)
from stock.permissions import user_can_manage_warehouse
can_change_status = user_can_manage_warehouse(request.user, warehouse)
return render(request, "stock/mobile/stock_inventory_detail_mobile.html", {
"inventory": inventory,
"items": items,
"can_validate": can_validate,
"can_change_status": can_change_status,
})
@login_required
@stock_access_required
def stock_new_inventory_mobile(request):
"""Mobile form to create a new inventory."""
from stock.models import Inventory, WarehouseLocation, Warehouse
from stock.permissions import get_user_accessible_warehouses
accessible_warehouses = get_user_accessible_warehouses(request.user)
locations = (
WarehouseLocation.objects.filter(warehouse__id__in=accessible_warehouses)
.select_related('warehouse')
.order_by('warehouse__name', 'name')
)
# Group locations by warehouse for the template
warehouses_with_locations = {}
for loc in locations:
wh = loc.warehouse
warehouses_with_locations.setdefault(wh, []).append(loc)
grouped_locations = [
{'warehouse': wh, 'locations': locs}
for wh, locs in warehouses_with_locations.items()
]
error = None
performed_by = ''
location_id = ''
if request.method == 'POST':
location_id = request.POST.get('warehouse_location', '').strip()
performed_by = request.POST.get('performed_by', '').strip()
if not location_id:
error = _("Veuillez sélectionner un emplacement.")
else:
location = WarehouseLocation.objects.filter(
pk=location_id,
warehouse__id__in=accessible_warehouses,
).first()
if not location:
error = _("Emplacement invalide ou non accessible.")
else:
inventory = Inventory.objects.create(
warehouse_location=location,
performed_by=performed_by,
status=Inventory.STATUS_IN_PROGRESS,
)
# Pré-remplir les articles avec les produits existants à cet emplacement
from stock.models import Stock, InventoryItem
existing_stocks = (
Stock.objects.filter(location=location, quantity__gt=0)
.select_related('product')
.order_by('product__name')
)
for s in existing_stocks:
InventoryItem.objects.create(
inventory=inventory,
product=s.product,
expected_quantity=s.quantity,
counted_quantity=s.quantity,
)
messages.success(request, _("Inventaire créé avec succès."))
return redirect('mobile:stock_inventory_detail_mobile', pk=inventory.pk)
return render(request, "stock/mobile/stock_new_inventory_mobile.html", {
'grouped_locations': grouped_locations,
'error': error,
'performed_by': performed_by,
'location_id': location_id,
})
@login_required
@stock_access_required
def stock_purchases_mobile(request):
"""Mobile list of purchase orders (commandes)."""
from stock.models import PurchaseOrder
from stock.permissions import get_user_accessible_warehouses
accessible_warehouses = get_user_accessible_warehouses(request.user)
purchase_orders = PurchaseOrder.objects.filter(
warehouse__id__in=accessible_warehouses
).select_related('supplier', 'warehouse').order_by('-date_ordered')[:200]
return render(request, "stock/mobile/stock_purchases_mobile.html", {
'purchase_orders': purchase_orders,
})
@login_required
@stock_access_required
def stock_purchase_order_detail_mobile(request, pk):
"""Mobile detail page for a purchase order (bon de commande)."""
from django.shortcuts import get_object_or_404
from stock.models import PurchaseOrder, PurchaseOrderItem
from stock.permissions import get_user_accessible_warehouses
from common.models import UserConfig
from django.db.models import Sum as _Sum
accessible_warehouses = get_user_accessible_warehouses(request.user)
purchase_order = get_object_or_404(
PurchaseOrder.objects.filter(
warehouse__id__in=accessible_warehouses
).select_related('supplier', 'warehouse')
.prefetch_related('items__product', 'items__location__warehouse'),
pk=pk,
)
qr_code = purchase_order.generate_qr_code()
items_with_stock = []
for item in purchase_order.items.all():
items_with_stock.append({
'item': item,
})
try:
user_config = request.user.config
can_manage_purchase = user_config.can_manage_purchase_orders_any()
except UserConfig.DoesNotExist:
can_manage_purchase = False
return render(request, "stock/mobile/stock_purchase_order_detail_mobile.html", {
'purchase_order': purchase_order,
'qr_code': qr_code,
'items_with_stock': items_with_stock,
'can_manage_purchase': can_manage_purchase,
})
@login_required
@stock_access_required
def stock_preparation_order_detail_mobile(request, pk):
"""Mobile detail page for a preparation order (bon de préparation)."""
from django.shortcuts import get_object_or_404
from stock.models import PreparationOrder, Stock
from stock.permissions import get_user_accessible_warehouses
from common.models import UserConfig
preparation_order = get_object_or_404(
PreparationOrder.objects.select_related(
'intervention',
'intervention__intervention_manager',
).prefetch_related(
'preparationorderitem_set__product__category',
'preparationorderitem_set__production_orders',
'other_items__category',
'other_items__sign_panel_type',
'other_items__production_orders',
'documents',
),
pk=pk,
)
# Ajout de documents via POST
if request.method == 'POST' and request.FILES.getlist('documents'):
from stock.models import PreparationOrderDocument
for f in request.FILES.getlist('documents'):
PreparationOrderDocument.objects.create(
preparation_order=preparation_order,
file=f,
name=f.name,
)
messages.success(request, _('Document(s) ajouté(s) avec succès.'))
return redirect('mobile:stock_preparation_order_detail_mobile', pk=pk)
items_with_stock = []
for item in preparation_order.preparationorderitem_set.all():
product = item.product
available_stock = product.total_stock
reserved_stock = product.reserved_quantity
prod_order = item.production_orders.first()
items_with_stock.append({
'item': item,
'available_stock': available_stock,
'reserved_stock': reserved_stock,
'is_sufficient': available_stock >= item.quantity,
'production_order': prod_order,
'locations': Stock.objects.filter(product=product, quantity__gt=0).select_related('location__warehouse'),
})
try:
user_config = request.user.config
can_manage = user_config.has_role('stock_manager') or user_config.has_role('admin') or user_config.can_manage_preparation_orders_any()
can_cancel = user_config.can_manage_preparation_orders_any()
can_manage_purchase = user_config.can_manage_purchase_orders_any()
except UserConfig.DoesNotExist:
can_manage = False
can_cancel = False
can_manage_purchase = False
other_items_with_info = []
for oi in preparation_order.other_items.all():
other_items_with_info.append({
'item': oi,
'production_order': oi.production_orders.first(),
})
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", {
'preparation_order': preparation_order,
'items_with_stock': items_with_stock,
'other_items': other_items_with_info,
'can_manage': can_manage,
'can_cancel': can_cancel,
'can_manage_purchase': can_manage_purchase,
'exit_batches': exit_batches,
'return_batches': return_batches,
})
@check_thematic_access('nature')
def green_surfaces_edit_standalone(request):
"""Mobile interface for editing green surfaces with CustomDrawToolbar in standalone mode (test page).
This page demonstrates the CustomDrawToolbar without callbacks.
The toolbar manages all drawing operations internally.
"""
context = {}
return render(request, "mobile/green_surfaces_edit_standalone.html", context)