diff --git a/VERSION.txt b/VERSION.txt index 63c4a6a..0b51dff 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.3 (b3b18bd) \ 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 a9baff2..13f115b 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import SiteSettings, Visit +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'), @@ -28,6 +28,10 @@ class SiteSettingsAdmin(admin.ModelAdmin): }), ) +@admin.register(Maintenance) +class MaintenanceAdmin(admin.ModelAdmin): + list_display = ("name","start_date", "end_date") + @admin.register(Visit) class VisitAdmin(admin.ModelAdmin): 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 index 3e76796..3167553 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -111,7 +111,11 @@ class VisitTrackingMiddleware: # 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', 'last_seen']) + visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'path', 'last_seen']) 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 63d9b18..c28ae82 100644 --- a/core/models.py +++ b/core/models.py @@ -6,6 +6,7 @@ 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) @@ -34,6 +35,12 @@ class SiteSettings(models.Model): verbose_name = "Réglages du site" 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). 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 d5b3597..f2dab3b 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -55,6 +55,8 @@ INSTALLED_APPS = [ 'users', 'progression', 'blog', + + 'discord_integration.apps.DiscordIntegrationConfig', ] MIDDLEWARE = [ @@ -84,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', ], @@ -207,4 +210,9 @@ def get_git_version(): GIT_VERSION = get_git_version() EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND') -DEFAULT_FROM_EMAIL = dotenv.get_key('.env', 'EMAIL_HOST_USER') \ No newline at end of file +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 159fb3f..22ab337 100644 --- a/home/views.py +++ b/home/views.py @@ -9,6 +9,50 @@ 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] @@ -29,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 @@ -52,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() @@ -61,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')) ) @@ -82,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')) ) @@ -90,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')) ) @@ -100,8 +138,6 @@ def stats_dashboard(request): 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() @@ -134,16 +170,8 @@ def stats_dashboard(request): # 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) @@ -157,8 +185,8 @@ 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': { @@ -199,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 84a7094..8cd5325 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -4,35 +4,17 @@ {% 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" %}
@@ -89,6 +71,20 @@
+
+
+

Activité en direct (5 dernières minutes)

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

Évolution quotidienne

@@ -172,5 +168,43 @@ plugins: {legend: {position: 'bottom'}} } }); + + // Live activity polling + function humanizeSeconds(s) { + if (s < 60) return s + 's'; + const m = Math.floor(s/60); const r = s % 60; + if (m < 60) return m + 'm' + (r?(' '+r+'s'):''); + const h = Math.floor(m/60); const mr = m % 60; return h + 'h' + (mr?(' '+mr+'m'):''); + } + async function fetchLive() { + try { + const res = await fetch('{% url "home:live_activity" %}', {headers: {'Accept': 'application/json'}}); + if (!res.ok) throw new Error('HTTP '+res.status); + const payload = await res.json(); + const tbody = document.querySelector('#live-activity-table tbody'); + tbody.innerHTML = ''; + const items = payload.items || []; + if (items.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); td.colSpan = 6; td.textContent = 'Aucune activité récente'; + tr.appendChild(td); tbody.appendChild(tr); return; + } + for (const it of items) { + const tr = document.createElement('tr'); + const type = document.createElement('td'); type.textContent = it.is_user ? 'Utilisateur' : 'Visiteur'; + const ident = document.createElement('td'); ident.textContent = it.is_user ? (it.username || 'Utilisateur') : it.visitor; + const page = document.createElement('td'); page.textContent = it.path || '/'; + const ago = document.createElement('td'); ago.textContent = humanizeSeconds(it.seconds_ago); + const src = document.createElement('td'); src.textContent = it.source || ''; + const country = document.createElement('td'); country.textContent = it.country || ''; + tr.append(type, ident, page, ago, src, country); + tbody.appendChild(tr); + } + } catch (e) { + // silent fail in UI + } + } + fetchLive(); + setInterval(fetchLive, 10000); {% 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 %} -
    - {% for message in messages %} - {{ message }} - {% endfor %} -
+ {% 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 %} +
    + {% for message in messages %} + {{ message }} + {% endfor %} +
+ {% 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 @@ -
+{% now "n" as month %} +
@@ -35,5 +37,8 @@ Partir de Zero ©2024 - {% now "Y" %} v{{ SITE_VERSION }} Site fièrement créer par AV Interactive + {% if month == '12' %} + Joyeuses fêtes + {% endif %}
\ No newline at end of file diff --git a/templates/partials/_header.html b/templates/partials/_header.html index 20db214..4c3e694 100644 --- a/templates/partials/_header.html +++ b/templates/partials/_header.html @@ -2,7 +2,12 @@
{% if settings.site_logo %}{% endif %} - Partir de zéro + Partir de zéro + {% now "n" as month %} + {% if month == '12' %} + + {% endif %} +
/* Anthony Violet */
@@ -40,10 +45,10 @@ {% endif %} +
  • Discord
  • {% if user.is_authenticated and user.is_staff %} -
  • - Admin -
  • +
  • Admin
  • +
  • Stats
  • {% endif %}
  • {% if user.is_authenticated %} diff --git a/templates/partials/_stats_head.html b/templates/partials/_stats_head.html new file mode 100644 index 0000000..1eba9b1 --- /dev/null +++ b/templates/partials/_stats_head.html @@ -0,0 +1,65 @@ +{# Partials: Shared head for stats pages (Chart.js + unified styles) #} + + + + diff --git a/templates/partials/_stats_toolbar.html b/templates/partials/_stats_toolbar.html new file mode 100644 index 0000000..6241f0a --- /dev/null +++ b/templates/partials/_stats_toolbar.html @@ -0,0 +1,15 @@ +{# Partials: Shared period toolbar for stats pages #} +
    +
    + + + Du {{ start_date }} au {{ end_date }} +
    +
    Mise en cache 15 minutes
    + {% block stats_toolbar_extra %}{% endblock %} + {# Keep block for optional extensions on specific pages #} +
    diff --git a/templates/users/my_courses.html b/templates/users/my_courses.html index f672141..dab8a7f 100644 --- a/templates/users/my_courses.html +++ b/templates/users/my_courses.html @@ -6,12 +6,27 @@ {% endblock %}

    Mes cours

    -

    Retrouvez ici la liste de tous les cours que vous suivez.

    - +

    Retrouvez ici la liste de tous les cours que vous suivez et votre progression.

    + + {% if progress_list %} + + {% else %} +

    Vous ne suivez aucun cours pour le moment.

    + {% endif %}
  • {% endblock %} \ No newline at end of file diff --git a/templates/users/profile.html b/templates/users/profile.html index 57be63e..f9aae18 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -22,24 +22,27 @@

    Mes cours

    - {% with courses=user.course_set.all %} - {% if courses %} - - - {% else %} -

    Aucun cours suivi pour le moment.

    - {% endif %} + {% with progress_list=latest_progress %} + {% if progress_list %} +
      + {% for p in progress_list %} + {% with course=p.course %} +
    • + + {{ course.name }} + {{ course.name }} + +
      Progression: {{ p.percent_completed }}%
      +
    • + {% endwith %} + {% endfor %} +
    + + {% else %} +

    Aucun cours suivi pour le moment.

    + {% endif %} {% endwith %}
    diff --git a/users/views.py b/users/views.py index bf3c51d..a6d89cc 100644 --- a/users/views.py +++ b/users/views.py @@ -3,6 +3,7 @@ from django.contrib import messages from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout from django.contrib.auth.models import User from courses.models import Course +from progression.models import Progression from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm from django.contrib.auth.decorators import login_required from django.core.mail import send_mail @@ -81,7 +82,17 @@ def logout(request): def profile(request): if not hasattr(request.user, 'profile'): return redirect('complete_profile') - return render(request, 'users/profile.html') + + latest_progress = ( + Progression.objects + .filter(user=request.user) + .select_related('course') + .prefetch_related('completed_lessons') + .order_by('-updated_at')[:5] + ) + + # Affiche les 5 derniers cours regardés avec leur progression + return render(request, 'users/profile.html', {'latest_progress': latest_progress}) @login_required(login_url='login') def complete_profile(request): @@ -130,9 +141,16 @@ def account_update(request): @login_required(login_url='login') def my_courses(request): - user_courses = Course.objects.filter(author=request.user.id) - print(user_courses) - return render(request, 'users/my_courses.html', {'user_courses' : user_courses}) + # Liste tous les cours suivis par l'utilisateur avec leur progression + progress_list = ( + Progression.objects + .filter(user=request.user) + .select_related('course') + .prefetch_related('completed_lessons') + .order_by('-updated_at') + ) + + return render(request, 'users/my_courses.html', {'progress_list': progress_list}) def create_post(request): # Implement post creation logic here