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
+13
View File
@@ -0,0 +1,13 @@
SECRET_KEY=changez-cette-cle-secrete-en-production
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
SITE_DOMAIN=your-domain.com
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=noreply@your-domain.com
EMAIL_HOST_PASSWORD=votre-mot-de-passe
DEFAULT_FROM_EMAIL=LOGIS <noreply@your-domain.com>
ADMIN_EMAIL=admin@your-domain.com
+43
View File
@@ -0,0 +1,43 @@
# ── Secrets — NE JAMAIS committer ────────────────────────────────────────────
.env
*.env
context.md
# ── Base de données ───────────────────────────────────────────────────────────
db.sqlite3
*.sqlite3
# ── Médias uploadés (photos des biens) ───────────────────────────────────────
media/
# ── Fichiers générés ─────────────────────────────────────────────────────────
staticfiles/
__pycache__/
*.py[cod]
*.pyo
.cache/
# ── Environnement virtuel ─────────────────────────────────────────────────────
venv/
.venv/
env/
# ── Configs de déploiement (contiennent domaine/chemins réels) ───────────────
conf/nginx.conf
conf/supervisor.conf
conf/gunicorn.conf.py
# ── Logs ─────────────────────────────────────────────────────────────────────
*.log
logs/
# ── IDE / OS ──────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# ── Compilations traductions ──────────────────────────────────────────────────
locale/**/*.mo
+134
View File
@@ -0,0 +1,134 @@
# LOGIS PERSONNEL — Application immobilière
Site vitrine bilingue (français / anglais) de gestion et présentation de biens immobiliers, construit avec Django 4.2.
**URL de production** : https://logis.miraceti.net
**Admin** : https://logis.miraceti.net/admin/
---
## Stack technique
| Composant | Technologie |
|---|---|
| Backend | Django 4.2, Python 3.13 |
| Serveur | Gunicorn 3 workers, port 8091 |
| Proxy | nginx (SSL certbot, rate-limit) |
| Process | Supervisor |
| Images | django-resized → WEBP automatique |
| Tri drag-and-drop | django-admin-sortable2 |
| Fichiers statiques | Whitenoise |
| Config | python-decouple (.env) |
| Base de données | SQLite |
---
## Installation
```bash
python3 -m venv venv
source venv/bin/activate
unset REQUESTS_CA_BUNDLE SSL_CERT_FILE CURL_CA_BUNDLE # VPS spécifique
pip install -r requirements.txt
cp .env.example .env # puis éditer .env
./manage.py migrate
./manage.py createsuperuser
./manage.py collectstatic
```
### Développement
```bash
./manage.py runserver
```
Le fichier `manage.py` utilise `logis_project.settings.development` par défaut.
### Production
```bash
# Copier les configs
sudo cp conf/nginx.conf /etc/nginx/sites-available/logis
sudo ln -sf /etc/nginx/sites-available/logis /etc/nginx/sites-enabled/logis
sudo cp conf/supervisor.conf /etc/supervisor/conf.d/logis.conf
# Créer les répertoires de logs
sudo mkdir -p /var/log/logis
sudo chown miraceti:miraceti /var/log/logis
# Permissions home (nginx doit traverser le répertoire)
chmod 711 /home/miraceti
# SSL
sudo certbot certonly --webroot -w /var/www/html -d logis.miraceti.net
# Démarrer
sudo systemctl reload nginx
sudo supervisorctl reread && sudo supervisorctl update
sudo supervisorctl start logis
```
---
## Commandes courantes
```bash
# Redémarrer l'application après modification
sudo supervisorctl restart logis
# Voir les logs
tail -f /var/log/logis/gunicorn_error.log
tail -f /var/log/logis/django.log
# Appliquer une migration
DJANGO_SETTINGS_MODULE=logis_project.settings.production ./manage.py migrate
# Régénérer les statiques
DJANGO_SETTINGS_MODULE=logis_project.settings.production ./manage.py collectstatic --no-input
# Traductions
./manage.py makemessages -l en
./manage.py compilemessages
```
---
## Variables d'environnement (.env)
| Variable | Description |
|---|---|
| `SECRET_KEY` | Clé secrète Django |
| `DEBUG` | `False` en production |
| `ALLOWED_HOSTS` | Hosts autorisés, séparés par des virgules |
| `EMAIL_HOST` | Serveur SMTP |
| `EMAIL_PORT` | Port SMTP (587 pour STARTTLS) |
| `EMAIL_HOST_USER` | Identifiant SMTP |
| `EMAIL_HOST_PASSWORD` | Mot de passe SMTP |
| `EMAIL_USE_TLS` | `True` pour STARTTLS |
| `DEFAULT_FROM_EMAIL` | Adresse expéditeur |
| `ADMIN_EMAIL` | Destinataire des formulaires de contact |
---
## Structure des répertoires
```
LOGIS/
├── conf/ # nginx, gunicorn, supervisor
├── locale/ # Traductions fr/en (.po / .mo)
├── logis_project/ # Settings, urls, wsgi
│ └── settings/
│ ├── base.py
│ ├── development.py
│ └── production.py
├── properties/ # Application principale
│ ├── models.py # Property, Room, Photo, ContactRequest…
│ ├── admin.py # Interface d'administration
│ ├── views.py # Liste, détail, contact (AJAX)
│ ├── sitemaps.py # Sitemap XML
│ └── templatetags/ # property_tags (filtres, lang_switch_url…)
├── static/ # Sources CSS / JS / images
├── staticfiles/ # Sortie collectstatic (servi par nginx)
├── templates/ # Templates HTML + emails + robots.txt
└── media/ # Uploads (photos des biens)
```
+22
View File
@@ -0,0 +1,22 @@
"""
Configuration Gunicorn pour LOGIS en production.
"""
# Adresse d'écoute (nginx fait le proxy depuis le port 80/443)
bind = '127.0.0.1:8091'
# Nombre de workers : (2 × CPU) + 1 est une heuristique classique
workers = 3
worker_class = 'sync'
timeout = 120
keepalive = 5
max_requests = 1000
max_requests_jitter = 50
# Journaux
accesslog = '/var/log/logis/gunicorn_access.log'
errorlog = '/var/log/logis/gunicorn_error.log'
loglevel = 'info'
capture_output = True
# Répertoire de travail
chdir = '/path/to/LOGIS'
+90
View File
@@ -0,0 +1,90 @@
# Configuration nginx pour LOGIS
# Copier dans : /etc/nginx/sites-available/logis
# Puis : ln -s /etc/nginx/sites-available/logis /etc/nginx/sites-enabled/
# SSL fourni par : certbot --nginx -d your-domain.com
# Rate-limit sur le formulaire de contact : 10 requêtes/min par IP
limit_req_zone $binary_remote_addr zone=contact:10m rate=10r/m;
# Redirection HTTP → HTTPS
server {
listen 80;
listen [::]:80;
server_name your-domain.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
# Serveur HTTPS principal
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name your-domain.com;
# Certificats SSL (générés par certbot)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Sécurité des en-têtes
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Taille max des uploads (photos haute résolution)
client_max_body_size 25M;
# Fichiers statiques servis directement par nginx
location /static/ {
alias /path/to/LOGIS/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Fichiers médias (photos uploadées)
location /media/ {
alias /path/to/LOGIS/media/;
expires 7d;
add_header Cache-Control "public";
access_log off;
}
# Scans automatiques — drop silencieux (pas de réponse)
location ~* \.(php|asp|aspx|jsp|cgi|pl|sh|bash|env|git|svn|htaccess)$ {
return 444;
}
location ~* ^/(cgi-bin|vendor|phpunit|Autodiscover|autodiscover|wp-admin|wp-login|phpmyadmin|admin\.php|xmlrpc\.php|shell|config|\.well-known/(?!acme)) {
return 444;
}
# Rate-limit sur l'endpoint de contact (fr + en)
location ~* ^/(en/)?contact/ {
limit_req zone=contact burst=5 nodelay;
proxy_pass http://127.0.0.1:8091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy vers Gunicorn (port 8091)
location / {
proxy_pass http://127.0.0.1:8091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_buffering on;
}
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Script d'installation et de configuration de LOGIS en production
# Exécuter une seule fois sur le VPS en tant que l'utilisateur applicatif
# Usage : bash conf/setup_production.sh
set -euo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV="$BASE_DIR/venv"
LOG_DIR="/var/log/logis"
echo "==> Répertoire de l'application : $BASE_DIR"
# ── 1. Répertoires de logs ────────────────────────────────────────────────────
echo "==> Création du répertoire de logs…"
sudo mkdir -p "$LOG_DIR"
sudo chown "$USER:$USER" "$LOG_DIR"
sudo chmod 755 "$LOG_DIR"
# ── 2. Environnement virtuel Python ──────────────────────────────────────────
if [ ! -d "$VENV" ]; then
echo "==> Création du virtualenv…"
python3 -m venv "$VENV"
fi
echo "==> Installation des dépendances…"
"$VENV/bin/pip" install --upgrade pip
"$VENV/bin/pip" install -r "$BASE_DIR/requirements.txt"
# ── 3. Fichier .env de production ────────────────────────────────────────────
if [ ! -f "$BASE_DIR/.env" ]; then
echo "==> Copie de .env.example → .env (à personnaliser !)"
cp "$BASE_DIR/.env.example" "$BASE_DIR/.env"
echo ""
echo " ⚠ Éditez maintenant $BASE_DIR/.env avec vos vraies valeurs"
echo " (SECRET_KEY, EMAIL_*, ALLOWED_HOSTS, SITE_DOMAIN…)"
echo ""
fi
# ── 4. Migrations et fichiers statiques ──────────────────────────────────────
echo "==> Migrations…"
DJANGO_SETTINGS_MODULE=logis_project.settings.production \
"$VENV/bin/python" "$BASE_DIR/manage.py" migrate --noinput
echo "==> Fichiers statiques…"
DJANGO_SETTINGS_MODULE=logis_project.settings.production \
"$VENV/bin/python" "$BASE_DIR/manage.py" collectstatic --noinput
echo "==> Compilation des traductions…"
DJANGO_SETTINGS_MODULE=logis_project.settings.production \
"$VENV/bin/python" "$BASE_DIR/manage.py" compilemessages
# ── 5. Supervisor ────────────────────────────────────────────────────────────
echo "==> Installation du service Supervisor…"
sudo cp "$BASE_DIR/conf/supervisor.conf" /etc/supervisor/conf.d/logis.conf
sudo supervisorctl reread
sudo supervisorctl update
# ── 6. Nginx ─────────────────────────────────────────────────────────────────
echo "==> Installation de la configuration nginx…"
sudo cp "$BASE_DIR/conf/nginx.conf" /etc/nginx/sites-available/logis
if [ ! -f /etc/nginx/sites-enabled/logis ]; then
sudo ln -s /etc/nginx/sites-available/logis /etc/nginx/sites-enabled/logis
fi
sudo nginx -t && sudo systemctl reload nginx
# ── 7. Certbot SSL ────────────────────────────────────────────────────────────
echo ""
echo "==> Pour générer le certificat SSL, exécutez :"
echo " sudo certbot --nginx -d \$(grep SITE_DOMAIN $BASE_DIR/.env | cut -d= -f2)"
echo ""
echo "✓ Installation terminée."
echo " Créez un superutilisateur : $VENV/bin/python $BASE_DIR/manage.py createsuperuser"
+21
View File
@@ -0,0 +1,21 @@
; Configuration Supervisor pour le processus Gunicorn de LOGIS
; Copier dans : /etc/supervisor/conf.d/logis.conf
; Puis : supervisorctl reread && supervisorctl update
[program:logis]
command=/path/to/LOGIS/venv/bin/gunicorn
--config /path/to/LOGIS/conf/gunicorn.conf.py
logis_project.wsgi:application
directory=/path/to/LOGIS
environment=DJANGO_SETTINGS_MODULE="logis_project.settings.production"
user=your-user
group=your-user
autostart=true
autorestart=true
startretries=3
redirect_stderr=true
stdout_logfile=/var/log/logis/supervisor.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5
stopasgroup=true
killasgroup=true
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
View File
+9
View File
@@ -0,0 +1,9 @@
"""
Point d'entrée ASGI pour le projet LOGIS.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logis_project.settings.production')
application = get_asgi_application()
View File
+138
View File
@@ -0,0 +1,138 @@
"""
Configuration de base commune à tous les environnements.
"""
from pathlib import Path
from django.utils.translation import gettext_lazy as _
from decouple import config, Csv
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = config('SECRET_KEY')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sitemaps',
'django_resized',
'adminsortable2',
'properties',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'logis_project.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.i18n',
'properties.context_processors.site_settings',
],
},
},
]
WSGI_APPLICATION = 'logis_project.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# Internationalisation
LANGUAGE_CODE = 'fr'
LANGUAGES = [
('fr', _('Français')),
('en', _('English')),
]
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_L10N = True
USE_TZ = True
LOCALE_PATHS = [BASE_DIR / 'locale']
# Fichiers statiques
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Fichiers médias
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# django-resized : configuration par défaut
DJANGORESIZED_DEFAULT_SIZE = [1920, 1080]
DJANGORESIZED_DEFAULT_SCALE = 1.0
DJANGORESIZED_DEFAULT_QUALITY = 85
DJANGORESIZED_DEFAULT_KEEP_META = True
DJANGORESIZED_DEFAULT_FORCE_FORMAT = 'WEBP'
DJANGORESIZED_DEFAULT_FORMAT_EXTENSIONS = {'WEBP': '.webp', 'JPEG': '.jpg'}
DJANGORESIZED_DEFAULT_NORMALIZE_ROTATION = True
# Configuration email (envoi uniquement)
EMAIL_BACKEND = config(
'EMAIL_BACKEND',
default='django.core.mail.backends.smtp.EmailBackend',
)
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
SITE_DOMAIN = config('SITE_DOMAIN', default='')
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='LOGIS <noreply@example.com>')
ADMIN_EMAIL = config('ADMIN_EMAIL', default='admin@example.com')
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {'class': 'logging.StreamHandler'},
},
'root': {
'handlers': ['console'],
'level': 'WARNING',
},
'loggers': {
'properties': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
}
+15
View File
@@ -0,0 +1,15 @@
"""
Configuration pour l'environnement de développement.
"""
from .base import * # noqa: F401, F403
from decouple import config
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0']
# Email en console pendant le développement
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Désactive la compression des fichiers statiques en dev
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
+63
View File
@@ -0,0 +1,63 @@
"""
Configuration pour l'environnement de production.
"""
from .base import * # noqa: F401, F403
from decouple import config, Csv
DEBUG = False
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv(), default='localhost')
# Sécurité HTTPS
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# Cache des sessions
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# Logging en production vers fichier
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'class': 'logging.FileHandler',
'filename': '/var/log/logis/django.log',
'formatter': 'verbose',
},
},
'root': {
'handlers': ['file'],
'level': 'WARNING',
},
'loggers': {
'django.request': {
'handlers': ['file'],
'level': 'ERROR', # 404 → silencieux, seuls les 500 sont loggués
'propagate': False,
},
'django.security': {
'handlers': ['file'],
'level': 'ERROR',
'propagate': False,
},
'properties': {
'handlers': ['file'],
'level': 'INFO',
'propagate': False,
},
},
}
+33
View File
@@ -0,0 +1,33 @@
"""
URLs principales du projet LOGIS.
"""
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include
from django.contrib.sitemaps.views import sitemap
from django.views.generic import TemplateView
from properties.sitemaps import PropertySitemap
admin.site.site_header = 'LOGIS — Administration'
admin.site.site_title = 'LOGIS'
admin.site.index_title = 'Gestion des biens immobiliers'
_sitemaps = {'properties': PropertySitemap()}
urlpatterns = [
path('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')),
path('sitemap.xml', sitemap, {'sitemaps': _sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type='text/plain')),
]
urlpatterns += i18n_patterns(
path('', include('properties.urls')),
prefix_default_language=False,
)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+9
View File
@@ -0,0 +1,9 @@
"""
Point d'entrée WSGI pour le projet LOGIS.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logis_project.settings.production')
application = get_wsgi_application()
Executable
+23
View File
@@ -0,0 +1,23 @@
#!venv/bin/python
##!/usr/bin/env python
"""Utilitaire de ligne de commande Django pour les tâches administratives."""
import os
import sys
def main():
"""Exécute les tâches administratives."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logis_project.settings.development')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Impossible d'importer Django. Vérifiez que Django est installé "
"et disponible dans votre variable d'environnement PYTHONPATH, "
"et que vous avez activé votre virtualenv."
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
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')
+7
View File
@@ -0,0 +1,7 @@
Django>=4.2,<5.1
django-resized>=1.0.2
Pillow>=10.0.0
gunicorn>=21.0.0
python-decouple>=3.8
django-admin-sortable2>=2.1
whitenoise>=6.6.0
+816
View File
@@ -0,0 +1,816 @@
/* ============================================================
LOGIS — Thème sombre moderne, compatible mobile
============================================================ */
/* ── Variables ───────────────────────────────────────────── */
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-card-hov: #1c2430;
--bg-sidebar: #111827;
--border: #30363d;
--accent: #e94560;
--accent-hov: #c73652;
--gold: #c9a84c;
--text: #e6edf3;
--text-muted: #8b949e;
--text-faint: #484f58;
--success: #3fb950;
--shadow: 0 4px 16px rgba(0,0,0,.45);
--radius: 10px;
--radius-sm: 6px;
--transition: .2s ease;
--max-w: 1200px;
}
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
main { flex: 1; }
img { display: block; max-width: 100%; height: auto; }
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font: inherit; border: none; background: none; }
ul { list-style: none; }
/* ── Utilitaires ─────────────────────────────────────────── */
.container { max-width: var(--max-w); margin: 0 auto; padding: 0 1.25rem; }
/* ── Boutons ─────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
padding: .75rem 1.75rem;
border-radius: var(--radius-sm);
font-size: .95rem;
font-weight: 600;
transition: background var(--transition), transform var(--transition),
box-shadow var(--transition);
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover, .btn-primary:focus-visible {
background: var(--accent-hov);
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(233,69,96,.35);
}
.btn-full { width: 100%; }
.btn-hero {
margin-top: 1.5rem;
padding: .9rem 2.5rem;
font-size: 1.05rem;
}
/* ── Navigation ──────────────────────────────────────────── */
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(13,17,23,.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.navbar-brand {
display: flex;
align-items: center;
gap: .6rem;
font-size: 1.3rem;
font-weight: 700;
letter-spacing: .05em;
}
.brand-icon { font-size: 1.4rem; }
.brand-name { color: var(--text); }
/* Sélecteur de langue */
.lang-form { display: inline; }
.lang-switcher {
display: flex;
gap: .25rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: .2rem .3rem;
}
.lang-btn {
padding: .25rem .65rem;
border-radius: 16px;
font-size: .8rem;
font-weight: 600;
color: var(--text-muted);
transition: background var(--transition), color var(--transition);
}
.lang-btn.active,
.lang-btn:hover { background: var(--accent); color: #fff; }
/* ── Hero ────────────────────────────────────────────────── */
.hero {
position: relative;
min-height: clamp(400px, 70vh, 680px);
display: flex;
align-items: center;
background: var(--bg-card)
var(--hero-bg, none)
center/cover no-repeat;
}
.hero-generic { background-image: none; background: linear-gradient(135deg,#0d1117,#1a1a2e); }
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(13,17,23,.85) 0%,
rgba(13,17,23,.55) 60%,
rgba(13,17,23,.25) 100%
);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 620px;
}
.hero-label {
display: inline-block;
padding: .3rem .9rem;
background: var(--accent);
color: #fff;
border-radius: 20px;
font-size: .8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 1rem;
}
.hero-title {
font-size: clamp(1.8rem, 5vw, 3.2rem);
font-weight: 800;
line-height: 1.15;
margin-bottom: .75rem;
}
.hero-meta {
color: var(--text-muted);
font-size: 1.05rem;
margin-bottom: .5rem;
}
.hero-price {
font-size: clamp(1.4rem, 3vw, 2rem);
font-weight: 700;
color: var(--gold);
}
.hero-subtitle {
font-size: 1.15rem;
color: var(--text-muted);
margin-top: .5rem;
}
/* ── Grille des biens ────────────────────────────────────── */
.properties-section { padding: 4rem 0; }
.properties-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
/* ── Carte bien ──────────────────────────────────────────── */
.property-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: transform var(--transition), box-shadow var(--transition),
border-color var(--transition);
}
.property-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
border-color: var(--accent);
}
.property-card-link { display: block; }
.property-card-img-wrapper {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
background: var(--bg);
}
.property-card-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .4s ease;
}
.property-card:hover .property-card-img { transform: scale(1.04); }
.property-card-img-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 4rem;
color: var(--text-faint);
}
/* Badges sur la vignette */
.property-card-status {
position: absolute;
top: .75rem;
left: .75rem;
padding: .25rem .75rem;
border-radius: 20px;
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
}
.status-sale { background: var(--accent); color: #fff; }
.status-rent { background: #0e7fe0; color: #fff; }
.property-card-dpe {
position: absolute;
top: .75rem;
right: .75rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-weight: 800;
font-size: .9rem;
color: #fff;
}
.property-card-body { padding: 1.25rem; }
.property-card-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: .4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.property-card-location {
font-size: .85rem;
color: var(--text-muted);
margin-bottom: .85rem;
}
.property-card-specs {
display: flex;
gap: .75rem;
flex-wrap: wrap;
font-size: .85rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.property-card-specs li { display: flex; align-items: center; gap: .3rem; }
.property-card-price {
font-size: 1.25rem;
font-weight: 800;
color: var(--gold);
}
.property-card-price small { font-size: .8rem; font-weight: 400; color: var(--text-muted); }
.empty-state {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
/* ── Page de détail ──────────────────────────────────────── */
.detail-page { padding-bottom: 4rem; }
.detail-header {
padding-top: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.detail-header-meta {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: .75rem;
}
.back-link {
font-size: .88rem;
color: var(--text-muted);
transition: color var(--transition);
}
.back-link:hover { color: var(--accent); }
.detail-status {
padding: .25rem .85rem;
border-radius: 20px;
font-size: .78rem;
font-weight: 700;
text-transform: uppercase;
}
.detail-title {
font-size: clamp(1.5rem, 4vw, 2.4rem);
font-weight: 800;
margin-bottom: .5rem;
}
.detail-location { color: var(--text-muted); margin-bottom: .75rem; }
.detail-price {
font-size: clamp(1.4rem, 3vw, 2rem);
font-weight: 800;
color: var(--gold);
}
.detail-price small { font-size: .8rem; font-weight: 400; color: var(--text-muted); }
/* ── Galerie ─────────────────────────────────────────────── */
.detail-gallery {
margin-top: 2rem;
}
.gallery-tabs {
display: flex;
gap: .5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: .75rem;
}
.gallery-tab {
padding: .4rem 1rem;
border-radius: 20px;
font-size: .85rem;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.gallery-tab:hover { color: var(--text); border-color: var(--text-muted); }
.gallery-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: .5rem;
display: none;
}
.gallery-grid.active { display: grid; }
.gallery-item {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
border-radius: var(--radius-sm);
border: 2px solid transparent;
transition: border-color var(--transition), transform var(--transition);
background: var(--bg-card);
}
.gallery-item:hover { border-color: var(--accent); transform: scale(1.02); }
.gallery-thumb {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .3s ease;
}
.gallery-item:hover .gallery-thumb { transform: scale(1.06); }
.gallery-item-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: .35rem .5rem;
background: linear-gradient(transparent, rgba(0,0,0,.7));
color: #fff;
font-size: .75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.no-photos { color: var(--text-muted); padding: 2rem 0; }
/* ── Corps détail ────────────────────────────────────────── */
.detail-body {
display: grid;
grid-template-columns: 1fr 340px;
gap: 2rem;
margin-top: 2.5rem;
align-items: start;
}
@media (max-width: 900px) {
.detail-body { grid-template-columns: 1fr; }
}
.detail-section { margin-bottom: 2.5rem; }
.section-title {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: .5rem;
border-bottom: 2px solid var(--accent);
display: inline-block;
}
.detail-description {
color: var(--text-muted);
line-height: 1.8;
}
.detail-description p { margin-bottom: .85rem; }
/* Environnement */
.env-type {
font-size: 1rem;
font-weight: 600;
margin-bottom: .75rem;
}
.env-description {
color: var(--text-muted);
margin-bottom: 1.5rem;
line-height: 1.7;
}
.env-services {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.env-group-title {
font-size: .9rem;
font-weight: 700;
margin-bottom: .6rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .05em;
}
.env-list { display: flex; flex-direction: column; gap: .3rem; }
.env-item {
display: flex;
align-items: center;
gap: .5rem;
font-size: .88rem;
}
.env-available { color: var(--success); }
.env-unavailable { color: var(--text-faint); }
.env-icon {
width: 1.2rem;
text-align: center;
font-weight: 700;
flex-shrink: 0;
}
/* ── Sidebar ─────────────────────────────────────────────── */
.detail-sidebar { display: flex; flex-direction: column; gap: 1.25rem; }
.sidebar-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
.sidebar-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: .5rem;
border-bottom: 1px solid var(--border);
}
.key-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .4rem 1rem;
}
.key-info dt {
font-size: .78rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
padding-top: .6rem;
border-top: 1px solid var(--border);
}
.key-info dd {
font-size: .9rem;
font-weight: 600;
padding-top: .6rem;
border-top: 1px solid var(--border);
}
.key-info dt:nth-child(1), .key-info dd:nth-child(2) { border-top: none; padding-top: 0; }
.exterior-badges { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; }
.badge {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .25rem .7rem;
border-radius: 20px;
font-size: .78rem;
font-weight: 600;
}
.badge-exterior { background: rgba(201,168,76,.15); color: var(--gold); border: 1px solid rgba(201,168,76,.3); }
.features-list { margin-top: 1.25rem; }
.features-title {
font-size: .8rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .05em;
margin-bottom: .6rem;
}
.features-list ul {
display: flex;
flex-direction: column;
gap: .35rem;
}
.features-list li {
font-size: .88rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: .4rem;
}
.features-list li::before { content: '✓'; color: var(--success); font-size: .85rem; }
/* Diagnostics */
.diagnostics-card {}
.diag-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem 0;
border-bottom: 1px solid var(--border);
gap: .5rem;
}
.diag-row:last-of-type { border-bottom: none; }
.diag-label { font-size: .85rem; color: var(--text-muted); }
.dpe-badge {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .3rem .8rem;
border-radius: var(--radius-sm);
color: #fff;
font-weight: 700;
}
.dpe-badge strong { font-size: 1.1rem; }
.dpe-badge small { font-size: .75rem; font-weight: 400; opacity: .85; }
.dpe-unknown { background: #444; color: var(--text-muted); }
.dpe-scale-wrapper { margin-top: 1rem; }
.dpe-scale { display: flex; flex-direction: column; gap: 3px; }
.dpe-scale-label { font-size: .75rem; color: var(--text-faint); margin-bottom: .4rem; }
.dpe-bar {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 50px;
height: 22px;
border-radius: 0 4px 4px 0;
padding: 0 8px;
color: #fff;
font-size: .75rem;
font-weight: 700;
transition: filter .2s;
}
.dpe-active { filter: brightness(1.2); box-shadow: 0 0 0 2px #fff; }
.dpe-arrow { font-size: .7rem; font-weight: 400; opacity: .9; }
/* Contact sidebar */
.contact-intro {
font-size: .9rem;
color: var(--text-muted);
margin-bottom: 1.25rem;
line-height: 1.6;
}
/* ── Modal générique ─────────────────────────────────────── */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal[hidden] { display: none; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(4px);
}
.modal-box {
position: relative;
z-index: 1;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
padding: 2rem;
box-shadow: var(--shadow);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 1.5rem;
color: var(--text-muted);
line-height: 1;
transition: color var(--transition);
}
.modal-close:hover { color: var(--accent); }
.modal-title {
font-size: 1.15rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
/* ── Formulaire de contact ───────────────────────────────── */
.hp-field { position: absolute; left: -9999px; top: -9999px; opacity: 0; }
.contact-form { display: flex; flex-direction: column; gap: 1rem; }
.form-group { display: flex; flex-direction: column; gap: .35rem; }
.form-group label {
font-size: .85rem;
font-weight: 600;
color: var(--text-muted);
}
.required { color: var(--accent); }
.optional { color: var(--text-faint); font-weight: 400; }
.form-group input,
.form-group textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: .65rem .9rem;
font-size: .95rem;
transition: border-color var(--transition);
width: 100%;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea { resize: vertical; min-height: 100px; }
.field-error { font-size: .8rem; color: var(--accent); min-height: 1em; }
.form-notice {
font-size: .78rem;
color: var(--text-faint);
line-height: 1.5;
}
.form-global-error {
background: rgba(233,69,96,.12);
border: 1px solid rgba(233,69,96,.3);
border-radius: var(--radius-sm);
padding: .75rem 1rem;
font-size: .88rem;
color: var(--accent);
}
.form-global-error[hidden] { display: none; }
.contact-success {
text-align: center;
padding: 1.5rem 0;
}
.contact-success[hidden] { display: none; }
.success-icon { font-size: 2.5rem; color: var(--success); }
.success-title { font-size: 1.15rem; font-weight: 700; margin: .75rem 0 .4rem; }
.success-body { color: var(--text-muted); }
/* ── Modal galerie ───────────────────────────────────────── */
.gallery-modal {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-modal[hidden] { display: none; }
.gallery-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,.92);
}
.gallery-modal-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
max-width: 1100px;
padding: 1rem;
}
.gallery-modal-figure {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: .75rem;
}
.gallery-modal-figure img {
max-height: 80vh;
max-width: 100%;
border-radius: var(--radius-sm);
object-fit: contain;
user-select: none;
}
.gallery-modal-caption {
color: rgba(255,255,255,.7);
font-size: .88rem;
text-align: center;
}
.gallery-modal-counter {
position: absolute;
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
color: rgba(255,255,255,.5);
font-size: .8rem;
background: rgba(0,0,0,.5);
padding: .2rem .6rem;
border-radius: 20px;
}
.gallery-modal-close {
position: absolute;
top: 1rem;
right: 1rem;
color: rgba(255,255,255,.7);
font-size: 2rem;
line-height: 1;
z-index: 2;
transition: color var(--transition);
}
.gallery-modal-close:hover { color: #fff; }
.gallery-modal-nav {
color: rgba(255,255,255,.6);
font-size: 3rem;
line-height: 1;
padding: .5rem;
transition: color var(--transition), transform var(--transition);
flex-shrink: 0;
}
.gallery-modal-nav:hover { color: #fff; transform: scale(1.15); }
.gallery-modal-nav:disabled { opacity: .2; pointer-events: none; }
/* ── Pied de page ────────────────────────────────────────── */
.site-footer {
background: var(--bg-card);
border-top: 1px solid var(--border);
padding: 2rem 0;
margin-top: 2rem;
}
.footer-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: .4rem;
text-align: center;
}
.footer-brand {
font-weight: 700;
font-size: 1rem;
letter-spacing: .05em;
}
.footer-copy { font-size: .82rem; color: var(--text-muted); }
/* ── Responsive ──────────────────────────────────────────── */
@media (max-width: 768px) {
.hero { min-height: 320px; }
.hero-content { max-width: 100%; }
.properties-grid { grid-template-columns: 1fr; }
.gallery-grid { grid-template-columns: repeat(2, 1fr); }
.env-services { grid-template-columns: 1fr; }
.gallery-modal-nav { display: none; }
.gallery-modal-content { padding: 3rem .5rem .5rem; }
.key-info { grid-template-columns: 1fr; }
.key-info dt, .key-info dd { border-top: none; padding-top: 0; }
.key-info dt { margin-top: .5rem; }
}
@media (max-width: 480px) {
.navbar { height: 3.5rem; }
.brand-name { display: none; }
.gallery-grid { grid-template-columns: repeat(2, 1fr); }
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<text x="16" y="24" font-size="22" text-anchor="middle" font-family="sans-serif">🏠</text>
</svg>

After

Width:  |  Height:  |  Size: 218 B

+188
View File
@@ -0,0 +1,188 @@
/**
* Gestion du formulaire de contact en modal avec envoi AJAX.
* Protections : CSRF, honeypot, limitation de débit côté session.
*/
class ContactForm {
/**
* @param {HTMLFormElement} form
* @param {HTMLElement} modal
* @param {string} url - endpoint POST
* @param {string} csrfToken
*/
constructor(form, modal, url, csrfToken) {
this.form = form;
this.modal = modal;
this.url = url;
this.csrfToken = csrfToken;
this.overlay = modal.querySelector('#contact-overlay');
this.btnClose = modal.querySelector('#contact-modal-close');
this.btnOpen = document.querySelector('#open-contact-modal');
this.submit = form.querySelector('#contact-submit');
this.success = modal.querySelector('#contact-success');
this.globalErr = modal.querySelector('#contact-global-error');
this._bindEvents();
}
// ── Initialisation ─────────────────────────────────────────────────────────
_bindEvents() {
// Ouvrir
if (this.btnOpen) {
this.btnOpen.addEventListener('click', () => {
this._setPropertyId(this.btnOpen.dataset.property);
this.open();
});
}
// Fermer
if (this.btnClose) this.btnClose.addEventListener('click', () => this.close());
if (this.overlay) this.overlay.addEventListener('click', () => this.close());
// Clavier
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this.modal.hidden) this.close();
});
// Soumission
this.form.addEventListener('submit', (e) => this._handleSubmit(e));
// Nettoyage des erreurs à la saisie
this.form.querySelectorAll('input, textarea').forEach((el) => {
el.addEventListener('input', () => this._clearFieldError(el.name));
});
}
// ── API publique ───────────────────────────────────────────────────────────
open() {
this._resetForm();
this.modal.hidden = false;
document.body.style.overflow = 'hidden';
this.form.querySelector('#id_name')?.focus();
}
close() {
this.modal.hidden = true;
document.body.style.overflow = '';
}
// ── Soumission ─────────────────────────────────────────────────────────────
async _handleSubmit(e) {
e.preventDefault();
if (!this._validateLocal()) return;
this._setLoading(true);
this._hideGlobalError();
const formData = new FormData(this.form);
try {
const response = await fetch(this.url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': this.csrfToken,
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success) {
this._showSuccess();
} else if (data.errors) {
this._showFieldErrors(data.errors);
} else if (data.error) {
this._showGlobalError(data.error);
}
} catch (err) {
this._showGlobalError(
(window.I18N?.networkError) || 'Une erreur est survenue. Veuillez réessayer.'
);
} finally {
this._setLoading(false);
}
}
// ── Validation locale légère ───────────────────────────────────────────────
_validateLocal() {
let valid = true;
const name = this.form.querySelector('#id_name');
const email = this.form.querySelector('#id_email');
const msg = this.form.querySelector('#id_message');
if (!name.value.trim()) {
this._setFieldError('name', name.required ? 'Ce champ est requis.' : '');
valid = false;
}
if (!email.value.trim() || !email.value.includes('@')) {
this._setFieldError('email', 'Adresse email invalide.');
valid = false;
}
if (!msg.value.trim() || msg.value.trim().length < 10) {
this._setFieldError('message', 'Votre message est trop court.');
valid = false;
}
return valid;
}
// ── Utilitaires ────────────────────────────────────────────────────────────
_setPropertyId(id) {
const input = this.form.querySelector('#id_property_id');
if (input) input.value = id || '';
}
_setLoading(loading) {
if (!this.submit) return;
const i18n = window.I18N || {};
this.submit.disabled = loading;
this.submit.textContent = loading
? (i18n.sending || 'Envoi en cours…')
: (i18n.send || 'Envoyer');
}
_showSuccess() {
this.form.hidden = true;
this.success.hidden = false;
}
_resetForm() {
this.form.reset();
this.form.hidden = false;
this.success.hidden = true;
this._hideGlobalError();
this.form.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; });
}
_setFieldError(name, msg) {
const el = this.form.querySelector(`.field-error[data-field="${name}"]`);
if (el) el.textContent = msg;
}
_clearFieldError(name) {
this._setFieldError(name, '');
}
_showFieldErrors(errors) {
for (const [field, msgs] of Object.entries(errors)) {
const list = Array.isArray(msgs) ? msgs : [msgs];
this._setFieldError(field, list.join(' '));
}
}
_showGlobalError(msg) {
if (!this.globalErr) return;
this.globalErr.textContent = msg;
this.globalErr.hidden = false;
}
_hideGlobalError() {
if (this.globalErr) this.globalErr.hidden = true;
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Galerie photos avec modal plein écran.
* Gère : onglets par pièce, navigation clavier, swipe mobile.
*/
class Gallery {
/**
* @param {Object} data - { 'gallery-key': [{src, caption}, …], … }
* @param {HTMLElement} modal - élément .gallery-modal
*/
constructor(data, modal) {
this.data = data; // toutes les galeries indexées par clé
this.currentKey = null; // clé de la galerie active
this.currentIndex = 0; // index dans la galerie active
this.modal = modal;
this.img = modal.querySelector('#gallery-modal-img');
this.caption = modal.querySelector('#gallery-modal-caption');
this.counter = modal.querySelector('#gallery-counter');
this.overlay = modal.querySelector('#gallery-overlay');
this.btnClose = modal.querySelector('#gallery-close');
this.btnPrev = modal.querySelector('#gallery-prev');
this.btnNext = modal.querySelector('#gallery-next');
this._touchStartX = 0;
this._bindEvents();
this._initTabs();
}
// ── Initialisation ─────────────────────────────────────────────────────────
_bindEvents() {
// Boutons de navigation
this.btnClose.addEventListener('click', () => this.close());
this.overlay.addEventListener('click', () => this.close());
this.btnPrev.addEventListener('click', () => this.prev());
this.btnNext.addEventListener('click', () => this.next());
// Clavier
document.addEventListener('keydown', (e) => {
if (this.modal.hidden) return;
if (e.key === 'Escape') this.close();
if (e.key === 'ArrowLeft') this.prev();
if (e.key === 'ArrowRight') this.next();
});
// Swipe mobile
this.modal.addEventListener('touchstart', (e) => {
this._touchStartX = e.touches[0].clientX;
}, { passive: true });
this.modal.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - this._touchStartX;
if (Math.abs(dx) > 50) {
dx < 0 ? this.next() : this.prev();
}
});
// Délégation : clic sur les vignettes
document.addEventListener('click', (e) => {
const item = e.target.closest('.gallery-item[data-gallery]');
if (!item) return;
this.open(item.dataset.gallery, parseInt(item.dataset.index, 10));
});
}
_initTabs() {
document.querySelectorAll('.gallery-tab').forEach((tab) => {
tab.addEventListener('click', () => {
// Mise à jour des onglets actifs
document.querySelectorAll('.gallery-tab').forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
// Affichage du panneau correspondant
const roomId = tab.dataset.room;
const panelId = roomId === 'general' ? 'room-general' : `room-${roomId}`;
document.querySelectorAll('.gallery-grid').forEach(g => g.classList.remove('active'));
const panel = document.getElementById(panelId);
if (panel) panel.classList.add('active');
});
});
}
// ── API publique ───────────────────────────────────────────────────────────
open(key, index) {
this.currentKey = key;
this.currentIndex = index;
this._render();
this.modal.hidden = false;
document.body.style.overflow = 'hidden';
this.btnClose.focus();
}
close() {
this.modal.hidden = true;
document.body.style.overflow = '';
}
prev() {
const photos = this.data[this.currentKey] || [];
this.currentIndex = (this.currentIndex - 1 + photos.length) % photos.length;
this._render();
}
next() {
const photos = this.data[this.currentKey] || [];
this.currentIndex = (this.currentIndex + 1) % photos.length;
this._render();
}
// ── Rendu ──────────────────────────────────────────────────────────────────
_render() {
const photos = this.data[this.currentKey] || [];
if (!photos.length) return;
const photo = photos[this.currentIndex];
this.img.src = photo.src;
this.img.alt = photo.caption || '';
this.caption.textContent = photo.caption || '';
const total = photos.length;
const of = (window.I18N && I18N.of) ? I18N.of : '/';
this.counter.textContent = `${this.currentIndex + 1} ${of} ${total}`;
// Désactiver les flèches si une seule photo
this.btnPrev.disabled = total <= 1;
this.btnNext.disabled = total <= 1;
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Point d'entrée principal — instancie Gallery et ContactForm
* après le chargement du DOM.
*/
document.addEventListener('DOMContentLoaded', () => {
// ── Galerie photos ─────────────────────────────────────────────────────────
const galleryModal = document.getElementById('gallery-modal');
if (galleryModal && typeof GALLERY_DATA !== 'undefined') {
window._gallery = new Gallery(GALLERY_DATA, galleryModal);
}
// ── Formulaire de contact ──────────────────────────────────────────────────
const contactModal = document.getElementById('contact-modal');
const contactForm = document.getElementById('contact-form');
if (contactModal && contactForm && typeof CONTACT_URL !== 'undefined') {
window._contactForm = new ContactForm(
contactForm,
contactModal,
CONTACT_URL,
typeof CSRF_TOKEN !== 'undefined' ? CSRF_TOKEN : '',
);
}
// ── Disparition auto des messages flash Django ─────────────────────────────
document.querySelectorAll('.alert-auto-hide').forEach((alert) => {
setTimeout(() => {
alert.style.opacity = '0';
alert.style.transition = 'opacity .5s';
setTimeout(() => alert.remove(), 500);
}, 4000);
});
});
@@ -0,0 +1,14 @@
{% extends "admin/change_form.html" %}
{% load i18n %}
{% block object-tools-items %}
{% if original %}
<li>
<a href="{% url 'admin:properties_property_upload_photos' original.pk %}"
class="historylink">
📷 Ajout groupé de photos
</a>
</li>
{% endif %}
{{ block.super }}
{% endblock %}
@@ -0,0 +1,240 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block title %}📷 Ajout de photos — {{ property.name_fr }}{% endblock %}
{% block extrahead %}
{{ block.super }}
<style>
.upload-card {
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
padding: 24px;
max-width: 680px;
margin: 20px 0;
}
.upload-card h2 { margin-top: 0; font-size: 16px; color: #417690; }
.upload-card .form-row { margin-bottom: 16px; }
.upload-card label {
display: block;
font-weight: bold;
margin-bottom: 4px;
font-size: 13px;
}
.upload-card select,
.upload-card input[type=text],
.upload-card input[type=file] {
width: 100%;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
box-sizing: border-box;
}
.upload-card .help { font-size: 11px; color: #888; margin-top: 4px; }
.upload-card .separator {
text-align: center;
color: #999;
font-size: 12px;
margin: 4px 0;
position: relative;
}
.drop-zone {
border: 2px dashed #417690;
border-radius: 6px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: background .2s;
background: #f9fbfc;
}
.drop-zone.dragover { background: #e8f3fa; border-color: #2c6a8e; }
.drop-zone p { margin: 0; color: #417690; font-size: 14px; }
.drop-zone small { color: #888; font-size: 12px; }
#file-preview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.preview-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 4px;
overflow: hidden;
border: 1px solid #ddd;
}
.preview-item img { width: 100%; height: 100%; object-fit: cover; }
.preview-item span {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,.55);
color: #fff;
font-size: 9px;
padding: 2px 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.submit-row { display: flex; gap: 10px; margin-top: 20px; }
.btn-upload {
background: #417690;
color: #fff;
border: none;
padding: 10px 22px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.btn-upload:hover { background: #2c6a8e; }
.btn-again {
background: #f0f0f0;
color: #333;
border: 1px solid #ccc;
padding: 10px 18px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
#file-count { font-size: 12px; color: #417690; margin-top: 6px; }
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Accueil</a>
<a href="{% url 'admin:app_list' app_label='properties' %}">Properties</a>
<a href="{% url 'admin:properties_property_changelist' %}">Biens immobiliers</a>
<a href="{% url 'admin:properties_property_change' property.pk %}">{{ property.name_fr }}</a>
Ajout de photos
</div>
{% endblock %}
{% block content %}
<h1>📷 Ajout groupé de photos — <em>{{ property.name_fr }}</em></h1>
{% if messages %}
{% for msg in messages %}
<p class="success">{{ msg }}</p>
{% endfor %}
{% endif %}
<form method="post" enctype="multipart/form-data" id="upload-form">
{% csrf_token %}
<div class="upload-card">
<h2>1. Sélectionner ou créer une pièce</h2>
<div class="form-row">
<label for="id_room">Pièce existante :</label>
<select name="room" id="id_room">
<option value="">— Photos générales (sans pièce) —</option>
{% for room in rooms %}
<option value="{{ room.pk }}">{{ room.name_fr }}</option>
{% endfor %}
</select>
</div>
<div class="separator">— ou créer une nouvelle pièce —</div>
<div class="form-row">
<label for="id_new_room_fr">Nom français de la nouvelle pièce :</label>
<input type="text" name="new_room_fr" id="id_new_room_fr"
placeholder="ex : Cuisine, Salon, Chambre 1…"
autocomplete="off">
<p class="help">Si renseigné, cette pièce est créée et les photos lui sont attribuées (prioritaire sur la sélection ci-dessus).</p>
</div>
<div class="form-row">
<label for="id_new_room_en">Nom anglais (optionnel) :</label>
<input type="text" name="new_room_en" id="id_new_room_en"
placeholder="ex : Kitchen, Living room, Bedroom 1…"
autocomplete="off">
</div>
</div>
<div class="upload-card">
<h2>2. Choisir les photos</h2>
<div class="drop-zone" id="drop-zone">
<p>📂 Glisser-déposer les photos ici</p>
<small>ou cliquer pour sélectionner (sélection multiple possible)</small>
<input type="file" name="images" id="id_images"
multiple accept="image/*"
style="position:absolute;opacity:0;width:0;height:0">
</div>
<div id="file-count"></div>
<div id="file-preview"></div>
</div>
<div class="submit-row">
<button type="submit" name="next" value="change" class="btn-upload">
✓ Télécharger et revenir à la fiche
</button>
<button type="submit" name="next" value="again" class="btn-again">
✓ Télécharger et ajouter une autre pièce
</button>
<a href="{% url 'admin:properties_property_change' property.pk %}"
style="padding:10px 16px;font-size:14px;color:#666">
Annuler
</a>
</div>
</form>
<script>
(function () {
const dropZone = document.getElementById('drop-zone');
const input = document.getElementById('id_images');
const preview = document.getElementById('file-preview');
const fileCount = document.getElementById('file-count');
let dt = new DataTransfer();
// Ouvrir le sélecteur au clic sur la zone
dropZone.addEventListener('click', () => input.click());
// Drag-and-drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
addFiles(e.dataTransfer.files);
});
input.addEventListener('change', () => {
addFiles(input.files);
input.value = '';
});
function addFiles(files) {
Array.from(files).forEach(f => {
if (!f.type.startsWith('image/')) return;
dt.items.add(f);
showThumb(f);
});
syncInput();
}
function showThumb(file) {
const reader = new FileReader();
reader.onload = (e) => {
const div = document.createElement('div');
div.className = 'preview-item';
div.innerHTML = `<img src="${e.target.result}" alt="${file.name}">
<span>${file.name}</span>`;
preview.appendChild(div);
};
reader.readAsDataURL(file);
}
function syncInput() {
input.files = dt.files;
const n = dt.files.length;
fileCount.textContent = n > 0 ? `${n} photo(s) sélectionnée(s)` : '';
}
})();
</script>
{% endblock %}
+96
View File
@@ -0,0 +1,96 @@
{% load i18n static property_tags %}<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}{% trans "Biens immobiliers à vendre et à louer LOGIS" %}{% endblock %}">
{% block meta_keywords_tag %}{% endblock %}
<title>{% block title %}LOGIS{% endblock %} — {% trans "Immobilier" %}</title>
<link rel="canonical" href="{% block canonical %}{{ request.build_absolute_uri }}{% endblock %}">
<meta property="og:site_name" content="LOGIS PERSONNEL">
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:locale" content="{{ LANGUAGE_CODE }}">
<meta property="og:title" content="{% block og_title %}LOGIS — {% trans 'Immobilier' %}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{% trans "Biens immobiliers à vendre et à louer LOGIS" %}{% endblock %}">
<meta property="og:url" content="{{ request.build_absolute_uri }}">
{% block og_image_tag %}{% endblock %}
{% block extra_head %}{% endblock %}
{# Alternate links hreflang pour le SEO bilingue — URLs réelles traduites #}
{% get_available_languages as LANGUAGES %}
{% for lang_code, lang_name in LANGUAGES %}
{% lang_switch_url lang_code as hreflang_url %}
<link rel="alternate" hreflang="{{ lang_code }}" href="{{ request.scheme }}://{{ request.get_host }}{{ hreflang_url }}">
{% endfor %}
<link rel="icon" type="image/svg+xml" href="{% static 'images/favicon.svg' %}">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
<body>
{# ── Navigation ─────────────────────────────────────────────────────────── #}
<header class="site-header">
<nav class="navbar container">
<a href="{% url 'properties:list' %}" class="navbar-brand" aria-label="{% trans 'Accueil LOGIS' %}">
<span class="brand-icon">🏠</span>
<span class="brand-name">LOGIS PERSONNEL</span>
</a>
<div class="navbar-actions">
{# Sélecteur de langue — un formulaire par langue avec next pré-traduit #}
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
<div class="lang-switcher" role="group" aria-label="{% trans 'Choisir la langue' %}">
{% for lang_code, lang_name in LANGUAGES %}
{% lang_switch_url lang_code as target_url %}
<form action="{% url 'set_language' %}" method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="next" value="{{ target_url }}">
<button type="submit" name="language" value="{{ lang_code }}"
class="lang-btn {% if lang_code == LANGUAGE_CODE %}active{% endif %}"
aria-pressed="{% if lang_code == LANGUAGE_CODE %}true{% else %}false{% endif %}">
{{ lang_code|upper }}
</button>
</form>
{% endfor %}
</div>
</div>
</nav>
</header>
{# ── Contenu principal ───────────────────────────────────────────────────── #}
<main id="main-content">
{% block content %}{% endblock %}
</main>
{# ── Pied de page ────────────────────────────────────────────────────────── #}
<footer class="site-footer">
<div class="container footer-inner">
<p class="footer-brand">LOGIS PERSONNEL &mdash; {% trans "Transactions immobilières" %}</p>
<p class="footer-copy">&copy; {% now "Y" %} LOGIS PERSONNEL. {% trans "Tous droits réservés." %}</p>
</div>
</footer>
{# ── Modal de la galerie (instanciée par Gallery.js) ────────────────────── #}
<div id="gallery-modal" class="gallery-modal" role="dialog" aria-modal="true" aria-label="{% trans 'Galerie photos' %}" hidden>
<div class="gallery-modal-overlay" id="gallery-overlay"></div>
<div class="gallery-modal-content">
<button class="gallery-modal-close" id="gallery-close" aria-label="{% trans 'Fermer' %}">&times;</button>
<button class="gallery-modal-nav prev" id="gallery-prev" aria-label="{% trans 'Photo précédente' %}">&#8249;</button>
<figure class="gallery-modal-figure">
<img id="gallery-modal-img" src="" alt="">
<figcaption id="gallery-modal-caption" class="gallery-modal-caption"></figcaption>
</figure>
<button class="gallery-modal-nav next" id="gallery-next" aria-label="{% trans 'Photo suivante' %}">&#8250;</button>
<div id="gallery-counter" class="gallery-modal-counter"></div>
</div>
</div>
<script src="{% static 'js/Gallery.js' %}"></script>
<script src="{% static 'js/ContactForm.js' %}"></script>
<script src="{% static 'js/main.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Demande de contact LOGIS</title>
</head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;">
<div style="background:#1a1a2e;color:#fff;padding:20px 30px;">
<h1 style="margin:0;font-size:22px;">LOGIS &mdash; Nouvelle demande de contact</h1>
</div>
<div style="padding:30px;">
<table style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;color:#666;width:120px;">Date</td>
<td style="padding:8px 0;font-weight:bold;">{{ contact.created_at|date:"d/m/Y à H:i" }}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Nom</td>
<td style="padding:8px 0;font-weight:bold;">{{ contact.name }}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#666;">Email</td>
<td style="padding:8px 0;">
<a href="mailto:{{ contact.email }}" style="color:#e94560;">{{ contact.email }}</a>
</td>
</tr>
{% if contact.phone %}
<tr>
<td style="padding:8px 0;color:#666;">Téléphone</td>
<td style="padding:8px 0;">{{ contact.phone }}</td>
</tr>
{% endif %}
{% if property %}
<tr>
<td style="padding:8px 0;color:#666;">Bien</td>
<td style="padding:8px 0;">{{ property.name_fr }} — {{ property.city }}</td>
</tr>
{% endif %}
</table>
<div style="margin-top:20px;background:#f9f9f9;border-left:4px solid #e94560;padding:15px 20px;border-radius:0 4px 4px 0;">
<p style="margin:0 0 8px;color:#666;font-size:13px;text-transform:uppercase;letter-spacing:.5px;">Message</p>
<p style="margin:0;white-space:pre-wrap;">{{ contact.message }}</p>
</div>
</div>
<div style="background:#f0f0f0;padding:15px 30px;text-align:center;color:#999;font-size:12px;">
LOGIS{% if SITE_DOMAIN %} — {{ SITE_DOMAIN }}{% endif %} &bull; IP : {{ contact.ip_address|default:"—" }}
</div>
</div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
LOGIS — Nouvelle demande de contact
====================================
Date : {{ contact.created_at|date:"d/m/Y H:i" }}
Nom : {{ contact.name }}
Email : {{ contact.email }}
Téléphone : {{ contact.phone|default:"—" }}
{% if property %}Bien : {{ property.name_fr }} ({{ property.city }})
{% endif %}
Adresse IP : {{ contact.ip_address|default:"—" }}
Message :
---------
{{ contact.message }}
+1
View File
@@ -0,0 +1 @@
{% load i18n %}[LOGIS] Demande de renseignements de {{ contact.name }}{% if property %} — {{ property.name_fr }}{% endif %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% load i18n static property_tags %}
{% block title %}{% trans "Biens immobiliers" %}{% endblock %}
{% block content %}
{# ── Hero ────────────────────────────────────────────────────────────────── #}
{% if properties.count == 1 %}
{% with prop=properties.first %}
<section class="hero"
{% if prop.thumbnail %}
style="--hero-bg:url('{{ prop.thumbnail.url }}')"
{% elif prop.cover_photo %}
style="--hero-bg:url('{{ prop.cover_photo.image.url }}')"
{% endif %}>
<div class="hero-overlay"></div>
<div class="hero-content container">
<span class="hero-label">
{% if prop.status == 'sale' %}{% trans "À vendre" %}{% else %}{% trans "À louer" %}{% endif %}
</span>
<h1 class="hero-title">{{ prop.name }}</h1>
<p class="hero-meta">
{{ prop.city }}&ensp;&bull;&ensp;{{ prop.living_area }} m²&ensp;&bull;&ensp;{{ prop.rooms_count }} {% trans "pièces" %}
</p>
{% if not prop.price_on_request %}
<p class="hero-price">{{ prop.price|price_fr }}</p>
{% else %}
<p class="hero-price">{% trans "Prix sur demande" %}</p>
{% endif %}
<a href="{{ prop.get_absolute_url }}" class="btn btn-primary btn-hero">
{% trans "Découvrir ce bien" %} →
</a>
</div>
</section>
{% endwith %}
{% else %}
<section class="hero hero-generic">
<div class="hero-overlay"></div>
<div class="hero-content container">
<h1 class="hero-title">{% trans "Nos biens immobiliers" %}</h1>
<p class="hero-subtitle">{% trans "Trouvez votre futur chez-vous" %}</p>
</div>
</section>
{% endif %}
{# ── Grille des biens ────────────────────────────────────────────────────── #}
<section class="properties-section">
<div class="container">
{% if not properties %}
<div class="empty-state">
<p>{% trans "Aucun bien immobilier disponible pour le moment." %}</p>
</div>
{% else %}
<div class="properties-grid">
{% for prop in properties %}
<article class="property-card" data-status="{{ prop.status }}">
<a href="{{ prop.get_absolute_url }}" class="property-card-link">
<div class="property-card-img-wrapper">
{% if prop.thumbnail %}
<img src="{{ prop.thumbnail.url }}"
alt="{{ prop.name }}"
class="property-card-img"
loading="lazy">
{% elif prop.cover_photo %}
<img src="{{ prop.cover_photo.image.url }}"
alt="{{ prop.name }}"
class="property-card-img"
loading="lazy">
{% else %}
<div class="property-card-img-placeholder">🏠</div>
{% endif %}
<span class="property-card-status
{% if prop.status == 'sale' %}status-sale{% else %}status-rent{% endif %}">
{% if prop.status == 'sale' %}{% trans "À vendre" %}{% else %}{% trans "À louer" %}{% endif %}
</span>
{% if prop.energy_class %}
<span class="property-card-dpe" style="background:{{ prop.energy_class|dpe_color }}">
{{ prop.energy_class }}
</span>
{% endif %}
</div>
<div class="property-card-body">
<h2 class="property-card-title">{{ prop.name }}</h2>
<p class="property-card-location">📍 {{ prop.city }} ({{ prop.postal_code }})</p>
<ul class="property-card-specs">
<li title="{% trans 'Surface habitable' %}">
<span class="spec-icon"></span> {{ prop.living_area }} m²
</li>
<li title="{% trans 'Pièces' %}">
<span class="spec-icon">🚪</span> {{ prop.rooms_count }} {% trans "p." %}
</li>
<li title="{% trans 'Chambres' %}">
<span class="spec-icon">🛏</span> {{ prop.bedrooms_count }}
</li>
<li title="{% trans 'Salles d\'eau' %}">
<span class="spec-icon">🚿</span> {{ prop.bathrooms_count }}
</li>
</ul>
<p class="property-card-price">
{% if prop.price_on_request %}
{% trans "Prix sur demande" %}
{% else %}
{{ prop.price|price_fr }}
{% if prop.status == 'rent' %}<small>{% trans "/ mois" %}</small>{% endif %}
{% endif %}
</p>
</div>
</a>
</article>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}
+352
View File
@@ -0,0 +1,352 @@
{% extends "base.html" %}
{% load i18n static property_tags %}
{% block title %}{{ property.seo_title }}{% endblock %}
{% block meta_description %}{{ property.seo_description }}{% endblock %}
{% block meta_keywords_tag %}<meta name="keywords" content="{{ property.seo_keywords }}">{% endblock %}
{% block canonical %}{{ request.build_absolute_uri }}{% endblock %}
{% block og_title %}{{ property.seo_title }}{% endblock %}
{% block og_description %}{{ property.seo_description }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_image_tag %}{% if property.thumbnail %}<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ property.thumbnail.url }}">{% elif property.cover_photo %}<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ property.cover_photo.image.url }}">{% endif %}{% endblock %}
{% block extra_head %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "RealEstateListing",
"name": "{{ property.name|escapejs }}",
"description": "{{ property.seo_description|escapejs }}",
"url": "{{ request.build_absolute_uri }}",
{% if property.thumbnail %}"image": "{{ request.scheme }}://{{ request.get_host }}{{ property.thumbnail.url }}",{% elif property.cover_photo %}"image": "{{ request.scheme }}://{{ request.get_host }}{{ property.cover_photo.image.url }}",{% endif %}
"address": {
"@type": "PostalAddress",
"streetAddress": "{{ property.address|escapejs }}",
"addressLocality": "{{ property.city|escapejs }}",
"postalCode": "{{ property.postal_code|escapejs }}",
"addressCountry": "FR"
}{% if not property.price_on_request %},
"offers": {
"@type": "Offer",
"price": "{{ property.price }}",
"priceCurrency": "EUR"
}{% endif %}
}
</script>
{% endblock %}
{% block content %}
<div class="detail-page">
{# ── En-tête du bien ───────────────────────────────────────────────────── #}
<section class="detail-header container">
<div class="detail-header-meta">
<a href="{% url 'properties:list' %}" class="back-link">
← {% trans "Retour aux annonces" %}
</a>
<span class="detail-status
{% if property.status == 'sale' %}status-sale{% else %}status-rent{% endif %}">
{% if property.status == 'sale' %}{% trans "À vendre" %}{% else %}{% trans "À louer" %}{% endif %}
</span>
</div>
<h1 class="detail-title">{{ property.name }}</h1>
<p class="detail-location">
📍 {{ property.address }}, {{ property.city }} ({{ property.postal_code }})
</p>
<p class="detail-price">
{% if property.price_on_request %}
{% trans "Prix sur demande" %}
{% else %}
{{ property.price|price_fr }}
{% if property.status == 'rent' %}<small>{% trans "/ mois" %}</small>{% endif %}
{% endif %}
</p>
</section>
{# ── Galerie photos ────────────────────────────────────────────────────── #}
<section class="detail-gallery container" id="gallery-section">
{# Onglets par pièce #}
{% if rooms_with_photos or general_photos %}
<div class="gallery-tabs" role="tablist" aria-label="{% trans 'Photos par pièce' %}">
{% if general_photos %}
<button class="gallery-tab active" role="tab" aria-selected="true"
data-room="general" aria-controls="room-general">
{% trans "Toutes" %}
</button>
{% endif %}
{% for room in rooms_with_photos %}
{% if room.photos.all %}
<button class="gallery-tab {% if not general_photos and forloop.first %}active{% endif %}"
role="tab" aria-selected="{% if not general_photos and forloop.first %}true{% else %}false{% endif %}"
data-room="{{ room.pk }}" aria-controls="room-{{ room.pk }}">
{{ room.name }}
</button>
{% endif %}
{% endfor %}
</div>
{# Grille photos — général #}
{% if general_photos %}
<div class="gallery-grid active" id="room-general" role="tabpanel">
{% for photo in general_photos %}
<button class="gallery-item" data-gallery="main" data-index="{{ forloop.counter0 }}"
aria-label="{{ photo.caption|default:property.name }}">
<img src="{{ photo.image.url }}" alt="{{ photo.caption|default:property.name }}"
loading="lazy" class="gallery-thumb">
{% if photo.caption %}<span class="gallery-item-caption">{{ photo.caption }}</span>{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
{# Grille photos — par pièce #}
{% for room in rooms_with_photos %}
{% if room.photos.all %}
<div class="gallery-grid {% if not general_photos and forloop.first %}active{% endif %}"
id="room-{{ room.pk }}" role="tabpanel">
{% for photo in room.photos.all %}
<button class="gallery-item" data-gallery="room-{{ room.pk }}" data-index="{{ forloop.counter0 }}"
aria-label="{{ photo.caption|default:room.name }}">
<img src="{{ photo.image.url }}" alt="{{ photo.caption|default:room.name }}"
loading="lazy" class="gallery-thumb">
{% if photo.caption %}<span class="gallery-item-caption">{{ photo.caption }}</span>{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% else %}
<p class="no-photos">{% trans "Aucune photo disponible." %}</p>
{% endif %}
</section>
{# ── Corps — infos + description ───────────────────────────────────────── #}
<div class="detail-body container">
<div class="detail-main">
{# Description #}
<section class="detail-section">
<h2 class="section-title">{% trans "Description" %}</h2>
<div class="detail-description">
{{ property.description|linebreaks }}
</div>
</section>
{# Environnement #}
{% if property.environment %}
<section class="detail-section">
<h2 class="section-title">{% trans "Situation géographique" %}</h2>
<p class="env-type">
{% with ltype=property.environment.location_type %}
{% if ltype == 'city' %}🏙 {% trans "Environnement urbain" %}
{% elif ltype == 'village' %}🏘 {% trans "Village" %}
{% else %}🌿 {% trans "Campagne" %}
{% endif %}
{% endwith %}
</p>
{% if property.environment.description %}
<p class="env-description">{{ property.environment.description }}</p>
{% endif %}
<div class="env-services">
<div class="env-group">
<h3 class="env-group-title">🛒 {% trans "Commerces de proximité" %}</h3>
<ul class="env-list">
{% for icon, label, available in property.environment.get_nearby_shops %}
<li class="env-item {% if available %}env-available{% else %}env-unavailable{% endif %}">
<span class="env-icon">{{ icon }}</span>
{{ label }}
</li>
{% endfor %}
</ul>
</div>
<div class="env-group">
<h3 class="env-group-title">🏛️ {% trans "Services publics" %}</h3>
<ul class="env-list">
{% for icon, label, available in property.environment.get_public_services %}
<li class="env-item {% if available %}env-available{% else %}env-unavailable{% endif %}">
<span class="env-icon">{{ icon }}</span>
{{ label }}
</li>
{% endfor %}
</ul>
</div>
<div class="env-group">
<h3 class="env-group-title">🩺 {% trans "Santé" %}</h3>
<ul class="env-list">
{% for icon, label, available in property.environment.get_health_services %}
<li class="env-item {% if available %}env-available{% else %}env-unavailable{% endif %}">
<span class="env-icon">{{ icon }}</span>
{{ label }}
</li>
{% endfor %}
</ul>
</div>
</div>
</section>
{% endif %}
</div>{# /detail-main #}
{# ── Fiche récapitulative (sidebar) ──────────────────────────────────── #}
<aside class="detail-sidebar">
<div class="sidebar-card">
<h2 class="sidebar-title">{% trans "Informations clés" %}</h2>
<dl class="key-info">
<dt>{% trans "Type de bien" %}</dt>
<dd>{{ property.get_property_type_display }}</dd>
<dt>{% trans "Surface habitable" %}</dt>
<dd>{{ property.living_area }} m²</dd>
<dt>{% trans "Surface totale" %}</dt>
<dd>{{ property.total_area }} m²</dd>
{% if property.total_land_area %}
<dt>{% trans "Surface terrain" %}</dt>
<dd>{{ property.total_land_area }} m²</dd>
{% endif %}
<dt>{% trans "Nombre de pièces" %}</dt>
<dd>{{ property.rooms_count }}</dd>
<dt>{% trans "Chambres" %}</dt>
<dd>{{ property.bedrooms_count }}</dd>
<dt>{% trans "Salles d'eau" %}</dt>
<dd>{{ property.bathrooms_count }}</dd>
{% if property.exposure %}
<dt>{% trans "Exposition" %}</dt>
<dd>{{ property.get_exposure_display }}</dd>
{% endif %}
{% if property.construction_year %}
<dt>{% trans "Année de construction" %}</dt>
<dd>{{ property.construction_year }}</dd>
{% endif %}
<dt>{% trans "État du bien" %}</dt>
<dd>{{ property.get_condition_display }}</dd>
<dt>{% trans "Ascenseur" %}</dt>
<dd>{% if property.has_elevator %}{% trans "Oui" %}{% else %}{% trans "Non" %}{% endif %}</dd>
</dl>
{# Extérieurs #}
{% if property.has_garden or property.has_pool or property.has_dependencies %}
<div class="exterior-badges">
{% if property.has_garden %}
<span class="badge badge-exterior">🌳 {% trans "Jardin" %}</span>
{% endif %}
{% if property.has_pool %}
<span class="badge badge-exterior">🏊 {% trans "Piscine" %}</span>
{% endif %}
{% if property.has_dependencies %}
<span class="badge badge-exterior">🏚 {% trans "Dépendances" %}</span>
{% endif %}
</div>
{% endif %}
{# Caractéristiques libres #}
{% if property.features.all %}
<div class="features-list">
<h3 class="features-title">{% trans "Caractéristiques" %}</h3>
<ul>
{% for feat in property.features.all %}
<li>
{% if feat.icon %}<span>{{ feat.icon }}</span> {% endif %}
{{ feat.name }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{# Diagnostics DPE / GES #}
{% if property.energy_class or property.ges_class %}
<div class="sidebar-card diagnostics-card">
<h2 class="sidebar-title">{% trans "Diagnostics" %}</h2>
{% if property.energy_class %}
<div class="diag-row">
<span class="diag-label">{% trans "Classe énergie (DPE)" %}</span>
{% dpe_badge property.energy_class property.energy_value "kWh/m²/an" %}
</div>
{% endif %}
{% if property.ges_class %}
<div class="diag-row">
<span class="diag-label">{% trans "Émissions GES" %}</span>
{% dpe_badge property.ges_class property.ges_value "kgCO₂/m²/an" %}
</div>
{% endif %}
<div class="dpe-scale-wrapper">
{% dpe_scale property.energy_class property.ges_class %}
</div>
</div>
{% endif %}
{# Bouton de contact #}
<div class="sidebar-card contact-card">
<h2 class="sidebar-title">{% trans "Renseignements" %}</h2>
<p class="contact-intro">
{% trans "Vous souhaitez visiter ce bien ou obtenir plus d'informations ?" %}
</p>
<button class="btn btn-primary btn-full" id="open-contact-modal"
data-property="{{ property.pk }}">
✉ {% trans "Envoyer un message" %}
</button>
</div>
</aside>
</div>{# /detail-body #}
</div>{# /detail-page #}
{# ── Modal de contact ─────────────────────────────────────────────────────── #}
{% include "properties/partials/contact_modal.html" %}
{% endblock %}
{% block extra_js %}
<script>
// Données des galeries transmises au JS
const GALLERY_DATA = {
{% if general_photos %}
'main': [
{% for photo in general_photos %}
{ src: '{{ photo.image.url }}', caption: '{{ photo.caption|escapejs }}' }{% if not forloop.last %},{% endif %}
{% endfor %}
],
{% endif %}
{% for room in rooms_with_photos %}
{% if room.photos.all %}
'room-{{ room.pk }}': [
{% for photo in room.photos.all %}
{ src: '{{ photo.image.url }}', caption: '{{ photo.caption|escapejs }}' }{% if not forloop.last %},{% endif %}
{% endfor %}
]{% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
};
const CONTACT_URL = '{% url "properties:contact" %}';
const CSRF_TOKEN = '{{ csrf_token }}';
const I18N = {
of: '{% trans "sur" %}',
sending: '{% trans "Envoi en cours…" %}',
send: '{% trans "Envoyer" %}',
successTitle: '{% trans "Message envoyé !" %}',
successMsg: '{% trans "Nous vous répondrons dans les plus brefs délais." %}',
};
</script>
{% endblock %}
@@ -0,0 +1,73 @@
{% load i18n %}
{# Modale de contact — injectée dans la page detail #}
<div id="contact-modal" class="modal" role="dialog" aria-modal="true"
aria-labelledby="contact-modal-title" hidden>
<div class="modal-overlay" id="contact-overlay"></div>
<div class="modal-box">
<button class="modal-close" id="contact-modal-close" aria-label="{% trans 'Fermer' %}">&times;</button>
<h2 id="contact-modal-title" class="modal-title">
✉ {% trans "Demande de renseignements" %}
</h2>
{# Zone de retour message succès #}
<div id="contact-success" class="contact-success" hidden>
<p class="success-icon"></p>
<p class="success-title">{% trans "Message envoyé !" %}</p>
<p class="success-body">{% trans "Nous vous répondrons dans les plus brefs délais." %}</p>
</div>
{# Formulaire #}
<form id="contact-form" class="contact-form" novalidate>
{% csrf_token %}
{# Honeypot invisible #}
<div class="hp-field" aria-hidden="true">
<input type="text" name="website" id="id_website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="property_id" id="id_property_id">
<div class="form-group">
<label for="id_name">{% trans "Nom complet" %} <span class="required">*</span></label>
<input type="text" id="id_name" name="name" required
autocomplete="name" maxlength="200"
placeholder="{% trans 'Votre nom et prénom' %}">
<span class="field-error" data-field="name"></span>
</div>
<div class="form-group">
<label for="id_email">{% trans "Adresse email" %} <span class="required">*</span></label>
<input type="email" id="id_email" name="email" required
autocomplete="email"
placeholder="{% trans 'votre@email.com' %}">
<span class="field-error" data-field="email"></span>
</div>
<div class="form-group">
<label for="id_phone">{% trans "Téléphone" %} <span class="optional">({% trans "facultatif" %})</span></label>
<input type="tel" id="id_phone" name="phone"
autocomplete="tel" maxlength="30"
placeholder="+33 6 XX XX XX XX">
<span class="field-error" data-field="phone"></span>
</div>
<div class="form-group">
<label for="id_message">{% trans "Message" %} <span class="required">*</span></label>
<textarea id="id_message" name="message" required rows="5"
placeholder="{% trans 'Votre message, vos questions…' %}"></textarea>
<span class="field-error" data-field="message"></span>
</div>
<p class="form-notice">
{% trans "* champs obligatoires — vos coordonnées ne seront utilisées que pour répondre à votre demande." %}
</p>
<div class="form-global-error" id="contact-global-error" hidden></div>
<button type="submit" class="btn btn-primary btn-full" id="contact-submit">
{% trans "Envoyer" %}
</button>
</form>
</div>
</div>
@@ -0,0 +1,14 @@
{% load i18n property_tags %}
{# Échelle visuelle DPE / GES style barreaux #}
<div class="dpe-scale">
<p class="dpe-scale-label">{% trans "Échelle de performance" %}</p>
{% for cls in classes %}
<div class="dpe-bar {% if cls == energy_class %}dpe-active{% endif %}"
style="background:{{ dpe_colors|get_item:cls }};width:calc(30px + {{ forloop.counter }}*22px)">
<span>{{ cls }}</span>
{% if cls == energy_class %}
<span class="dpe-arrow">◀ {% trans "DPE" %}</span>
{% endif %}
</div>
{% endfor %}
</div>
+6
View File
@@ -0,0 +1,6 @@
User-agent: *
Disallow: /admin/
Disallow: /i18n/
Allow: /
Sitemap: {{ request.scheme }}://{{ request.get_host }}/sitemap.xml