""" Modèles de données pour l'application de gestion immobilière LOGIS. """ import builtins # accès explicite à @property quand le champ FK 'property' masquerait le built-in from django.db import models from django.utils.translation import gettext_lazy as _ from django.utils.translation import get_language from django.utils.text import slugify from django_resized import ResizedImageField class BilingualMixin: """Mixin apportant la traduction automatique des champs bilingues (fr/en).""" def get_translated(self, field_base): """Retourne la valeur du champ dans la langue active, avec repli sur le français.""" lang = (get_language() or 'fr').split('-')[0] value = getattr(self, f'{field_base}_{lang}', None) if not value: value = getattr(self, f'{field_base}_fr', '') return value class PropertyFeature(BilingualMixin, models.Model): """Caractéristique d'un bien (ex: parquet, double vitrage, cheminée…).""" name_fr = models.CharField(_('nom (français)'), max_length=100) name_en = models.CharField(_('name (English)'), max_length=100) icon = models.CharField( _('icône'), max_length=50, blank=True, help_text=_('Classe CSS ou caractère unicode (ex: 🔥)') ) class Meta: verbose_name = _('caractéristique') verbose_name_plural = _('caractéristiques') ordering = ['name_fr'] def __str__(self): return self.name_fr @property def name(self): """Nom dans la langue active.""" return self.get_translated('name') class Property(BilingualMixin, models.Model): """Bien immobilier principal.""" PROPERTY_TYPES = [ ('house', _('Maison')), ('apartment', _('Appartement')), ('land', _('Terrain')), ('commercial', _('Local commercial')), ('other', _('Autre')), ] STATUS_CHOICES = [ ('sale', _('À vendre')), ('rent', _('À louer')), ] ENERGY_CLASSES = [(c, c) for c in 'ABCDEFG'] GES_CLASSES = [(c, c) for c in 'ABCDEFG'] EXPOSURE_CHOICES = [ ('N', _('Nord')), ('S', _('Sud')), ('E', _('Est')), ('O', _('Ouest')), ('NE', _('Nord-Est')), ('NO', _('Nord-Ouest')), ('SE', _('Sud-Est')), ('SO', _('Sud-Ouest')), ] CONDITION_CHOICES = [ ('new', _('Neuf')), ('exellent', _('Exellent état')), ('good', _('Bon état')), ('to_refresh', _('À rafraîchir')), ('to_renovate', _('À rénover')), ] # Identification name_fr = models.CharField(_('nom (français)'), max_length=200) name_en = models.CharField(_('name (English)'), max_length=200) slug = models.SlugField(_('slug'), max_length=220, unique=True, blank=True) property_type = models.CharField(_('type de bien'), max_length=20, choices=PROPERTY_TYPES) status = models.CharField(_('statut'), max_length=10, choices=STATUS_CHOICES) is_active = models.BooleanField(_('actif / visible'), default=True) # Prix price = models.DecimalField(_('prix (€)'), max_digits=12, decimal_places=0) price_on_request = models.BooleanField(_('prix sur demande'), default=False) # Surfaces (m²) total_land_area = models.DecimalField( _('surface totale du terrain (m²)'), max_digits=10, decimal_places=0, null=True, blank=True, ) living_area = models.DecimalField(_('surface habitable (m²)'), max_digits=8, decimal_places=0) total_area = models.DecimalField(_('surface totale (m²)'), max_digits=8, decimal_places=0, default=0) # Pièces rooms_count = models.PositiveSmallIntegerField(_('nombre de pièces')) bedrooms_count = models.PositiveSmallIntegerField(_('nombre de chambres')) bathrooms_count = models.PositiveSmallIntegerField(_('nombre de salles d\'eau')) # Équipements has_elevator = models.BooleanField(_('ascenseur'), default=False) exposure = models.CharField(_('exposition'), max_length=2, choices=EXPOSURE_CHOICES, blank=True) has_garden = models.BooleanField(_('jardin'), default=False) has_pool = models.BooleanField(_('piscine'), default=False) has_dependencies = models.BooleanField(_('dépendances'), default=False) # Construction construction_year = models.PositiveSmallIntegerField(_('année de construction'), null=True, blank=True) condition = models.CharField(_('état du bien'), max_length=20, choices=CONDITION_CHOICES) # Diagnostics énergétiques energy_class = models.CharField(_('classe énergie (DPE)'), max_length=1, choices=ENERGY_CLASSES, blank=True) energy_value = models.PositiveSmallIntegerField( _('valeur DPE (kWh/m²/an)'), null=True, blank=True, ) ges_class = models.CharField(_('classe GES'), max_length=1, choices=GES_CLASSES, blank=True) ges_value = models.PositiveSmallIntegerField( _('valeur GES (kgCO₂/m²/an)'), null=True, blank=True, ) # Descriptions bilingues description_fr = models.TextField(_('description (français)')) description_en = models.TextField(_('description (English)')) # Localisation address = models.CharField(_('adresse'), max_length=300) city = models.CharField(_('ville'), max_length=100) postal_code = models.CharField(_('code postal'), max_length=10) latitude = models.DecimalField(_('latitude'), max_digits=9, decimal_places=6, null=True, blank=True) longitude = models.DecimalField(_('longitude'), max_digits=9, decimal_places=6, null=True, blank=True) # Image de couverture (pour la liste des biens) thumbnail = ResizedImageField( size=[900, 600], quality=85, upload_to='properties/covers/', force_format='WEBP', null=True, blank=True, verbose_name=_('image de couverture'), ) # SEO (facultatif — auto-généré si vide) meta_title_fr = models.CharField(_('titre SEO (français)'), max_length=70, blank=True) meta_title_en = models.CharField(_('SEO title (English)'), max_length=70, blank=True) meta_description_fr = models.CharField(_('description SEO (français)'), max_length=160, blank=True) meta_description_en = models.CharField(_('SEO description (English)'), max_length=160, blank=True) meta_keywords_fr = models.CharField(_('mots-clés SEO (français)'), max_length=300, blank=True) meta_keywords_en = models.CharField(_('SEO keywords (English)'), max_length=300, blank=True) # Caractéristiques libres features = models.ManyToManyField( PropertyFeature, blank=True, verbose_name=_('caractéristiques'), ) created_at = models.DateTimeField(_('créé le'), auto_now_add=True) updated_at = models.DateTimeField(_('modifié le'), auto_now=True) class Meta: verbose_name = _('bien immobilier') verbose_name_plural = _('biens immobiliers') ordering = ['-created_at'] def __str__(self): return self.name_fr def save(self, *args, **kwargs): """Génère automatiquement le slug depuis le nom français.""" if not self.slug: self.slug = slugify(self.name_fr) super().save(*args, **kwargs) @property def name(self): """Nom dans la langue active.""" return self.get_translated('name') @property def description(self): """Description dans la langue active.""" return self.get_translated('description') @property def cover_photo(self): """Retourne la photo de couverture ou la première photo disponible.""" cover = self.photos.filter(is_cover=True).first() if cover: return cover return self.photos.first() @property def price_display(self): """Prix formaté pour l'affichage.""" if self.price_on_request: return _('Prix sur demande') return f'{int(self.price):,} €'.replace(',', ' ') @property def seo_title(self): """Titre SEO : champ personnalisé ou génération automatique.""" custom = self.get_translated('meta_title') if custom: return custom return f'{self.name} — {self.get_property_type_display()} à {self.city}' @property def seo_description(self): """Meta description : champ personnalisé ou début de description tronqué.""" custom = self.get_translated('meta_description') if custom: return custom desc = self.get_translated('description') words = desc.split() truncated = ' '.join(words[:28]) return (truncated + '…') if len(words) > 28 else truncated @property def seo_keywords(self): """Mots-clés SEO : champ personnalisé ou génération depuis type/ville/caractéristiques.""" custom = self.get_translated('meta_keywords') if custom: return custom parts = [ self.city, self.postal_code, self.get_property_type_display(), self.get_status_display(), ] parts += [f.name for f in self.features.all()[:5]] return ', '.join(filter(None, parts)) def get_absolute_url(self): from django.urls import reverse return reverse('properties:detail', kwargs={'slug': self.slug}) class PropertyEnvironment(BilingualMixin, models.Model): """Informations sur l'environnement géographique du bien.""" LOCATION_TYPES = [ ('city', _('Ville')), ('village', _('Village')), ('countryside', _('Campagne')), ] property = models.OneToOneField( Property, on_delete=models.CASCADE, related_name='environment', verbose_name=_('bien'), ) location_type = models.CharField(_('type de localisation'), max_length=20, choices=LOCATION_TYPES) description_fr = models.TextField(_('description géographique (français)'), blank=True) description_en = models.TextField(_('geographic description (English)'), blank=True) # Commerces de proximité has_supermarket = models.BooleanField(_('supérette'), default=False) has_food_shop = models.BooleanField(_('alimentation'), default=False) has_bakery = models.BooleanField(_('boulangerie'), default=False) has_newspaper_shop = models.BooleanField(_('journaux / tabac presse'), default=False) has_bar = models.BooleanField(_('bar / café'), default=False) has_tobacco_shop = models.BooleanField(_('bureau de tabac'), default=False) has_garage = models.BooleanField(_('garage / garagiste'), default=False) has_gas_station = models.BooleanField(_('station essence'), default=False) # Services mairie / éducation has_city_hall_services = models.BooleanField(_('services de la mairie'), default=False) has_nursery = models.BooleanField(_('crèche'), default=False) has_public_school = models.BooleanField(_('école publique'), default=False) # Santé has_doctor = models.BooleanField(_('médecin'), default=False) has_pharmacy = models.BooleanField(_('pharmacie'), default=False) has_physiotherapist = models.BooleanField(_('cabinet kinésithérapeute'), default=False) has_nurse = models.BooleanField(_('cabinet infirmier'), default=False) class Meta: verbose_name = _('environnement') verbose_name_plural = _('environnements') def __str__(self): return f'Environnement — {self.property.name_fr}' # @builtins.property car 'property' est un champ FK dans cette classe, # ce qui masquerait le built-in Python @property. @builtins.property def description(self): """Description dans la langue active.""" return self.get_translated('description') def get_nearby_shops(self): """Retourne les commerces sous forme de triplets (icône, label, présent).""" return [ ('🛒', _('Supérette'), self.has_supermarket), ('🏪', _('Alimentation'), self.has_food_shop), ('🥖', _('Boulangerie'), self.has_bakery), ('📰', _('Journaux / Presse'), self.has_newspaper_shop), ('🍺', _('Bar / Café'), self.has_bar), ('🚬', _('Bureau de tabac'), self.has_tobacco_shop), ('🔧', _('Garagiste'), self.has_garage), ('⛽', _('Station essence'), self.has_gas_station), ] def get_public_services(self): """Retourne les services publics sous forme de triplets (icône, label, présent).""" return [ ('🏛️', _('Services mairie'), self.has_city_hall_services), ('🍼', _('Crèche'), self.has_nursery), ('🏫', _('École publique'), self.has_public_school), ] def get_health_services(self): """Retourne les services de santé sous forme de triplets (icône, label, présent).""" return [ ('🩺', _('Médecin'), self.has_doctor), ('💊', _('Pharmacie'), self.has_pharmacy), ('🤸', _('Kinésithérapeute'), self.has_physiotherapist), ('🩹', _('Cabinet infirmier'), self.has_nurse), ] class Room(BilingualMixin, models.Model): """Pièce d'un bien immobilier, utilisée pour organiser l'album photos.""" property = models.ForeignKey( Property, on_delete=models.CASCADE, related_name='rooms', verbose_name=_('bien'), ) name_fr = models.CharField(_('nom (français)'), max_length=100) name_en = models.CharField(_('name (English)'), max_length=100) order = models.PositiveSmallIntegerField(_('ordre'), default=0, db_index=True) class Meta: verbose_name = _('pièce') verbose_name_plural = _('pièces') ordering = ['order', 'id'] def __str__(self): return f'{self.property.name_fr} — {self.name_fr}' @builtins.property def name(self): """Nom dans la langue active.""" return self.get_translated('name') class Photo(BilingualMixin, models.Model): """Photo associée à un bien et optionnellement à une pièce.""" property = models.ForeignKey( Property, on_delete=models.CASCADE, related_name='photos', verbose_name=_('bien'), ) room = models.ForeignKey( Room, on_delete=models.SET_NULL, null=True, blank=True, related_name='photos', verbose_name=_('pièce'), ) image = ResizedImageField( size=[1600, 1067], quality=85, upload_to='properties/photos/', force_format='WEBP', verbose_name=_('image'), ) caption_fr = models.CharField(_('légende (français)'), max_length=200, blank=True) caption_en = models.CharField(_('caption (English)'), max_length=200, blank=True) is_cover = models.BooleanField(_('photo de couverture'), default=False) order = models.PositiveSmallIntegerField(_('ordre'), default=0, db_index=True) class Meta: verbose_name = _('photo') verbose_name_plural = _('photos') ordering = ['order', 'id'] def __str__(self): return f'Photo {self.order + 1} — {self.property.name_fr}' @builtins.property def caption(self): """Légende dans la langue active.""" return self.get_translated('caption') class ContactRequest(models.Model): """Demande de contact envoyée depuis le formulaire public.""" property = models.ForeignKey( Property, on_delete=models.SET_NULL, null=True, blank=True, related_name='contact_requests', verbose_name=_('bien concerné'), ) name = models.CharField(_('nom'), max_length=200) email = models.EmailField(_('email')) phone = models.CharField(_('téléphone'), max_length=30, blank=True) message = models.TextField(_('message')) ip_address = models.GenericIPAddressField(_('adresse IP'), null=True, blank=True) created_at = models.DateTimeField(_('reçu le'), auto_now_add=True) class Meta: verbose_name = _('demande de contact') verbose_name_plural = _('demandes de contact') ordering = ['-created_at'] def __str__(self): return f'{self.name} <{self.email}> — {self.created_at:%d/%m/%Y %H:%M}'