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