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
View File
+293
View File
@@ -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)}'
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
+13
View File
@@ -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', ''),
}
+74
View File
@@ -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
+166
View File
@@ -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'),
),
]
+43
View File
@@ -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)'),
),
]
View File
+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}'
+16
View File
@@ -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
View File
+98
View File
@@ -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, '')
+13
View File
@@ -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'),
]
+147
View File
@@ -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')