diff --git a/VERSION.txt b/VERSION.txt index 6da9a00..0b51dff 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.1 (e44e31b) \ No newline at end of file +1.5.0 (2ec4a5c) \ No newline at end of file diff --git a/blog/context_processor.py b/blog/context_processor.py index a1666a4..d09e196 100644 --- a/blog/context_processor.py +++ b/blog/context_processor.py @@ -1,5 +1,5 @@ from .models import Post def posts_list(request): - posts = Post.objects.all() + posts = Post.objects.all().order_by('-created_at') return {'posts': posts} \ No newline at end of file diff --git a/blog/models.py b/blog/models.py index 387d847..5d0d68c 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.urls import reverse class Post(models.Model): name = models.CharField(max_length=200) @@ -15,4 +16,7 @@ class Post(models.Model): verbose_name_plural = "Articles" def __str__(self): - return self.name \ No newline at end of file + return self.name + + def get_absolute_url(self): + return reverse('blog:post_detail', kwargs={'slug': self.slug}) \ No newline at end of file diff --git a/core/admin.py b/core/admin.py index 0989d6d..13f115b 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, Maintenance @admin.register(SiteSettings) class SiteSettingsAdmin(admin.ModelAdmin): @@ -14,7 +14,7 @@ class SiteSettingsAdmin(admin.ModelAdmin): # Petite astuce visuelle pour l'admin fieldsets = ( ('Général', { - 'fields': ('site_name', 'site_logo') + 'fields': ('site_name', 'site_logo', 'receive_emails_active') }), ('Réseaux Sociaux', { 'fields': ('facebook_url', 'twitter_url', 'youtube_url'), @@ -27,3 +27,14 @@ class SiteSettingsAdmin(admin.ModelAdmin): 'fields': ('blog_title', 'blog_description') }), ) + +@admin.register(Maintenance) +class MaintenanceAdmin(admin.ModelAdmin): + list_display = ("name","start_date", "end_date") + + +@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/apps.py b/core/apps.py index 26f78a8..a2ee61b 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig - class CoreConfig(AppConfig): name = 'core' + + def ready(self): + import courses.signals \ No newline at end of file diff --git a/core/context_processor.py b/core/context_processor.py index a1410e3..eccaebf 100644 --- a/core/context_processor.py +++ b/core/context_processor.py @@ -1,5 +1,14 @@ -from .models import SiteSettings +from django.utils.timesince import timesince + +from .models import SiteSettings, Maintenance def site_settings(request): # On récupère le premier objet, ou None s'il n'existe pas encore - return {'settings': SiteSettings.objects.first()} \ No newline at end of file + return {'settings': SiteSettings.objects.first()} + +def site_maintenance(request): + last = Maintenance.objects.last() + start = last.start_date if last else None + end = last.end_date if last else None + delay = timesince(start, end) if start and end else None + return {'maintenance': Maintenance.objects.last(), 'delay': delay} \ No newline at end of file diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..3167553 --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,121 @@ +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 + # Mettre à jour la page courante et l'horodatage + if visit.path != path: + visit.path = path[:512] + dirty = True + visit.last_seen = now + dirty = True + if dirty: + visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'path', '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/migrations/0005_sitesettings_receive_emails_active.py b/core/migrations/0005_sitesettings_receive_emails_active.py new file mode 100644 index 0000000..81c0441 --- /dev/null +++ b/core/migrations/0005_sitesettings_receive_emails_active.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2025-12-17 09:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_visit'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='receive_emails_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/core/migrations/0006_maintenance.py b/core/migrations/0006_maintenance.py new file mode 100644 index 0000000..1cac83f --- /dev/null +++ b/core/migrations/0006_maintenance.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2025-12-17 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_sitesettings_receive_emails_active'), + ] + + operations = [ + migrations.CreateModel( + name='Maintenance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=False)), + ('message', models.TextField(blank=True)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('end_date', models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/core/migrations/0007_maintenance_name.py b/core/migrations/0007_maintenance_name.py new file mode 100644 index 0000000..eef8f59 --- /dev/null +++ b/core/migrations/0007_maintenance_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2025-12-17 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_maintenance'), + ] + + operations = [ + migrations.AddField( + model_name='maintenance', + name='name', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/core/models.py b/core/models.py index b95ca68..c28ae82 100644 --- a/core/models.py +++ b/core/models.py @@ -1,9 +1,12 @@ 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") site_logo = models.ImageField(upload_to='settings/', blank=True) contact_email = models.EmailField(blank=True) + receive_emails_active = models.BooleanField(default=True) # Réseaux sociaux facebook_url = models.URLField(blank=True) @@ -30,4 +33,42 @@ 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 Maintenance(models.Model): + is_active = models.BooleanField(default=False) + name = models.CharField(max_length=200, blank=True) + message = models.TextField(blank=True) + start_date = models.DateTimeField(blank=True, null=True) + end_date = models.DateTimeField(blank=True, null=True) + +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/core/urls.py b/core/urls.py new file mode 100644 index 0000000..64f9484 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('update_database', views.update_database, name='update_database'), + path('clear_cache', views.clear_cache, name='clear_cache'), + path('regen_static_files', views.regen_static_files, name='regen_static_files'), + path('reload_server', views.reload_server, name='reload_server') +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 91ea44a..204f4f0 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,23 @@ from django.shortcuts import render +from django.core.management import call_command +from django.core.cache import cache +import subprocess -# Create your views here. +def update_database(request): + call_command('makemigrations') + call_command('migrate') + message = "La base de données à bien été mise à jour !" + return render(request, 'home.html', {'message': message}) + +def clear_cache(request): + cache.clear() + message = "Le cache à bien été effacé !" + return render(request, 'home.html', {'message': message}) + +def regen_static_files(request): + call_command('collectstatic', '--noinput') + message = "Les fichiers statics ont bien été générés !" + return render(request, 'home.html', {'message': message}) + +def reload_server(request): + pass \ No newline at end of file diff --git a/courses/apps.py b/courses/apps.py index 89f1ba2..9a0a0cd 100644 --- a/courses/apps.py +++ b/courses/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class CoursesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'courses' \ No newline at end of file diff --git a/courses/context_processors.py b/courses/context_processors.py index 2a4772e..4a22831 100644 --- a/courses/context_processors.py +++ b/courses/context_processors.py @@ -1,5 +1,8 @@ -from .models import Course +from .models import Course, Comment def course_list(request): courses = Course.objects.all() - return {'courses': courses} \ No newline at end of file + return {'courses': courses} + +def courses_comments(request): + return {'comments_count': Comment.objects.all()} \ No newline at end of file diff --git a/courses/signals.py b/courses/signals.py new file mode 100644 index 0000000..0258ca7 --- /dev/null +++ b/courses/signals.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.core.mail import send_mail +from .models import Comment +from core.models import SiteSettings + +@receiver(post_save, sender=Comment) +def send_email_notification(sender, instance, created, **kwargs): + if created and SiteSettings.objects.first().receive_emails_active: + subject = f"Nouveau commentaire sur la leçon - {instance.lesson.name} du cours {instance.lesson.module.course.name}" + message = f"Le commentaire suivant à été envoyé par {instance.user}:\n{instance.content}" + send_mail(subject, message, "infos@partirdezero.com", ['anthony.violet@outlook.be'], fail_silently=False) diff --git a/devart/settings.py b/devart/settings.py index 2736323..f2dab3b 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 @@ -53,6 +55,8 @@ INSTALLED_APPS = [ 'users', 'progression', 'blog', + + 'discord_integration.apps.DiscordIntegrationConfig', ] MIDDLEWARE = [ @@ -64,7 +68,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - + 'core.middleware.VisitTrackingMiddleware', ] ROOT_URLCONF = 'devart.urls' @@ -82,6 +86,7 @@ TEMPLATES = [ 'devart.context_processor.app_version', 'core.context_processor.site_settings', + 'core.context_processor.site_maintenance', 'courses.context_processors.course_list', 'blog.context_processor.posts_list', ], @@ -202,4 +207,12 @@ 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 = dotenv.get_key('.env', 'EMAIL_BACKEND') +EMAIL_HOST = dotenv.get_key('.env', 'EMAIL_HOST') +EMAIL_PORT = dotenv.get_key('.env', 'EMAIL_PORT') +EMAIL_USE_TLS = dotenv.get_key('.env', 'EMAIL_USE_TLS') == 'True' +EMAIL_HOST_USER = dotenv.get_key('.env', 'EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = dotenv.get_key('.env', 'EMAIL_HOST_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER \ No newline at end of file diff --git a/devart/sitemap.py b/devart/sitemap.py index e761d33..c675d9d 100644 --- a/devart/sitemap.py +++ b/devart/sitemap.py @@ -12,7 +12,7 @@ class CourseSitemap(sitemaps.Sitemap): priority = 0.9 def items(self): - return Course.objects.filter(enable=True) # Exemple de filtre + return Course.objects.filter(enable=True).order_by('id') def location(self, item): # Assure-toi que ton modèle Course a bien une méthode get_absolute_url diff --git a/devart/urls.py b/devart/urls.py index 17cb4e7..036a891 100644 --- a/devart/urls.py +++ b/devart/urls.py @@ -22,12 +22,13 @@ from django.http import HttpResponse from devart.sitemap import CourseSitemap, StaticViewSitemap from django.contrib.sitemaps.views import sitemap -# La vue pour le robots.txt def robots_txt(request): lines = [ "User-agent: *", "Disallow: /admin/", "Disallow: /users/", + "Disallow: /maintenance/", + "Disallow: /core/", "Allow: /", "Sitemap: https://partirdezero.com/sitemap.xml", # On indique déjà où sera le plan ] @@ -39,6 +40,7 @@ sitemaps_dict = { } urlpatterns = [ + path('core/', include('core.urls')), path('admin/', admin.site.urls), path('', include('home.urls')), path('courses/', include('courses.urls')), diff --git a/discord_integration/__init__.py b/discord_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/admin.py b/discord_integration/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/discord_integration/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/discord_integration/apps.py b/discord_integration/apps.py new file mode 100644 index 0000000..56fe46c --- /dev/null +++ b/discord_integration/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class DiscordIntegrationConfig(AppConfig): + name = 'discord_integration' + + def ready(self): + import discord_integration.signals diff --git a/discord_integration/core/BotClass.py b/discord_integration/core/BotClass.py new file mode 100644 index 0000000..ac9da47 --- /dev/null +++ b/discord_integration/core/BotClass.py @@ -0,0 +1,14 @@ +import discord +from discord import app_commands + +intents = discord.Intents.default() +intents.members = True +intents.message_content = True + +class Bot(discord.Client): + def __init__(self, *, intents: discord.Intents): + super().__init__(intents=intents) + self.tree = app_commands.CommandTree(self) + + async def setup_hook(self): + await self.tree.sync() \ No newline at end of file diff --git a/discord_integration/core/__init__.py b/discord_integration/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/core/announces_logic.py b/discord_integration/core/announces_logic.py new file mode 100644 index 0000000..6781202 --- /dev/null +++ b/discord_integration/core/announces_logic.py @@ -0,0 +1,57 @@ +from asgiref.sync import sync_to_async +import discord +from discord.ext import tasks +from discord_integration.models import DiscordNotification + +def get_pending_notifications(): + return list(DiscordNotification.objects.filter(is_announced=False)) + +def mark_as_done(notif): + notif.is_announced = True + notif.save() + +def process_notifications(): + # Cette fonction fait tout le travail SQL "interdit" en mode async + notifs = list(DiscordNotification.objects.filter(is_announced=False)) + results = [] + for n in notifs: + # Ici, on peut toucher à content_object car on est en mode "sync" ! + obj = n.content_object + if obj: + # 1. On cherche la description, sinon le contenu, sinon rien + teaser = getattr(obj, 'description', getattr(obj, 'content', "")) + + # 2. Sécurité : On coupe à 3000 caractères pour éviter les erreurs Discord + if len(teaser) > 3000: + teaser = teaser[:2997] + "..." + + results.append({ + 'notif_model': n, + 'title': getattr(obj, 'title', getattr(obj, 'name', str(obj))), + 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else "#", + 'summary': teaser + }) + + return results + +@tasks.loop(seconds=5.0) +async def check_announcements(client, channel_id): + announcements = await sync_to_async(process_notifications)() + + for data in announcements: + title = data['title'] + link = data['url'] + notif = data['notif_model'] + summary = data['summary'] + + embed = discord.Embed( + title = f"📣 Nouveau contenu : {title}", + url = f"https://partirdezero.com{link}", + description = summary, + color=discord.Color.blue() + ) + + channel_id = client.get_channel(channel_id) + if channel_id: + await channel_id.send(embed=embed) + await sync_to_async(mark_as_done)(notif) diff --git a/discord_integration/core/enums.py b/discord_integration/core/enums.py new file mode 100644 index 0000000..75c7ced --- /dev/null +++ b/discord_integration/core/enums.py @@ -0,0 +1,16 @@ +XP = { + "MESSAGE": 5 +} + +RANK = { + 1: "Nouveau membre", + 3: "Membre", + 7: "Habitué du comptoir", + 12: "Expert", + 18: "Chevalier du code", + 25: "Baron C#", + 35: "Lord Script", + 50: "Héros des architectures", + 75: "Vétéran", + 100: "Légende" +} \ No newline at end of file diff --git a/discord_integration/core/level_logic.py b/discord_integration/core/level_logic.py new file mode 100644 index 0000000..fe42f8f --- /dev/null +++ b/discord_integration/core/level_logic.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone + +from asgiref.sync import sync_to_async +import discord +from datetime import datetime +from discord_integration.models import DiscordLevel +from enums import XP, RANK +from discord import app_commands + +def get_user(id_discord): + user, created = DiscordLevel.objects.get_or_create(discord_id=id_discord) + return user, created + +def update_user_xp(user, xp_to_add): + leveled_up = False + + # 1. Mise à jour de l'XP et du temps + user.total_xp += xp_to_add + user.last_message = datetime.now(timezone.utc) + + # 2. Calcul du niveau théorique + calculated_level = int(0.5 + (0.25 + user.total_xp / 50)**0.5) + + # 3. Vérification du Level Up + if calculated_level > user.level: + user.level = calculated_level + leveled_up = True + + new_rank = RANK[1] + + for level_threshold, rank_name in RANK.items(): + if user.level >= level_threshold: + new_rank = rank_name + else: + break + + user.rank = new_rank + + user.save() + return user, leveled_up + +async def check_add_xp(message, client): + id_discord = message.author.id + username = message.author.name + + user_db, created = await sync_to_async(get_user)(id_discord) + + if not created and user_db.last_message: + delta = datetime.now(timezone.utc) - user_db.last_message + if delta.seconds < 6: + return + + user_db, leveled_up = await sync_to_async(update_user_xp)(user_db, XP["MESSAGE"]) + + if leveled_up: + # On crée un petit message sympa + await message.channel.send( + f"🎊 **LEVEL UP** 🎊\nBravo {message.author.mention}, tu passes **niveau {user_db.level}** ! " + f"On applaudit tous bien fort ! Clap Clap !!" + ) + else: + # Juste un petit log console pour toi + print(f"✨ XP ajouté pour {message.author.name} (Total: {user_db.total_xp})") + +# AJOUT DES COMMANDES /xp et /level +@app_commands.command(name="level", description="Permet de connaitre ton xp actuel") +async def get_xp(interaction: discord.Interaction): + user_id = interaction.user.id + user_db, _ = await sync_to_async(get_user)(user_id) + await interaction.response.send_message(f"{interaction.user.mention} : Tu as {user_db.total_xp} XP et tu es niveau {user_db.level} !\nTon rang est **{user_db.rank}** !") \ No newline at end of file diff --git a/discord_integration/core/main_bot.py b/discord_integration/core/main_bot.py new file mode 100644 index 0000000..6fa7d4c --- /dev/null +++ b/discord_integration/core/main_bot.py @@ -0,0 +1,73 @@ +import discord +import django +import os, sys + +import dotenv +from discord.ext import commands + +# On import django pour communiquer avec +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devart.settings') +django.setup() + +# Import des fonctions +from role_logic import check_role_reaction +from announces_logic import check_announcements +from random_phrase import get_random_phrase +from level_logic import check_add_xp, get_xp +import BotClass + +# CONFIGURATION +TOKEN = dotenv.get_key(BASE_DIR + '/.env', 'D_TOKEN') +MESSAGE_ID = 1450928822156263505 # L'ID du message des règles (clic droit > Copier l'identifiant) +ROLE_ID = 1450920002868875435 # L'ID du rôle "Membres" +ANNOUNCEMENT_CHANNEL_ID = 1450912559774306346 +EMOJI_VALIDATION = "✅" + +# LES INTENTS (PERMISSIONS DU BOT) +intents = discord.Intents.default() +intents.members = True # Important pour pouvoir donner des rôles +intents.message_content = True + +client = BotClass.Bot(intents=intents) +client.tree.add_command(get_random_phrase) +client.tree.add_command(get_xp) + +@client.event +async def on_ready(): + print(f'✅ Bot connecté : {client.user}') + + try: + synced = await client.tree.sync() + print(f"🌍 {len(synced)} commandes slash synchronisées !") + except Exception as e: + print(f"❌ Erreur de synchronisation : {e}") + + if not check_announcements.is_running(): + check_announcements.start(client, ANNOUNCEMENT_CHANNEL_ID) + +@client.event +async def on_message(message): + if message.author == client.user: + return + if message.guild is None: + author = message.author + await message.channel.send("Bonjour !\nJe suis un bot destiné à tester les nouvelles fonctionnalités de Discord. Pour le moment, je suis qu'en lecture seule.") + else: + await check_add_xp(message, client) + + +@client.event +async def on_raw_reaction_add(payload): + # On envoie tout le nécessaire à notre fonction dans role_logic.py + await check_role_reaction( + payload, + client, + MESSAGE_ID, + ROLE_ID, + EMOJI_VALIDATION + ) + +client.run(TOKEN) diff --git a/discord_integration/core/random_phrase.py b/discord_integration/core/random_phrase.py new file mode 100644 index 0000000..e8cfea2 --- /dev/null +++ b/discord_integration/core/random_phrase.py @@ -0,0 +1,32 @@ +import discord +import random + +from discord import app_commands + +phrase = [ + "Yo !", + "Quoi de neuf ?", + "Je suis occupé à compter mes octets.", + "Vive la Belgique ! 🇧🇪", + "C’est pas un bug, c’est une fonctionnalité non documentée ! 🐛", + "Est-ce qu’on peut dire que mon code est une œuvre d’art ? Non ? Dommage.", + "Je ne plante pas, je fais une pause créative.", + "Quelqu’un a vu mon point-virgule ? Il a disparu depuis le dernier commit.", + "Je mangerais bien une mitraillette sauce andalouse, mais mon système digestif est en 404. 🍟", + "42. Voilà. Maintenant, pose-moi une vraie question.", + "On mange quoi ? Ah non, c'est vrai, je suis un robot... Tristesse infinie. 🤖", + "C'est écrit en Python, donc c'est forcément élégant, non ?", + "Un petit café ? Pour moi, une petite dose d'électricité suffira.", + "Je parie que tu n'as pas encore fait ton `git push` aujourd'hui. Je te surveille ! 👀", + "En Belgique, on n'a peut-être pas toujours du soleil, mais on a les meilleures frites ! 🇧🇪🍟", + "Il y a 10 types de personnes : celles qui comprennent le binaire, et les autres.", + "Mon processeur chauffe... soit je réfléchis trop, soit ton code est trop complexe !", + "Tout va bien, tant que personne ne touche au dossier `migrations` de Django...", + "Sais-tu pourquoi les développeurs détestent la nature ? Parce qu'il y a trop de bugs. 🌳", + "On n'est pas là pour trier des lentilles, une fois ! On code ou quoi ? 🇧🇪" +] + +@app_commands.command(name="random_phrase", description="Envoi une phrase aléatoire !") +async def get_random_phrase(interaction: discord.Interaction): + choice = random.choice(phrase) + await interaction.response.send_message(choice) \ No newline at end of file diff --git a/discord_integration/core/role_logic.py b/discord_integration/core/role_logic.py new file mode 100644 index 0000000..8dd2dbc --- /dev/null +++ b/discord_integration/core/role_logic.py @@ -0,0 +1,39 @@ +import discord + + +async def check_role_reaction(payload, client, target_message_id, target_role_id, target_emoji): + # 1. On vérifie si c'est le bon message + if payload.message_id != target_message_id: + return # On ignore si ce n'est pas le bon message + + # 2. On vérifie si c'est le bon emoji + if str(payload.emoji) == target_emoji: + guild = client.get_guild(payload.guild_id) + if guild is None: + print("Erreur: Impossible de trouver le serveur (Guild is None).") + return + + member = guild.get_member(payload.user_id) + if member is None: + print("Erreur: Impossible de trouver le membre (Member is None).") + return + + role = guild.get_role(target_role_id) + if role is None: + print("Erreur : Le role n'existe pas.") + return + + try: + await member.add_roles(role) + print(f"🎉 SUCCÈS : Rôle donné à {member.name} !") + try: + await member.send("Bienvenue ! Tu as accès aux salons.") + except: + print("Note: MP bloqués par l'utilisateur.") + except discord.Forbidden: + print("⛔ ERREUR PERMISSION : Je n'ai pas le droit de donner ce rôle !") + print( + "👉 SOLUTION : Va dans Paramètres Serveur > Rôles. Glisse le rôle 'PartirDeZero Bot' AU-DESSUS du rôle 'Membres'.") + + except Exception as e: + print(f"❌ Erreur inconnue : {e}") \ No newline at end of file diff --git a/discord_integration/migrations/0001_initial.py b/discord_integration/migrations/0001_initial.py new file mode 100644 index 0000000..6c269a9 --- /dev/null +++ b/discord_integration/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0 on 2025-12-18 08:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('is_announced', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + ), + ] diff --git a/discord_integration/migrations/0002_discordlevel.py b/discord_integration/migrations/0002_discordlevel.py new file mode 100644 index 0000000..b35f4b8 --- /dev/null +++ b/discord_integration/migrations/0002_discordlevel.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0 on 2025-12-18 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discord_integration', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('discord_id', models.BigIntegerField()), + ('total_xp', models.PositiveIntegerField(default=0)), + ('level', models.PositiveIntegerField(default=1)), + ('rank', models.TextField(default='Nouveau membre')), + ('last_message', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/discord_integration/migrations/__init__.py b/discord_integration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/models.py b/discord_integration/models.py new file mode 100644 index 0000000..614a096 --- /dev/null +++ b/discord_integration/models.py @@ -0,0 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.contrib.contenttypes.models import ContentType + +class DiscordNotification(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + is_announced = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Annonces pour {self.content_object} ({'✅' if self.is_announced else '⏳'})" + +class DiscordLevel(models.Model): + discord_id = models.BigIntegerField() + total_xp = models.PositiveIntegerField(default=0) + level = models.PositiveIntegerField(default=1) + rank = models.TextField(default="Nouveau membre") + last_message = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/discord_integration/signals.py b/discord_integration/signals.py new file mode 100644 index 0000000..cd5897e --- /dev/null +++ b/discord_integration/signals.py @@ -0,0 +1,18 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import DiscordNotification + +@receiver(post_save, sender="blog.Post") +def create_discord_notification_blog(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) + +@receiver(post_save, sender="courses.Course") +def create_discord_notification_course(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) + +@receiver(post_save, sender="courses.Lesson") +def create_discord_notification_lesson(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) \ No newline at end of file diff --git a/discord_integration/tests.py b/discord_integration/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/discord_integration/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/discord_integration/views.py b/discord_integration/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/discord_integration/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/home/urls.py b/home/urls.py index 80f5069..52e2435 100644 --- a/home/urls.py +++ b/home/urls.py @@ -7,4 +7,6 @@ urlpatterns = [ path('premium/', views.premium, name='premium'), # Tableau de bord statistiques (réservé superadministrateurs) path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'), + path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'), + path('dashboard/stats/live-activity/', views.live_activity, name='live_activity'), ] \ No newline at end of file diff --git a/home/views.py b/home/views.py index 9702e82..22ab337 100644 --- a/home/views.py +++ b/home/views.py @@ -2,12 +2,57 @@ 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 from blog.models import Post from progression.models import Progression import json +from django.http import JsonResponse + + +# -------------------- +# Helpers Stats Module +# -------------------- + +def _parse_period(request, default=30, options=None): + """Parse period parameter 'p' from request and compute date range. + + Returns (p, now_dt, start_dt, start_date, end_date) + - now_dt is timezone-aware now + - start_dt is datetime at start of range (inclusive) + - start_date/end_date are date objects for convenient filtering + """ + if options is None: + options = [7, 30, 90, 180] + try: + p = int(request.GET.get('p', default)) + except (TypeError, ValueError): + p = default + if p not in options: + p = default + now_dt = timezone.now() + start_dt = now_dt - timezone.timedelta(days=p - 1) + return p, now_dt, start_dt, start_dt.date(), now_dt.date() + + +def _build_series_for_range(start_date, end_date, qs, date_key='day', count_key='c'): + """Build a continuous daily series from an aggregated queryset. + + qs must yield dicts with date_key (date as string or date) and count_key. + Returns (labels_days, values_counts) + """ + counts = {str(item[date_key]): item[count_key] for item in qs} + days = [] + values = [] + d = start_date + while d <= end_date: + key = str(d) + days.append(key) + values.append(counts.get(key, 0)) + d += timezone.timedelta(days=1) + return days, values def home(request): courses = Course.objects.order_by('-created_at')[:6] @@ -28,19 +73,13 @@ def stats_dashboard(request): # Période period_options = [7, 30, 90, 180] - try: - p = int(request.GET.get('p', 30)) - except ValueError: - p = 30 - if p not in period_options: - p = 30 - - now = timezone.now() - start_dt = now - timezone.timedelta(days=p-1) # inclut aujourd'hui + p, now, start_dt, period_start_date, period_end_date = _parse_period( + request, default=30, options=period_options + ) # Utilisateurs total_users = User.objects.count() - new_users_qs = User.objects.filter(date_joined__date__gte=start_dt.date(), date_joined__date__lte=now.date()) + new_users_qs = User.objects.filter(date_joined__date__gte=period_start_date, date_joined__date__lte=period_end_date) # Séries quotidiennes nouveaux utilisateurs new_users_by_day = ( new_users_qs @@ -51,7 +90,7 @@ def stats_dashboard(request): # Activité approximée via Progression mise à jour active_users_qs = ( - Progression.objects.filter(updated_at__date__gte=start_dt.date(), updated_at__date__lte=now.date()) + Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date) .values('user').distinct() ) active_users_count = active_users_qs.count() @@ -60,7 +99,7 @@ def stats_dashboard(request): total_courses = Course.objects.count() total_courses_enabled = Course.objects.filter(enable=True).count() new_courses_by_day = ( - Course.objects.filter(created_at__date__gte=start_dt.date(), created_at__date__lte=now.date()) + Course.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date) .extra(select={'day': "date(created_at)"}) .values('day').annotate(c=Count('id')) ) @@ -81,7 +120,7 @@ def stats_dashboard(request): # Si la table M2M n'a pas de timestamps, on utilisera updated_at de Progression pour l'activité par jour # donc on refait une série quotidienne d'activité progression progress_activity_by_day = ( - Progression.objects.filter(updated_at__date__gte=start_dt.date(), updated_at__date__lte=now.date()) + Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date) .extra(select={'day': "date(updated_at)"}) .values('day').annotate(c=Count('id')) ) @@ -89,7 +128,7 @@ def stats_dashboard(request): # Blog total_posts = Post.objects.count() new_posts_by_day = ( - Post.objects.filter(created_at__date__gte=start_dt.date(), created_at__date__lte=now.date()) + Post.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date) .extra(select={'day': "date(created_at)"}) .values('day').annotate(c=Count('id')) ) @@ -98,18 +137,41 @@ def stats_dashboard(request): revenus_disponibles = False technique_disponible = False + # Visites / Trafic + 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} - days = [] - values = [] - d = start_dt.date() - while d <= now.date(): - key = str(d) - days.append(key) - values.append(counts.get(key, 0)) - d += timezone.timedelta(days=1) - return days, values + # Wrapper conservant l'API locale mais utilisant le helper commun + return _build_series_for_range(period_start_date, period_end_date, qs, date_key=date_key, count_key=count_key) days_users, values_new_users = build_series_dict(new_users_by_day) days_courses, values_new_courses = build_series_dict(new_courses_by_day) @@ -123,14 +185,17 @@ def stats_dashboard(request): context = { 'period_options': period_options, 'p': p, - 'start_date': start_dt.date(), - 'end_date': now.date(), + 'start_date': period_start_date, + 'end_date': period_end_date, # KPI 'kpi': { '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 +218,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 @@ -160,3 +227,130 @@ def stats_dashboard(request): context['labels_json'] = json.dumps(context['series']['days']) return render(request, 'home/stats_dashboard.html', context) + + +@user_passes_test(lambda u: u.is_superuser) +@cache_page(60 * 15) +def stats_charts(request): + """Page dédiée aux graphiques (réservée superadmins).""" + # Période (utilise les mêmes helpers que le dashboard pour harmonisation) + period_options = [7, 30, 90, 180] + p, _now, _start_dt, period_start_date, period_end_date = _parse_period( + request, default=30, options=period_options + ) + + # Trafic par jour (visiteurs uniques) + visits_qs = ( + Visit.objects + .filter(date__gte=period_start_date, date__lte=period_end_date) + .values('date') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('date') + ) + + # Conversions par jour (visiteurs devenus utilisateurs) + conversions_qs = ( + Visit.objects + .filter( + became_user_at__isnull=False, + became_user_at__date__gte=period_start_date, + became_user_at__date__lte=period_end_date, + ) + .extra(select={'day': "date(became_user_at)"}) + .values('day') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('day') + ) + + def build_series_dict(qs, date_key='date', count_key='c'): + counts = {str(item[date_key]): item[count_key] for item in qs} + days = [] + values = [] + d = period_start_date + while d <= period_end_date: + key = str(d) + days.append(key) + values.append(counts.get(key, 0)) + d += timezone.timedelta(days=1) + return days, values + + labels, visitors_series = _build_series_for_range(period_start_date, period_end_date, visits_qs, date_key='date') + _, conversions_series = _build_series_for_range(period_start_date, period_end_date, conversions_qs, date_key='day') + + # Sources & Pays (sur la période) + period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date) + top_sources_qs = ( + period_visits + .values('source') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('-c')[:10] + ) + top_countries_qs = ( + period_visits + .exclude(country='') + .values('country') + .annotate(c=Count('visitor_id', distinct=True)) + .order_by('-c')[:10] + ) + + sources_labels = [(row['source'] or 'Direct/Unknown') for row in top_sources_qs] + sources_values = [row['c'] for row in top_sources_qs] + countries_labels = [row['country'] for row in top_countries_qs] + countries_values = [row['c'] for row in top_countries_qs] + + context = { + 'period_options': period_options, + 'p': p, + 'start_date': period_start_date, + 'end_date': period_end_date, + 'labels_json': json.dumps(labels), + 'visitors_series_json': json.dumps(visitors_series), + 'conversions_series_json': json.dumps(conversions_series), + 'sources_labels_json': json.dumps(sources_labels), + 'sources_values_json': json.dumps(sources_values), + 'countries_labels_json': json.dumps(countries_labels), + 'countries_values_json': json.dumps(countries_values), + } + + return render(request, 'home/stats_charts.html', context) + + +@user_passes_test(lambda u: u.is_superuser) +def live_activity(request): + """Retourne en JSON l'activité récente (5 dernières minutes): + visiteurs et utilisateurs et leur page actuelle. + """ + now = timezone.now() + since = now - timezone.timedelta(minutes=5) + qs = ( + Visit.objects + .filter(last_seen__gte=since) + .order_by('-last_seen') + ) + + data = [] + for v in qs[:200]: + username = None + is_user = False + if v.user_id: + is_user = True + # safe access if user deleted + try: + username = v.user.username + except Exception: + username = 'Utilisateur' + visitor_label = v.visitor_id[:8] + seconds_ago = int((now - v.last_seen).total_seconds()) + data.append({ + 'visitor': visitor_label, + 'is_user': is_user, + 'username': username, + 'path': v.path, + 'last_seen': v.last_seen.isoformat(), + 'seconds_ago': seconds_ago, + 'date': str(v.date), + 'country': v.country, + 'source': v.source, + }) + + return JsonResponse({'now': now.isoformat(), 'items': data}) diff --git a/static/css/app.css b/static/css/app.css index 367520c..2531ab5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2330,6 +2330,16 @@ input[type="text"], input[type="email"], input[type="password"], textarea { filter: brightness(1.05); } +.btn-warning, .button-warning { + background-color: var(--warning); + border-color: var(--warning); + color: var(--warning-contrast); +} + +.btn-warning:hover, .button-warning:hover { + filter: brightness(1.05); +} + /* Tailles */ .btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; } .btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; } @@ -2662,4 +2672,22 @@ ul.flash_messages li.error { ul.flash_messages li.success { background-color: var(--success); color: var(--success-contrast); +} + +.message-warning { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--warning); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; +} + +.message-info { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--primary); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; } \ No newline at end of file diff --git a/static/css/christmas.css b/static/css/christmas.css new file mode 100644 index 0000000..19cf591 --- /dev/null +++ b/static/css/christmas.css @@ -0,0 +1,99 @@ +/* PartirDeZero — Décos de Noël (chargées uniquement en décembre) */ + +/* Barre festive discrète sous la navbar */ +.site-nav { position: relative; } +.site-nav::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 4px; + background: repeating-linear-gradient(45deg, + #d61c4e 0 12px, + #1b8f3a 12px 24px, + #ffffff 24px 36px); + opacity: .55; + pointer-events: none; +} + +/* Emoji sapin à côté du titre */ +.pdz-festive-emoji { + margin-left: .35rem; + filter: drop-shadow(0 1px 0 rgba(0,0,0,.2)); +} + +/* Overlay neige — ultra léger, non bloquant */ +.pdz-snow { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 5; /* au-dessus du fond, sous les modales si existantes */ + background-image: + radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,.9) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 80px 120px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%), + radial-gradient(2px 2px at 150px 80px, rgba(255,255,255,.85) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 250px 20px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%); + background-repeat: repeat; + background-size: 300px 200px, 400px 300px, 350px 250px, 500px 400px; + animation: pdzSnowFall 18s linear infinite; +} + +/* Plusieurs couches pour un effet de profondeur via parallax */ +.pdz-snow::before, +.pdz-snow::after { + content: ""; + position: absolute; inset: 0; + background-image: + radial-gradient(2px 2px at 40px 60px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 120px 200px, rgba(255,255,255,.75) 50%, rgba(255,255,255,0) 51%), + radial-gradient(2px 2px at 220px 160px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%); + background-repeat: repeat; +} +.pdz-snow::before { + background-size: 260px 180px, 380px 280px, 320px 220px; + animation: pdzSnowFallSlow 28s linear infinite; +} +.pdz-snow::after { + background-size: 200px 140px, 300px 220px, 260px 200px; + animation: pdzSnowFallFast 12s linear infinite; +} + +@keyframes pdzSnowFall { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} +@keyframes pdzSnowFallSlow { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} +@keyframes pdzSnowFallFast { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} + +/* Respect des préférences d'accessibilité */ +@media (prefers-reduced-motion: reduce) { + .pdz-snow, .pdz-snow::before, .pdz-snow::after { animation: none; } +} + +/* ——— Footer: petite touche festive discrète ——— */ +footer.pdz-xmas { position: relative; } +footer.pdz-xmas::before { + content: ""; + position: absolute; + left: 0; right: 0; top: 0; + height: 4px; + background: repeating-linear-gradient(45deg, + #d61c4e 0 12px, + #1b8f3a 12px 24px, + #ffffff 24px 36px); + opacity: .55; + pointer-events: none; +} + +.footer-legal .pdz-holiday-greeting { + display: inline-flex; + align-items: center; + gap: .35rem; + color: var(--fg); + font-weight: 500; +} 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/staticfiles/js/functions.js b/staticfiles/js/functions.js index d5195f9..500562a 100644 --- a/staticfiles/js/functions.js +++ b/staticfiles/js/functions.js @@ -12,4 +12,86 @@ function show(id) { htmlChange.id = `show-${id}`; let buttonChange = document.getElementById(id); buttonChange.onclick = function() { hide(id); }; -} \ No newline at end of file +} + +// Fonction pour supprimer le message d'alerte après 5 secondes +document.addEventListener('DOMContentLoaded', function() { + let messages = document.querySelector('.flash_messages') + if (messages) { + setTimeout(function() { + messages.style.opacity = 0; + setTimeout(function() { + messages.style.display = 'none'; + }, 1000); + }, 5000); + } + + // Theme toggle setup + var toggle = document.getElementById('themeToggle'); + var themeLink = document.getElementById('theme-css'); + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + if (theme === 'light') { + if (themeLink) themeLink.href = themeLink.href.replace('colors_dark.css', 'colors_light.css'); + } else { + if (themeLink) themeLink.href = themeLink.href.replace('colors_light.css', 'colors_dark.css'); + } + var icon = toggle && toggle.querySelector('i'); + if (icon) { + icon.classList.remove('fa-sun','fa-moon'); + icon.classList.add(theme === 'light' ? 'fa-moon' : 'fa-sun'); + } + if (toggle) { + toggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); + toggle.setAttribute('aria-label', theme === 'light' ? 'Activer le thème sombre' : 'Activer le thème clair'); + toggle.title = toggle.getAttribute('aria-label'); + } + } + try { + var current = document.documentElement.getAttribute('data-theme') || 'dark'; + applyTheme(current); + if (toggle) { + toggle.addEventListener('click', function() { + var next = (document.documentElement.getAttribute('data-theme') === 'light') ? 'dark' : 'light'; + localStorage.setItem('pdz-theme', next); + applyTheme(next); + }); + } + } catch(e) {} + + // Mobile nav toggle + try { + var navToggle = document.getElementById('navToggle'); + var primaryNav = document.getElementById('primaryNav'); + if (navToggle && primaryNav) { + function setExpanded(expanded) { + navToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + navToggle.setAttribute('aria-label', expanded ? 'Fermer le menu' : 'Ouvrir le menu'); + var icon = navToggle.querySelector('i'); + if (icon) { + icon.classList.remove('fa-bars','fa-xmark'); + icon.classList.add(expanded ? 'fa-xmark' : 'fa-bars'); + } + primaryNav.classList.toggle('is-open', expanded); + } + + navToggle.addEventListener('click', function() { + var expanded = navToggle.getAttribute('aria-expanded') === 'true'; + setExpanded(!expanded); + }); + + // Close menu when a link is clicked (on small screens) + primaryNav.addEventListener('click', function(e) { + var target = e.target; + if (target.tagName === 'A' || target.closest('a')) { + setExpanded(false); + } + }); + + // Close on Escape + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') setExpanded(false); + }); + } + } catch(e) {} +}); \ No newline at end of file diff --git a/templates/home/stats_charts.html b/templates/home/stats_charts.html new file mode 100644 index 0000000..15f4972 --- /dev/null +++ b/templates/home/stats_charts.html @@ -0,0 +1,78 @@ +{% extends "layout.html" %} +{% load static %} + +{% block title %}- Stats · Graphiques{% endblock %} + +{% block extra_head %} + {% include "partials/_stats_head.html" %} +{% endblock %} + +{% block content %} +
+

Graphiques statistiques

+ + {% include "partials/_stats_toolbar.html" %} + +
+
+

Visiteurs uniques par jour

+ +
+
+

Conversions (visiteurs devenus utilisateurs) par jour

+ +
+
+ +
+
+

Top sources (visiteurs uniques)

+ +
+
+

Top pays (visiteurs uniques)

+ +
+
+ +

← Retour au tableau de bord

+
+ + +{% endblock %} diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html index e5daaed..8cd5325 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -4,37 +4,31 @@ {% block title %}- Tableau de bord{% endblock %} {% block extra_head %} - - + {% include "partials/_stats_head.html" %} {% endblock %} {% block content %}

Tableau de bord statistiques

+

+ → Voir la page de graphiques +

-
-
- - - Du {{ start_date }} au {{ end_date }} -
-
Mise en cache 15 minutes
-
+ {% include "partials/_stats_toolbar.html" %}
+
+

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 }}
@@ -77,6 +71,20 @@
+
+
+

Activité en direct (5 dernières minutes)

+ + + + + + + +
TypeIdentitéPageIl y aSourcePays
Chargement…
+
+
+

Évolution quotidienne

@@ -108,6 +116,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 %} diff --git a/templates/layout.html b/templates/layout.html index 2e7a521..1d70ca8 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,24 +84,33 @@ - {% block header %} - {% include "partials/_header.html" %} - {% endblock %} - -
- {% if messages %} - + {% if maintenance.is_active == True %} + {% include "maintenance.html" %} + {% else %} + {% now "n" as month %} + {% if month == '12' %} + + {% endif %} + {% block header %} + {% include "partials/_header.html" %} + {% endblock %} - {% block content %}{% endblock %} -
+
+ {% if messages %} + + {% endif %} - {% block footer %} - {% include "partials/_footer.html" %} - {% endblock %} + {% block content %}{% endblock %} +
+ + {% block footer %} + {% include "partials/_footer.html" %} + {% endblock %} + {% endif %} \ No newline at end of file diff --git a/templates/maintenance.html b/templates/maintenance.html new file mode 100644 index 0000000..77a3aa7 --- /dev/null +++ b/templates/maintenance.html @@ -0,0 +1,28 @@ +{% load comment_format %} +
+

Maintenance : {{ maintenance.name }}

+ {{ maintenance.message|comment_markdown }} +
Durée estimée : {{ delay }}
+
Début de la maintenance : {{ maintenance.start_date }}
+
Fin de la maintenance estimé : {{ maintenance.end_date }}
+ + {% if message %} +

{{ message }}

+ {% endif %} + + {% if user.is_superuser %} +
+
+ +
+
Commande de redemarrage serveur : kill -HUP $(cat /var/www/vhosts/partirdezero.com/httpdocs/run/gunicorn.pid)
+ + {% endif %} +
\ No newline at end of file diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 29b2d41..42cc419 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -1,4 +1,5 @@ -