Compare commits

..

14 commits

Author SHA1 Message Date
40a8c6d106 Suppression des logs de débogage des signaux et amélioration du formatage du rang dans la réponse Discord. 2025-12-18 14:50:36 +01:00
2a6f670361 Ajout du modèle DiscordLevel avec champs pour la gestion des niveaux et de l'XP dans discord_integration. 2025-12-18 14:41:11 +01:00
3f90cfa339 Ajout des commandes de gestion du niveau, d'une logique XP, et de nouvelles fonctionnalités bot dans discord_integration. 2025-12-18 14:38:31 +01:00
ad6600e4f6 Mise à jour du lien Discord dans _header.html. 2025-12-18 10:35:56 +01:00
afc2614535 Mise à jour de la version applicative dans VERSION.txt. 2025-12-18 10:29:14 +01:00
2ec4a5c065 Ajout de l'application discord_integration avec modèles, migrations, logique d'annonces et gestion des rôles dans Discord. 2025-12-18 10:28:28 +01:00
1354568495 Mise à jour de la version applicative dans VERSION.txt. 2025-12-17 14:19:21 +01:00
e9754c2713 Ajout de la commande de redémarrage du serveur dans le template maintenance, d'une URL dédiée à reload_server, et des styles associés. 2025-12-17 14:18:48 +01:00
1685fe0a6d Mise à jour de la version applicative dans VERSION.txt. 2025-12-17 12:48:51 +01:00
536f4e303f Ajout du mode maintenance avec modèle, vues, URL, contexte, et intégration des templates. Ajout de nouvelles fonctionnalités côté client, comme le basculement de thème et les interactions de navigation mobile. 2025-12-17 12:48:05 +01:00
acd9f42cea Ajout du champ receive_emails_active dans SiteSettings, mise à jour de l'admin, ajout d'un signal pour les notifications par email sur les commentaires, et amélioration des context_processors. 2025-12-17 11:19:27 +01:00
38e4ce2562 Ajout des partials _stats_head.html et _stats_toolbar.html pour harmoniser les styles et outils des pages de statistiques. 2025-12-16 15:16:18 +01:00
18b807bf5a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 15:03:46 +01:00
d20302be0e Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 14:16:24 +01:00
45 changed files with 854 additions and 123 deletions

View file

@ -1 +1 @@
1.3.7 (8f0fad4)
1.5.0 (2ec4a5c)

View file

@ -1,4 +1,5 @@
from django.db import models
from django.urls import reverse
class Post(models.Model):
name = models.CharField(max_length=200)
@ -16,3 +17,6 @@ class Post(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})

View file

@ -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):

View file

@ -1,5 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
def ready(self):
import courses.signals

View file

@ -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()}
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}

View file

@ -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),
),
]

View file

@ -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)),
],
),
]

View file

@ -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),
),
]

View file

@ -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).

9
core/urls.py Normal file
View file

@ -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')
]

View file

@ -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

View file

@ -1,6 +1,5 @@
from django.apps import AppConfig
class CoursesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'courses'

View file

@ -1,5 +1,8 @@
from .models import Course
from .models import Course, Comment
def course_list(request):
courses = Course.objects.all()
return {'courses': courses}
def courses_comments(request):
return {'comments_count': Comment.objects.all()}

12
courses/signals.py Normal file
View file

@ -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)

View file

@ -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',
],

View file

@ -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')),

View file

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class DiscordIntegrationConfig(AppConfig):
name = 'discord_integration'
def ready(self):
import discord_integration.signals

View file

@ -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()

View file

View file

@ -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)

View file

@ -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"
}

View file

@ -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}** !")

View file

@ -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)

View file

@ -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 ! 🇧🇪",
"Cest pas un bug, cest une fonctionnalité non documentée ! 🐛",
"Est-ce quon peut dire que mon code est une œuvre dart ? Non ? Dommage.",
"Je ne plante pas, je fais une pause créative.",
"Quelquun 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)

View file

@ -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}")

View file

@ -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')),
],
),
]

View file

@ -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)),
],
),
]

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View file

@ -11,6 +11,49 @@ 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]
return render(request, 'home.html', {'courses': courses})
@ -30,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
@ -53,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()
@ -62,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'))
)
@ -83,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'))
)
@ -91,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'))
)
@ -101,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()
@ -135,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)
@ -158,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': {
@ -206,19 +233,11 @@ def stats_dashboard(request):
@cache_page(60 * 15)
def stats_charts(request):
"""Page dédiée aux graphiques (réservée superadmins)."""
# Période
# Période (utilise les mêmes helpers que le dashboard pour harmonisation)
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)
period_start_date = start_dt.date()
period_end_date = now.date()
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 = (
@ -255,8 +274,8 @@ def stats_charts(request):
d += timezone.timedelta(days=1)
return days, values
labels, visitors_series = build_series_dict(visits_qs, date_key='date')
_, conversions_series = build_series_dict(conversions_qs, date_key='day')
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)

View file

@ -2673,3 +2673,21 @@ 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;
}

View file

@ -13,3 +13,85 @@ function show(id) {
let buttonChange = document.getElementById(id);
buttonChange.onclick = function() { hide(id); };
}
// 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) {}
});

View file

@ -4,32 +4,14 @@
{% block title %}- Stats · Graphiques{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
.card{background:var(--bg-200);border-radius:12px;padding:16px;border:1px solid var(--bg-300)}
.grid{display:grid;grid-template-columns:1fr;gap:24px;margin:16px 0}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.toolbar{display:flex;gap:8px;align-items:center;justify-content:space-between;margin:8px 0}
@media (max-width: 960px){.grid-2{grid-template-columns:1fr}}
</style>
{% include "partials/_stats_head.html" %}
{% endblock %}
{% block content %}
<div class="container">
<h1>Graphiques statistiques</h1>
<form method="get" class="toolbar">
<div>
<label for="p">Période:&nbsp;</label>
<select id="p" name="p" onchange="this.form.submit()">
{% for opt in period_options %}
<option value="{{ opt }}" {% if p == opt %}selected{% endif %}>{{ opt }} jours</option>
{% endfor %}
</select>
<span style="margin-left:8px;color:var(--fg-300)">Du {{ start_date }} au {{ end_date }}</span>
</div>
<div style="color:var(--fg-300)">Mise en cache 15 minutes</div>
</form>
{% include "partials/_stats_toolbar.html" %}
<div class="grid">
<div class="card">

View file

@ -4,17 +4,7 @@
{% block title %}- Tableau de bord{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:16px 0}
.card{background:var(--bg-200);border-radius:12px;padding:16px;border:1px solid var(--bg-300)}
.card h3{margin:0 0 8px 0;font-size:16px;color:var(--fg-300)}
.kpi{font-size:28px;font-weight:700}
.toolbar{display:flex;gap:8px;align-items:center;justify-content:space-between;margin:8px 0}
.charts{display:grid;grid-template-columns:1fr;gap:24px;margin:24px 0}
.tables{display:grid;grid-template-columns:1fr 1fr;gap:24px}
@media (max-width: 960px){.stats-grid{grid-template-columns:repeat(2,1fr)}.tables{grid-template-columns:1fr}}
</style>
{% include "partials/_stats_head.html" %}
{% endblock %}
{% block content %}
@ -24,18 +14,7 @@
<a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a>
</p>
<form method="get" class="toolbar">
<div>
<label for="p">Période:&nbsp;</label>
<select id="p" name="p" onchange="this.form.submit()">
{% for opt in period_options %}
<option value="{{ opt }}" {% if p == opt %}selected{% endif %}>{{ opt }} jours</option>
{% endfor %}
</select>
<span style="margin-left:8px;color:var(--fg-300)">Du {{ start_date }} au {{ end_date }}</span>
</div>
<div style="color:var(--fg-300)">Mise en cache 15 minutes</div>
</form>
{% include "partials/_stats_toolbar.html" %}
<section class="stats-grid">
<div class="card">

View file

@ -84,6 +84,9 @@
<script defer>hljs.highlightAll();</script>
</head>
<body>
{% if maintenance.is_active == True %}
{% include "maintenance.html" %}
{% else %}
{% now "n" as month %}
{% if month == '12' %}
<!-- Overlay neige discret, non interactif -->
@ -108,5 +111,6 @@
{% block footer %}
{% include "partials/_footer.html" %}
{% endblock %}
{% endif %}
</body>
</html>

View file

@ -0,0 +1,28 @@
{% load comment_format %}
<section>
<h1>Maintenance : {{ maintenance.name }}</h1>
{{ maintenance.message|comment_markdown }}
<div class="text-right">Durée estimée : {{ delay }}</div>
<div class="text-right">Début de la maintenance : {{ maintenance.start_date }}</div>
<div class="text-right">Fin de la maintenance estimé : {{ maintenance.end_date }}</div>
{% if message %}
<h2>{{ message }}</h2>
{% endif %}
{% if user.is_superuser %}
<div style="border-bottom: 2px solid white"></div>
<div>
<ul>
<li><a href="{% url 'update_database' %}">Mettre à jour la base de données</a></li>
<li><a href="">Nettoyer la base de données</a></li>
<li><a href="{% url 'clear_cache' %}">Effacer le cache</a></li>
<li><a href="{% url 'regen_static_files' %}">Régénérer les fichiers static</a></li>
<div style="border-bottom: 2px solid white; margin: 5px;"></div>
<li><a href="{% url 'admin:index' %}" class="btn btn-warning" target="_blank">Panel Admin</a></li>
</ul>
</div>
<div class="message-warning"><i class="fa-solid fa-terminal" style="color:orange;border-right: 1px solid orange; padding: 5px;"> </i><strong> Commande de redemarrage serveur : </strong>kill -HUP $(cat /var/www/vhosts/partirdezero.com/httpdocs/run/gunicorn.pid)</div>
<div class="message-info"><i class="fa-solid fa-link" style="color:orange;border-right: 1px solid orange; padding: 5px;"> </i> <a href="https://trusting-moser.82-165-125-100.plesk.page:8443/modules/ssh-terminal/" target="_blank">Accès terminal SSH</a></div>
{% endif %}
</section>

View file

@ -29,6 +29,7 @@
{% if settings.contact_email %}
<li><a href="mailto:contact@exemple.com"><i class="fa-solid fa-envelope"></i> Email</a></li>
{% endif %}
<li><a href="https://discord.gg/bAkuKHWFqU" target="_blank"><i class="fab fa-discord"></i> Communauté</a></li>
</ul>
</div>
</div>

View file

@ -45,11 +45,10 @@
{% endif %}
</ul>
</li>
<li><a href="https://discord.gg/7kk6AJsAVn" target="_blank" class="button"><i class="fab fa-discord"></i> Discord</a></li>
{% if user.is_authenticated and user.is_staff %}
<li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
<a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a>
</li>
<li><a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a></li>
<li><a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a></li>
{% endif %}
<li>
{% if user.is_authenticated %}

View file

@ -0,0 +1,65 @@
{# Partials: Shared head for stats pages (Chart.js + unified styles) #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
/* Utilities */
.text-muted{color:var(--text-muted)}
/* Cards and basic blocks */
.card{
background:var(--surface);
border-radius:var(--r-3);
padding:var(--space-4);
border:1px solid var(--border-subtle);
box-shadow:var(--shadow-1);
transition:transform var(--transition-1), box-shadow var(--transition-1);
}
.card:hover{transform:translateY(-2px);box-shadow:var(--shadow-2)}
.card h3{margin:0 0 8px 0;font-size:16px;color:var(--text-muted);letter-spacing:.2px}
.kpi{font-size:clamp(22px,4vw,32px);font-weight:700;line-height:1.2}
.toolbar{display:flex;gap:12px;align-items:center;justify-content:space-between;margin:8px 0;padding:4px 0}
.toolbar label{color:var(--text-muted)}
.toolbar select{background:var(--surface);color:var(--text);border:1px solid var(--border-subtle);border-radius:var(--r-2);padding:6px 10px}
/* Grids used across stats pages */
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-4);margin:16px 0}
.charts{display:grid;grid-template-columns:1fr;gap:24px;margin:24px 0}
.tables{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.grid{display:grid;grid-template-columns:1fr;gap:24px;margin:16px 0}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px}
@media (max-width: 1280px){
.stats-grid{grid-template-columns:repeat(3,1fr)}
}
/* Responsive */
@media (max-width: 960px){
.stats-grid{grid-template-columns:repeat(2,1fr)}
.tables{grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr}
}
@media (max-width: 560px){
.toolbar{flex-direction:column;align-items:flex-start;gap:8px}
}
/* Tables */
table{width:100%;border-collapse:collapse}
thead th{font-weight:600;text-align:left;border-bottom:1px solid var(--border-subtle);padding:8px 6px;color:var(--text-muted)}
tbody td{border-bottom:1px solid var(--border-subtle);padding:8px 6px}
tbody tr:nth-child(even){background:rgba(255,255,255,0.02)}
tbody tr:hover{background:rgba(255,255,255,0.04)}
</style>
<script>
// Harmonize Chart.js defaults with theme tokens
if (window.Chart) {
try {
const cs = getComputedStyle(document.documentElement);
const text = cs.getPropertyValue('--text') || '#c4d7e0';
const font = cs.getPropertyValue('--font-sans') || 'system-ui, sans-serif';
Chart.defaults.color = String(text).trim();
Chart.defaults.font.family = String(font).trim();
Chart.defaults.plugins.legend.labels.boxWidth = 12;
} catch (e) {
// no-op
}
}
</script>

View file

@ -0,0 +1,15 @@
{# Partials: Shared period toolbar for stats pages #}
<form method="get" class="toolbar">
<div>
<label for="p">Période:&nbsp;</label>
<select id="p" name="p" onchange="this.form.submit()">
{% for opt in period_options %}
<option value="{{ opt }}" {% if p == opt %}selected{% endif %}>{{ opt }} jours</option>
{% endfor %}
</select>
<span style="margin-left:8px" class="text-muted">Du {{ start_date }} au {{ end_date }}</span>
</div>
<div class="text-muted">Mise en cache 15 minutes</div>
{% block stats_toolbar_extra %}{% endblock %}
{# Keep block for optional extensions on specific pages #}
</form>