From ca0211e841cc012a031c2cb97e8d6e37067ed043 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 23:03:16 +0100 Subject: [PATCH 01/32] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 6da9a00..cb7e607 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.1 (e44e31b) \ No newline at end of file +1.3.2 (ab8307b) \ No newline at end of file From fc4939577ab5fc180b725d7d5cc6629f11d044b7 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:19:47 +0100 Subject: [PATCH 02/32] =?UTF-8?q?Ajout=20de=20la=20gestion=20de=20l'activa?= =?UTF-8?q?tion=20des=20comptes=20utilisateur=20par=20e-mail,=20des=20vali?= =?UTF-8?q?dations=20pour=20les=20pseudos=20existants,=20et=20configuratio?= =?UTF-8?q?n=20par=20d=C3=A9faut=20de=20l'e-mail=20backend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devart/settings.py | 5 +++- users/forms.py | 10 +++++++- users/tokens.py | 4 ++++ users/urls.py | 2 ++ users/views.py | 57 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 users/tokens.py diff --git a/devart/settings.py b/devart/settings.py index 2736323..a352b3f 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -202,4 +202,7 @@ def get_git_version(): else: return "Version inconnue (Fichier manquant)" -GIT_VERSION = get_git_version() \ No newline at end of file +GIT_VERSION = get_git_version() + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +DEFAULT_FROM_EMAIL = 'noreply@partirdezero.local' \ No newline at end of file diff --git a/users/forms.py b/users/forms.py index 2335d4c..362845e 100644 --- a/users/forms.py +++ b/users/forms.py @@ -27,7 +27,15 @@ class UserRegistrationForm(forms.Form): password2 = cleaned_data.get("password2") if password1 and password2 and password1 != password2: - raise forms.ValidationError("Passwords do not match") + raise forms.ValidationError("Les mots de passe ne correspondent pas.") + + return cleaned_data + + def clean_username(self): + username = self.cleaned_data.get('username') + if username and User.objects.filter(username__iexact=username).exists(): + raise forms.ValidationError("Ce pseudo est déjà pris. Veuillez en choisir un autre.") + return username class UserLoginForm(forms.Form): username = forms.CharField( diff --git a/users/tokens.py b/users/tokens.py new file mode 100644 index 0000000..bc82caf --- /dev/null +++ b/users/tokens.py @@ -0,0 +1,4 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator + +# Générateur de tokens pour l'activation de compte +activation_token = PasswordResetTokenGenerator() diff --git a/users/urls.py b/users/urls.py index cdf7a52..e46826d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -7,6 +7,8 @@ urlpatterns = [ path('', views.register, name='register'), path('login/', views.login, name='login'), path('logout/', views.logout, name='logout'), + # Activation de compte par lien tokenisé + path('activate///', views.activate, name='activate'), path('profile/view//', views.another_profile, name='another_profile'), path('complete-profile/', views.complete_profile, name='complete_profile'), path('profile/', views.profile, name='profile'), diff --git a/users/views.py b/users/views.py index 7b6ce5e..bf3c51d 100644 --- a/users/views.py +++ b/users/views.py @@ -5,6 +5,11 @@ from django.contrib.auth.models import User from courses.models import Course from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm from django.contrib.auth.decorators import login_required +from django.core.mail import send_mail +from django.urls import reverse +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes +from .tokens import activation_token def register(request): # Si l'utilisateur est deja connecté, on le redirige vers la page de profil @@ -13,13 +18,36 @@ def register(request): if request.method == 'POST': form = UserRegistrationForm(request.POST) if form.is_valid(): + # Crée un utilisateur inactif en attente d'activation par e‑mail user = User.objects.create_user( username=form.cleaned_data['username'], email=form.cleaned_data['email'], password=form.cleaned_data['password1'] ) - auth_login(request, user) - return redirect('profile') + user.is_active = False + user.save() + + # Envoi du lien d'activation par e‑mail + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = activation_token.make_token(user) + activation_link = request.build_absolute_uri( + reverse('activate', kwargs={'uidb64': uid, 'token': token}) + ) + subject = 'Active ton compte sur partirdezero' + message = ( + 'Bienvenue !\n\n' + 'Pour activer ton compte, clique sur le lien suivant (valide pendant une durée limitée) :\n' + f'{activation_link}\n\n' + "Si tu n'es pas à l'origine de cette inscription, ignore ce message." + ) + try: + send_mail(subject, message, None, [user.email], fail_silently=True) + except Exception: + # Même si l'envoi échoue, on n'interrompt pas le flux; un admin vérifiera la config e‑mail + pass + + messages.success(request, "Inscription réussie. Vérifie ta boîte mail pour activer ton compte.") + return redirect('login') else: form = UserRegistrationForm() return render(request, 'users/register.html', {'form': form}) @@ -32,8 +60,15 @@ def login(request): password = form.cleaned_data['password'] user = authenticate(request, username=username, password=password) if user is not None: + if not user.is_active: + messages.error(request, "Votre compte n'est pas encore activé. Consultez l'e‑mail d'activation envoyé." ) + return render(request, 'users/login.html', {'form': form}) auth_login(request, user) return redirect('profile') + else: + # Identifiants invalides: avertir l'utilisateur + messages.error(request, "Nom d'utilisateur ou mot de passe incorrect.") + return render(request, 'users/login.html', {'form': form}) else: form = UserLoginForm() return render(request, 'users/login.html', {'form': form}) @@ -106,3 +141,21 @@ def create_post(request): def another_profile(request, user_id): user = User.objects.get(id=user_id) return render(request, 'users/another_profile.html', {'user': user}) + +# Activation de compte via lien tokenisé +def activate(request, uidb64, token): + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except Exception: + user = None + + if user and activation_token.check_token(user, token): + if not user.is_active: + user.is_active = True + user.save() + messages.success(request, 'Votre compte a été activé. Vous pouvez maintenant vous connecter.') + return redirect('login') + + messages.error(request, "Lien d'activation invalide ou expiré. Demandez un nouveau lien ou inscrivez‑vous à nouveau.") + return redirect('register') From b3b18bd15ae65a848e87a3e5f3b5aa2c426cc752 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:20:12 +0100 Subject: [PATCH 03/32] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index cb7e607..572f6ef 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.2 (ab8307b) \ No newline at end of file +1.3.2 (ca0211e) \ No newline at end of file From bec74976ba95c5c731b880491df84c04309b3c00 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:20:26 +0100 Subject: [PATCH 04/32] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 572f6ef..63c4a6a 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.2 (ca0211e) \ No newline at end of file +1.3.3 (b3b18bd) \ No newline at end of file From 6e8a2bc2875e727286d6f5b3e5bf2fa517f29dd5 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:28:20 +0100 Subject: [PATCH 05/32] =?UTF-8?q?Ajout=20du=20suivi=20des=20visites=20:=20?= =?UTF-8?q?mod=C3=A8le=20`Visit`,=20middleware=20de=20tracking,=20mises=20?= =?UTF-8?q?=C3=A0=20jour=20des=20vues=20et=20du=20tableau=20de=20bord=20st?= =?UTF-8?q?atistiques.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/admin.py | 9 ++- core/middleware.py | 117 ++++++++++++++++++++++++++++ core/migrations/0004_visit.py | 41 ++++++++++ core/models.py | 36 ++++++++- devart/settings.py | 8 +- home/views.py | 39 ++++++++++ templates/home/stats_dashboard.html | 41 ++++++++++ 7 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 core/middleware.py create mode 100644 core/migrations/0004_visit.py diff --git a/core/admin.py b/core/admin.py index 0989d6d..a9baff2 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import SiteSettings +from .models import SiteSettings, Visit @admin.register(SiteSettings) class SiteSettingsAdmin(admin.ModelAdmin): @@ -27,3 +27,10 @@ class SiteSettingsAdmin(admin.ModelAdmin): 'fields': ('blog_title', 'blog_description') }), ) + + +@admin.register(Visit) +class VisitAdmin(admin.ModelAdmin): + list_display = ("date", "visitor_id", "user", "source", "country", "first_seen", "last_seen") + list_filter = ("date", "country", "source") + search_fields = ("visitor_id", "referrer", "utm_source", "utm_medium", "utm_campaign") diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..3e76796 --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,117 @@ +import uuid +from urllib.parse import urlparse +from django.utils import timezone +from .models import Visit + + +class VisitTrackingMiddleware: + """Middleware très léger pour enregistrer des statistiques de visites. + + - Assigne un cookie visiteur persistant (vid) si absent + - Enregistre/Met à jour une ligne Visit par visiteur et par jour + - Capture la source (UTM/referrer) et le pays si disponible via headers + """ + + COOKIE_NAME = 'vid' + COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2 # 2 ans + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + vid = request.COOKIES.get(self.COOKIE_NAME) + if not vid: + vid = uuid.uuid4().hex + + request.visitor_id = vid + + # Enregistrer la visite (agrégée par jour) + try: + self._track(request, vid) + except Exception: + # On ne casse jamais la requête pour des stats + pass + + response = self.get_response(request) + # S'assurer que le cookie est posé + if request.COOKIES.get(self.COOKIE_NAME) != vid: + response.set_cookie( + self.COOKIE_NAME, + vid, + max_age=self.COOKIE_MAX_AGE, + httponly=True, + samesite='Lax', + ) + return response + + def _track(self, request, vid): + # On ignore l'admin et les assets statiques + path = request.path + if path.startswith('/admin') or path.startswith('/static') or path.startswith('/staticfiles'): + return + + now = timezone.now() + date = now.date() + + ref = request.META.get('HTTP_REFERER', '')[:512] + utm_source = request.GET.get('utm_source', '')[:100] + utm_medium = request.GET.get('utm_medium', '')[:100] + utm_campaign = request.GET.get('utm_campaign', '')[:150] + + # Déterminer source + source = '' + if utm_source: + source = utm_source + elif ref: + try: + netloc = urlparse(ref).netloc + source = netloc + except Exception: + source = ref[:150] + + # Déterminer pays via en-têtes si fournis par proxy/CDN + country = ( + request.META.get('HTTP_CF_IPCOUNTRY') + or request.META.get('HTTP_X_APPENGINE_COUNTRY') + or request.META.get('HTTP_X_COUNTRY') + or '' + )[:64] + + became_user_at = now if request.user.is_authenticated else None + visit, created = Visit.objects.get_or_create( + visitor_id=vid, + date=date, + defaults={ + 'path': path[:512], + 'referrer': ref, + 'utm_source': utm_source, + 'utm_medium': utm_medium, + 'utm_campaign': utm_campaign, + 'source': source, + 'country': country, + 'first_seen': now, + 'last_seen': now, + 'user': request.user if request.user.is_authenticated else None, + 'became_user_at': became_user_at, + } + ) + + if not created: + # Mise à jour basique + dirty = False + if not visit.source and source: + visit.source = source + dirty = True + if not visit.country and country: + visit.country = country + dirty = True + if request.user.is_authenticated and visit.user_id is None: + visit.user = request.user + dirty = True + # Marquer la conversion si pas encore définie + if visit.became_user_at is None: + visit.became_user_at = now + visit.last_seen = now + dirty = True + if dirty: + visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen']) diff --git a/core/migrations/0004_visit.py b/core/migrations/0004_visit.py new file mode 100644 index 0000000..6301e48 --- /dev/null +++ b/core/migrations/0004_visit.py @@ -0,0 +1,41 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_alter_sitesettings_blog_description_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Visit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visitor_id', models.CharField(db_index=True, max_length=64)), + ('date', models.DateField(db_index=True)), + ('first_seen', models.DateTimeField(default=django.utils.timezone.now)), + ('last_seen', models.DateTimeField(default=django.utils.timezone.now)), + ('path', models.CharField(blank=True, max_length=512)), + ('referrer', models.CharField(blank=True, max_length=512)), + ('utm_source', models.CharField(blank=True, max_length=100)), + ('utm_medium', models.CharField(blank=True, max_length=100)), + ('utm_campaign', models.CharField(blank=True, max_length=150)), + ('source', models.CharField(blank=True, help_text='Domaine de provenance ou utm_source', max_length=150)), + ('country', models.CharField(blank=True, max_length=64)), + ('became_user_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='visits', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-date', '-last_seen'], + }, + ), + migrations.AlterUniqueTogether( + name='visit', + unique_together={('visitor_id', 'date')}, + ), + ] diff --git a/core/models.py b/core/models.py index b95ca68..63d9b18 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,6 @@ from django.db import models +from django.conf import settings +from django.utils import timezone class SiteSettings(models.Model): site_name = models.CharField(max_length=200, default="Mon Super Site") @@ -30,4 +32,36 @@ class SiteSettings(models.Model): class Meta: verbose_name = "Réglages du site" - verbose_name_plural = "Réglages du site" \ No newline at end of file + verbose_name_plural = "Réglages du site" + + +class Visit(models.Model): + """Enregistrement simplifié des visites (agrégées par jour et visiteur). + + Objectif: fournir des stats de base sans dépendances externes. + """ + visitor_id = models.CharField(max_length=64, db_index=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='visits' + ) + date = models.DateField(db_index=True) + first_seen = models.DateTimeField(default=timezone.now) + last_seen = models.DateTimeField(default=timezone.now) + + path = models.CharField(max_length=512, blank=True) + referrer = models.CharField(max_length=512, blank=True) + utm_source = models.CharField(max_length=100, blank=True) + utm_medium = models.CharField(max_length=100, blank=True) + utm_campaign = models.CharField(max_length=150, blank=True) + source = models.CharField(max_length=150, blank=True, help_text="Domaine de provenance ou utm_source") + country = models.CharField(max_length=64, blank=True) + + # Conversion: première fois où un visiteur devient utilisateur authentifié + became_user_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('visitor_id', 'date') + ordering = ['-date', '-last_seen'] + + def __str__(self): + return f"{self.visitor_id} @ {self.date}" \ No newline at end of file diff --git a/devart/settings.py b/devart/settings.py index a352b3f..d5b3597 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ from pathlib import Path import os + +import dotenv from dotenv import load_dotenv import devart.context_processor @@ -64,7 +66,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - + 'core.middleware.VisitTrackingMiddleware', ] ROOT_URLCONF = 'devart.urls' @@ -204,5 +206,5 @@ def get_git_version(): GIT_VERSION = get_git_version() -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -DEFAULT_FROM_EMAIL = 'noreply@partirdezero.local' \ No newline at end of file +EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND') +DEFAULT_FROM_EMAIL = dotenv.get_key('.env', 'EMAIL_HOST_USER') \ No newline at end of file diff --git a/home/views.py b/home/views.py index 9702e82..159fb3f 100644 --- a/home/views.py +++ b/home/views.py @@ -2,6 +2,7 @@ from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import user_passes_test from django.utils import timezone from django.db.models import Count +from core.models import Visit from django.views.decorators.cache import cache_page from django.contrib.auth.models import User from courses.models import Course, Lesson @@ -98,6 +99,39 @@ def stats_dashboard(request): revenus_disponibles = False technique_disponible = False + # Visites / Trafic + period_start_date = start_dt.date() + period_end_date = now.date() + period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date) + + unique_visitors = period_visits.values('visitor_id').distinct().count() + + earlier_visitors_qs = Visit.objects.filter(date__lt=period_start_date).values('visitor_id').distinct() + returning_visitors = period_visits.filter(visitor_id__in=earlier_visitors_qs).values('visitor_id').distinct().count() + + converted_visitors = ( + period_visits + .filter(became_user_at__isnull=False, became_user_at__date__gte=period_start_date, became_user_at__date__lte=period_end_date) + .values('visitor_id').distinct().count() + ) + + top_sources_qs = ( + period_visits + .values('source') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('-c') + ) + top_countries_qs = ( + period_visits + .exclude(country='') + .values('country') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('-c') + ) + + top_sources_table = [(row['source'] or 'Direct/Unknown', row['c']) for row in top_sources_qs[:10]] + top_countries_table = [(row['country'], row['c']) for row in top_countries_qs[:10]] + # Helper pour avoir toutes les dates de la période et remplir les trous def build_series_dict(qs, date_key='day', count_key='c'): counts = {str(item[date_key]): item[count_key] for item in qs} @@ -131,6 +165,9 @@ def stats_dashboard(request): 'total_users': total_users, 'new_users_period': sum(values_new_users), 'active_users_period': active_users_count, + 'unique_visitors': unique_visitors, + 'returning_visitors': returning_visitors, + 'converted_visitors': converted_visitors, 'total_courses': total_courses, 'courses_enabled': total_courses_enabled, 'total_lessons': total_lessons, @@ -153,6 +190,8 @@ def stats_dashboard(request): # Tables 'new_users_table': new_users_table, 'new_courses_table': new_courses_table, + 'top_sources_table': top_sources_table, + 'top_countries_table': top_countries_table, } # Sérialisation JSON pour Chart.js diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html index e5daaed..84a7094 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -35,6 +35,18 @@
+
+

Visiteurs uniques (période)

+
{{ kpi.unique_visitors }}
+
+
+

Visiteurs revenants (période)

+
{{ kpi.returning_visitors }}
+
+
+

Conversions en utilisateurs (période)

+
{{ kpi.converted_visitors }}
+

Utilisateurs (total)

{{ kpi.total_users }}
@@ -108,6 +120,35 @@
+ +
+
+

Top sources (visiteurs uniques)

+ + + + {% for row in top_sources_table %} + + {% empty %} + + {% endfor %} + +
SourceVisiteurs
{{ row.0 }}{{ row.1 }}
Aucune donnée
+
+
+

Top pays (visiteurs uniques)

+ + + + {% for row in top_countries_table %} + + {% empty %} + + {% endfor %} + +
PaysVisiteurs
{{ row.0 }}{{ row.1 }}
Aucune donnée
+
+
+ +{% endblock %} + +{% block content %} +
+

Graphiques statistiques

+ +
+
+ + + Du {{ start_date }} au {{ end_date }} +
+
Mise en cache 15 minutes
+
+ +
+
+

Visiteurs uniques par jour

+ +
+
+

Conversions (visiteurs devenus utilisateurs) par jour

+ +
+
+ +
+
+

Top sources (visiteurs uniques)

+ +
+
+

Top pays (visiteurs uniques)

+ +
+
+ +

← Retour au tableau de bord

+
+ + +{% endblock %} From e1f8a23f3df8c5781a0c220bdc251db746553fb0 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:53:26 +0100 Subject: [PATCH 10/32] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 3894f62..299b2e2 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.5 (7869abf) \ No newline at end of file +1.3.5 (1b0ccc5) \ No newline at end of file From 91f7f795469c69ad71ccf5ff5f930ed9976ff5d0 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:10:43 +0100 Subject: [PATCH 11/32] =?UTF-8?q?Ajout=20des=20d=C3=A9corations=20et=20ani?= =?UTF-8?q?mations=20de=20neige=20pour=20les=20f=C3=AAtes=20de=20fin=20d'a?= =?UTF-8?q?nn=C3=A9e,=20charg=C3=A9es=20conditionnellement=20en=20d=C3=A9c?= =?UTF-8?q?embre.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/functions.js | 26 +----------------------- templates/layout.html | 36 +++++++++++++++++++++++++++++++++ templates/partials/_footer.html | 6 +++++- templates/partials/_header.html | 7 ++++++- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/static/js/functions.js b/static/js/functions.js index db8ff92..500562a 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -94,28 +94,4 @@ document.addEventListener('DOMContentLoaded', function() { }); } } catch(e) {} -}); - -// Fonction pour générer des flocons de neige -function createSnowflake() { - const snowflake = document.createElement('div'); - snowflake.classList.add('snowflake'); - snowflake.textContent = '•'; - - snowflake.style.left = `${Math.random() * 100}vw`; - - const size = Math.random() * 1.5 + 0.5; - snowflake.style.fontSize = `${size}em`; - - const duration = Math.random() * 5 + 5; - snowflake.style.animationDuration = `${duration}s`; - - document.body.appendChild(snowflake); - - setTimeout(() => { - snowflake.remove(); - }, duration * 1000); -} - -// On génère les flocons toutes les 300ms -setInterval(createSnowflake, 300); \ No newline at end of file +}); \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 2e7a521..2a39086 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -31,6 +31,37 @@ + {% now "n" as month %} + {% if month == '12' %} + + + + {% endif %} + {% block extra_head %}{% endblock %} @@ -53,6 +84,11 @@ + {% now "n" as month %} + {% if month == '12' %} + + + {% endif %} {% block header %} {% include "partials/_header.html" %} {% endblock %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 29b2d41..934a5e3 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -1,4 +1,5 @@ -