refactor: remove TeambuildingVote feature and associated views, templates, and tests

This commit is contained in:
kdeterme 2026-08-12 17:40:59 +02:00
parent d9ee551e42
commit 526c8185fa
11 changed files with 20 additions and 1265 deletions

View file

@ -1,15 +1,7 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from .models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition, TeambuildingVote
@admin.register(TeambuildingVote)
class TeambuildingVoteAdmin(admin.ModelAdmin):
list_display = ("id", "voter_name", "vote_gael", "vote_charly", "vote_tequila", "vote_vivien", "created_at")
list_filter = ("created_at",)
search_fields = ("voter_name",)
from .models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition
class HomeWidgetConditionInline(admin.TabularInline):

View file

@ -225,34 +225,6 @@ msgstr "Condition"
msgid "Conditions"
msgstr "Terms"
#: home/models.py:341
msgid "Nom (optionnel)"
msgstr "optional"
#: home/models.py:342
msgid "La journée 'Gaël Non Di Dju'"
msgstr "The 'Gaël Non Di Dju' day"
#: home/models.py:343
msgid "La journée 'La cité de Charly'"
msgstr "'The city of Charly' day"
#: home/models.py:344
msgid "La journée 'Tequila Cadena'"
msgstr "Tequila Cadena Day"
#: home/models.py:345
msgid "La journée 'Vivien de La Grande Propreté'"
msgstr "The 'Vivien de La Grande Propreté' day"
#: home/models.py:352
msgid "Vote Teambuilding"
msgstr "Teambuilding voting"
#: home/models.py:353
msgid "Votes Teambuilding"
msgstr "Teambuilding Votes"
#: home/templates/home/_add_repair_intervention_modal.html:9
msgid "Nouvelle intervention de réparation"
msgstr "New repair intervention"

View file

@ -230,34 +230,6 @@ msgstr "État"
msgid "Conditions"
msgstr "Conditions"
#: home/models.py:341
msgid "Nom (optionnel)"
msgstr "Nom (optionnel)"
#: home/models.py:342
msgid "La journée 'Gaël Non Di Dju'"
msgstr "La journée 'Gaël Non Di Dju'"
#: home/models.py:343
msgid "La journée 'La cité de Charly'"
msgstr "La journée 'La cité de Charly'"
#: home/models.py:344
msgid "La journée 'Tequila Cadena'"
msgstr "La journée 'Tequila Cadena'"
#: home/models.py:345
msgid "La journée 'Vivien de La Grande Propreté'"
msgstr "La journée 'Vivien de La Grande Propreté'"
#: home/models.py:352
msgid "Vote Teambuilding"
msgstr "Vote Teambuilding"
#: home/models.py:353
msgid "Votes Teambuilding"
msgstr "Votes Teambuilding"
#: home/templates/home/_add_repair_intervention_modal.html:9
msgid "Nouvelle intervention de réparation"
msgstr "Nouvelle intervention de réparation"

View file

@ -225,34 +225,6 @@ msgstr "Staat"
msgid "Conditions"
msgstr "Voorwaarden"
#: home/models.py:341
msgid "Nom (optionnel)"
msgstr "optioneel"
#: home/models.py:342
msgid "La journée 'Gaël Non Di Dju'"
msgstr "De 'Gaël Non Di Dju'-dag"
#: home/models.py:343
msgid "La journée 'La cité de Charly'"
msgstr "Dag 'De stad van Charly'"
#: home/models.py:344
msgid "La journée 'Tequila Cadena'"
msgstr "Tequila Cadena-dag"
#: home/models.py:345
msgid "La journée 'Vivien de La Grande Propreté'"
msgstr "De dag van 'Vivien de La Grande Propreté'"
#: home/models.py:352
msgid "Vote Teambuilding"
msgstr "Stemmen voor teambuilding"
#: home/models.py:353
msgid "Votes Teambuilding"
msgstr "Stemmen voor teambuilding"
#: home/templates/home/_add_repair_intervention_modal.html:9
msgid "Nouvelle intervention de réparation"
msgstr "Nieuwe reparatie-interventie"

View file

@ -0,0 +1,16 @@
# Generated by Django 6.0.7 on 2026-08-12 15:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('home', '0001_initial'),
]
operations = [
migrations.DeleteModel(
name='TeambuildingVote',
),
]

View file

@ -335,21 +335,3 @@ class HomeWidgetCondition(models.Model):
if self.is_negated:
return not res
return res
class TeambuildingVote(models.Model):
voter_name = models.CharField(max_length=100, blank=True, null=True, verbose_name=_("Nom (optionnel)"))
vote_gael = models.IntegerField(default=0, verbose_name=_("La journée 'Gaël Non Di Dju'"))
vote_charly = models.IntegerField(default=0, verbose_name=_("La journée 'La cité de Charly'"))
vote_tequila = models.IntegerField(default=0, verbose_name=_("La journée 'Tequila Cadena'"))
vote_vivien = models.IntegerField(default=0, verbose_name=_("La journée 'Vivien de La Grande Propreté'"))
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Vote #{self.id} - {self.voter_name or 'Anonyme'}"
class Meta:
verbose_name = _("Vote Teambuilding")
verbose_name_plural = _("Votes Teambuilding")

View file

@ -1,594 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flexi Teambuilding - Vote</title>
<link rel="icon" href="{% static 'common/favicon.ico' %}" sizes="any">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Plus+Jakarta+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link href="{% static 'common/bootstrap-icons.min.css' %}" rel="stylesheet">
<!-- Bootstrap CSS (local) -->
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
<style>
:root {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #311042 100%);
--card-bg: rgba(30, 41, 59, 0.45);
--card-border: rgba(255, 255, 255, 0.08);
--accent-color: #d946ef;
--accent-gradient: linear-gradient(90deg, #a855f7 0%, #d946ef 100%);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--star-color: #fbbf24;
--star-empty: #475569;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg-gradient);
background-attachment: fixed;
color: var(--text-main);
min-height: 100vh;
padding: 3rem 0;
overflow-x: hidden;
}
h1, h2, h3, .heading-font {
font-family: 'Outfit', sans-serif;
font-weight: 800;
}
.glass-header {
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--card-border);
padding: 2rem;
border-radius: 24px;
margin-bottom: 3rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
text-align: center;
}
.header-title {
font-size: 2.8rem;
background: linear-gradient(120deg, #c084fc, #f472b6, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.header-subtitle {
color: var(--text-muted);
font-size: 1.1rem;
font-weight: 300;
}
.proposal-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 2rem;
height: 100%;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.proposal-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: var(--accent-gradient);
opacity: 0;
transition: opacity 0.3s ease;
}
.proposal-card:hover {
transform: translateY(-8px);
border-color: rgba(217, 70, 239, 0.3);
box-shadow: 0 12px 30px rgba(217, 70, 239, 0.15);
}
.proposal-card:hover::before {
opacity: 1;
}
.card-number {
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 3.5rem;
font-weight: 900;
color: rgba(255, 255, 255, 0.03);
line-height: 1;
user-select: none;
font-family: 'Outfit', sans-serif;
}
.proposal-title {
font-size: 1.4rem;
color: #f1f5f9;
margin-bottom: 1rem;
padding-right: 2rem;
font-weight: 700;
border-left: 3px solid var(--accent-color);
padding-left: 0.75rem;
}
.proposal-details {
list-style-type: none;
padding-left: 0;
margin-bottom: 2rem;
flex-grow: 1;
}
.proposal-details li {
position: relative;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.95rem;
line-height: 1.5;
color: #cbd5e1;
}
.proposal-details li::before {
content: "\F2E6"; /* Bootstrap Icon Chevron Right */
font-family: "bootstrap-icons";
position: absolute;
left: 0;
top: 2px;
color: var(--accent-color);
font-size: 0.85rem;
}
.badge-budget {
background: rgba(217, 70, 239, 0.15);
border: 1px solid rgba(217, 70, 239, 0.3);
color: #f472b6;
font-size: 0.85rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border-radius: 8px;
display: inline-flex;
align-items: center;
margin-top: auto;
align-self: flex-start;
}
.rating-wrapper {
background: rgba(15, 23, 42, 0.5);
border-radius: 16px;
padding: 1rem;
margin-top: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
text-align: center;
}
.rating-title {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 1px;
margin-bottom: 0.5rem;
font-weight: 600;
}
.stars-container {
display: inline-flex;
gap: 0.75rem;
justify-content: center;
align-items: center;
}
.star-btn {
background: none;
border: none;
padding: 0;
font-size: 2.2rem;
color: var(--star-empty);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.star-btn:hover {
transform: scale(1.25);
}
.star-btn.active {
color: var(--star-color);
text-shadow: 0 0 15px rgba(251, 191, 36, 0.6);
}
.star-btn.hover {
color: rgba(251, 191, 36, 0.75);
}
.rating-feedback {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 0.5rem;
font-weight: 500;
min-height: 1.25rem;
transition: color 0.3s ease;
}
.rating-feedback.rated {
color: #34d399;
}
.voter-identity-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 2.5rem;
margin-top: 3rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.input-voter-name {
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--card-border);
border-radius: 12px;
color: var(--text-main);
padding: 0.9rem 1.2rem;
font-size: 1.05rem;
transition: all 0.3s ease;
}
.input-voter-name:focus {
background: rgba(15, 23, 42, 0.8);
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(217, 70, 239, 0.25);
color: var(--text-main);
outline: none;
}
.btn-submit-vote {
background: var(--accent-gradient);
border: none;
color: white;
font-weight: 700;
font-size: 1.15rem;
padding: 0.9rem 2.5rem;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 4px 15px rgba(217, 70, 239, 0.4);
font-family: 'Outfit', sans-serif;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-submit-vote:hover {
transform: scale(1.03) translateY(-2px);
box-shadow: 0 8px 25px rgba(217, 70, 239, 0.6);
}
.btn-submit-vote:active {
transform: translateY(0);
}
.reset-btn {
font-size: 0.75rem;
color: var(--text-muted);
text-decoration: none;
margin-left: 0.5rem;
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s ease;
}
.reset-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
/* Responsive styling tweak */
@media (max-width: 768px) {
body {
padding: 1.5rem 0;
}
.header-title {
font-size: 2.2rem;
}
.proposal-card {
padding: 1.5rem;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Header Block -->
<div class="glass-header">
<div class="card-number" style="left:1rem; right:auto; top:0.5rem;">FTB</div>
<h1 class="header-title">Flexi Teambuilding</h1>
<p class="header-subtitle">Exprimez votre avis sur les 4 propositions de journée pour le prochain Teambuilding !</p>
<div class="badge bg-secondary px-3 py-2 text-uppercase tracking-wider" style="font-size:0.75rem; border-radius:50px;">
<i class="bi bi-star-fill me-1 text-warning"></i> Vote : de 0 à 3 étoiles
</div>
</div>
<!-- Main Vote Form -->
<form method="POST" id="voteForm" class="needs-validation" novalidate>
{% csrf_token %}
<!-- Proposals Grid -->
<div class="row g-4">
<!-- Proposal 1: Gaël -->
<div class="col-lg-6 col-md-12">
<div class="proposal-card">
<div class="card-number">01</div>
<div>
<h3 class="proposal-title">La journée "Gaël Non Di Dju"</h3>
<ul class="proposal-details">
<li>RDV à Tour & Taxi à 10h pour un padel</li>
<li>Repas convivial dans les alentours</li>
<li>BattleKart de folie</li>
<li>Debrief autour d'un bon cocktail ou à la brasserie de la Senne</li>
</ul>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="badge-budget">
<i class="bi bi-currency-euro me-1"></i> Budget : ~150 € / pers.
</span>
</div>
<!-- Rating Widget -->
<div class="rating-wrapper">
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
<span class="rating-title">Votre évaluation</span>
<button type="button" class="reset-btn" onclick="resetRating('gael')">Effacer (0★)</button>
</div>
<div class="stars-container" data-proposal="gael">
<button type="button" class="star-btn" data-value="1"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="2"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="3"><i class="bi bi-star-fill"></i></button>
</div>
<input type="hidden" name="vote_gael" id="input_gael" value="0">
<div class="rating-feedback" id="feedback_gael">Pas encore voté</div>
</div>
</div>
</div>
<!-- Proposal 2: Charly -->
<div class="col-lg-6 col-md-12">
<div class="proposal-card">
<div class="card-number">02</div>
<div>
<h3 class="proposal-title">La journée "La cité de Charly"</h3>
<ul class="proposal-details">
<li>RDV vers 10h30 à la station Demey</li>
<li>Petit dèj et balade dans les superbes cités jardin de Watermael-Boitsfort</li>
<li>En souvenir de notre jeunesse, on prend le bus (dingue !!!)</li>
<li>Repas convivial aux alentours du cimetière d'Ixelles, au feeling du jour</li>
<li>Escape game palpitant à Ixelles</li>
<li>Debriefing dans un rade local, ou évaluation des améliorations de la DGI sur base de la place Flagey</li>
</ul>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="badge-budget">
<i class="bi bi-currency-euro me-1"></i> Budget : ~100 € / pers.
</span>
</div>
<!-- Rating Widget -->
<div class="rating-wrapper">
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
<span class="rating-title">Votre évaluation</span>
<button type="button" class="reset-btn" onclick="resetRating('charly')">Effacer (0★)</button>
</div>
<div class="stars-container" data-proposal="charly">
<button type="button" class="star-btn" data-value="1"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="2"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="3"><i class="bi bi-star-fill"></i></button>
</div>
<input type="hidden" name="vote_charly" id="input_charly" value="0">
<div class="rating-feedback" id="feedback_charly">Pas encore voté</div>
</div>
</div>
</div>
<!-- Proposal 3: Tequila -->
<div class="col-lg-6 col-md-12">
<div class="proposal-card">
<div class="card-number">03</div>
<div>
<h3 class="proposal-title">La journée "Tequila Cadena"</h3>
<ul class="proposal-details">
<li>10h30 : Tequila en attendant la retardataire, puis 10h40 : Tequila pour bien débuter la journée</li>
<li>10h45 : Exercices fun de théâtre et d'improvisation</li>
<li>Tequila pour l'apéro, suivi d'un repas chaleureux</li>
<li>Tequila pour le digestif</li>
<li>Voyage dans le temps en visitant quelques authentiques estaminets</li>
<li>Tequila finale pour se dire au revoir</li>
</ul>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="badge-budget">
<i class="bi bi-currency-euro me-1"></i> Budget : ~75 € / pers.
</span>
</div>
<!-- Rating Widget -->
<div class="rating-wrapper">
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
<span class="rating-title">Votre évaluation</span>
<button type="button" class="reset-btn" onclick="resetRating('tequila')">Effacer (0★)</button>
</div>
<div class="stars-container" data-proposal="tequila">
<button type="button" class="star-btn" data-value="1"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="2"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="3"><i class="bi bi-star-fill"></i></button>
</div>
<input type="hidden" name="vote_tequila" id="input_tequila" value="0">
<div class="rating-feedback" id="feedback_tequila">Pas encore voté</div>
</div>
</div>
</div>
<!-- Proposal 4: Vivien -->
<div class="col-lg-6 col-md-12">
<div class="proposal-card">
<div class="card-number">04</div>
<div>
<h3 class="proposal-title">La journée "Vivien de La Grande Propreté"</h3>
<ul class="proposal-details">
<li>10h00 : RDV chez Bruxelles Propreté pour une présentation historique sur cette administration soeur</li>
<li>10h45 : Présentation instructive sur les statuts juridiques de Bruxelles Propreté</li>
<li>12h00 : Sandwich du jour offert</li>
<li>Accompagnement en immersion totale d'une équipe de collecte de sacs (avec tour en camion et siège passager !)</li>
<li>Inspection approfondie de sacs d'ordure pour identifier le non-respect du tri</li>
<li>Délicieux jus de pomme bio pour clôturer cette formidable journée</li>
</ul>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="badge bg-dark border border-secondary text-secondary px-2.5 py-1.5" style="font-size:0.8rem; border-radius:8px;">
<i class="bi bi-gift me-1"></i> Budget minimal
</span>
</div>
<!-- Rating Widget -->
<div class="rating-wrapper">
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
<span class="rating-title">Votre évaluation</span>
<button type="button" class="reset-btn" onclick="resetRating('vivien')">Effacer (0★)</button>
</div>
<div class="stars-container" data-proposal="vivien">
<button type="button" class="star-btn" data-value="1"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="2"><i class="bi bi-star-fill"></i></button>
<button type="button" class="star-btn" data-value="3"><i class="bi bi-star-fill"></i></button>
</div>
<input type="hidden" name="vote_vivien" id="input_vivien" value="0">
<div class="rating-feedback" id="feedback_vivien">Pas encore voté</div>
</div>
</div>
</div>
</div>
<!-- Voter identity and Submission block -->
<div class="voter-identity-card">
<div class="row justify-content-center align-items-center g-4">
<div class="col-md-7 col-12 text-center text-md-start">
<h3 class="mb-2">Prêt à soumettre ?</h3>
<p class="text-muted mb-0" style="font-size:0.95rem;">
Vous pouvez indiquer votre nom ci-contre pour que l'organisateur sache qui a voté (facultatif). Le vote est anonyme par défaut.
</p>
</div>
<div class="col-md-5 col-12">
<div class="d-flex flex-column gap-3">
<input type="text" name="voter_name" class="form-control input-voter-name" placeholder="Votre nom (facultatif)" maxlength="100">
<button type="submit" class="btn-submit-vote w-100">
<i class="bi bi-check-circle-fill me-2"></i> Soumettre mes votes
</button>
</div>
</div>
</div>
<div class="text-center mt-4 pt-3 border-top border-secondary border-opacity-25">
<a href="{% url 'home:flexi_teambuilding_results' %}" class="text-info text-decoration-none" style="font-size:0.95rem;">
<i class="bi bi-bar-chart-fill me-1"></i> Voir les résultats actuels sans voter
</a>
</div>
</div>
</form>
</div>
<!-- Scripts for interactive stars and validation -->
<script>
const feedbacks = {
0: "Aucun intérêt (0★)",
1: "Pourquoi pas (1★)",
2: "Très bonne idée (2★)",
3: "Le rêve absolu ! (3★)"
};
document.addEventListener("DOMContentLoaded", () => {
const containers = document.querySelectorAll(".stars-container");
containers.forEach(container => {
const proposal = container.getAttribute("data-proposal");
const input = document.getElementById(`input_${proposal}`);
const feedback = document.getElementById(`feedback_${proposal}`);
const stars = container.querySelectorAll(".star-btn");
stars.forEach((star, index) => {
// Click handler
star.addEventListener("click", () => {
const val = index + 1;
input.value = val;
updateStars(stars, val);
feedback.textContent = feedbacks[val];
feedback.classList.add("rated");
});
// Hover states
star.addEventListener("mouseenter", () => {
highlightStars(stars, index + 1);
});
star.addEventListener("mouseleave", () => {
const currentVal = parseInt(input.value) || 0;
highlightStars(stars, currentVal);
});
});
});
});
function highlightStars(stars, val) {
stars.forEach((s, idx) => {
if (idx < val) {
s.classList.add("hover");
} else {
s.classList.remove("hover");
}
});
}
function updateStars(stars, val) {
stars.forEach((s, idx) => {
if (idx < val) {
s.classList.add("active");
} else {
s.classList.remove("active");
}
});
}
function resetRating(proposal) {
const input = document.getElementById(`input_${proposal}`);
const feedback = document.getElementById(`feedback_${proposal}`);
const container = document.querySelector(`.stars-container[data-proposal="${proposal}"]`);
const stars = container.querySelectorAll(".star-btn");
input.value = 0;
updateStars(stars, 0);
highlightStars(stars, 0);
feedback.textContent = feedbacks[0];
feedback.classList.remove("rated");
}
</script>
</body>
</html>

View file

@ -1,430 +0,0 @@
{% load static %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flexi Teambuilding - Résultats</title>
<link rel="icon" href="{% static 'common/favicon.ico' %}" sizes="any">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Plus+Jakarta+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link href="{% static 'common/bootstrap-icons.min.css' %}" rel="stylesheet">
<!-- Bootstrap CSS (local) -->
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
<style>
:root {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #311042 100%);
--card-bg: rgba(30, 41, 59, 0.45);
--card-border: rgba(255, 255, 255, 0.08);
--accent-color: #06b6d4;
--accent-gradient: linear-gradient(90deg, #3b82f6 0%, #06b6d4 100%);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--star-color: #fbbf24;
--success-color: #10b981;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg-gradient);
background-attachment: fixed;
color: var(--text-main);
min-height: 100vh;
padding: 3rem 0;
overflow-x: hidden;
}
h1, h2, h3, .heading-font {
font-family: 'Outfit', sans-serif;
font-weight: 800;
}
.glass-header {
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--card-border);
padding: 2rem;
border-radius: 24px;
margin-bottom: 3rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
text-align: center;
position: relative;
}
.header-title {
font-size: 2.8rem;
background: linear-gradient(120deg, #60a5fa, #38bdf8, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.header-subtitle {
color: var(--text-muted);
font-size: 1.1rem;
font-weight: 300;
}
.success-banner {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 16px;
padding: 1.25rem 2rem;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
gap: 1rem;
color: #a7f3d0;
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.08);
animation: slideInDown 0.5s ease-out;
}
@keyframes slideInDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.stats-summary {
background: rgba(15, 23, 42, 0.3);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 1.5rem 2.5rem;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
text-align: center;
}
.stats-number {
font-size: 3.5rem;
font-weight: 800;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
line-height: 1;
}
.results-container {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 24px;
padding: 2.5rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
margin-bottom: 3rem;
}
.result-row {
padding: 1.75rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.result-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.result-row:first-child {
padding-top: 0;
}
.proposal-name {
font-size: 1.25rem;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 0.75rem;
}
.stars-avg {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.star-icon {
font-size: 1.3rem;
color: var(--star-color);
}
.star-icon.empty {
color: #475569;
}
.score-badge {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
padding: 0.25rem 0.6rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 700;
margin-left: 0.5rem;
border: 1px solid rgba(251, 191, 36, 0.25);
}
.custom-progress {
background: #1e293b;
height: 14px;
border-radius: 50px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.03);
margin-top: 0.5rem;
}
.custom-progress-bar {
background: var(--accent-gradient);
height: 100%;
border-radius: 50px;
transition: width 1s ease-out;
box-shadow: 0 0 12px rgba(6, 182, 212, 0.4);
}
.participants-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 24px;
padding: 2.5rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.participant-item {
background: rgba(15, 23, 42, 0.3);
border: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 0.8rem 1.2rem;
margin-bottom: 0.75rem;
font-size: 0.95rem;
color: #cbd5e1;
display: flex;
justify-content: space-between;
align-items: center;
}
.participant-item span.time {
font-size: 0.75rem;
color: var(--text-muted);
}
.btn-action-back {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-muted);
font-weight: 500;
font-size: 0.95rem;
padding: 0.75rem 1.75rem;
border-radius: 12px;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-action-back:hover {
color: var(--text-main);
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.05);
}
.card-number {
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 3.5rem;
font-weight: 900;
color: rgba(255, 255, 255, 0.03);
line-height: 1;
user-select: none;
font-family: 'Outfit', sans-serif;
}
</style>
</head>
<body>
<div class="container">
<!-- Success Banner -->
{% if success %}
<div class="success-banner">
<i class="bi bi-check-circle-fill" style="font-size: 1.75rem; color: var(--success-color);"></i>
<div>
<h5 class="mb-0 fw-bold">Votre vote a bien été enregistré !</h5>
<p class="mb-0" style="font-size: 0.9rem; opacity: 0.85;">Merci pour votre participation active au teambuilding.</p>
</div>
</div>
{% endif %}
<!-- Header Block -->
<div class="glass-header">
<div class="card-number" style="left:1rem; right:auto; top:0.5rem;">FTB</div>
<h1 class="header-title">Résultats des Votes</h1>
<p class="header-subtitle">Visualisation en temps réel des avis exprimés sur le Flexi Teambuilding</p>
<div class="stats-summary d-inline-flex px-4 py-2 mt-2">
<div>
<div class="stats-number">{{ total_votes }}</div>
<div class="text-uppercase tracking-wider" style="font-size:0.75rem; color: var(--text-muted); font-weight:600; margin-top:2px;">
Participation{{ total_votes|pluralize }}
</div>
</div>
</div>
</div>
<!-- Results Overview -->
<div class="results-container">
<h2 class="mb-4 pb-2 border-bottom border-secondary border-opacity-25 fw-bold" style="font-size: 1.6rem;">
Moyennes des évaluations
</h2>
{% if total_votes > 0 %}
<!-- GAEL -->
<div class="result-row">
<div class="proposal-name">1) La journée "Gaël Non Di Dju"</div>
<div class="d-flex justify-content-between align-items-center">
<div class="stars-avg" id="stars-gael" data-score="{{ avg_gael }}">
<!-- Star icons generated dynamically by JavaScript -->
<span class="score-badge">{{ avg_gael }} ★</span>
</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ pct_gael }}% d'approbation</div>
</div>
<div class="custom-progress">
<div class="custom-progress-bar" style="width: 0%;" data-width="{{ pct_gael }}%"></div>
</div>
</div>
<!-- CHARLY -->
<div class="result-row">
<div class="proposal-name">2) La journée "La cité de Charly"</div>
<div class="d-flex justify-content-between align-items-center">
<div class="stars-avg" id="stars-charly" data-score="{{ avg_charly }}">
<span class="score-badge">{{ avg_charly }} ★</span>
</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ pct_charly }}% d'approbation</div>
</div>
<div class="custom-progress">
<div class="custom-progress-bar" style="width: 0%;" data-width="{{ pct_charly }}%"></div>
</div>
</div>
<!-- TEQUILA -->
<div class="result-row">
<div class="proposal-name">3) La journée "Tequila Cadena"</div>
<div class="d-flex justify-content-between align-items-center">
<div class="stars-avg" id="stars-tequila" data-score="{{ avg_tequila }}">
<span class="score-badge">{{ avg_tequila }} ★</span>
</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ pct_tequila }}% d'approbation</div>
</div>
<div class="custom-progress">
<div class="custom-progress-bar" style="width: 0%;" data-width="{{ pct_tequila }}%"></div>
</div>
</div>
<!-- VIVIEN -->
<div class="result-row">
<div class="proposal-name">4) La journée "Vivien de La Grande Propreté"</div>
<div class="d-flex justify-content-between align-items-center">
<div class="stars-avg" id="stars-vivien" data-score="{{ avg_vivien }}">
<span class="score-badge">{{ avg_vivien }} ★</span>
</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ pct_vivien }}% d'approbation</div>
</div>
<div class="custom-progress">
<div class="custom-progress-bar" style="width: 0%;" data-width="{{ pct_vivien }}%"></div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-chat-left-dots text-secondary mb-3" style="font-size: 3rem; display:block;"></i>
<h4 class="text-muted">Aucun vote n'a encore été enregistré.</h4>
<p class="text-muted mb-0">Soyez le premier à donner votre avis !</p>
</div>
{% endif %}
</div>
<!-- Voters / Participants List -->
{% if total_votes > 0 %}
<div class="participants-card mb-5">
<h2 class="mb-4 pb-2 border-bottom border-secondary border-opacity-25 fw-bold" style="font-size: 1.6rem;">
Votants récents
</h2>
<div class="row">
{% for vote in votes %}
<div class="col-md-6 col-12">
<div class="participant-item">
<span class="fw-medium">
<i class="bi bi-person-circle me-2 text-info"></i>{{ vote.voter_name|default:"Votant Anonyme" }}
</span>
<span class="time">Voté le {{ vote.created_at|date:"d/m/Y à H:i" }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Actions block -->
<div class="text-center">
<a href="{% url 'home:flexi_teambuilding' %}" class="btn-action-back">
<i class="bi bi-arrow-left"></i> Retour au formulaire de vote
</a>
</div>
</div>
<!-- Scripts for star calculation and progress bars animation -->
<script>
document.addEventListener("DOMContentLoaded", () => {
// Animate progress bars
setTimeout(() => {
const bars = document.querySelectorAll(".custom-progress-bar");
bars.forEach(bar => {
bar.style.width = bar.getAttribute("data-width");
});
}, 150);
// Generate stars representation for averages
const ids = ["stars-gael", "stars-charly", "stars-tequila", "stars-vivien"];
ids.forEach(id => {
const wrapper = document.getElementById(id);
if (!wrapper) return;
const score = parseFloat(wrapper.getAttribute("data-score")) || 0;
const badge = wrapper.querySelector(".score-badge");
// Clear wrapper content, keeping badge
wrapper.innerHTML = "";
// Build 3 stars
for (let i = 1; i <= 3; i++) {
const star = document.createElement("span");
if (i <= score) {
star.className = "star-icon bi bi-star-fill";
} else if (i - 0.5 <= score) {
star.className = "star-icon bi bi-star-half";
} else {
star.className = "star-icon empty bi bi-star";
}
wrapper.appendChild(star);
}
wrapper.appendChild(badge);
});
});
</script>
</body>
</html>

View file

@ -4,7 +4,7 @@ from datetime import date
from common.models import Role, Thematic, UserConfig, UserThematics
from contracts.models import Company, CompanyTeam, Contract
from home.models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition, TeambuildingVote
from home.models import HomeWidget, HomeWidgetConditionGroup, HomeWidgetCondition
class HomeWidgetConditionTests(TestCase):
@ -187,67 +187,6 @@ class HomeWidgetConditionTests(TestCase):
self.assertEqual(self.group.get_summary(), "not(role=admin)")
class TeambuildingVoteViewsTests(TestCase):
def test_flexi_teambuilding_get(self):
# Page should be publicly accessible without logging in
response = self.client.get("/flexi-teambuilding/")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "home/flexi_teambuilding.html")
def test_flexi_teambuilding_post(self):
# Submit a vote (publicly accessible)
response = self.client.post("/flexi-teambuilding/", {
"voter_name": "Jean-Luc",
"vote_gael": "3",
"vote_charly": "2",
"vote_tequila": "1",
"vote_vivien": "0"
})
# Should redirect to results page with success query param
self.assertRedirects(response, "/flexi-teambuilding/results/?success=1")
# Verify the database entry
self.assertEqual(TeambuildingVote.objects.count(), 1)
vote = TeambuildingVote.objects.first()
self.assertEqual(vote.voter_name, "Jean-Luc")
self.assertEqual(vote.vote_gael, 3)
self.assertEqual(vote.vote_charly, 2)
self.assertEqual(vote.vote_tequila, 1)
self.assertEqual(vote.vote_vivien, 0)
def test_flexi_teambuilding_results_calculation(self):
# Create dummy votes
TeambuildingVote.objects.create(
voter_name="Alice",
vote_gael=3,
vote_charly=2,
vote_tequila=1,
vote_vivien=0
)
TeambuildingVote.objects.create(
voter_name="Bob",
vote_gael=1,
vote_charly=2,
vote_tequila=3,
vote_vivien=3
)
# Access results page (publicly accessible)
response = self.client.get("/flexi-teambuilding/results/")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "home/flexi_teambuilding_results.html")
# Check context averages
self.assertEqual(response.context["total_votes"], 2)
self.assertEqual(response.context["avg_gael"], 2.0)
self.assertEqual(response.context["avg_charly"], 2.0)
self.assertEqual(response.context["avg_tequila"], 2.0)
self.assertEqual(response.context["avg_vivien"], 1.5)
self.assertEqual(response.context["pct_gael"], 66.7)
self.assertEqual(response.context["pct_charly"], 66.7)
self.assertEqual(response.context["pct_tequila"], 66.7)
self.assertEqual(response.context["pct_vivien"], 50.0)
class HomeIndexViewTests(TestCase):

View file

@ -7,6 +7,4 @@ urlpatterns = [
path("", views.index, name="index"),
path("privacy/", views.privacy, name="privacy"),
path("api/incident-count/", views.incident_count_api, name="incident_count_api"),
path("flexi-teambuilding/", views.flexi_teambuilding, name="flexi_teambuilding"),
path("flexi-teambuilding/results/", views.flexi_teambuilding_results, name="flexi_teambuilding_results"),
]

View file

@ -27,7 +27,7 @@ from interventions.models import Intervention, InterventionTimeLine, STATUS_ORDE
from common.models import UserConfig, UserThematics, UserContractAccess
from contracts.models import CompanyMember, Contract
from contracts.permissions import get_allowed_contracts_for_user
from home.models import HomeWidget, TeambuildingVote
from home.models import HomeWidget
from observations.models import Observation
from stock.models import PreparationOrder, ProductionOrder, StockAlert, PurchaseOrderItem, StockMovement, StockMovementBatch, PurchaseOrder
@ -843,68 +843,4 @@ def incident_count_api(request):
}
for obs in qs[:5]
]
return JsonResponse({'count': qs.count(), 'incidents': incidents})
@login_not_required
def flexi_teambuilding(request):
if request.method == "POST":
voter_name = request.POST.get("voter_name", "").strip() or None
vote_gael = int(request.POST.get("vote_gael", 0))
vote_charly = int(request.POST.get("vote_charly", 0))
vote_tequila = int(request.POST.get("vote_tequila", 0))
vote_vivien = int(request.POST.get("vote_vivien", 0))
# Validate rating values
vote_gael = max(0, min(3, vote_gael))
vote_charly = max(0, min(3, vote_charly))
vote_tequila = max(0, min(3, vote_tequila))
vote_vivien = max(0, min(3, vote_vivien))
TeambuildingVote.objects.create(
voter_name=voter_name,
vote_gael=vote_gael,
vote_charly=vote_charly,
vote_tequila=vote_tequila,
vote_vivien=vote_vivien
)
return redirect(reverse("home:flexi_teambuilding_results") + "?success=1")
return render(request, "home/flexi_teambuilding.html")
@login_not_required
def flexi_teambuilding_results(request):
from django.db.models import Avg
votes = TeambuildingVote.objects.all().order_by("-created_at")
total_votes = votes.count()
averages = votes.aggregate(
avg_gael=Avg("vote_gael"),
avg_charly=Avg("vote_charly"),
avg_tequila=Avg("vote_tequila"),
avg_vivien=Avg("vote_vivien")
)
avg_gael = round(averages["avg_gael"] or 0, 2)
avg_charly = round(averages["avg_charly"] or 0, 2)
avg_tequila = round(averages["avg_tequila"] or 0, 2)
avg_vivien = round(averages["avg_vivien"] or 0, 2)
success = request.GET.get("success") == "1"
context = {
"votes": votes,
"total_votes": total_votes,
"avg_gael": avg_gael,
"avg_charly": avg_charly,
"avg_tequila": avg_tequila,
"avg_vivien": avg_vivien,
"pct_gael": round((avg_gael / 3) * 100, 1) if avg_gael else 0,
"pct_charly": round((avg_charly / 3) * 100, 1) if avg_charly else 0,
"pct_tequila": round((avg_tequila / 3) * 100, 1) if avg_tequila else 0,
"pct_vivien": round((avg_vivien / 3) * 100, 1) if avg_vivien else 0,
"success": success
}
return render(request, "home/flexi_teambuilding_results.html", context)
return JsonResponse({'count': qs.count(), 'incidents': incidents})