First commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Configuration de l'interface d'administration Django pour les biens immobiliers.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from adminsortable2.admin import SortableAdminMixin, SortableAdminBase, SortableInlineAdminMixin
|
||||
|
||||
from .models import (
|
||||
Property, PropertyEnvironment, PropertyFeature,
|
||||
Room, Photo, ContactRequest,
|
||||
)
|
||||
|
||||
# ─── Couleurs des classes DPE / GES ──────────────────────────────────────────
|
||||
DPE_COLORS = {
|
||||
'A': '#00B050', 'B': '#67B346', 'C': '#ACCC54',
|
||||
'D': '#F5D33B', 'E': '#F6A600', 'F': '#E95A0C', 'G': '#CE1622',
|
||||
}
|
||||
|
||||
|
||||
def _class_badge(letter, label=''):
|
||||
"""Génère un badge coloré HTML pour une classe DPE/GES."""
|
||||
if not letter:
|
||||
return format_html('<span style="color:#666">—</span>')
|
||||
color = DPE_COLORS.get(letter, '#888')
|
||||
text = f'{letter} {label}'.strip()
|
||||
return format_html(
|
||||
'<span style="background:{};color:#fff;padding:2px 10px;border-radius:4px;'
|
||||
'font-weight:bold;font-size:13px">{}</span>',
|
||||
color, text,
|
||||
)
|
||||
|
||||
|
||||
# ─── Inlines ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class RoomInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||
"""Inline d'édition des pièces avec tri drag-and-drop."""
|
||||
model = Room
|
||||
extra = 1
|
||||
fields = ('name_fr', 'name_en', 'order')
|
||||
|
||||
|
||||
class PhotoInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||
"""Inline d'édition des photos avec tri drag-and-drop."""
|
||||
model = Photo
|
||||
extra = 1
|
||||
fields = ('image', 'room', 'caption_fr', 'caption_en', 'is_cover', 'order')
|
||||
readonly_fields = ('preview',)
|
||||
|
||||
def preview(self, obj):
|
||||
"""Aperçu miniature de la photo."""
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height:60px;border-radius:4px">',
|
||||
obj.image.url,
|
||||
)
|
||||
return '—'
|
||||
preview.short_description = _('Aperçu')
|
||||
|
||||
|
||||
class PropertyEnvironmentInline(admin.StackedInline):
|
||||
"""Inline d'édition de l'environnement du bien."""
|
||||
model = PropertyEnvironment
|
||||
can_delete = False
|
||||
extra = 0
|
||||
fieldsets = [
|
||||
(_('Localisation'), {
|
||||
'fields': ['location_type', 'description_fr', 'description_en'],
|
||||
}),
|
||||
(_('Commerces de proximité'), {
|
||||
'fields': [
|
||||
('has_supermarket', 'has_food_shop', 'has_bakery'),
|
||||
('has_newspaper_shop', 'has_bar', 'has_tobacco_shop'),
|
||||
('has_garage', 'has_gas_station'),
|
||||
],
|
||||
'classes': ['collapse'],
|
||||
}),
|
||||
(_('Services mairie / éducation'), {
|
||||
'fields': [
|
||||
('has_city_hall_services', 'has_nursery', 'has_public_school'),
|
||||
],
|
||||
'classes': ['collapse'],
|
||||
}),
|
||||
(_('Services de santé'), {
|
||||
'fields': [
|
||||
('has_doctor', 'has_pharmacy', 'has_physiotherapist', 'has_nurse'),
|
||||
],
|
||||
'classes': ['collapse'],
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
# ─── Admin principal ──────────────────────────────────────────────────────────
|
||||
|
||||
@admin.register(Property)
|
||||
class PropertyAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"""Administration des biens immobiliers."""
|
||||
|
||||
change_form_template = 'admin/properties/property/change_form.html'
|
||||
|
||||
list_display = [
|
||||
'name_fr', 'city', 'type_badge', 'status_badge',
|
||||
'price_display', 'living_area_display',
|
||||
'energy_badge', 'ges_badge', 'is_active',
|
||||
]
|
||||
list_filter = ['property_type', 'status', 'is_active', 'energy_class', 'condition']
|
||||
search_fields = ['name_fr', 'name_en', 'address', 'city', 'postal_code']
|
||||
prepopulated_fields = {'slug': ('name_fr',)}
|
||||
filter_horizontal = ('features',)
|
||||
inlines = [PropertyEnvironmentInline, RoomInline, PhotoInline]
|
||||
|
||||
fieldsets = [
|
||||
(_('Identification'), {
|
||||
'fields': ['name_fr', 'name_en', 'slug', 'is_active'],
|
||||
}),
|
||||
(_('Classification'), {
|
||||
'fields': [('property_type', 'status'), ('price', 'price_on_request')],
|
||||
}),
|
||||
(_('Surfaces'), {
|
||||
'fields': [('living_area', 'total_area', 'total_land_area')],
|
||||
}),
|
||||
(_('Composition'), {
|
||||
'fields': [('rooms_count', 'bedrooms_count', 'bathrooms_count')],
|
||||
}),
|
||||
(_('Équipements'), {
|
||||
'fields': [
|
||||
('has_elevator', 'exposure'),
|
||||
('has_garden', 'has_pool', 'has_dependencies'),
|
||||
'features',
|
||||
],
|
||||
}),
|
||||
(_('Construction'), {
|
||||
'fields': [('construction_year', 'condition')],
|
||||
}),
|
||||
(_('Diagnostics énergétiques'), {
|
||||
'fields': [
|
||||
('energy_class', 'energy_value'),
|
||||
('ges_class', 'ges_value'),
|
||||
],
|
||||
}),
|
||||
(_('Description'), {
|
||||
'fields': ['description_fr', 'description_en'],
|
||||
}),
|
||||
(_('Localisation'), {
|
||||
'fields': ['address', ('city', 'postal_code'), ('latitude', 'longitude')],
|
||||
}),
|
||||
(_('Image de couverture'), {
|
||||
'fields': ['thumbnail'],
|
||||
}),
|
||||
(_('SEO'), {
|
||||
'fields': [
|
||||
('meta_title_fr', 'meta_title_en'),
|
||||
('meta_description_fr', 'meta_description_en'),
|
||||
('meta_keywords_fr', 'meta_keywords_en'),
|
||||
],
|
||||
'classes': ['collapse'],
|
||||
'description': 'Laisser vide pour une génération automatique.',
|
||||
}),
|
||||
]
|
||||
|
||||
def type_badge(self, obj):
|
||||
return obj.get_property_type_display()
|
||||
type_badge.short_description = _('Type')
|
||||
|
||||
def status_badge(self, obj):
|
||||
color = '#2196F3' if obj.status == 'sale' else '#4CAF50'
|
||||
return format_html(
|
||||
'<span style="background:{};color:#fff;padding:2px 8px;border-radius:3px;font-size:12px">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
)
|
||||
status_badge.short_description = _('Statut')
|
||||
|
||||
def price_display(self, obj):
|
||||
return obj.price_display
|
||||
price_display.short_description = _('Prix')
|
||||
|
||||
def living_area_display(self, obj):
|
||||
return f'{int(obj.living_area)} m²'
|
||||
living_area_display.short_description = _('Surface')
|
||||
|
||||
def energy_badge(self, obj):
|
||||
return _class_badge(obj.energy_class, f'{obj.energy_value} kWh' if obj.energy_value else '')
|
||||
energy_badge.short_description = _('DPE')
|
||||
|
||||
def ges_badge(self, obj):
|
||||
return _class_badge(obj.ges_class, f'{obj.ges_value} kg' if obj.ges_value else '')
|
||||
ges_badge.short_description = _('GES')
|
||||
|
||||
# ── URL et vue d'upload groupé ────────────────────────────────────────────
|
||||
|
||||
def get_urls(self):
|
||||
"""Ajoute l'URL de l'upload groupé de photos."""
|
||||
urls = super().get_urls()
|
||||
custom = [
|
||||
path(
|
||||
'<int:property_pk>/photos/upload/',
|
||||
self.admin_site.admin_view(self.upload_photos_view),
|
||||
name='properties_property_upload_photos',
|
||||
),
|
||||
]
|
||||
return custom + urls
|
||||
|
||||
def upload_photos_view(self, request, property_pk):
|
||||
"""Vue d'upload groupé : une ou plusieurs photos vers une pièce en un seul envoi."""
|
||||
prop = get_object_or_404(Property, pk=property_pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
files = request.FILES.getlist('images')
|
||||
room = None
|
||||
|
||||
# Création d'une nouvelle pièce si demandée
|
||||
new_room_fr = request.POST.get('new_room_fr', '').strip()
|
||||
new_room_en = request.POST.get('new_room_en', '').strip()
|
||||
if new_room_fr:
|
||||
room = Room.objects.create(
|
||||
property=prop,
|
||||
name_fr=new_room_fr,
|
||||
name_en=new_room_en or new_room_fr,
|
||||
order=prop.rooms.count(),
|
||||
)
|
||||
else:
|
||||
room_pk = request.POST.get('room') or None
|
||||
if room_pk:
|
||||
room = get_object_or_404(Room, pk=room_pk, property=prop)
|
||||
|
||||
# Ordre de départ : après les photos existantes de cette pièce
|
||||
base_order = (
|
||||
Photo.objects.filter(property=prop, room=room).count()
|
||||
)
|
||||
created = 0
|
||||
for i, f in enumerate(files):
|
||||
Photo.objects.create(
|
||||
property=prop,
|
||||
room=room,
|
||||
image=f,
|
||||
order=base_order + i,
|
||||
)
|
||||
created += 1
|
||||
|
||||
room_label = room.name_fr if room else '(général)'
|
||||
from django.contrib import messages as dj_messages
|
||||
dj_messages.success(
|
||||
request,
|
||||
f'{created} photo(s) ajoutée(s) → pièce : {room_label}',
|
||||
)
|
||||
# Retour vers la page suivante ou la fiche du bien
|
||||
next_url = request.POST.get('next', '')
|
||||
if next_url == 'again':
|
||||
return redirect('.')
|
||||
return redirect(
|
||||
reverse('admin:properties_property_change', args=[property_pk])
|
||||
)
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
'property': prop,
|
||||
'rooms': prop.rooms.order_by('order'),
|
||||
'title': f'📷 Ajout de photos — {prop.name_fr}',
|
||||
'opts': self.model._meta,
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
'admin/properties/property/upload_photos.html',
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PropertyFeature)
|
||||
class PropertyFeatureAdmin(admin.ModelAdmin):
|
||||
"""Administration des caractéristiques de biens."""
|
||||
list_display = ['name_fr', 'name_en', 'icon']
|
||||
search_fields = ['name_fr', 'name_en']
|
||||
|
||||
|
||||
@admin.register(ContactRequest)
|
||||
class ContactRequestAdmin(admin.ModelAdmin):
|
||||
"""Administration des demandes de contact (lecture seule)."""
|
||||
list_display = ['created_at', 'name', 'email', 'phone', 'property', 'ip_address']
|
||||
list_filter = ['created_at', 'property']
|
||||
search_fields = ['name', 'email', 'message']
|
||||
readonly_fields = [
|
||||
'property', 'name', 'email', 'phone',
|
||||
'message', 'ip_address', 'created_at',
|
||||
]
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Processeurs de contexte globaux injectés dans tous les templates.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def site_settings(request):
|
||||
"""Injecte les paramètres globaux du site dans le contexte des templates."""
|
||||
return {
|
||||
'SITE_NAME': 'LOGIS',
|
||||
'SITE_DOMAIN': getattr(settings, 'SITE_DOMAIN', ''),
|
||||
'ADMIN_EMAIL': getattr(settings, 'ADMIN_EMAIL', ''),
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Formulaires publics de l'application LOGIS.
|
||||
"""
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
"""Formulaire de demande de renseignements sur un bien immobilier.
|
||||
|
||||
Protégé par :
|
||||
- token CSRF Django (automatique)
|
||||
- champ honeypot invisible (doit rester vide)
|
||||
- limitation de débit côté session (5 minutes entre deux envois)
|
||||
"""
|
||||
|
||||
name = forms.CharField(
|
||||
max_length=200,
|
||||
label=_('Nom complet'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Votre nom et prénom'),
|
||||
'autocomplete': 'name',
|
||||
}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_('Adresse email'),
|
||||
widget=forms.EmailInput(attrs={
|
||||
'placeholder': _('votre@email.com'),
|
||||
'autocomplete': 'email',
|
||||
}),
|
||||
)
|
||||
phone = forms.CharField(
|
||||
max_length=30,
|
||||
required=False,
|
||||
label=_('Téléphone'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('+33 6 XX XX XX XX'),
|
||||
'autocomplete': 'tel',
|
||||
}),
|
||||
)
|
||||
message = forms.CharField(
|
||||
label=_('Message'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'placeholder': _('Votre message, vos questions…'),
|
||||
'rows': 5,
|
||||
}),
|
||||
)
|
||||
property_id = forms.IntegerField(
|
||||
widget=forms.HiddenInput(),
|
||||
required=False,
|
||||
)
|
||||
# Champ honeypot — doit rester vide ; les robots le remplissent
|
||||
website = forms.CharField(
|
||||
required=False,
|
||||
label='',
|
||||
widget=forms.TextInput(attrs={
|
||||
'style': 'position:absolute;left:-9999px;top:-9999px;',
|
||||
'tabindex': '-1',
|
||||
'autocomplete': 'off',
|
||||
'aria-hidden': 'true',
|
||||
}),
|
||||
)
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data.get('name', '').strip()
|
||||
if len(name) < 2:
|
||||
raise forms.ValidationError(_('Veuillez indiquer votre nom complet.'))
|
||||
return name
|
||||
|
||||
def clean_message(self):
|
||||
msg = self.cleaned_data.get('message', '').strip()
|
||||
if len(msg) < 10:
|
||||
raise forms.ValidationError(_('Votre message est trop court.'))
|
||||
return msg
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-28 07:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_resized.forms
|
||||
import properties.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Property',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_fr', models.CharField(max_length=200, verbose_name='nom (français)')),
|
||||
('name_en', models.CharField(max_length=200, verbose_name='name (English)')),
|
||||
('slug', models.SlugField(blank=True, max_length=220, unique=True, verbose_name='slug')),
|
||||
('property_type', models.CharField(choices=[('house', 'Maison'), ('apartment', 'Appartement'), ('land', 'Terrain'), ('commercial', 'Local commercial'), ('other', 'Autre')], max_length=20, verbose_name='type de bien')),
|
||||
('status', models.CharField(choices=[('sale', 'À vendre'), ('rent', 'À louer')], max_length=10, verbose_name='statut')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='actif / visible')),
|
||||
('price', models.DecimalField(decimal_places=0, max_digits=12, verbose_name='prix (€)')),
|
||||
('price_on_request', models.BooleanField(default=False, verbose_name='prix sur demande')),
|
||||
('total_land_area', models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='surface totale du terrain (m²)')),
|
||||
('living_area', models.DecimalField(decimal_places=0, max_digits=8, verbose_name='surface habitable (m²)')),
|
||||
('rooms_count', models.PositiveSmallIntegerField(verbose_name='nombre de pièces')),
|
||||
('bedrooms_count', models.PositiveSmallIntegerField(verbose_name='nombre de chambres')),
|
||||
('bathrooms_count', models.PositiveSmallIntegerField(verbose_name="nombre de salles d'eau")),
|
||||
('has_elevator', models.BooleanField(default=False, verbose_name='ascenseur')),
|
||||
('exposure', models.CharField(blank=True, choices=[('N', 'Nord'), ('S', 'Sud'), ('E', 'Est'), ('O', 'Ouest'), ('NE', 'Nord-Est'), ('NO', 'Nord-Ouest'), ('SE', 'Sud-Est'), ('SO', 'Sud-Ouest')], max_length=2, verbose_name='exposition')),
|
||||
('has_garden', models.BooleanField(default=False, verbose_name='jardin')),
|
||||
('has_pool', models.BooleanField(default=False, verbose_name='piscine')),
|
||||
('has_dependencies', models.BooleanField(default=False, verbose_name='dépendances')),
|
||||
('construction_year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='année de construction')),
|
||||
('condition', models.CharField(choices=[('new', 'Neuf'), ('good', 'Bon état'), ('to_refresh', 'À rafraîchir'), ('to_renovate', 'À rénover')], max_length=20, verbose_name='état du bien')),
|
||||
('energy_class', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=1, verbose_name='classe énergie (DPE)')),
|
||||
('energy_value', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='valeur DPE (kWh/m²/an)')),
|
||||
('ges_class', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=1, verbose_name='classe GES')),
|
||||
('ges_value', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='valeur GES (kgCO₂/m²/an)')),
|
||||
('description_fr', models.TextField(verbose_name='description (français)')),
|
||||
('description_en', models.TextField(verbose_name='description (English)')),
|
||||
('address', models.CharField(max_length=300, verbose_name='adresse')),
|
||||
('city', models.CharField(max_length=100, verbose_name='ville')),
|
||||
('postal_code', models.CharField(max_length=10, verbose_name='code postal')),
|
||||
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True, verbose_name='latitude')),
|
||||
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True, verbose_name='longitude')),
|
||||
('thumbnail', django_resized.forms.ResizedImageField(blank=True, crop=None, force_format='WEBP', keep_meta=True, null=True, quality=85, scale=1.0, size=[900, 600], upload_to='properties/covers/', verbose_name='image de couverture')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='créé le')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='modifié le')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'bien immobilier',
|
||||
'verbose_name_plural': 'biens immobiliers',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
bases=(properties.models.BilingualMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PropertyFeature',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_fr', models.CharField(max_length=100, verbose_name='nom (français)')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='name (English)')),
|
||||
('icon', models.CharField(blank=True, help_text='Classe CSS ou caractère unicode (ex: 🔥)', max_length=50, verbose_name='icône')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'caractéristique',
|
||||
'verbose_name_plural': 'caractéristiques',
|
||||
'ordering': ['name_fr'],
|
||||
},
|
||||
bases=(properties.models.BilingualMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Room',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_fr', models.CharField(max_length=100, verbose_name='nom (français)')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='name (English)')),
|
||||
('order', models.PositiveSmallIntegerField(db_index=True, default=0, verbose_name='ordre')),
|
||||
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='properties.property', verbose_name='bien')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'pièce',
|
||||
'verbose_name_plural': 'pièces',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
bases=(properties.models.BilingualMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PropertyEnvironment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('location_type', models.CharField(choices=[('city', 'Ville'), ('village', 'Village'), ('countryside', 'Campagne')], max_length=20, verbose_name='type de localisation')),
|
||||
('description_fr', models.TextField(blank=True, verbose_name='description géographique (français)')),
|
||||
('description_en', models.TextField(blank=True, verbose_name='geographic description (English)')),
|
||||
('has_supermarket', models.BooleanField(default=False, verbose_name='supérette')),
|
||||
('has_food_shop', models.BooleanField(default=False, verbose_name='alimentation')),
|
||||
('has_bakery', models.BooleanField(default=False, verbose_name='boulangerie')),
|
||||
('has_newspaper_shop', models.BooleanField(default=False, verbose_name='journaux / tabac presse')),
|
||||
('has_bar', models.BooleanField(default=False, verbose_name='bar / café')),
|
||||
('has_tobacco_shop', models.BooleanField(default=False, verbose_name='bureau de tabac')),
|
||||
('has_garage', models.BooleanField(default=False, verbose_name='garage / garagiste')),
|
||||
('has_gas_station', models.BooleanField(default=False, verbose_name='station essence')),
|
||||
('has_city_hall_services', models.BooleanField(default=False, verbose_name='services de la mairie')),
|
||||
('has_nursery', models.BooleanField(default=False, verbose_name='crèche')),
|
||||
('has_public_school', models.BooleanField(default=False, verbose_name='école publique')),
|
||||
('has_doctor', models.BooleanField(default=False, verbose_name='médecin')),
|
||||
('has_pharmacy', models.BooleanField(default=False, verbose_name='pharmacie')),
|
||||
('has_physiotherapist', models.BooleanField(default=False, verbose_name='cabinet kinésithérapeute')),
|
||||
('has_nurse', models.BooleanField(default=False, verbose_name='cabinet infirmier')),
|
||||
('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='environment', to='properties.property', verbose_name='bien')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'environnement',
|
||||
'verbose_name_plural': 'environnements',
|
||||
},
|
||||
bases=(properties.models.BilingualMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='features',
|
||||
field=models.ManyToManyField(blank=True, to='properties.propertyfeature', verbose_name='caractéristiques'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Photo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, quality=85, scale=1.0, size=[1600, 1067], upload_to='properties/photos/', verbose_name='image')),
|
||||
('caption_fr', models.CharField(blank=True, max_length=200, verbose_name='légende (français)')),
|
||||
('caption_en', models.CharField(blank=True, max_length=200, verbose_name='caption (English)')),
|
||||
('is_cover', models.BooleanField(default=False, verbose_name='photo de couverture')),
|
||||
('order', models.PositiveSmallIntegerField(db_index=True, default=0, verbose_name='ordre')),
|
||||
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='properties.property', verbose_name='bien')),
|
||||
('room', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to='properties.room', verbose_name='pièce')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'photo',
|
||||
'verbose_name_plural': 'photos',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
bases=(properties.models.BilingualMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContactRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='nom')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email')),
|
||||
('phone', models.CharField(blank=True, max_length=30, verbose_name='téléphone')),
|
||||
('message', models.TextField(verbose_name='message')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='adresse IP')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='reçu le')),
|
||||
('property', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contact_requests', to='properties.property', verbose_name='bien concerné')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'demande de contact',
|
||||
'verbose_name_plural': 'demandes de contact',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-28 08:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('properties', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='total_area',
|
||||
field=models.DecimalField(decimal_places=0, default=0, max_digits=8, verbose_name='surface totale (m²)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='property',
|
||||
name='condition',
|
||||
field=models.CharField(choices=[('new', 'Neuf'), ('exellent', 'Exellent état'), ('good', 'Bon état'), ('to_refresh', 'À rafraîchir'), ('to_renovate', 'À rénover')], max_length=20, verbose_name='état du bien'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-28 16:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('properties', '0002_property_total_area_alter_property_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_description_en',
|
||||
field=models.CharField(blank=True, max_length=160, verbose_name='SEO description (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_description_fr',
|
||||
field=models.CharField(blank=True, max_length=160, verbose_name='description SEO (français)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_keywords_en',
|
||||
field=models.CharField(blank=True, max_length=300, verbose_name='SEO keywords (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_keywords_fr',
|
||||
field=models.CharField(blank=True, max_length=300, verbose_name='mots-clés SEO (français)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_title_en',
|
||||
field=models.CharField(blank=True, max_length=70, verbose_name='SEO title (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='meta_title_fr',
|
||||
field=models.CharField(blank=True, max_length=70, verbose_name='titre SEO (français)'),
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Sitemap XML pour les biens immobiliers actifs.
|
||||
"""
|
||||
from django.contrib.sitemaps import Sitemap
|
||||
from .models import Property
|
||||
|
||||
|
||||
class PropertySitemap(Sitemap):
|
||||
changefreq = 'weekly'
|
||||
priority = 0.8
|
||||
|
||||
def items(self):
|
||||
return Property.objects.filter(is_active=True).order_by('-updated_at')
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Tags et filtres de templates spécifiques à l'application LOGIS.
|
||||
"""
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
# Couleurs officielles des classes DPE
|
||||
DPE_COLORS = {
|
||||
'A': '#00B050', 'B': '#67B346', 'C': '#ACCC54',
|
||||
'D': '#F5D33B', 'E': '#F6A600', 'F': '#E95A0C', 'G': '#CE1622',
|
||||
}
|
||||
|
||||
|
||||
@register.filter
|
||||
def dpe_color(letter):
|
||||
"""Retourne la couleur hexadécimale correspondant à une classe DPE/GES."""
|
||||
return DPE_COLORS.get(str(letter).upper(), '#888888')
|
||||
|
||||
|
||||
@register.filter
|
||||
def price_fr(value):
|
||||
"""Formate un nombre décimal en prix européen (ex: 250 000 €)."""
|
||||
try:
|
||||
return f'{int(value):,} €'.replace(',', ' ')
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def dpe_badge(letter, value=None, unit='kWh/m²/an'):
|
||||
"""Génère un badge HTML coloré pour une classe DPE ou GES."""
|
||||
if not letter:
|
||||
return mark_safe('<span class="dpe-badge dpe-unknown">—</span>')
|
||||
color = DPE_COLORS.get(str(letter).upper(), '#888')
|
||||
extra = f'<small>{value} {unit}</small>' if value else ''
|
||||
return mark_safe(
|
||||
f'<span class="dpe-badge" style="background:{color}">'
|
||||
f'<strong>{letter}</strong>{extra}</span>'
|
||||
)
|
||||
|
||||
|
||||
@register.inclusion_tag('properties/partials/dpe_scale.html')
|
||||
def dpe_scale(energy_class, ges_class):
|
||||
"""Affiche la double échelle DPE/GES avec la classe courante mise en valeur."""
|
||||
return {
|
||||
'classes': list('ABCDEFG'),
|
||||
'dpe_colors': DPE_COLORS,
|
||||
'energy_class': energy_class,
|
||||
'ges_class': ges_class,
|
||||
}
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def lang_switch_url(context, lang_code):
|
||||
"""Retourne l'URL de la page courante traduite dans lang_code.
|
||||
|
||||
Calcule la cible directement via resolve/reverse au lieu de déléguer à
|
||||
translate_url(), qui peut échouer avec prefix_default_language=False.
|
||||
"""
|
||||
from django.urls import resolve, reverse, Resolver404, NoReverseMatch
|
||||
from django.utils.translation import override
|
||||
|
||||
request = context.get('request')
|
||||
if not request:
|
||||
return '/'
|
||||
try:
|
||||
match = resolve(request.path_info)
|
||||
with override(lang_code):
|
||||
return reverse(match.view_name, args=match.args, kwargs=match.kwargs)
|
||||
except (Resolver404, NoReverseMatch):
|
||||
pass
|
||||
with override(lang_code):
|
||||
try:
|
||||
return reverse('properties:list')
|
||||
except NoReverseMatch:
|
||||
return '/'
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
"""Retourne dictionary[key] depuis un template (dict|get_item:key)."""
|
||||
return dictionary.get(key, '')
|
||||
|
||||
|
||||
@register.filter
|
||||
def service_icon(service_name):
|
||||
"""Retourne une icône unicode pour un service de proximité."""
|
||||
icons = {
|
||||
'supermarket': '🛒', 'food_shop': '🥦', 'bakery': '🥖',
|
||||
'newspaper_shop': '📰', 'bar': '☕', 'tobacco_shop': '🚬',
|
||||
'garage': '🔧', 'gas_station': '⛽',
|
||||
'city_hall': '🏛️', 'nursery': '🍼', 'public_school': '🏫',
|
||||
'doctor': '🩺', 'pharmacy': '💊', 'physiotherapist': '🏃',
|
||||
'nurse': '💉',
|
||||
}
|
||||
return icons.get(service_name, '✓')
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
URLs de l'application properties.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'properties'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.PropertyListView.as_view(), name='list'),
|
||||
path('bien/<slug:slug>/', views.PropertyDetailView.as_view(), name='detail'),
|
||||
path('contact/', views.ContactView.as_view(), name='contact'),
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Vues de l'application LOGIS.
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.http import JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, DetailView, View
|
||||
|
||||
from .forms import ContactForm
|
||||
from .models import Property, ContactRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PropertyListView(ListView):
|
||||
"""Page d'accueil — liste des biens immobiliers actifs."""
|
||||
|
||||
model = Property
|
||||
template_name = 'index.html'
|
||||
context_object_name = 'properties'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Retourne les biens actifs avec leurs relations préchargées."""
|
||||
return (
|
||||
Property.objects.filter(is_active=True)
|
||||
.prefetch_related('photos', 'features')
|
||||
.select_related('environment')
|
||||
)
|
||||
|
||||
|
||||
class PropertyDetailView(DetailView):
|
||||
"""Page de détail d'un bien immobilier."""
|
||||
|
||||
model = Property
|
||||
template_name = 'properties/detail.html'
|
||||
context_object_name = 'property'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Retourne uniquement les biens actifs."""
|
||||
return (
|
||||
Property.objects.filter(is_active=True)
|
||||
.prefetch_related('photos', 'rooms__photos', 'features')
|
||||
.select_related('environment')
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
prop = self.object
|
||||
context['contact_form'] = ContactForm(
|
||||
initial={'property_id': prop.pk}
|
||||
)
|
||||
context['rooms_with_photos'] = prop.rooms.prefetch_related('photos').all()
|
||||
context['general_photos'] = prop.photos.filter(room__isnull=True)
|
||||
return context
|
||||
|
||||
|
||||
class ContactView(View):
|
||||
"""Endpoint AJAX pour le formulaire de contact."""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Accepte uniquement les requêtes AJAX
|
||||
if not request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return JsonResponse({'success': False}, status=400)
|
||||
|
||||
form = ContactForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
return JsonResponse({'success': False, 'errors': form.errors})
|
||||
|
||||
# Vérification honeypot : si rempli, c'est un robot
|
||||
if form.cleaned_data.get('website'):
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
# Limitation de débit : 5 minutes entre deux envois par session
|
||||
last_contact = request.session.get('last_contact')
|
||||
if last_contact:
|
||||
try:
|
||||
last_time = datetime.datetime.fromisoformat(last_contact)
|
||||
if timezone.is_naive(last_time):
|
||||
last_time = timezone.make_aware(last_time)
|
||||
elapsed = (timezone.now() - last_time).total_seconds()
|
||||
if elapsed < 300:
|
||||
remaining = int((300 - elapsed) / 60) + 1
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(_(
|
||||
'Merci de patienter encore %(min)s minute(s) avant d\'envoyer un nouveau message.'
|
||||
) % {'min': remaining}),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Récupération du bien concerné
|
||||
property_obj = None
|
||||
pid = form.cleaned_data.get('property_id')
|
||||
if pid:
|
||||
try:
|
||||
property_obj = Property.objects.get(pk=pid, is_active=True)
|
||||
except Property.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Enregistrement en base
|
||||
contact = ContactRequest.objects.create(
|
||||
property=property_obj,
|
||||
name=form.cleaned_data['name'],
|
||||
email=form.cleaned_data['email'],
|
||||
phone=form.cleaned_data.get('phone', ''),
|
||||
message=form.cleaned_data['message'],
|
||||
ip_address=self._get_client_ip(request),
|
||||
)
|
||||
|
||||
# Envoi de l'email à l'administrateur
|
||||
try:
|
||||
ctx = {'contact': contact, 'property': property_obj}
|
||||
subject = render_to_string('emails/contact_subject.txt', ctx).strip()
|
||||
body_text = render_to_string('emails/contact_body.txt', ctx)
|
||||
body_html = render_to_string('emails/contact_body.html', ctx)
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=body_text,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[settings.ADMIN_EMAIL],
|
||||
html_message=body_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
# L'email échoue mais le contact est enregistré en base
|
||||
logger.error('Échec envoi email contact #%s : %s', contact.pk, exc)
|
||||
|
||||
request.session['last_contact'] = timezone.now().isoformat()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': str(_('Votre message a bien été envoyé. Nous vous répondrons dans les plus brefs délais.')),
|
||||
})
|
||||
|
||||
def _get_client_ip(self, request):
|
||||
"""Extrait l'adresse IP réelle du client derrière un proxy."""
|
||||
forwarded = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if forwarded:
|
||||
return forwarded.split(',')[0].strip()
|
||||
return request.META.get('REMOTE_ADDR')
|
||||
Reference in New Issue
Block a user