diff --git a/inventory/models.py b/inventory/models.py index 733a1262..33c4b206 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -1,3 +1,4 @@ +import logging import uuid from datetime import datetime from django.conf import settings @@ -51,6 +52,10 @@ from imagekit.processors import ResizeToFill # from simple_history.models import HistoricalRecords from plans.models import Invoice +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + class Base(models.Model): id = models.UUIDField( unique=True, @@ -3720,4 +3725,32 @@ class Ticket(models.Model): return ", ".join(parts) def __str__(self): - return f"#{self.id} - {self.subject} ({self.status})" \ No newline at end of file + return f"#{self.id} - {self.subject} ({self.status})" + + +class CarImage(models.Model): + car = models.OneToOneField('Car', on_delete=models.CASCADE, related_name='generated_image') + image_hash = models.CharField(max_length=64, unique=True) + image = models.ImageField(upload_to='car_images/', null=True, blank=True) + is_generating = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def generate_hash(self): + """Simple hash generation""" + car = self.car + hash_string = f"{car.id_car_make.name if car.id_car_make else ''}-{car.id_car_model.name if car.id_car_model else ''}-{car.year}-{getattr(car, 'color', 'default')}" + return hashlib.sha256(hash_string.encode()).hexdigest() + + def schedule_generation(self): + """Schedule image generation""" + from django_q.tasks import async_task + from inventory.tasks import generate_car_image_task + + self.is_generating = True + self.save() + + async_task( + generate_car_image_task, + self.id, + task_name=f"generate_car_image_{self.car.vin}" + ) \ No newline at end of file diff --git a/inventory/signals.py b/inventory/signals.py index 6d4aa775..3a1ef0ef 100644 --- a/inventory/signals.py +++ b/inventory/signals.py @@ -413,24 +413,26 @@ def create_item_model(sender, instance, created, **kwargs): # quotation.save() -@receiver(post_save, sender=models.CarColors) -def update_car_when_color_changed(sender, instance, **kwargs): - """ - Signal receiver to handle updates to a car instance when its related - CarColors instance is modified. Triggered by the `post_save` signal - for the `CarColors` model. Ensures that the associated `Car` instance - is saved, propagating changes effectively. +# @receiver(post_save, sender=models.CarColors) +# def update_car_hash_when_color_changed(sender, instance, **kwargs): +# """ +# Signal receiver to handle updates to a car instance when its related +# CarColors instance is modified. Triggered by the `post_save` signal +# for the `CarColors` model. Ensures that the associated `Car` instance +# is saved, propagating changes effectively. - :param sender: The model class (`CarColors`) that was saved. - :type sender: Type[models.CarColors] - :param instance: The specific instance of `CarColors` that was saved. - :type instance: models.CarColors - :param kwargs: Additional keyword arguments passed by the signal. - :type kwargs: dict - :return: None - """ - car = instance.car - car.save() +# :param sender: The model class (`CarColors`) that was saved. +# :type sender: Type[models.CarColors] +# :param instance: The specific instance of `CarColors` that was saved. +# :type instance: models.CarColors +# :param kwargs: Additional keyword arguments passed by the signal. +# :type kwargs: dict +# :return: None +# """ + +# car = instance.car +# car.hash = car.get_hash +# car.save() @receiver(post_save, sender=models.Opportunity) @@ -1260,4 +1262,41 @@ def send_ticket_notification(sender, instance, created, **kwargs): kwargs={"dealer_slug": instance.dealer.slug, "ticket_id": instance.pk}, ), ), - ) \ No newline at end of file + ) + + +@receiver(post_save, sender=models.CarColors) +def handle_car_image(sender, instance, created, **kwargs): + """ + Simple handler for car image generation + """ + try: + # Create or get car image record + car = instance.car + car_image, created = models.CarImage.objects.get_or_create(car=car, defaults={'image_hash': car.get_hash}) + + # Check for existing image with same hash + existing = models.CarImage.objects.filter( + image_hash=car_image.image_hash, + image__isnull=False + ).exclude(car=car).first() + + if existing: + # Copy existing image + car_image.image.save( + existing.image.name, + existing.image.file, + save=True + ) + logger.info(f"Reused image for car {car.vin}") + else: + # Schedule async generation + async_task( + 'inventory.tasks.generate_car_image_task', + car_image.id, + task_name=f"generate_car_image_{car.vin}" + ) + logger.info(f"Scheduled image generation for car {car.vin}") + + except Exception as e: + logger.error(f"Error handling car image for {car.vin}: {e}") \ No newline at end of file diff --git a/inventory/tasks.py b/inventory/tasks.py index 6c15e5f2..e0a52c12 100644 --- a/inventory/tasks.py +++ b/inventory/tasks.py @@ -1,5 +1,8 @@ import base64 import logging +import requests +from PIL import Image +from io import BytesIO from plans.models import Plan from django.urls import reverse from django.conf import settings @@ -18,7 +21,8 @@ from .utils import get_accounts_data,create_account from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User, Group, Permission -from inventory.models import DealerSettings, Dealer,Schedule,Notification,CarReservation,CarStatusChoices +from inventory.models import DealerSettings, Dealer,Schedule,Notification,CarReservation,CarStatusChoices,CarImage + logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -928,4 +932,30 @@ def remove_reservation_by_id(reservation_id): logger.error(f"Error removing reservation with ID {reservation_id}: {e}") def test_task(**kwargs): - print("TASK : ",kwargs.get("dealer")) \ No newline at end of file + print("TASK : ",kwargs.get("dealer")) + + +def generate_car_image_task(car_image_id): + """ + Simple async task to generate car image + """ + from inventory.utils import generate_car_image_simple + try: + car_image = CarImage.objects.get(id=car_image_id) + result = generate_car_image_simple(car_image) + + return { + 'success': result.get('success', False), + 'car_image_id': car_image_id, + 'error': result.get('error'), + 'message': 'Image generated' if result.get('success') else 'Generation failed' + } + + except CarImage.DoesNotExist: + error_msg = f"CarImage with id {car_image_id} not found" + logger.error(error_msg) + return {'success': False, 'error': error_msg} + except Exception as e: + error_msg = f"Unexpected error: {e}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} \ No newline at end of file diff --git a/inventory/utils.py b/inventory/utils.py index 1886d8fc..687ffaf5 100644 --- a/inventory/utils.py +++ b/inventory/utils.py @@ -3,11 +3,15 @@ import secrets import logging import datetime import requests +from PIL import Image +from io import BytesIO from decimal import Decimal from inventory import models from django.urls import reverse from django.conf import settings +from urllib.parse import urljoin from django.utils import timezone +from django.db import transaction from django_ledger.io import roles from django.contrib import messages from django.shortcuts import redirect @@ -20,16 +24,17 @@ from django_ledger.models import ( BillModel, VendorModel, ) +from django.core.files.base import ContentFile from django_ledger.models.items import ItemModel from django.utils.translation import get_language from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ +from django_q.models import Schedule as DjangoQSchedule from django.contrib.contenttypes.models import ContentType from django_ledger.models.transactions import TransactionModel from django_ledger.models.journal_entry import JournalEntryModel -from django.db import transaction -from django_q.models import Schedule as DjangoQSchedule -from django_ledger.io import roles +# from .tasks import generate_car_image_task + logger = logging.getLogger(__name__) @@ -1649,7 +1654,7 @@ def _post_sale_and_cogs(invoice, dealer): ) TransactionModel.objects.create( journal_entry=je_sale, - + account=vat_acc, amount=car.get_additional_services_vat, tx_type='credit', @@ -2348,4 +2353,274 @@ def create_account(entity, coa, account_data): account.save() logger.info(f"Created default account: {account}") except Exception as e: - logger.error(f"Error creating default account: {account_data['code']}, {e}") \ No newline at end of file + logger.error(f"Error creating default account: {account_data['code']}, {e}") + + +def get_or_generate_car_image(car): + """ + Utility function to get or generate car image asynchronously + """ + try: + car_image, created = models.CarImage.objects.get_or_create(car=car) + + if created: + car_image.image_hash = car.get_hash + car_image.save() + + if car_image.image: + return car_image.image.url + + # Check for existing image with same hash + existing = models.CarImage.objects.filter( + image_hash=car_image.image_hash, + image__isnull=False + ).exclude(car=car).first() + + if existing: + car_image.image.save( + existing.image.name, + existing.image.file, + save=True + ) + return car_image.image.url + + # If no image exists and not already generating, schedule generation + if not car_image.is_generating: + car_image.schedule_image_generation() + + return None # Return None while image is being generated + + except Exception as e: + logger.error(f"Error getting/generating car image: {e}") + return None + +def force_regenerate_car_image(car): + """ + Force regeneration of car image (useful for admin actions) + """ + try: + car_image, created = models.CarImage.objects.get_or_create(car=car) + car_image.image_hash = car.get_hash + car_image.image.delete(save=False) # Remove old image + car_image.is_generating = False + car_image.generation_attempts = 0 + car_image.last_generation_error = None + car_image.save() + + car_image.schedule_image_generation() + return True + + except Exception as e: + logger.error(f"Error forcing image regeneration: {e}") + return False + +class CarImageAPIClient: + """Simple client to handle authenticated requests to the car image API""" + + BASE_URL = "http://127.0.0.1:8888" + USERNAME = "ismail.mosa.ibrahim@gmail.com" + PASSWORD = "Supremk4!" + + def __init__(self): + self.session = None + self.csrf_token = None + + def login(self): + """Login to the API and maintain session""" + try: + # Start fresh session + self.session = requests.Session() + + # Step 1: Get CSRF token + response = self.session.get(f"{self.BASE_URL}/login") + response.raise_for_status() + + # Get CSRF token from cookies + self.csrf_token = self.session.cookies.get('csrftoken') + if not self.csrf_token: + raise Exception("CSRF token not found in cookies") + + # Step 2: Login + login_data = { + "username": self.USERNAME, + "password": self.PASSWORD, + "csrfmiddlewaretoken": self.csrf_token + } + + login_response = self.session.post( + f"{self.BASE_URL}/login", + data=login_data + ) + + if login_response.status_code != 200: + raise Exception(f"Login failed with status {login_response.status_code}") + + logger.info("Successfully logged in to car image API") + return True + + except Exception as e: + logger.error(f"Login failed: {e}") + self.session = None + self.csrf_token = None + return False + + def generate_image(self, payload): + """Generate car image using authenticated session""" + if not self.session or not self.csrf_token: + if not self.login(): + raise Exception("Cannot generate image: Login failed") + + try: + headers = { + 'X-CSRFToken': self.csrf_token, + 'Referer': self.BASE_URL, + } + print(payload) + generate_data = { + "year": payload['year'], + "make": payload['make'], + "model": payload['model'], + "exterior_color": payload['color'], + "angle": "3/4 rear", + "reference_image": "" + } + + response = self.session.post( + f"{self.BASE_URL}/generate", + json=generate_data, + headers=headers, + timeout=160 + ) + + response.raise_for_status() + + # Parse response + result = response.json() + image_url = result.get('url') + + if not image_url: + raise Exception("No image URL in response") + + # Download the actual image + image_response = self.session.get( + f"{self.BASE_URL}{image_url}", + timeout=160 + ) + + image_response.raise_for_status() + + return image_response.content, None + + except requests.RequestException as e: + error_msg = f"API request failed: {e}" + logger.error(error_msg) + return None, error_msg + except Exception as e: + error_msg = f"Unexpected error: {e}" + logger.error(error_msg) + return None, error_msg + +# Global client instance +api_client = CarImageAPIClient() + +def resize_image(image_data, max_size=(800, 600)): + """ + Resize image to make it smaller while maintaining aspect ratio + Returns resized image data in the same format + """ + try: + img = Image.open(BytesIO(image_data)) + original_format = img.format + original_mode = img.mode + + # Calculate new size maintaining aspect ratio + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Save back to bytes in original format + output_buffer = BytesIO() + + if original_format and original_format.upper() in ['JPEG', 'JPG']: + img.save(output_buffer, format='JPEG', quality=95, optimize=True) + elif original_format and original_format.upper() == 'PNG': + # Preserve transparency for PNG + if original_mode == 'RGBA': + img.save(output_buffer, format='PNG', optimize=True) + else: + img.save(output_buffer, format='PNG', optimize=True) + else: + # Default to JPEG for other formats + if img.mode in ('RGBA', 'LA', 'P'): + # Convert to RGB if image has transparency + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'RGBA': + background.paste(img, mask=img.split()[3]) + else: + background.paste(img, (0, 0)) + img = background + img.save(output_buffer, format='JPEG', quality=95, optimize=True) + + resized_data = output_buffer.getvalue() + + logger.info(f"Resized image from {len(image_data)} to {len(resized_data)} bytes") + return resized_data, None + + except Exception as e: + error_msg = f"Image resizing failed: {e}" + logger.error(error_msg) + return None, error_msg + +def generate_car_image_simple(car_image): + """ + Simple function to generate car image with authentication and resizing + """ + car = car_image.car + + # Prepare payload + payload = { + 'make': car.id_car_make.name if car.id_car_make else '', + 'model': car.id_car_model.name if car.id_car_model else '', + 'year': car.year, + 'color': car.colors.exterior.name + } + + logger.info(f"Generating image for car {car.vin}") + + # Generate image using API client + image_data, error = api_client.generate_image(payload) + + if error: + return {'success': False, 'error': error} + + if not image_data: + return {'success': False, 'error': 'No image data received'} + + try: + # Resize the image to make it smaller + resized_data, resize_error = resize_image(image_data, max_size=(800, 600)) + + if resize_error: + # If resizing fails, use original image but log warning + logger.warning(f"Resizing failed, using original image: {resize_error}") + resized_data = image_data + + # Determine file extension based on content + try: + img = Image.open(BytesIO(resized_data)) + file_extension = img.format.lower() if img.format else 'jpg' + except: + file_extension = 'jpg' + + # Save the resized image + car_image.image.save( + f"{car_image.image_hash}.{file_extension}", + ContentFile(resized_data), + save=False + ) + + logger.info(f"Successfully generated and resized image for car {car.vin}") + return {'success': True} + + except Exception as e: + error_msg = f"Image processing failed: {e}" + logger.error(error_msg) + return {'success': False, 'error': error_msg} \ No newline at end of file diff --git a/static/images/car_images/370df757c47466bfb70106880aee1f587c8b2dbeb760008d26d83ae3180a8cf9.png b/static/images/car_images/370df757c47466bfb70106880aee1f587c8b2dbeb760008d26d83ae3180a8cf9.png new file mode 100644 index 00000000..b657fcfb Binary files /dev/null and b/static/images/car_images/370df757c47466bfb70106880aee1f587c8b2dbeb760008d26d83ae3180a8cf9.png differ diff --git a/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c.png b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c.png new file mode 100644 index 00000000..8c22617c Binary files /dev/null and b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c.png differ diff --git a/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_GIABVaX.png b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_GIABVaX.png new file mode 100644 index 00000000..f9a9b63f Binary files /dev/null and b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_GIABVaX.png differ diff --git a/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_TaF9jXJ.png b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_TaF9jXJ.png new file mode 100644 index 00000000..c0b265db Binary files /dev/null and b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_TaF9jXJ.png differ diff --git a/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_Xi6gGPz.png b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_Xi6gGPz.png new file mode 100644 index 00000000..d52612cc Binary files /dev/null and b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_Xi6gGPz.png differ diff --git a/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_elzHw3E.png b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_elzHw3E.png new file mode 100644 index 00000000..43249c33 Binary files /dev/null and b/static/images/car_images/b1872f9d53118c9722ce29bfaf776074982b86af4dae8476c8b63661c79a4d9c_elzHw3E.png differ diff --git a/static/images/car_images/b9972af9fc7780b4efee193a1a361c388c86f933a9321092b10673c1b28ba853.png b/static/images/car_images/b9972af9fc7780b4efee193a1a361c388c86f933a9321092b10673c1b28ba853.png new file mode 100644 index 00000000..c68274ad Binary files /dev/null and b/static/images/car_images/b9972af9fc7780b4efee193a1a361c388c86f933a9321092b10673c1b28ba853.png differ diff --git a/templates/header.html b/templates/header.html index ba9a621d..5564f93f 100644 --- a/templates/header.html +++ b/templates/header.html @@ -26,7 +26,7 @@ - + {% endif %} {% if perms.inventory.view_car%} @@ -433,13 +433,13 @@ {% if request.user.is_authenticated%} - +