Compare commits
No commits in common. "master" and "1.3.7" have entirely different histories.
51 changed files with 155 additions and 1034 deletions
|
|
@ -1 +1 @@
|
|||
1.5.0 (2ec4a5c)
|
||||
1.3.6 (91f7f79)
|
||||
|
|
@ -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})
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = 'core'
|
||||
|
||||
def ready(self):
|
||||
import courses.signals
|
||||
|
|
@ -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}
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoursesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'courses'
|
||||
|
|
@ -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()}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DiscordIntegrationConfig(AppConfig):
|
||||
name = 'discord_integration'
|
||||
|
||||
def ready(self):
|
||||
import discord_integration.signals
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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}** !")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 ! 🇧🇪",
|
||||
"C’est pas un bug, c’est une fonctionnalité non documentée ! 🐛",
|
||||
"Est-ce qu’on peut dire que mon code est une œuvre d’art ? Non ? Dommage.",
|
||||
"Je ne plante pas, je fais une pause créative.",
|
||||
"Quelqu’un a vu mon point-virgule ? Il a disparu depuis le dernier commit.",
|
||||
"Je mangerais bien une mitraillette sauce andalouse, mais mon système digestif est en 404. 🍟",
|
||||
"42. Voilà. Maintenant, pose-moi une vraie question.",
|
||||
"On mange quoi ? Ah non, c'est vrai, je suis un robot... Tristesse infinie. 🤖",
|
||||
"C'est écrit en Python, donc c'est forcément élégant, non ?",
|
||||
"Un petit café ? Pour moi, une petite dose d'électricité suffira.",
|
||||
"Je parie que tu n'as pas encore fait ton `git push` aujourd'hui. Je te surveille ! 👀",
|
||||
"En Belgique, on n'a peut-être pas toujours du soleil, mais on a les meilleures frites ! 🇧🇪🍟",
|
||||
"Il y a 10 types de personnes : celles qui comprennent le binaire, et les autres.",
|
||||
"Mon processeur chauffe... soit je réfléchis trop, soit ton code est trop complexe !",
|
||||
"Tout va bien, tant que personne ne touche au dossier `migrations` de Django...",
|
||||
"Sais-tu pourquoi les développeurs détestent la nature ? Parce qu'il y a trop de bugs. 🌳",
|
||||
"On n'est pas là pour trier des lentilles, une fois ! On code ou quoi ? 🇧🇪"
|
||||
]
|
||||
|
||||
@app_commands.command(name="random_phrase", description="Envoi une phrase aléatoire !")
|
||||
async def get_random_phrase(interaction: discord.Interaction):
|
||||
choice = random.choice(phrase)
|
||||
await interaction.response.send_message(choice)
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
|
@ -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'),
|
||||
]
|
||||
145
home/views.py
145
home/views.py
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
});
|
||||
|
|
@ -4,14 +4,32 @@
|
|||
{% block title %}- Stats · Graphiques{% 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>
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Graphiques statistiques</h1>
|
||||
|
||||
{% include "partials/_stats_toolbar.html" %}
|
||||
<form method="get" class="toolbar">
|
||||
<div>
|
||||
<label for="p">Période: </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>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
|
|
|
|||
|
|
@ -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: </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 %}
|
||||
|
|
|
|||
|
|
@ -84,9 +84,6 @@
|
|||
<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 -->
|
||||
|
|
@ -111,6 +108,5 @@
|
|||
{% block footer %}
|
||||
{% include "partials/_footer.html" %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -29,7 +29,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>
|
||||
|
|
|
|||
|
|
@ -45,10 +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></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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{# Partials: Shared period toolbar for stats pages #}
|
||||
<form method="get" class="toolbar">
|
||||
<div>
|
||||
<label for="p">Période: </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>
|
||||
|
|
@ -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 %}
|
||||
<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>
|
||||
{% else %}
|
||||
<p class="muted">Vous ne suivez aucun cours pour le moment.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -22,19 +22,16 @@
|
|||
|
||||
<div class="profile-card">
|
||||
<h3>Mes cours</h3>
|
||||
{% with progress_list=latest_progress %}
|
||||
{% if progress_list %}
|
||||
{% with courses=user.course_set.all %}
|
||||
{% if courses %}
|
||||
<ul class="profile-courses">
|
||||
{% for p in progress_list %}
|
||||
{% with course=p.course %}
|
||||
{% 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>
|
||||
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="text-right">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue