Files
2026-05-28 23:43:20 +02:00

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