diff --git a/docker-compose.yml b/docker-compose.yml index 9c48864..7cf16a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,7 @@ services: image: quay.io/keycloak/keycloak:22.0.5 command: - start-dev - - --hostname=keycloak.ninolbt.com + - --hostname=localhost - --hostname-strict=false - --hostname-strict-https=false - --proxy=edge diff --git a/web/app.py b/web/app.py index 61cde0b..6935a46 100644 --- a/web/app.py +++ b/web/app.py @@ -1,29 +1,51 @@ import os import uuid import json -from flask import Flask, redirect, url_for, jsonify, session, render_template +# J'ai ajouté 'request' aux imports +from flask import Flask, redirect, url_for, jsonify, session, render_template, request from flask_sqlalchemy import SQLAlchemy from authlib.integrations.flask_client import OAuth app = Flask(__name__) ANNOUNCE_FILE = os.path.join(os.path.dirname(__file__), "annonces.json") +# Ta config DB actuelle app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://flaskuser:flaskpass@mariadb/flaskdb' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.secret_key = os.environ.get("SECRET_KEY", "dev-key") db = SQLAlchemy(app) -# Configuration de Authlib -oauth = OAuth(app) +# --- MODELE DE DONNEES POUR LES BLOCS --- +class Block(db.Model): + id = db.Column(db.Integer, primary_key=True) + block_type = db.Column(db.String(50)) # ex: 'iframe', 'buttons', 'html' + column_name = db.Column(db.String(20)) # ex: 'left', 'center', 'right' + position = db.Column(db.Integer) # pour l'ordre (0, 1, 2...) + data = db.Column(db.Text) # Contenu JSON (url, titre, etc.) + def to_dict(self): + return { + "id": self.id, + "type": self.block_type, + "column": self.column_name, + "position": self.position, + "data": json.loads(self.data) if self.data else {} + } + +# Création des tables si elles n'existent pas +with app.app_context(): + db.create_all() + +# ... (Ici, garde ta configuration OAUTH et tes routes Login/Logout/Auth inchangées) ... +# ... (Garde aussi tes fonctions load_announces, save_announces etc.) ... + +oauth = OAuth(app) keycloak = oauth.register( name='keycloak', client_id='flask-app', client_secret='T5G5jzCBiphnBNh9uuj0f6YNc9HrP8r4', server_metadata_url='https://keycloak.ninolbt.com/realms/gesthub/.well-known/openid-configuration', - client_kwargs={ - 'scope': 'openid profile email', - } + client_kwargs={'scope': 'openid profile email'} ) @app.route('/') @@ -31,7 +53,6 @@ def index(): user = session.get('user') if user: return render_template('view/index.html', user=user) - return redirect(url_for('login')) @app.route('/login') @@ -48,88 +69,84 @@ def auth(): userinfo = keycloak.parse_id_token(token, nonce=nonce) session['user'] = userinfo session["id_token"] = token.get("id_token") - app.logger.debug(f"User info: {userinfo}") return redirect('/') @app.route("/logout") def logout(): id_token = session.get("id_token") - print("ID Token Hint:", id_token) session.clear() return redirect( f"https://keycloak.ninolbt.com/realms/gesthub/protocol/openid-connect/logout" f"?post_logout_redirect_uri=https://dashboard.ninolbt.com" f"&id_token_hint={id_token}" ) -def load_announces(): - try: - with open(ANNOUNCE_FILE, "r", encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return [] -def save_announces(announces): - with open(ANNOUNCE_FILE, "w", encoding="utf-8") as f: - json.dump(announces, f, ensure_ascii=False, indent=2) +# --- API LAYOUT (Gestion des Blocs) --- -def get_next_id(announces): - return max((a.get("id", 0) for a in announces), default=0) + 1 +@app.route('/api/layout', methods=['GET']) +def get_layout(): + # Récupère tous les blocs triés par position + blocks = Block.query.order_by(Block.position).all() + return jsonify([b.to_dict() for b in blocks]) -@app.route("/api/annonces", methods=["GET"]) -def get_announces(): - return jsonify(load_announces()) - -@app.route("/api/annonces", methods=["POST"]) -def create_annonce(): +@app.route('/api/layout/save', methods=['POST']) +def save_layout(): + # Sauvegarde l'ordre et la colonne après un drag & drop user = session.get("user") if not user or "/admin" not in user.get("groups", []): - return jsonify({"error": "unauthorized"}), 403 + return jsonify({"error": "Unauthorized"}), 403 + + layout_data = request.json # Liste de {id, column, position} + + for item in layout_data: + block = Block.query.get(item['id']) + if block: + block.column_name = item['column'] + block.position = item['position'] + + db.session.commit() + return jsonify({"status": "saved"}) + +@app.route('/api/block/add', methods=['POST']) +def add_block(): + user = session.get("user") + if not user or "/admin" not in user.get("groups", []): + return jsonify({"error": "Unauthorized"}), 403 + data = request.json - if not data.get("text"): - return jsonify({"error": "missing text"}), 400 + new_block = Block( + block_type=data.get('type'), + column_name=data.get('column', 'center'), + position=99, # Ajoute à la fin par défaut + data=json.dumps(data.get('data', {})) + ) + db.session.add(new_block) + db.session.commit() + return jsonify(new_block.to_dict()) - announces = load_announces() - new_announce = { - "id": get_next_id(announces), - "text": data["text"], - "author": user.get("preferred_username", "admin") - } - announces.append(new_announce) - save_announces(announces) - return jsonify({"status": "ok", "announce": new_announce}) - -@app.route("/api/annonces/", methods=["DELETE"]) -def delete_annonce(annonce_id): +@app.route('/api/block/', methods=['DELETE']) +def delete_block(block_id): user = session.get("user") if not user or "/admin" not in user.get("groups", []): - return jsonify({"error": "unauthorized"}), 403 - announces = load_announces() - announces = [a for a in announces if a["id"] != annonce_id] - save_announces(announces) - return jsonify({"status": "deleted"}) + return jsonify({"error": "Unauthorized"}), 403 -@app.route("/api/annonces/", methods=["PUT"]) -def edit_annonce(annonce_id): - user = session.get("user") - if not user or "/admin" not in user.get("groups", []): - return jsonify({"error": "unauthorized"}), 403 - data = request.json - announces = load_announces() - found = False - for a in announces: - if a["id"] == annonce_id: - a["text"] = data.get("text", a["text"]) - found = True - if not found: - return jsonify({"error": "not found"}), 404 - save_announces(announces) - return jsonify({"status": "updated"}) + block = Block.query.get(block_id) + if block: + db.session.delete(block) + db.session.commit() + return jsonify({"status": "deleted"}) + return jsonify({"error": "not found"}), 404 + +# --- API ANNONCES (Tes routes existantes) --- +# ... (Colle ici tes routes /api/annonces existantes, elles sont très bien) ... +# ... (N'oublie pas la route /api/is_admin) ... @app.route("/api/is_admin") def is_admin(): user = session.get("user") + # Sécurité : si pas de user, renvoie false + if not user: return jsonify({"admin": False}) return jsonify({"admin": "/admin" in user.get("groups", [])}) - if __name__ == '__main__': - app.run(host='0.0.0.0', debug=True) + app.run(host='0.0.0.0', debug=True) \ No newline at end of file diff --git a/web/init_db.py b/web/init_db.py new file mode 100644 index 0000000..e38ae11 --- /dev/null +++ b/web/init_db.py @@ -0,0 +1,65 @@ +from app import app, db, Block +import json + +# Configuration des widgets actuels extraits de ton index.html +initial_blocks = [ + # 1. Colonne GAUCHE : Le Planning + Block( + block_type='iframe', + column_name='left', + position=0, + data=json.dumps({ + "title": "Planning / Agenda", + "url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/vu4nuxhf73ircznrkrcgzonno8a", + "height": "100%", + "styleClass": "planning" + }) + ), + # 2. Colonne CENTRALE : Le Trello (Board) + Block( + block_type='iframe', + column_name='center', + position=0, + data=json.dumps({ + "title": "", + "url": "https://mattermost.ninolbt.com/boards/team/8xj6d4ukwigk7rznqi3w339x7e/b3kmbqfwd33dmdy9g9g3ezaoxza/va5xp53m6spbi8qo6qnng7711me", + "height": "680", + "styleClass": "trello" + }) + ), + # 3. Colonne DROITE : Les boutons + Block( + block_type='buttons', + column_name='right', + position=0, + data=json.dumps({ + "links": [ + {"label": "Projet", "url": "#"}, + {"label": "GDD Global", "url": "#"}, + {"label": "Bible 3D Art", "url": "#"}, + {"label": "Bible GameDev", "url": "#"}, + {"label": "Réglement du HUB", "url": "#"} + ] + }) + ) +] + +def init_data(): + with app.app_context(): + # Crée les tables si elles n'existent pas encore + db.create_all() + + # Vérifie si la DB est déjà remplie pour éviter les doublons + if Block.query.first(): + print("La base de données contient déjà des blocs.") + return + + print("Injection des widgets par défaut...") + for block in initial_blocks: + db.session.add(block) + + db.session.commit() + print("Terminé ! Tes widgets sont en base de données.") + +if __name__ == "__main__": + init_data() \ No newline at end of file diff --git a/web/static/assets/js/index.js b/web/static/assets/js/index.js index 0327ed5..024d001 100644 --- a/web/static/assets/js/index.js +++ b/web/static/assets/js/index.js @@ -1,153 +1,155 @@ +// ... (Garde tout ton code existant pour le Dark mode, Menu, Contact, etc.) ... - // mode dark pour les gens qui ne sortent pas de chez eux voir la lumiere du jours - document.getElementById("toggle-darkmode").onclick = () => { - document.body.classList.toggle("dark"); - }; - - // profil - document.querySelector(".profile-menu").addEventListener("click", (e) => { - e.stopPropagation(); - document.querySelector(".profile-menu").classList.toggle("active"); - }); - - // notif - document.querySelector(".notification").addEventListener("click", (e) => { - e.stopPropagation(); - document.querySelector(".notification").classList.toggle("active"); - }); - - // syteme contact - const contactOptions = document.querySelectorAll('.contact-option:not(.expandable)'); - const contactModal = document.getElementById('contactModal'); - const recipientName = document.getElementById('recipient-name'); - const cancelBtn = document.getElementById('cancel-message'); - const sendBtn = document.getElementById('send-message'); - const dropdown = document.querySelector('.dropdown'); - const nestedDropdowns = document.querySelectorAll('.nested-dropdown'); - - //menu princ - dropdown.addEventListener('click', (e) => { - e.stopPropagation(); - const content = dropdown.querySelector('.dropdown-content'); - content.style.display = content.style.display === 'block' ? 'none' : 'block'; - }); - - // Gestion des sous-menus - nestedDropdowns.forEach(dropdown => { - dropdown.addEventListener('click', (e) => { - e.stopPropagation(); - const nestedContent = dropdown.querySelector('.nested-content'); - nestedContent.style.display = nestedContent.style.display === 'block' ? 'none' : 'block'; - }); - }); - - // Fermer les menus quand on clique ailleurs - document.addEventListener('click', () => { - document.querySelector('.dropdown-content').style.display = 'none'; - document.querySelectorAll('.nested-content').forEach(el => { - el.style.display = 'none'; - }); - }); - - contactOptions.forEach(option => { - option.addEventListener('click', (e) => { - e.preventDefault(); - if (option.dataset.role) { - recipientName.textContent = option.dataset.role; - contactModal.style.display = 'block'; - } - }); - }); - - document.querySelector('.close-modal').addEventListener('click', () => { - contactModal.style.display = 'none'; - }); - - cancelBtn.addEventListener('click', () => { - contactModal.style.display = 'none'; - }); - - sendBtn.addEventListener('click', () => { - const message = document.getElementById('message-content').value; - if (message.trim() !== '') { - contactModal.style.display = 'none'; - document.getElementById('message-content').value = ''; - } - }); - - // Fermer la modale si on clique en dehors - window.addEventListener('click', (e) => { - if (e.target === contactModal) { - contactModal.style.display = 'none'; - } - }); +// --- GESTION DU LAYOUT DYNAMIQUE --- -// Gestion des annonces +let isPageAdmin = false; - let isAdmin = false; - - fetch("/api/is_admin") - .then(res => res.json()) - .then(data => { - isAdmin = data.admin; - if (isAdmin) { - document.getElementById("admin-tools").style.display = "block"; - } - }); - - fetch("/api/annonces") - .then(res => res.json()) - .then(data => { - const container = document.getElementById("annonces-container"); - container.innerHTML = ""; - - data.forEach(item => { - const div = document.createElement("div"); - div.className = "postit"; - div.dataset.id = item.id; - div.innerHTML = ` - ${item.text} -
${item.author} - `; - - if (isAdmin) { - const editBtn = document.createElement("button"); - editBtn.textContent = "✏️"; - editBtn.onclick = () => editAnnonce(item.id, item.text); - div.appendChild(editBtn); - - const delBtn = document.createElement("button"); - delBtn.textContent = "🗑️"; - delBtn.onclick = () => deleteAnnonce(item.id); - div.appendChild(delBtn); - } - - container.appendChild(div); - }); - }); - - function submitAnnonce() { - const txt = document.getElementById("annonce-text").value; - fetch("/api/annonces", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: txt }) - }).then(() => location.reload()); - } - - function deleteAnnonce(id) { - fetch(`/api/annonces/${id}`, { - method: "DELETE" - }).then(() => location.reload()); - } - - function editAnnonce(id, oldText) { - const newText = prompt("Modifier l'annonce :", oldText); - if (newText && newText !== oldText) { - fetch(`/api/annonces/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: newText }) - }).then(() => location.reload()); +// 1. Vérification Admin et Initialisation +fetch("/api/is_admin") + .then(res => res.json()) + .then(data => { + isPageAdmin = data.admin; + if (isPageAdmin) { + document.getElementById("admin-panel").style.display = "block"; + document.body.classList.add('admin-active'); + initDragAndDrop(); + + // Afficher aussi les outils d'annonces si tu veux garder ça + const tools = document.getElementById("admin-tools"); + if(tools) tools.style.display = "block"; } - } \ No newline at end of file + loadLayout(); // Charger les blocs +}); + +// 2. Chargement des blocs depuis l'API +function loadLayout() { + fetch('/api/layout') + .then(res => res.json()) + .then(blocks => { + // Vider les colonnes + ['left', 'center', 'right'].forEach(id => document.getElementById(id).innerHTML = ''); + + // Remettre le titre au centre (optionnel si tu veux qu'il soit un bloc aussi) + document.getElementById('center').innerHTML = '

Gesthub

'; + + blocks.forEach(block => { + const col = document.getElementById(block.column); + if (col) { + const el = createBlockElement(block); + col.appendChild(el); + } + }); + }); +} + +// 3. Création du HTML d'un bloc +function createBlockElement(block) { + const div = document.createElement('div'); + div.className = 'draggable-item'; + div.dataset.id = block.id; + div.style.marginBottom = "1rem"; + div.style.background = block.type === 'buttons' ? 'transparent' : ''; + + // Barre de drag (visible seulement en admin via CSS) + let html = `
:: Deplacer ::
`; + + if (block.type === 'iframe') { + html += ` +
+ ${block.data.title ? `

${block.data.title}

` : ''} + +
+ `; + } else if (block.type === 'buttons') { + // block.data.links doit être un tableau d'objets {label, url} + let btns = ''; + if(block.data.links) { + block.data.links.forEach(link => { + btns += ``; + }); + } + html += `
${btns}
`; + } else if (block.type === 'html') { + html += `
${block.data.content}
`; + } + + div.innerHTML = html; + return div; +} + +// 4. Initialisation Drag & Drop (SortableJS) +function initDragAndDrop() { + const containers = [document.getElementById('left'), document.getElementById('center'), document.getElementById('right')]; + + containers.forEach(container => { + new Sortable(container, { + group: 'shared', // Permet de déplacer entre les colonnes + handle: '.drag-handle', // On ne peut attraper que par la poignée + animation: 150, + onEnd: function (evt) { + saveLayoutPosition(); + } + }); + }); +} + +// 5. Sauvegarde de la position +function saveLayoutPosition() { + const layout = []; + ['left', 'center', 'right'].forEach(colId => { + const col = document.getElementById(colId); + const items = col.querySelectorAll('.draggable-item'); + items.forEach((item, index) => { + layout.push({ + id: item.dataset.id, + column: colId, + position: index + }); + }); + }); + + fetch('/api/layout/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(layout) + }); +} + +// 6. Ajouter un Widget (Logique simple pour l'instant) +window.addNewWidget = function(type) { + let data = {}; + + if (type === 'iframe') { + const url = prompt("URL de l'iframe (Mattermost/Trello) :"); + const height = prompt("Hauteur (ex: 400) :", "400"); + if (!url) return; + data = { url: url, height: height, title: "Nouveau Widget" }; + } else if (type === 'buttons') { + // Pour faire simple, on ajoute un bouton par défaut, tu pourras éditer plus tard + data = { + links: [ + { label: "Nouveau Bouton", url: "https://google.com" } + ] + }; + } else if (type === 'html') { + const text = prompt("Texte du Post-it :"); + data = { content: text }; + } + + fetch('/api/block/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: type, column: 'center', data: data }) + }) + .then(res => res.json()) + .then(block => { + location.reload(); // Recharge pour afficher le nouveau bloc proprement + }); +} + +window.deleteBlock = function(id) { + if(confirm("Supprimer ce bloc ?")) { + fetch(`/api/block/${id}`, { method: 'DELETE' }) + .then(() => location.reload()); + } +} \ No newline at end of file diff --git a/web/templates/view/index.html b/web/templates/view/index.html index ce108cf..a892918 100644 --- a/web/templates/view/index.html +++ b/web/templates/view/index.html @@ -6,114 +6,49 @@ Gesthub + - -
-
- 🔔 -
-

📌 Rendu le 03.02.2026

-

📝 Nouvelle tâche ajoutée

-
-
- - - - - -
- {{ user['preferred_username'] }} -
- - Déconnexion -
-
-
- - - - +
- - + - -
-

Gesthub

-
- -
-
+
+

Gesthub

+
- - -
- - + + - + \ No newline at end of file