First commit
This commit is contained in:
@@ -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}'
|
||||
Reference in New Issue
Block a user