This commit is contained in:
tanguy_rousseau 2026-04-27 15:40:40 +02:00
parent dc31487dac
commit 98ac4d0609
9 changed files with 1270 additions and 0 deletions

3
__init__.py Normal file
View File

@ -0,0 +1,3 @@
def classFactory(iface):
from .main_plugin import AnimationPlugin
return AnimationPlugin(iface)

0
animation_form.py Normal file
View File

173
db_manager.py Normal file
View File

@ -0,0 +1,173 @@
import psycopg2
import json
class DBManager:
def __init__(self, host, port, dbname, user, password):
self.conn = psycopg2.connect(
host=host, port=port, dbname=dbname, user=user, password=password
)
self.conn.autocommit = False
def close(self):
if self.conn:
self.conn.close()
def fetch_all(self, query, params=None):
with self.conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchall()
def execute(self, query, params=None):
with self.conn.cursor() as cur:
cur.execute(query, params)
self.conn.commit()
# --- Reference data loaders ---
def get_animateurs(self):
return self.fetch_all("SELECT code_animateur, animateur FROM animation.animateurs ORDER BY animateur")
def get_lieux(self):
return self.fetch_all("SELECT code_lieux, lieux FROM animation.lieux_anim ORDER BY lieux")
def get_themes(self):
return self.fetch_all("SELECT code_type_anim, theme_animation FROM animation.theme_animation ORDER BY theme_animation")
def get_types_groupe(self):
return self.fetch_all("SELECT code_groupe, type_groupe FROM animation.type_groupe ORDER BY type_groupe")
def get_codes_annulation(self):
return self.fetch_all("SELECT code_annul, type_annulation FROM animation.code_annulation ORDER BY type_annulation")
def get_type_dossier_ens(self):
return self.fetch_all("SELECT code_dossier_ens, type_dossier_ens FROM animation.type_dossier_ens ORDER BY type_dossier_ens")
def get_etablissements(self):
return self.fetch_all("SELECT code_etablissement, nom_etablissement, commune FROM animation.liste_etablissements ORDER BY nom_etablissement")
def get_all_animations(self):
"""Retourne toutes les animations pour la liste déroulante (date + lieu + thème)."""
return self.fetch_all("""
SELECT da.code_animation,
da.date_anim,
da.lieux,
ta.theme_animation
FROM animation.donnees_animation da
LEFT JOIN animation.theme_animation ta ON ta.code_type_anim = da.code_type_anim
ORDER BY da.date_anim DESC, da.code_animation DESC
""")
def get_animation_by_id(self, code_animation):
rows = self.fetch_all(
"SELECT * FROM animation.donnees_animation WHERE code_animation = %s", (code_animation,)
)
if rows:
with self.conn.cursor() as cur:
cur.execute("SELECT * FROM animation.donnees_animation WHERE code_animation = %s", (code_animation,))
cols = [d[0] for d in cur.description]
cur.execute("SELECT * FROM animation.donnees_animation WHERE code_animation = %s", (code_animation,))
row = cur.fetchone()
return dict(zip(cols, row)) if row else None
return None
# --- Insertions ---
def add_animateur(self, nom):
rows = self.fetch_all("SELECT MAX(code_animateur) FROM animation.animateurs")
next_code = (rows[0][0] or 0) + 1
self.execute("INSERT INTO animation.animateurs (code_animateur, animateur) VALUES (%s, %s)", (next_code, nom))
return next_code, nom
def add_lieux(self, lieux, type_lieux='', type_lieux_regroupement='', geom_wkt=None, srid=4326):
rows = self.fetch_all("SELECT MAX(code_lieux) FROM animation.lieux_anim")
next_code = (rows[0][0] or 0) + 1
if geom_wkt:
self.execute(
"INSERT INTO animation.lieux_anim (code_lieux, lieux, type_lieux, type_lieux_regroupement, geom) "
"VALUES (%s,%s,%s,%s,ST_SetSRID(ST_GeomFromText(%s),%s))",
(next_code, lieux, type_lieux, type_lieux_regroupement, geom_wkt, srid)
)
else:
self.execute(
"INSERT INTO animation.lieux_anim (code_lieux, lieux, type_lieux, type_lieux_regroupement) VALUES (%s,%s,%s,%s)",
(next_code, lieux, type_lieux, type_lieux_regroupement)
)
return next_code, lieux
def add_theme(self, theme, famille=''):
rows = self.fetch_all("SELECT MAX(code_type_anim) FROM animation.theme_animation")
next_code = (rows[0][0] or 0) + 1
self.execute("INSERT INTO animation.theme_animation (code_type_anim, theme_animation, famille_theme_animation) VALUES (%s,%s,%s)",
(next_code, theme, famille))
return next_code, theme
def add_annulation(self, type_annulation):
rows = self.fetch_all("SELECT MAX(code_annul) FROM animation.code_annulation")
next_code = (rows[0][0] or 0) + 1
self.execute("INSERT INTO animation.code_annulation (code_annul, type_annulation) VALUES (%s,%s)",
(next_code, type_annulation))
return next_code, type_annulation
def add_type_dossier(self, type_dossier):
rows = self.fetch_all("SELECT MAX(code_dossier_ens) FROM animation.type_dossier_ens")
next_code = (rows[0][0] or 0) + 1
self.execute("INSERT INTO animation.type_dossier_ens (code_dossier_ens, type_dossier_ens) VALUES (%s,%s)",
(next_code, type_dossier))
return next_code, type_dossier
def add_etablissement(self, nom, commune, prive=False, geom_wkt=None, srid=4326):
rows = self.fetch_all("SELECT MAX(code_etablissement) FROM animation.liste_etablissements")
next_code = (rows[0][0] or 0) + 1
if geom_wkt:
self.execute(
"INSERT INTO animation.liste_etablissements (code_etablissement, nom_etablissement, prive, commune, geom) "
"VALUES (%s,%s,%s,%s,ST_SetSRID(ST_GeomFromText(%s),%s))",
(next_code, nom, prive, commune, geom_wkt, srid)
)
else:
self.execute(
"INSERT INTO animation.liste_etablissements (code_etablissement, nom_etablissement, prive, commune) VALUES (%s,%s,%s,%s)",
(next_code, nom, prive, commune)
)
return next_code, nom, commune
def get_next_code_animation(self):
rows = self.fetch_all("SELECT MAX(code_animation) FROM animation.donnees_animation")
return (rows[0][0] or 0) + 1
def insert_donnees_animation(self, data: dict):
sql = """
INSERT INTO animation.donnees_animation (
code_animation, date_anim, duree_anim, animateurs, prepa_deplacemt, temps_total,
code_groupe, precisions_type_de_groupe, commune_provenance, nom_etablissement,
nbre_pers_total, nbre_pers_enfants, nbre_pers_adultes,
lieux, precisions_lieux_anim, accueil_pin, theme_detaille,
code_type_anim, payant, type_annul, remarques,
type_dossier_ens, realise_par_tiers, detail_tiers
) VALUES (
%(code_animation)s, %(date_anim)s, %(duree_anim)s, %(animateurs)s, %(prepa_deplacemt)s, %(temps_total)s,
%(code_groupe)s, %(precisions_type_de_groupe)s, %(commune_provenance)s, %(nom_etablissement)s,
%(nbre_pers_total)s, %(nbre_pers_enfants)s, %(nbre_pers_adultes)s,
%(lieux)s, %(precisions_lieux_anim)s, %(accueil_pin)s, %(theme_detaille)s,
%(code_type_anim)s, %(payant)s, %(type_annul)s, %(remarques)s,
%(type_dossier_ens)s, %(realise_par_tiers)s, %(detail_tiers)s
)
"""
self.execute(sql, data)
def update_donnees_animation(self, code_animation, data: dict):
sql = """
UPDATE animation.donnees_animation SET
date_anim=%(date_anim)s, duree_anim=%(duree_anim)s, animateurs=%(animateurs)s,
prepa_deplacemt=%(prepa_deplacemt)s, temps_total=%(temps_total)s,
code_groupe=%(code_groupe)s, precisions_type_de_groupe=%(precisions_type_de_groupe)s,
commune_provenance=%(commune_provenance)s, nom_etablissement=%(nom_etablissement)s,
nbre_pers_total=%(nbre_pers_total)s, nbre_pers_enfants=%(nbre_pers_enfants)s,
nbre_pers_adultes=%(nbre_pers_adultes)s,
lieux=%(lieux)s, precisions_lieux_anim=%(precisions_lieux_anim)s,
accueil_pin=%(accueil_pin)s, theme_detaille=%(theme_detaille)s,
code_type_anim=%(code_type_anim)s, payant=%(payant)s, type_annul=%(type_annul)s,
remarques=%(remarques)s, type_dossier_ens=%(type_dossier_ens)s,
realise_par_tiers=%(realise_par_tiers)s, detail_tiers=%(detail_tiers)s
WHERE code_animation=%(code_animation)s
"""
data['code_animation'] = code_animation
self.execute(sql, data)

42
dialog_add.py Normal file
View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from qgis.PyQt.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QHBoxLayout,
QLineEdit, QCheckBox, QPushButton
)
class AddEtablissementDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Ajouter un établissement")
layout = QVBoxLayout()
self.setLayout(layout)
form = QFormLayout()
self.nom_edit = QLineEdit()
self.nom_edit.setPlaceholderText("Nom de l'établissement")
self.commune_edit = QLineEdit()
self.commune_edit.setPlaceholderText("Commune")
self.prive_check = QCheckBox("Établissement privé")
form.addRow("Nom* :", self.nom_edit)
form.addRow("Commune :", self.commune_edit)
form.addRow("", self.prive_check)
layout.addLayout(form)
btns = QHBoxLayout()
ok = QPushButton("Ajouter")
cancel = QPushButton("Annuler")
btns.addWidget(ok)
btns.addWidget(cancel)
layout.addLayout(btns)
ok.clicked.connect(self.accept)
cancel.clicked.connect(self.reject)
def values(self):
return (
self.nom_edit.text().strip(),
self.prive_check.isChecked(),
self.commune_edit.text().strip() or None,
)

257
dialog_auth.py Normal file
View File

@ -0,0 +1,257 @@
from qgis.PyQt.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QMessageBox,
QSpinBox,
QComboBox,
QWidget,
)
from qgis.PyQt.QtCore import Qt
from qgis.core import QgsApplication, QgsAuthMethodConfig, QgsSettings
from .db_manager import DBManager
SETTINGS_SELECTED_CONNECTION = "000"
def try_connect_from_keychain():
"""
Tente de se connecter via une connexion PostGIS déjà enregistrée dans QGIS
(et potentiellement associée à une config d'authentification 'authcfg').
Retourne un DBManager connecté ou None.
"""
try:
settings = QgsSettings()
connection_name = settings.value(SETTINGS_SELECTED_CONNECTION, "", type=str).strip()
if not connection_name:
return None
params = read_postgis_connection(connection_name)
if not params:
return None
return connect_from_params(params)
except Exception as e:
print(f"Keychain connection failed: {e}")
return None
def parse_uri(uri_string):
"""Parse QGIS URI format: host=value port=value dbname=value..."""
params = {}
# Remplacer les espaces de séparation par du split plus robuste
parts = uri_string.split()
for part in parts:
if '=' in part:
key, value = part.split('=', 1)
params[key.strip()] = value.strip("'\"")
return params
def list_postgis_connections():
"""Retourne les noms des connexions PostgreSQL enregistrées dans QGIS."""
settings = QgsSettings()
settings.beginGroup("PostgreSQL/connections")
names = settings.childGroups()
settings.endGroup()
return sorted(names)
def read_postgis_connection(name):
"""
Lit une connexion PostGIS depuis les settings QGIS.
Retourne un dict avec host/port/dbname/user/password/authcfg (password peut être vide).
"""
settings = QgsSettings()
base = f"PostgreSQL/connections/{name}"
host = settings.value(f"{base}/host", "", type=str)
port = settings.value(f"{base}/port", "5432", type=str)
dbname = settings.value(f"{base}/database", "", type=str)
user = settings.value(f"{base}/username", "", type=str)
password = settings.value(f"{base}/password", "", type=str)
authcfg = settings.value(f"{base}/authcfg", "", type=str)
if not host and not dbname:
return None
return {
"name": name,
"host": host or "localhost",
"port": int(port) if str(port).strip() else 5432,
"dbname": dbname,
"user": user,
"password": password,
"authcfg": authcfg,
}
def load_auth_config(authcfg):
"""Charge une config d'auth QGIS et retourne un dict utile."""
if not authcfg:
return None
auth_mgr = QgsApplication.authManager()
cfg = QgsAuthMethodConfig()
auth_mgr.loadAuthenticationConfig(authcfg, cfg, True)
config_map = cfg.configMap() or {}
uri = cfg.uri() or ""
return {
"id": cfg.id(),
"name": cfg.name(),
"method": cfg.method(),
"config_map": config_map,
"uri": uri,
}
def connect_from_params(params):
"""Construit un DBManager à partir des paramètres + authcfg si présent."""
authcfg = (params.get("authcfg") or "").strip()
user = params.get("user") or ""
password = params.get("password") or ""
if authcfg:
auth = load_auth_config(authcfg)
if auth:
# Pour les configs de type Basic, on trouve souvent username/password dans configMap
user = user or auth["config_map"].get("username") or auth["config_map"].get("user") or ""
password = password or auth["config_map"].get("password") or ""
if not password and auth.get("uri"):
uri_params = parse_uri(auth["uri"])
user = user or uri_params.get("user", "")
password = password or uri_params.get("password", "")
if not params.get("dbname"):
raise ValueError("Base de données manquante dans la connexion PostGIS QGIS.")
if not user:
raise ValueError("Utilisateur manquant (connexion QGIS + authcfg n'ont pas fourni de user).")
if not password:
raise ValueError("Mot de passe manquant (connexion QGIS + authcfg n'ont pas fourni de password).")
return DBManager(
host=params.get("host") or "localhost",
port=int(params.get("port") or 5432),
dbname=params.get("dbname") or "",
user=user,
password=password,
)
class AuthDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Connexion à la base de données Animation")
self.setMinimumWidth(420)
self.db = None
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
title = QLabel("<h2>Animation Connexion BDD</h2>")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
info = QLabel(
"<small>Sélectionnez une connexion PostgreSQL déjà enregistrée dans QGIS "
"(idéalement avec une configuration dauthentification associée).</small>"
)
info.setWordWrap(True)
layout.addWidget(info)
form = QFormLayout()
self.connection_combo = QComboBox()
self.refresh_button = QPushButton("Actualiser")
refresh_layout = QHBoxLayout()
refresh_layout.addWidget(self.connection_combo, 1)
refresh_layout.addWidget(self.refresh_button)
refresh_widget = QWidget()
refresh_widget.setLayout(refresh_layout)
form.addRow("Connexion PostGIS :", refresh_widget)
self.manual_host_edit = QLineEdit()
self.manual_port_spin = QSpinBox()
self.manual_port_spin.setRange(1, 65535)
self.manual_port_spin.setValue(5432)
self.manual_db_edit = QLineEdit()
self.manual_user_edit = QLineEdit()
self.manual_password_edit = QLineEdit()
self.manual_password_edit.setEchoMode(QLineEdit.Password)
form.addRow(QLabel("<b>Fallback manuel</b> (si la connexion QGIS ne contient pas le secret)"), QLabel(""))
form.addRow("Hôte :", self.manual_host_edit)
form.addRow("Port :", self.manual_port_spin)
form.addRow("Base de données :", self.manual_db_edit)
form.addRow("Utilisateur :", self.manual_user_edit)
form.addRow("Mot de passe :", self.manual_password_edit)
layout.addLayout(form)
btn_layout = QHBoxLayout()
self.btn_cancel = QPushButton("Annuler")
self.btn_cancel.clicked.connect(self.reject)
self.btn_connect = QPushButton("Se connecter")
self.btn_connect.setDefault(True)
self.btn_connect.clicked.connect(self.connect)
btn_layout.addWidget(self.btn_cancel)
btn_layout.addWidget(self.btn_connect)
layout.addLayout(btn_layout)
self.refresh_button.clicked.connect(self.refresh_connections)
self.connection_combo.currentIndexChanged.connect(self._on_connection_changed)
self.refresh_connections()
def connect(self):
try:
params = self._params_from_selection()
if params:
self.db = connect_from_params(params)
QgsSettings().setValue(SETTINGS_SELECTED_CONNECTION, params.get("name", ""))
self.accept()
return
# fallback manuel
self.db = DBManager(
host=self.manual_host_edit.text().strip() or "localhost",
port=self.manual_port_spin.value(),
dbname=self.manual_db_edit.text().strip(),
user=self.manual_user_edit.text().strip(),
password=self.manual_password_edit.text(),
)
self.accept()
except Exception as e:
QMessageBox.critical(self, "Erreur de connexion", str(e))
def refresh_connections(self):
self.connection_combo.clear()
self.connection_combo.addItem("", "")
for name in list_postgis_connections():
self.connection_combo.addItem(name, name)
# Pré-sélection du dernier choix
last = QgsSettings().value(SETTINGS_SELECTED_CONNECTION, "", type=str)
if last:
idx = self.connection_combo.findData(last)
if idx >= 0:
self.connection_combo.setCurrentIndex(idx)
else:
self._on_connection_changed()
def _on_connection_changed(self):
params = self._params_from_selection()
if not params:
return
# Pré-remplir le fallback manuel avec les valeurs non sensibles
self.manual_host_edit.setText(params.get("host", "") or "")
self.manual_port_spin.setValue(int(params.get("port") or 5432))
self.manual_db_edit.setText(params.get("dbname", "") or "")
self.manual_user_edit.setText(params.get("user", "") or "")
self.manual_password_edit.clear()
def _params_from_selection(self):
name = (self.connection_combo.currentData() or "").strip()
if not name:
return None
return read_postgis_connection(name)

718
dialog_saisie.py Normal file
View File

@ -0,0 +1,718 @@
import json
from datetime import date
from qgis.PyQt.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QPushButton, QMessageBox, QComboBox,
QDateEdit, QSpinBox, QCheckBox, QScrollArea, QWidget,
QListWidget, QListWidgetItem, QInputDialog, QSizePolicy,
QFrame, QGroupBox
)
from qgis.PyQt.QtCore import Qt, QDate
from qgis.PyQt.QtGui import QFont, QColor
from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject
from qgis.utils import iface
from .map_point_tool import PointCaptureTool
# ---------------------------------------------------------------------------
# Widgets utilitaires
# ---------------------------------------------------------------------------
class ComboWithAdd(QWidget):
def __init__(self, add_callback, parent=None):
super().__init__(parent)
self._add_callback = add_callback
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.combo = QComboBox()
self.combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.btn_add = QPushButton("+")
self.btn_add.setFixedWidth(30)
self.btn_add.setToolTip("Ajouter un nouvel élément")
self.btn_add.clicked.connect(lambda: self._add_callback(self))
layout.addWidget(self.combo)
layout.addWidget(self.btn_add)
def current_data(self):
return self.combo.currentData()
def current_text(self):
return self.combo.currentText()
def populate(self, items, placeholder=None):
self.combo.clear()
if placeholder:
self.combo.addItem(placeholder, None)
for code, label in items:
self.combo.addItem(label, code)
def add_item(self, code, label):
self.combo.addItem(label, code)
self.combo.setCurrentIndex(self.combo.count() - 1)
def set_by_text(self, text):
idx = self.combo.findText(text)
if idx >= 0:
self.combo.setCurrentIndex(idx)
def set_by_data(self, data):
idx = self.combo.findData(data)
if idx >= 0:
self.combo.setCurrentIndex(idx)
class AnimateursWidget(QWidget):
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.list_widget = QListWidget()
self.list_widget.setMaximumHeight(90)
layout.addWidget(self.list_widget)
btn_layout = QHBoxLayout()
self.combo = QComboBox()
self.combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
btn_add = QPushButton("Ajouter")
btn_add.clicked.connect(self._add_existing)
btn_new = QPushButton("+ Nouveau")
btn_new.clicked.connect(self._add_new)
btn_remove = QPushButton("Retirer")
btn_remove.clicked.connect(self._remove_selected)
btn_layout.addWidget(self.combo)
btn_layout.addWidget(btn_add)
btn_layout.addWidget(btn_new)
btn_layout.addWidget(btn_remove)
layout.addLayout(btn_layout)
self.load_animateurs()
def load_animateurs(self):
self.combo.clear()
for code, nom in self.db.get_animateurs():
self.combo.addItem(nom, code)
def _add_existing(self):
code = self.combo.currentData()
nom = self.combo.currentText()
if code is None:
return
for i in range(self.list_widget.count()):
if self.list_widget.item(i).data(Qt.UserRole) == code:
return
item = QListWidgetItem(nom)
item.setData(Qt.UserRole, code)
self.list_widget.addItem(item)
def _add_new(self):
nom, ok = QInputDialog.getText(self, "Nouvel animateur", "Nom :")
if ok and nom.strip():
try:
code, label = self.db.add_animateur(nom.strip())
self.combo.addItem(label, code)
item = QListWidgetItem(label)
item.setData(Qt.UserRole, code)
self.list_widget.addItem(item)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _remove_selected(self):
for item in self.list_widget.selectedItems():
self.list_widget.takeItem(self.list_widget.row(item))
def get_value(self):
result = []
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
result.append({"code": item.data(Qt.UserRole), "nom": item.text()})
return json.dumps(result, ensure_ascii=False) if result else None
def set_value(self, json_str):
self.list_widget.clear()
if not json_str:
return
try:
data = json.loads(json_str) if isinstance(json_str, str) else json_str
for entry in data:
item = QListWidgetItem(entry.get("nom", ""))
item.setData(Qt.UserRole, entry.get("code"))
self.list_widget.addItem(item)
except Exception:
pass
# ---------------------------------------------------------------------------
# Dialogue principal
# ---------------------------------------------------------------------------
class SaisieDialog(QDialog):
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self._etab_map = {} # code -> (nom, commune)
self._edit_mode = False # True = on édite une animation existante
self._edit_code = None # code_animation en cours d'édition
self._pending_geom = None # (x, y, crs) en attente pour ajout lieu/etab
self._pending_geom_type = None # 'lieu' ou 'etab'
self._point_tool = None
self.setWindowTitle("Animation Saisie / Édition")
self.setMinimumWidth(720)
self.setMinimumHeight(820)
self._build_ui()
self._load_reference_data()
# -----------------------------------------------------------------------
# Construction UI
# -----------------------------------------------------------------------
def _build_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setSpacing(6)
# --- Bandeau titre ---
self.title_label = QLabel("<h2>Nouvelle animation</h2>")
self.title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.title_label)
# --- Sélecteur d'animation existante ---
grp_sel = QGroupBox("Charger / modifier une animation existante")
sel_layout = QHBoxLayout(grp_sel)
self.anim_combo = QComboBox()
self.anim_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.anim_combo.setPlaceholderText("── Nouvelle saisie ──")
self.anim_combo.addItem("── Nouvelle saisie ──", None)
self.anim_combo.currentIndexChanged.connect(self._on_anim_selected)
sel_layout.addWidget(QLabel("Animation :"))
sel_layout.addWidget(self.anim_combo)
btn_refresh = QPushButton("🔄")
btn_refresh.setFixedWidth(32)
btn_refresh.setToolTip("Rafraîchir la liste")
btn_refresh.clicked.connect(self._reload_animations_list)
sel_layout.addWidget(btn_refresh)
main_layout.addWidget(grp_sel)
# --- Zone scrollable du formulaire ---
scroll = QScrollArea()
scroll.setWidgetResizable(True)
container = QWidget()
self.fl = QFormLayout(container)
self.fl.setLabelAlignment(Qt.AlignRight)
self.fl.setSpacing(8)
scroll.setWidget(container)
main_layout.addWidget(scroll)
fl = self.fl
# ---- Date & Durée ----
self._section(fl, "Date et durée")
self.date_edit = QDateEdit(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("dd/MM/yyyy")
fl.addRow("Date *:", self.date_edit)
dur_w = QWidget()
dur_l = QHBoxLayout(dur_w)
dur_l.setContentsMargins(0, 0, 0, 0)
self.duree_h = QSpinBox()
self.duree_h.setRange(0, 24)
self.duree_h.setSuffix(" h")
self.duree_min = QSpinBox()
self.duree_min.setRange(0, 59)
self.duree_min.setSuffix(" min")
dur_l.addWidget(self.duree_h)
dur_l.addWidget(self.duree_min)
dur_l.addStretch()
fl.addRow("Durée :", dur_w)
self.prepa_spin = QSpinBox()
self.prepa_spin.setRange(0, 9999)
self.prepa_spin.setSuffix(" min")
for w in (self.duree_h, self.duree_min, self.prepa_spin):
w.valueChanged.connect(self._update_temps_total)
fl.addRow("Prépa / déplacement :", self.prepa_spin)
self.temps_total_label = QLabel("0 min")
fl.addRow("Temps total (calculé) :", self.temps_total_label)
# ---- Animateurs ----
self._sep(fl)
self._section(fl, "Animateurs")
self.animateurs_widget = AnimateursWidget(self.db)
fl.addRow("Animateurs :", self.animateurs_widget)
# ---- Groupe ----
self._sep(fl)
self._section(fl, "Groupe")
self.type_groupe_combo = ComboWithAdd(self._add_type_groupe)
fl.addRow("Type de groupe :", self.type_groupe_combo)
self.precisions_groupe_edit = QLineEdit()
fl.addRow("Précisions groupe :", self.precisions_groupe_edit)
# ---- Établissement ----
self._sep(fl)
self._section(fl, "Établissement / Provenance")
self.etab_combo = ComboWithAdd(self._add_etablissement)
self.etab_combo.combo.currentIndexChanged.connect(self._on_etab_changed)
fl.addRow("Établissement :", self.etab_combo)
self.commune_label = QLabel("")
self.commune_label.setStyleSheet("color:#555; font-style:italic;")
fl.addRow("Commune (auto) :", self.commune_label)
# ---- Participants ----
self._sep(fl)
self._section(fl, "Participants")
self.enfants_spin = QSpinBox()
self.enfants_spin.setRange(0, 9999)
self.enfants_spin.valueChanged.connect(self._update_total_pers)
fl.addRow("Enfants :", self.enfants_spin)
self.adultes_spin = QSpinBox()
self.adultes_spin.setRange(0, 9999)
self.adultes_spin.valueChanged.connect(self._update_total_pers)
fl.addRow("Adultes :", self.adultes_spin)
self.total_pers_label = QLabel("0")
fl.addRow("Total (calculé) :", self.total_pers_label)
# ---- Lieu ----
self._sep(fl)
self._section(fl, "Lieu")
self.lieux_combo = ComboWithAdd(self._add_lieu)
fl.addRow("Lieu :", self.lieux_combo)
self.precisions_lieux_edit = QLineEdit()
fl.addRow("Précisions lieu :", self.precisions_lieux_edit)
self.accueil_pin_check = QCheckBox("Oui")
fl.addRow("Accueil PIN :", self.accueil_pin_check)
# ---- Thème ----
self._sep(fl)
self._section(fl, "Thème")
self.theme_combo = ComboWithAdd(self._add_theme)
fl.addRow("Thème :", self.theme_combo)
self.theme_detaille_edit = QLineEdit()
fl.addRow("Thème détaillé :", self.theme_detaille_edit)
# ---- Statuts ----
self._sep(fl)
self._section(fl, "Statuts")
self.payant_check = QCheckBox("Oui")
fl.addRow("Payant :", self.payant_check)
self.annul_combo = ComboWithAdd(self._add_annulation)
fl.addRow("Annulation :", self.annul_combo)
self.dossier_combo = ComboWithAdd(self._add_dossier)
fl.addRow("Dossier enseignant :", self.dossier_combo)
self.tiers_check = QCheckBox("Oui")
self.tiers_check.stateChanged.connect(self._on_tiers_changed)
fl.addRow("Réalisé par tiers :", self.tiers_check)
self.detail_tiers_edit = QLineEdit()
self.detail_tiers_edit.setEnabled(False)
fl.addRow("Détail tiers :", self.detail_tiers_edit)
# ---- Remarques ----
self._sep(fl)
self.remarques_edit = QLineEdit()
fl.addRow("Remarques :", self.remarques_edit)
# --- Boutons d'action ---
btn_layout = QHBoxLayout()
btn_cancel = QPushButton("Fermer")
btn_cancel.clicked.connect(self.reject)
self.btn_new_form = QPushButton("✚ Nouveau")
self.btn_new_form.setToolTip("Réinitialiser pour une nouvelle saisie")
self.btn_new_form.clicked.connect(self._new_form)
self.btn_save_new = QPushButton("Enregistrer et nouveau")
self.btn_save_new.clicked.connect(self._save_and_new)
self.btn_save = QPushButton("Enregistrer")
self.btn_save.setDefault(True)
self.btn_save.clicked.connect(self._save)
btn_layout.addWidget(btn_cancel)
btn_layout.addWidget(self.btn_new_form)
btn_layout.addStretch()
btn_layout.addWidget(self.btn_save_new)
btn_layout.addWidget(self.btn_save)
main_layout.addLayout(btn_layout)
def _section(self, fl, title):
lbl = QLabel(f"<b>{title}</b>")
fl.addRow(lbl)
def _sep(self, fl):
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setStyleSheet("color:#ccc;")
fl.addRow(line)
# -----------------------------------------------------------------------
# Chargement données de référence
# -----------------------------------------------------------------------
def _load_reference_data(self):
try:
self.type_groupe_combo.populate(self.db.get_types_groupe(), "-- Sélectionner --")
etabs = self.db.get_etablissements()
self._etab_map = {c: (n, com) for c, n, com in etabs}
self.etab_combo.populate(
[(c, f"{n} ({com})" if com else n) for c, n, com in etabs],
"-- Sélectionner --"
)
self.lieux_combo.populate(self.db.get_lieux(), "-- Sélectionner --")
self.theme_combo.populate(self.db.get_themes(), "-- Sélectionner --")
self.annul_combo.populate(self.db.get_codes_annulation(), "Non")
self._set_default(self.annul_combo, "Non")
self.dossier_combo.populate(self.db.get_type_dossier_ens(), "Non")
self._set_default(self.dossier_combo, "Non")
self._reload_animations_list()
except Exception as e:
QMessageBox.critical(self, "Erreur chargement", str(e))
def _set_default(self, combo_widget, text):
idx = combo_widget.combo.findText(text)
if idx >= 0:
combo_widget.combo.setCurrentIndex(idx)
def _reload_animations_list(self):
self.anim_combo.blockSignals(True)
self.anim_combo.clear()
self.anim_combo.addItem("── Nouvelle saisie ──", None)
try:
rows = self.db.get_all_animations()
for code, date_anim, lieux, theme in rows:
date_str = date_anim.strftime("%d/%m/%Y") if date_anim else "?"
lieux_str = lieux or "Lieu ?"
theme_str = theme or "Thème ?"
label = f"{date_str} | {lieux_str} | {theme_str} [#{code}]"
self.anim_combo.addItem(label, code)
except Exception as e:
QMessageBox.warning(self, "Erreur", f"Impossible de charger les animations : {e}")
self.anim_combo.blockSignals(False)
# -----------------------------------------------------------------------
# Événements
# -----------------------------------------------------------------------
def _on_anim_selected(self, idx):
code = self.anim_combo.itemData(idx)
if code is None:
self._new_form()
return
try:
data = self.db.get_animation_by_id(code)
if data:
self._edit_mode = True
self._edit_code = code
self.title_label.setText(f"<h2>Édition animation #{code}</h2>")
self.btn_save.setText("Mettre à jour")
self.btn_save_new.setVisible(False)
self._populate_form(data)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _populate_form(self, data):
"""Remplit le formulaire avec les données d'une animation existante."""
if data.get('date_anim'):
d = data['date_anim']
self.date_edit.setDate(QDate(d.year, d.month, d.day))
duree = data.get('duree_anim', '0h00') or '0h00'
try:
if 'h' in duree:
h, m = duree.split('h')
self.duree_h.setValue(int(h))
self.duree_min.setValue(int(m) if m else 0)
except Exception:
pass
self.prepa_spin.setValue(data.get('prepa_deplacemt') or 0)
self.animateurs_widget.set_value(data.get('animateurs'))
self.type_groupe_combo.set_by_data(data.get('code_groupe'))
self.precisions_groupe_edit.setText(data.get('precisions_type_de_groupe') or '')
# Établissement : chercher par nom
nom_etab = data.get('nom_etablissement') or ''
for code, (nom, com) in self._etab_map.items():
if nom == nom_etab:
self.etab_combo.set_by_data(code)
break
self.enfants_spin.setValue(data.get('nbre_pers_enfants') or 0)
self.adultes_spin.setValue(data.get('nbre_pers_adultes') or 0)
self.lieux_combo.set_by_text(data.get('lieux') or '')
self.precisions_lieux_edit.setText(data.get('precisions_lieux_anim') or '')
self.accueil_pin_check.setChecked(bool(data.get('accueil_pin')))
self.theme_detaille_edit.setText(data.get('theme_detaille') or '')
self.theme_combo.set_by_data(data.get('code_type_anim'))
self.payant_check.setChecked(bool(data.get('payant')))
self.annul_combo.set_by_text(data.get('type_annul') or 'Non')
self.remarques_edit.setText(data.get('remarques') or '')
self.dossier_combo.set_by_text(data.get('type_dossier_ens') or 'Non')
realise = bool(data.get('realise_par_tiers'))
self.tiers_check.setChecked(realise)
self.detail_tiers_edit.setText(data.get('detail_tiers') or '')
self.detail_tiers_edit.setEnabled(realise)
def _new_form(self):
self._edit_mode = False
self._edit_code = None
self.title_label.setText("<h2>Nouvelle animation</h2>")
self.btn_save.setText("Enregistrer")
self.btn_save_new.setVisible(True)
self.anim_combo.blockSignals(True)
self.anim_combo.setCurrentIndex(0)
self.anim_combo.blockSignals(False)
self._reset_form()
def _on_etab_changed(self, idx):
code = self.etab_combo.combo.itemData(idx)
if code and code in self._etab_map:
self.commune_label.setText(self._etab_map[code][1] or "")
else:
self.commune_label.setText("")
def _on_tiers_changed(self, state):
self.detail_tiers_edit.setEnabled(state == Qt.Checked)
def _update_temps_total(self):
total = self.duree_h.value() * 60 + self.duree_min.value() + self.prepa_spin.value()
self.temps_total_label.setText(f"{total} min")
def _update_total_pers(self):
self.total_pers_label.setText(str(self.enfants_spin.value() + self.adultes_spin.value()))
# -----------------------------------------------------------------------
# Capture géométrie sur carte
# -----------------------------------------------------------------------
def _start_point_capture(self, geom_type, on_captured_callback):
"""
Lance l'outil de clic sur la carte. geom_type = 'lieu' ou 'etab'.
Après le clic, appelle on_captured_callback(wkt, srid).
"""
canvas = iface.mapCanvas()
if not canvas:
QMessageBox.warning(self, "Erreur", "Impossible d'accéder à la carte QGIS.")
return
prev_tool = canvas.mapTool()
tool = PointCaptureTool(canvas, prev_tool)
def on_point(x, y, crs):
# Convertir en WGS84 (4326) si nécessaire
wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
if crs != wgs84:
transform = QgsCoordinateTransform(crs, wgs84, QgsProject.instance())
from qgis.core import QgsPointXY
pt = transform.transform(x, y)
x, y = pt.x(), pt.y()
wkt = f"POINT({x} {y})"
on_captured_callback(wkt, 4326)
self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
self.activateWindow()
tool.pointCaptured.connect(on_point)
self._point_tool = tool
# Minimiser le dialogue et activer l'outil
self.showMinimized()
canvas.setMapTool(tool)
# -----------------------------------------------------------------------
# Callbacks ajout nouveaux éléments
# -----------------------------------------------------------------------
def _add_type_groupe(self, widget):
QMessageBox.information(self, "Info",
"L'ajout d'un type de groupe nécessite un code entier.\n"
"Veuillez contacter l'administrateur de la base.")
def _add_lieu(self, widget):
val, ok = QInputDialog.getText(self, "Nouveau lieu", "Nom du lieu :")
if not ok or not val.strip():
return
nom = val.strip()
reply = QMessageBox.question(self, "Géométrie",
"Souhaitez-vous localiser ce lieu sur la carte ?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
def on_geom(wkt, srid):
try:
code, label = self.db.add_lieux(nom, geom_wkt=wkt, srid=srid)
widget.add_item(code, label)
QMessageBox.information(self, "Succès", f"Lieu « {label} » ajouté avec géométrie.")
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
self._start_point_capture('lieu', on_geom)
else:
try:
code, label = self.db.add_lieux(nom)
widget.add_item(code, label)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _add_theme(self, widget):
val, ok = QInputDialog.getText(self, "Nouveau thème", "Libellé :")
if ok and val.strip():
try:
code, label = self.db.add_theme(val.strip())
widget.add_item(code, label)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _add_annulation(self, widget):
val, ok = QInputDialog.getText(self, "Nouveau type d'annulation", "Libellé :")
if ok and val.strip():
try:
code, label = self.db.add_annulation(val.strip())
widget.add_item(code, label)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _add_dossier(self, widget):
val, ok = QInputDialog.getText(self, "Nouveau type de dossier", "Libellé :")
if ok and val.strip():
try:
code, label = self.db.add_type_dossier(val.strip())
widget.add_item(code, label)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _add_etablissement(self, widget):
nom, ok = QInputDialog.getText(self, "Nouvel établissement", "Nom :")
if not ok or not nom.strip():
return
commune, ok2 = QInputDialog.getText(self, "Nouvel établissement", "Commune :")
if not ok2:
return
nom = nom.strip()
commune = commune.strip()
reply = QMessageBox.question(self, "Géométrie",
"Souhaitez-vous localiser cet établissement sur la carte ?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
def on_geom(wkt, srid):
try:
code, label, com = self.db.add_etablissement(nom, commune, geom_wkt=wkt, srid=srid)
self._etab_map[code] = (label, com)
display = f"{label} ({com})" if com else label
widget.add_item(code, display)
QMessageBox.information(self, "Succès", f"Établissement « {label} » ajouté avec géométrie.")
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
self._start_point_capture('etab', on_geom)
else:
try:
code, label, com = self.db.add_etablissement(nom, commune)
self._etab_map[code] = (label, com)
display = f"{label} ({com})" if com else label
widget.add_item(code, display)
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
# -----------------------------------------------------------------------
# Collecte, validation, enregistrement
# -----------------------------------------------------------------------
def _collect_data(self):
duree_str = f"{self.duree_h.value()}h{self.duree_min.value():02d}"
duree_min_total = self.duree_h.value() * 60 + self.duree_min.value()
temps_total = duree_min_total + self.prepa_spin.value() or None
etab_code = self.etab_combo.current_data()
nom_etab, commune_prov = (None, None)
if etab_code and etab_code in self._etab_map:
nom_etab, commune_prov = self._etab_map[etab_code]
lieux_txt = self.lieux_combo.current_text() if self.lieux_combo.current_data() else None
type_annul = self.annul_combo.current_text() if self.annul_combo.current_data() else None
type_dossier = self.dossier_combo.current_text() if self.dossier_combo.current_data() else None
realise_tiers = self.tiers_check.isChecked()
return {
"date_anim": self.date_edit.date().toPyDate(),
"duree_anim": duree_str,
"animateurs": self.animateurs_widget.get_value(),
"prepa_deplacemt": self.prepa_spin.value() or None,
"temps_total": temps_total,
"code_groupe": self.type_groupe_combo.current_data(),
"precisions_type_de_groupe": self.precisions_groupe_edit.text().strip() or None,
"commune_provenance": commune_prov,
"nom_etablissement": nom_etab,
"nbre_pers_enfants": self.enfants_spin.value() or None,
"nbre_pers_adultes": self.adultes_spin.value() or None,
"nbre_pers_total": (self.enfants_spin.value() + self.adultes_spin.value()) or None,
"lieux": lieux_txt,
"precisions_lieux_anim": self.precisions_lieux_edit.text().strip() or None,
"accueil_pin": self.accueil_pin_check.isChecked(),
"theme_detaille": self.theme_detaille_edit.text().strip() or None,
"code_type_anim": self.theme_combo.current_data(),
"payant": self.payant_check.isChecked(),
"type_annul": type_annul,
"remarques": self.remarques_edit.text().strip() or None,
"type_dossier_ens": type_dossier,
"realise_par_tiers": realise_tiers,
"detail_tiers": self.detail_tiers_edit.text().strip() if realise_tiers else None,
}
def _reset_form(self):
self.date_edit.setDate(QDate.currentDate())
self.duree_h.setValue(0)
self.duree_min.setValue(0)
self.prepa_spin.setValue(0)
self.animateurs_widget.list_widget.clear()
self.type_groupe_combo.combo.setCurrentIndex(0)
self.precisions_groupe_edit.clear()
self.etab_combo.combo.setCurrentIndex(0)
self.commune_label.setText("")
self.enfants_spin.setValue(0)
self.adultes_spin.setValue(0)
self.lieux_combo.combo.setCurrentIndex(0)
self.precisions_lieux_edit.clear()
self.accueil_pin_check.setChecked(False)
self.theme_combo.combo.setCurrentIndex(0)
self.theme_detaille_edit.clear()
self.payant_check.setChecked(False)
self.annul_combo.combo.setCurrentIndex(0)
self.remarques_edit.clear()
self.dossier_combo.combo.setCurrentIndex(0)
self.tiers_check.setChecked(False)
self.detail_tiers_edit.clear()
def _save(self):
try:
data = self._collect_data()
if self._edit_mode:
self.db.update_donnees_animation(self._edit_code, data)
QMessageBox.information(self, "Succès", f"Animation #{self._edit_code} mise à jour.")
self._reload_animations_list()
else:
code = self.db.get_next_code_animation()
data['code_animation'] = code
self.db.insert_donnees_animation(data)
QMessageBox.information(self, "Succès", f"Animation #{code} enregistrée.")
self._reload_animations_list()
self.accept()
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))
def _save_and_new(self):
try:
data = self._collect_data()
code = self.db.get_next_code_animation()
data['code_animation'] = code
self.db.insert_donnees_animation(data)
QMessageBox.information(self, "Succès", f"Animation #{code} enregistrée.")
self._reload_animations_list()
self._new_form()
except Exception as e:
QMessageBox.critical(self, "Erreur", str(e))

32
main_plugin.py Normal file
View File

@ -0,0 +1,32 @@
from qgis.PyQt.QtWidgets import QAction, QMessageBox, QDialog
from .dialog_auth import AuthDialog, try_connect_from_keychain
from .dialog_saisie import SaisieDialog
class AnimationPlugin:
def __init__(self, iface):
self.iface = iface
self.action = None
def initGui(self):
self.action = QAction("Saisie Animation", self.iface.mainWindow())
self.action.triggered.connect(self.run)
self.iface.addToolBarIcon(self.action)
self.iface.addPluginToMenu("Animation", self.action)
def unload(self):
self.iface.removePluginMenu("Animation", self.action)
self.iface.removeToolBarIcon(self.action)
def run(self):
# Tenter la connexion automatique via le coffre-fort QGIS
db = try_connect_from_keychain()
if db:
dlg = SaisieDialog(db, self.iface.mainWindow())
dlg.exec_()
else:
# Fallback : sélection d'une connexion QGIS (ou saisie manuelle)
dlg = AuthDialog(self.iface.mainWindow())
if dlg.exec_() == QDialog.Accepted and dlg.db:
saisie = SaisieDialog(dlg.db, self.iface.mainWindow())
saisie.exec_()

37
map_point_tool.py Normal file
View File

@ -0,0 +1,37 @@
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import pyqtSignal
from qgis.PyQt.QtGui import QCursor
from qgis.PyQt.QtCore import Qt
class PointCaptureTool(QgsMapTool):
"""
Outil de capture d'un point sur la carte QGIS.
Émet pointCaptured(x, y, crs) au clic gauche.
Se désactive automatiquement après un clic.
"""
pointCaptured = pyqtSignal(float, float, object)
def __init__(self, canvas, previous_tool=None):
super().__init__(canvas)
self.canvas = canvas
self.previous_tool = previous_tool
self.setCursor(QCursor(Qt.CrossCursor))
def canvasReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
point = self.toMapCoordinates(event.pos())
crs = self.canvas.mapSettings().destinationCrs()
self.pointCaptured.emit(point.x(), point.y(), crs)
# Restaurer l'outil précédent
if self.previous_tool:
self.canvas.setMapTool(self.previous_tool)
else:
self.canvas.unsetMapTool(self)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
if self.previous_tool:
self.canvas.setMapTool(self.previous_tool)
else:
self.canvas.unsetMapTool(self)

8
metadata.txt Normal file
View File

@ -0,0 +1,8 @@
[general]
name=Animation Saisie
qgisMinimumVersion=3.0
description=Plugin de saisie des données d'animation
version=1.0
author=Animation
email=contact@animation.fr
about=Plugin permettant la saisie des données dans la table animation.donnees_animation