Compare commits

..

No commits in common. "master" and "1.3.5" have entirely different histories.

54 changed files with 156 additions and 1251 deletions

View file

@ -1 +1 @@
1.5.0 (2ec4a5c)
1.3.3 (6e8a2bc)

View file

@ -1,5 +1,5 @@
from .models import Post
def posts_list(request):
posts = Post.objects.all().order_by('-created_at')
posts = Post.objects.all()
return {'posts': posts}

View file

@ -1,5 +1,4 @@
from django.db import models
from django.urls import reverse
class Post(models.Model):
name = models.CharField(max_length=200)
@ -17,6 +16,3 @@ 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, Maintenance
from .models import SiteSettings, Visit
@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', 'receive_emails_active')
'fields': ('site_name', 'site_logo')
}),
('Réseaux Sociaux', {
'fields': ('facebook_url', 'twitter_url', 'youtube_url'),
@ -28,10 +28,6 @@ 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,7 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
def ready(self):
import courses.signals

View file

@ -1,14 +1,5 @@
from django.utils.timesince import timesince
from .models import SiteSettings, Maintenance
from .models import SiteSettings
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

@ -111,11 +111,7 @@ 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', 'path', 'last_seen'])
visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen'])

View file

@ -1,18 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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

@ -1,18 +0,0 @@
# 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,7 +6,6 @@ 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)
@ -35,12 +34,6 @@ 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).

View file

@ -1,9 +0,0 @@
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,23 +1,3 @@
from django.shortcuts import render
from django.core.management import call_command
from django.core.cache import cache
import subprocess
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
# Create your views here.

View file

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

View file

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

View file

@ -1,12 +0,0 @@
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,8 +55,6 @@ INSTALLED_APPS = [
'users',
'progression',
'blog',
'discord_integration.apps.DiscordIntegrationConfig',
]
MIDDLEWARE = [
@ -86,7 +84,6 @@ 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

@ -12,7 +12,7 @@ class CourseSitemap(sitemaps.Sitemap):
priority = 0.9
def items(self):
return Course.objects.filter(enable=True).order_by('id')
return Course.objects.filter(enable=True) # Exemple de filtre
def location(self, item):
# Assure-toi que ton modèle Course a bien une méthode get_absolute_url

View file

@ -22,13 +22,12 @@ 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
]
@ -40,7 +39,6 @@ sitemaps_dict = {
}
urlpatterns = [
path('core/', include('core.urls')),
path('admin/', admin.site.urls),
path('', include('home.urls')),
path('courses/', include('courses.urls')),

View file

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

View file

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

View file

@ -1,14 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,73 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,26 +0,0 @@
# 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

@ -1,24 +0,0 @@
# 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

@ -1,20 +0,0 @@
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

@ -1,18 +0,0 @@
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

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

View file

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

View file

@ -8,5 +8,4 @@ urlpatterns = [
# 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'),
]

View file

@ -9,50 +9,6 @@ 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]
@ -73,13 +29,19 @@ def stats_dashboard(request):
# Période
period_options = [7, 30, 90, 180]
p, now, start_dt, period_start_date, period_end_date = _parse_period(
request, default=30, options=period_options
)
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
# Utilisateurs
total_users = User.objects.count()
new_users_qs = User.objects.filter(date_joined__date__gte=period_start_date, date_joined__date__lte=period_end_date)
new_users_qs = User.objects.filter(date_joined__date__gte=start_dt.date(), date_joined__date__lte=now.date())
# Séries quotidiennes nouveaux utilisateurs
new_users_by_day = (
new_users_qs
@ -90,7 +52,7 @@ def stats_dashboard(request):
# Activité approximée via Progression mise à jour
active_users_qs = (
Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date)
Progression.objects.filter(updated_at__date__gte=start_dt.date(), updated_at__date__lte=now.date())
.values('user').distinct()
)
active_users_count = active_users_qs.count()
@ -99,7 +61,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=period_start_date, created_at__date__lte=period_end_date)
Course.objects.filter(created_at__date__gte=start_dt.date(), created_at__date__lte=now.date())
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
@ -120,7 +82,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=period_start_date, updated_at__date__lte=period_end_date)
Progression.objects.filter(updated_at__date__gte=start_dt.date(), updated_at__date__lte=now.date())
.extra(select={'day': "date(updated_at)"})
.values('day').annotate(c=Count('id'))
)
@ -128,7 +90,7 @@ def stats_dashboard(request):
# Blog
total_posts = Post.objects.count()
new_posts_by_day = (
Post.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date)
Post.objects.filter(created_at__date__gte=start_dt.date(), created_at__date__lte=now.date())
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
@ -138,6 +100,8 @@ 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()
@ -170,8 +134,16 @@ 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'):
# 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)
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
days_users, values_new_users = build_series_dict(new_users_by_day)
days_courses, values_new_courses = build_series_dict(new_courses_by_day)
@ -185,8 +157,8 @@ def stats_dashboard(request):
context = {
'period_options': period_options,
'p': p,
'start_date': period_start_date,
'end_date': period_end_date,
'start_date': start_dt.date(),
'end_date': now.date(),
# KPI
'kpi': {
@ -233,11 +205,19 @@ def stats_dashboard(request):
@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)
# Période
period_options = [7, 30, 90, 180]
p, _now, _start_dt, period_start_date, period_end_date = _parse_period(
request, default=30, options=period_options
)
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()
# Trafic par jour (visiteurs uniques)
visits_qs = (
@ -274,8 +254,8 @@ def stats_charts(request):
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')
labels, visitors_series = build_series_dict(visits_qs, date_key='date')
_, conversions_series = build_series_dict(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)
@ -313,44 +293,3 @@ def stats_charts(request):
}
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})

View file

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

@ -1,99 +0,0 @@
/* 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;
}

View file

@ -95,3 +95,27 @@ 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);

View file

@ -13,85 +13,3 @@ 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

@ -1,78 +0,0 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}- Stats · Graphiques{% endblock %}
{% block extra_head %}
{% include "partials/_stats_head.html" %}
{% endblock %}
{% block content %}
<div class="container">
<h1>Graphiques statistiques</h1>
{% include "partials/_stats_toolbar.html" %}
<div class="grid">
<div class="card">
<h3>Visiteurs uniques par jour</h3>
<canvas id="chartVisitors" height="120"></canvas>
</div>
<div class="card">
<h3>Conversions (visiteurs devenus utilisateurs) par jour</h3>
<canvas id="chartConversions" height="120"></canvas>
</div>
</div>
<div class="grid-2">
<div class="card">
<h3>Top sources (visiteurs uniques)</h3>
<canvas id="chartSources" height="200"></canvas>
</div>
<div class="card">
<h3>Top pays (visiteurs uniques)</h3>
<canvas id="chartCountries" height="200"></canvas>
</div>
</div>
<p style="margin-top:16px"><a href="{% url 'home:stats_dashboard' %}">← Retour au tableau de bord</a></p>
</div>
<script>
const labels = {{ labels_json|safe }};
const visitorsSeries = {{ visitors_series_json|safe }};
const conversionsSeries = {{ conversions_series_json|safe }};
const sourcesLabels = {{ sources_labels_json|safe }};
const sourcesValues = {{ sources_values_json|safe }};
const countriesLabels = {{ countries_labels_json|safe }};
const countriesValues = {{ countries_values_json|safe }};
const ctxVisitors = document.getElementById('chartVisitors');
new Chart(ctxVisitors, {
type: 'line',
data: { labels, datasets: [{ label: 'Visiteurs uniques', data: visitorsSeries, borderColor: '#4f46e5', backgroundColor: 'rgba(79,70,229,.2)', tension:.2 }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } }
});
const ctxConv = document.getElementById('chartConversions');
new Chart(ctxConv, {
type: 'line',
data: { labels, datasets: [{ label: 'Conversions', data: conversionsSeries, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,.2)', tension:.2 }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } }
});
const ctxSources = document.getElementById('chartSources');
new Chart(ctxSources, {
type: 'doughnut',
data: { labels: sourcesLabels, datasets: [{ data: sourcesValues, backgroundColor: ['#4f46e5','#059669','#f59e0b','#ef4444','#10b981','#3b82f6','#8b5cf6','#f97316','#22c55e','#14b8a6'] }] },
options: { plugins: { legend: { position: 'bottom' } } }
});
const ctxCountries = document.getElementById('chartCountries');
new Chart(ctxCountries, {
type: 'bar',
data: { labels: countriesLabels, datasets: [{ label: 'Visiteurs', data: countriesValues, backgroundColor: '#f59e0b' }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { display: false } } }
});
</script>
{% endblock %}

View file

@ -4,17 +4,36 @@
{% block title %}- Tableau de bord{% endblock %}
{% block extra_head %}
{% include "partials/_stats_head.html" %}
<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>
{% endblock %}
{% block content %}
<div class="container">
<h1>Tableau de bord statistiques</h1>
<p style="margin:8px 0">
<a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a>
</p>
<p style="margin:8px 0"><a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a></p>
{% include "partials/_stats_toolbar.html" %}
<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>
<section class="stats-grid">
<div class="card">
@ -71,20 +90,6 @@
</div>
</section>
<section class="tables" id="live-activity">
<div class="card" style="grid-column: 1 / -1;">
<h3>Activité en direct (5 dernières minutes)</h3>
<table id="live-activity-table" style="width:100%">
<thead>
<tr><th>Type</th><th>Identité</th><th>Page</th><th>Il y a</th><th>Source</th><th>Pays</th></tr>
</thead>
<tbody>
<tr id="live-empty"><td colspan="6">Chargement…</td></tr>
</tbody>
</table>
</div>
</section>
<section class="charts">
<div class="card">
<h3>Évolution quotidienne</h3>
@ -168,43 +173,5 @@
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);
</script>
{% endblock %}

View file

@ -31,37 +31,6 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
{% now "n" as month %}
{% if month == '12' %}
<!-- Décorations de Noël (chargées uniquement en décembre) -->
<link rel="stylesheet" href="{% static 'css/christmas.css' %}">
<script>
// 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);
</script>
{% endif %}
{% block extra_head %}{% endblock %}
<script src="{% static 'js/functions.js' %}" defer></script>
@ -84,33 +53,24 @@
<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 -->
<div class="pdz-snow" aria-hidden="true"></div>
{% block header %}
{% include "partials/_header.html" %}
{% endblock %}
<main>
{% if messages %}
<ul class="flash_messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block header %}
{% include "partials/_header.html" %}
{% endblock %}
<main>
{% if messages %}
<ul class="flash_messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
</main>
{% block content %}{% endblock %}
</main>
{% block footer %}
{% include "partials/_footer.html" %}
{% endblock %}
{% endif %}
{% block footer %}
{% include "partials/_footer.html" %}
{% endblock %}
</body>
</html>

View file

@ -1,28 +0,0 @@
{% 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

@ -1,5 +1,4 @@
{% now "n" as month %}
<footer class="site-footer{% if month == '12' %} pdz-xmas{% endif %}">
<footer class="site-footer">
<div class="footer-columns">
<div class="about">
<h5>À propos</h5>
@ -29,7 +28,6 @@
{% 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>
@ -37,8 +35,5 @@
<span>Partir de Zero ©2024 - {% now "Y" %}</span>
<span>v{{ SITE_VERSION }}</span>
<span>Site fièrement créer par <a href="https://av-interactive.be" target="_blank" rel="noopener">AV Interactive</a></span>
{% if month == '12' %}
<span class="pdz-holiday-greeting" aria-label="Joyeuses fêtes">Joyeuses fêtes <span class="pdz-festive-emoji" role="img" aria-hidden="true">🎄</span></span>
{% endif %}
</div>
</footer>

View file

@ -2,12 +2,7 @@
<div class="brand">
<div class="brand-title">
{% if settings.site_logo %}<img src="{{ settings.site_logo.url }}" alt="{{ settings.site_name|default:'Logo' }}" class="logo" style="max-height:64px; height:auto; vertical-align:middle;">{% endif %}
<span class="site-title">Partir de zéro
{% now "n" as month %}
{% if month == '12' %}
<span class="pdz-festive-emoji" aria-hidden="true" title="Joyeuses fêtes">🎄</span>
{% endif %}
</span>
<span class="site-title">Partir de zéro</span>
</div>
<span class="subtitle comment">/* Anthony Violet */</span>
</div>
@ -45,10 +40,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></li>
<li><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>
{% endif %}
<li>
{% if user.is_authenticated %}

View file

@ -1,65 +0,0 @@
{# 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

@ -1,15 +0,0 @@
{# 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>

View file

@ -6,27 +6,12 @@
{% endblock %}
<div class="profile-details">
<h2>Mes cours</h2>
<p>Retrouvez ici la liste de tous les cours que vous suivez et votre progression.</p>
{% if progress_list %}
<ul class="profile-courses">
{% for p in progress_list %}
{% with course=p.course %}
<li>
<a href="{% url 'courses:show' course.name|slugify course.id %}">
{% if course.thumbnail %}
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
{% endif %}
<span>{{ course.name }}</span>
</a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<p class="muted">Vous ne suivez aucun cours pour le moment.</p>
{% endif %}
<p>Retrouvez ici la liste de tous les cours que vous suivez.</p>
<ul>
{% for course in user_courses %}
<li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}

View file

@ -22,27 +22,24 @@
<div class="profile-card">
<h3>Mes cours</h3>
{% with progress_list=latest_progress %}
{% if progress_list %}
<ul class="profile-courses">
{% for p in progress_list %}
{% with course=p.course %}
<li>
<a href="{% url 'courses:show' course_id=course.id course_name=course.slug %}">
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
<span>{{ course.name }}</span>
</a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li>
{% endwith %}
{% endfor %}
</ul>
<div class="text-right">
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
</div>
{% else %}
<p class="muted">Aucun cours suivi pour le moment.</p>
{% endif %}
{% with courses=user.course_set.all %}
{% if courses %}
<ul class="profile-courses">
{% for course in courses|slice:":6" %}
<li>
<a href="{% url 'courses:show' course_id=course.id course_name=course.slug %}">
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
<span>{{ course.name }}</span>
</a>
</li>
{% endfor %}
</ul>
<div class="text-right">
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
</div>
{% else %}
<p class="muted">Aucun cours suivi pour le moment.</p>
{% endif %}
{% endwith %}
</div>

View file

@ -3,7 +3,6 @@ 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
@ -82,17 +81,7 @@ def logout(request):
def profile(request):
if not hasattr(request.user, 'profile'):
return redirect('complete_profile')
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})
return render(request, 'users/profile.html')
@login_required(login_url='login')
def complete_profile(request):
@ -141,16 +130,9 @@ def account_update(request):
@login_required(login_url='login')
def my_courses(request):
# 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})
user_courses = Course.objects.filter(author=request.user.id)
print(user_courses)
return render(request, 'users/my_courses.html', {'user_courses' : user_courses})
def create_post(request):
# Implement post creation logic here