First commit

This commit is contained in:
2026-05-28 23:43:20 +02:00
commit 86cd7854c2
49 changed files with 8504 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
"""
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}'