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