diff --git a/VERSION.txt b/VERSION.txt index 43b4937..0b51dff 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.8 (7cf0496) \ No newline at end of file +1.5.0 (2ec4a5c) \ No newline at end of file diff --git a/blog/models.py b/blog/models.py index 387d847..5d0d68c 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.urls import reverse class Post(models.Model): name = models.CharField(max_length=200) @@ -15,4 +16,7 @@ class Post(models.Model): verbose_name_plural = "Articles" def __str__(self): - return self.name \ No newline at end of file + return self.name + + def get_absolute_url(self): + return reverse('blog:post_detail', kwargs={'slug': self.slug}) \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index abf8ffc..64f9484 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,5 +4,6 @@ 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('regen_static_files', views.regen_static_files, name='regen_static_files'), + path('reload_server', views.reload_server, name='reload_server') ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 3d8b995..204f4f0 100644 --- a/core/views.py +++ b/core/views.py @@ -17,4 +17,7 @@ def clear_cache(request): 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}) \ No newline at end of file + return render(request, 'home.html', {'message': message}) + +def reload_server(request): + pass \ No newline at end of file diff --git a/courses/apps.py b/courses/apps.py index 89f1ba2..9a0a0cd 100644 --- a/courses/apps.py +++ b/courses/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class CoursesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'courses' \ No newline at end of file diff --git a/devart/settings.py b/devart/settings.py index db43f75..f2dab3b 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -55,6 +55,8 @@ INSTALLED_APPS = [ 'users', 'progression', 'blog', + + 'discord_integration.apps.DiscordIntegrationConfig', ] MIDDLEWARE = [ diff --git a/discord_integration/__init__.py b/discord_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/admin.py b/discord_integration/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/discord_integration/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/discord_integration/apps.py b/discord_integration/apps.py new file mode 100644 index 0000000..56fe46c --- /dev/null +++ b/discord_integration/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class DiscordIntegrationConfig(AppConfig): + name = 'discord_integration' + + def ready(self): + import discord_integration.signals diff --git a/discord_integration/core/BotClass.py b/discord_integration/core/BotClass.py new file mode 100644 index 0000000..ac9da47 --- /dev/null +++ b/discord_integration/core/BotClass.py @@ -0,0 +1,14 @@ +import discord +from discord import app_commands + +intents = discord.Intents.default() +intents.members = True +intents.message_content = True + +class Bot(discord.Client): + def __init__(self, *, intents: discord.Intents): + super().__init__(intents=intents) + self.tree = app_commands.CommandTree(self) + + async def setup_hook(self): + await self.tree.sync() \ No newline at end of file diff --git a/discord_integration/core/__init__.py b/discord_integration/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/core/announces_logic.py b/discord_integration/core/announces_logic.py new file mode 100644 index 0000000..6781202 --- /dev/null +++ b/discord_integration/core/announces_logic.py @@ -0,0 +1,57 @@ +from asgiref.sync import sync_to_async +import discord +from discord.ext import tasks +from discord_integration.models import DiscordNotification + +def get_pending_notifications(): + return list(DiscordNotification.objects.filter(is_announced=False)) + +def mark_as_done(notif): + notif.is_announced = True + notif.save() + +def process_notifications(): + # Cette fonction fait tout le travail SQL "interdit" en mode async + notifs = list(DiscordNotification.objects.filter(is_announced=False)) + results = [] + for n in notifs: + # Ici, on peut toucher à content_object car on est en mode "sync" ! + obj = n.content_object + if obj: + # 1. On cherche la description, sinon le contenu, sinon rien + teaser = getattr(obj, 'description', getattr(obj, 'content', "")) + + # 2. Sécurité : On coupe à 3000 caractères pour éviter les erreurs Discord + if len(teaser) > 3000: + teaser = teaser[:2997] + "..." + + results.append({ + 'notif_model': n, + 'title': getattr(obj, 'title', getattr(obj, 'name', str(obj))), + 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else "#", + 'summary': teaser + }) + + return results + +@tasks.loop(seconds=5.0) +async def check_announcements(client, channel_id): + announcements = await sync_to_async(process_notifications)() + + for data in announcements: + title = data['title'] + link = data['url'] + notif = data['notif_model'] + summary = data['summary'] + + embed = discord.Embed( + title = f"📣 Nouveau contenu : {title}", + url = f"https://partirdezero.com{link}", + description = summary, + color=discord.Color.blue() + ) + + channel_id = client.get_channel(channel_id) + if channel_id: + await channel_id.send(embed=embed) + await sync_to_async(mark_as_done)(notif) diff --git a/discord_integration/core/enums.py b/discord_integration/core/enums.py new file mode 100644 index 0000000..75c7ced --- /dev/null +++ b/discord_integration/core/enums.py @@ -0,0 +1,16 @@ +XP = { + "MESSAGE": 5 +} + +RANK = { + 1: "Nouveau membre", + 3: "Membre", + 7: "Habitué du comptoir", + 12: "Expert", + 18: "Chevalier du code", + 25: "Baron C#", + 35: "Lord Script", + 50: "Héros des architectures", + 75: "Vétéran", + 100: "Légende" +} \ No newline at end of file diff --git a/discord_integration/core/level_logic.py b/discord_integration/core/level_logic.py new file mode 100644 index 0000000..fe42f8f --- /dev/null +++ b/discord_integration/core/level_logic.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone + +from asgiref.sync import sync_to_async +import discord +from datetime import datetime +from discord_integration.models import DiscordLevel +from enums import XP, RANK +from discord import app_commands + +def get_user(id_discord): + user, created = DiscordLevel.objects.get_or_create(discord_id=id_discord) + return user, created + +def update_user_xp(user, xp_to_add): + leveled_up = False + + # 1. Mise à jour de l'XP et du temps + user.total_xp += xp_to_add + user.last_message = datetime.now(timezone.utc) + + # 2. Calcul du niveau théorique + calculated_level = int(0.5 + (0.25 + user.total_xp / 50)**0.5) + + # 3. Vérification du Level Up + if calculated_level > user.level: + user.level = calculated_level + leveled_up = True + + new_rank = RANK[1] + + for level_threshold, rank_name in RANK.items(): + if user.level >= level_threshold: + new_rank = rank_name + else: + break + + user.rank = new_rank + + user.save() + return user, leveled_up + +async def check_add_xp(message, client): + id_discord = message.author.id + username = message.author.name + + user_db, created = await sync_to_async(get_user)(id_discord) + + if not created and user_db.last_message: + delta = datetime.now(timezone.utc) - user_db.last_message + if delta.seconds < 6: + return + + user_db, leveled_up = await sync_to_async(update_user_xp)(user_db, XP["MESSAGE"]) + + if leveled_up: + # On crée un petit message sympa + await message.channel.send( + f"🎊 **LEVEL UP** 🎊\nBravo {message.author.mention}, tu passes **niveau {user_db.level}** ! " + f"On applaudit tous bien fort ! Clap Clap !!" + ) + else: + # Juste un petit log console pour toi + print(f"✨ XP ajouté pour {message.author.name} (Total: {user_db.total_xp})") + +# AJOUT DES COMMANDES /xp et /level +@app_commands.command(name="level", description="Permet de connaitre ton xp actuel") +async def get_xp(interaction: discord.Interaction): + user_id = interaction.user.id + user_db, _ = await sync_to_async(get_user)(user_id) + await interaction.response.send_message(f"{interaction.user.mention} : Tu as {user_db.total_xp} XP et tu es niveau {user_db.level} !\nTon rang est **{user_db.rank}** !") \ No newline at end of file diff --git a/discord_integration/core/main_bot.py b/discord_integration/core/main_bot.py new file mode 100644 index 0000000..6fa7d4c --- /dev/null +++ b/discord_integration/core/main_bot.py @@ -0,0 +1,73 @@ +import discord +import django +import os, sys + +import dotenv +from discord.ext import commands + +# On import django pour communiquer avec +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devart.settings') +django.setup() + +# Import des fonctions +from role_logic import check_role_reaction +from announces_logic import check_announcements +from random_phrase import get_random_phrase +from level_logic import check_add_xp, get_xp +import BotClass + +# CONFIGURATION +TOKEN = dotenv.get_key(BASE_DIR + '/.env', 'D_TOKEN') +MESSAGE_ID = 1450928822156263505 # L'ID du message des règles (clic droit > Copier l'identifiant) +ROLE_ID = 1450920002868875435 # L'ID du rôle "Membres" +ANNOUNCEMENT_CHANNEL_ID = 1450912559774306346 +EMOJI_VALIDATION = "✅" + +# LES INTENTS (PERMISSIONS DU BOT) +intents = discord.Intents.default() +intents.members = True # Important pour pouvoir donner des rôles +intents.message_content = True + +client = BotClass.Bot(intents=intents) +client.tree.add_command(get_random_phrase) +client.tree.add_command(get_xp) + +@client.event +async def on_ready(): + print(f'✅ Bot connecté : {client.user}') + + try: + synced = await client.tree.sync() + print(f"🌍 {len(synced)} commandes slash synchronisées !") + except Exception as e: + print(f"❌ Erreur de synchronisation : {e}") + + if not check_announcements.is_running(): + check_announcements.start(client, ANNOUNCEMENT_CHANNEL_ID) + +@client.event +async def on_message(message): + if message.author == client.user: + return + if message.guild is None: + author = message.author + await message.channel.send("Bonjour !\nJe suis un bot destiné à tester les nouvelles fonctionnalités de Discord. Pour le moment, je suis qu'en lecture seule.") + else: + await check_add_xp(message, client) + + +@client.event +async def on_raw_reaction_add(payload): + # On envoie tout le nécessaire à notre fonction dans role_logic.py + await check_role_reaction( + payload, + client, + MESSAGE_ID, + ROLE_ID, + EMOJI_VALIDATION + ) + +client.run(TOKEN) diff --git a/discord_integration/core/random_phrase.py b/discord_integration/core/random_phrase.py new file mode 100644 index 0000000..e8cfea2 --- /dev/null +++ b/discord_integration/core/random_phrase.py @@ -0,0 +1,32 @@ +import discord +import random + +from discord import app_commands + +phrase = [ + "Yo !", + "Quoi de neuf ?", + "Je suis occupé à compter mes octets.", + "Vive la Belgique ! 🇧🇪", + "C’est pas un bug, c’est une fonctionnalité non documentée ! 🐛", + "Est-ce qu’on peut dire que mon code est une œuvre d’art ? Non ? Dommage.", + "Je ne plante pas, je fais une pause créative.", + "Quelqu’un a vu mon point-virgule ? Il a disparu depuis le dernier commit.", + "Je mangerais bien une mitraillette sauce andalouse, mais mon système digestif est en 404. 🍟", + "42. Voilà. Maintenant, pose-moi une vraie question.", + "On mange quoi ? Ah non, c'est vrai, je suis un robot... Tristesse infinie. 🤖", + "C'est écrit en Python, donc c'est forcément élégant, non ?", + "Un petit café ? Pour moi, une petite dose d'électricité suffira.", + "Je parie que tu n'as pas encore fait ton `git push` aujourd'hui. Je te surveille ! 👀", + "En Belgique, on n'a peut-être pas toujours du soleil, mais on a les meilleures frites ! 🇧🇪🍟", + "Il y a 10 types de personnes : celles qui comprennent le binaire, et les autres.", + "Mon processeur chauffe... soit je réfléchis trop, soit ton code est trop complexe !", + "Tout va bien, tant que personne ne touche au dossier `migrations` de Django...", + "Sais-tu pourquoi les développeurs détestent la nature ? Parce qu'il y a trop de bugs. 🌳", + "On n'est pas là pour trier des lentilles, une fois ! On code ou quoi ? 🇧🇪" +] + +@app_commands.command(name="random_phrase", description="Envoi une phrase aléatoire !") +async def get_random_phrase(interaction: discord.Interaction): + choice = random.choice(phrase) + await interaction.response.send_message(choice) \ No newline at end of file diff --git a/discord_integration/core/role_logic.py b/discord_integration/core/role_logic.py new file mode 100644 index 0000000..8dd2dbc --- /dev/null +++ b/discord_integration/core/role_logic.py @@ -0,0 +1,39 @@ +import discord + + +async def check_role_reaction(payload, client, target_message_id, target_role_id, target_emoji): + # 1. On vérifie si c'est le bon message + if payload.message_id != target_message_id: + return # On ignore si ce n'est pas le bon message + + # 2. On vérifie si c'est le bon emoji + if str(payload.emoji) == target_emoji: + guild = client.get_guild(payload.guild_id) + if guild is None: + print("Erreur: Impossible de trouver le serveur (Guild is None).") + return + + member = guild.get_member(payload.user_id) + if member is None: + print("Erreur: Impossible de trouver le membre (Member is None).") + return + + role = guild.get_role(target_role_id) + if role is None: + print("Erreur : Le role n'existe pas.") + return + + try: + await member.add_roles(role) + print(f"🎉 SUCCÈS : Rôle donné à {member.name} !") + try: + await member.send("Bienvenue ! Tu as accès aux salons.") + except: + print("Note: MP bloqués par l'utilisateur.") + except discord.Forbidden: + print("⛔ ERREUR PERMISSION : Je n'ai pas le droit de donner ce rôle !") + print( + "👉 SOLUTION : Va dans Paramètres Serveur > Rôles. Glisse le rôle 'PartirDeZero Bot' AU-DESSUS du rôle 'Membres'.") + + except Exception as e: + print(f"❌ Erreur inconnue : {e}") \ No newline at end of file diff --git a/discord_integration/migrations/0001_initial.py b/discord_integration/migrations/0001_initial.py new file mode 100644 index 0000000..6c269a9 --- /dev/null +++ b/discord_integration/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0 on 2025-12-18 08:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('is_announced', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + ), + ] diff --git a/discord_integration/migrations/0002_discordlevel.py b/discord_integration/migrations/0002_discordlevel.py new file mode 100644 index 0000000..b35f4b8 --- /dev/null +++ b/discord_integration/migrations/0002_discordlevel.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0 on 2025-12-18 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discord_integration', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('discord_id', models.BigIntegerField()), + ('total_xp', models.PositiveIntegerField(default=0)), + ('level', models.PositiveIntegerField(default=1)), + ('rank', models.TextField(default='Nouveau membre')), + ('last_message', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/discord_integration/migrations/__init__.py b/discord_integration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/models.py b/discord_integration/models.py new file mode 100644 index 0000000..614a096 --- /dev/null +++ b/discord_integration/models.py @@ -0,0 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.contrib.contenttypes.models import ContentType + +class DiscordNotification(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + is_announced = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Annonces pour {self.content_object} ({'✅' if self.is_announced else '⏳'})" + +class DiscordLevel(models.Model): + discord_id = models.BigIntegerField() + total_xp = models.PositiveIntegerField(default=0) + level = models.PositiveIntegerField(default=1) + rank = models.TextField(default="Nouveau membre") + last_message = models.DateTimeField(auto_now_add=True) \ No newline at end of file diff --git a/discord_integration/signals.py b/discord_integration/signals.py new file mode 100644 index 0000000..cd5897e --- /dev/null +++ b/discord_integration/signals.py @@ -0,0 +1,18 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import DiscordNotification + +@receiver(post_save, sender="blog.Post") +def create_discord_notification_blog(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) + +@receiver(post_save, sender="courses.Course") +def create_discord_notification_course(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) + +@receiver(post_save, sender="courses.Lesson") +def create_discord_notification_lesson(sender, instance, created, **kwargs): + if created: + DiscordNotification.objects.create(content_object=instance) \ No newline at end of file diff --git a/discord_integration/tests.py b/discord_integration/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/discord_integration/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/discord_integration/views.py b/discord_integration/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/discord_integration/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/static/css/app.css b/static/css/app.css index a4ddad5..2531ab5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2672,4 +2672,22 @@ ul.flash_messages li.error { ul.flash_messages li.success { background-color: var(--success); color: var(--success-contrast); +} + +.message-warning { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--warning); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; +} + +.message-info { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--primary); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; } \ No newline at end of file diff --git a/templates/maintenance.html b/templates/maintenance.html index 52f40a9..77a3aa7 100644 --- a/templates/maintenance.html +++ b/templates/maintenance.html @@ -19,8 +19,10 @@