401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""
|
||
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}'
|