Compare commits

..

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

88 changed files with 100 additions and 2733 deletions

View file

@ -1 +1 @@
1.5.0 (2ec4a5c) 1.0.2 (8668690)

View file

View file

@ -1,8 +0,0 @@
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ('name', 'tags', 'slug', 'created_at')
search_fields = ('name', 'tags')
prepopulated_fields = {"slug": ("name",)}

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

View file

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

View file

@ -1,30 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('tags', models.CharField(max_length=200)),
('slug', models.SlugField()),
('content', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Article',
'verbose_name_plural': 'Articles',
},
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 19:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='enable',
field=models.BooleanField(default=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 20:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_post_enable'),
]
operations = [
migrations.AddField(
model_name='post',
name='description',
field=models.TextField(default='Courte description'),
),
]

View file

@ -1,22 +0,0 @@
from django.db import models
from django.urls import reverse
class Post(models.Model):
name = models.CharField(max_length=200)
tags = models.CharField(max_length=200)
slug = models.SlugField()
description = models.TextField(default="Courte description")
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
enable = models.BooleanField(default=True)
class Meta:
verbose_name = "Article"
verbose_name_plural = "Articles"
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})

View file

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

View file

@ -1,8 +0,0 @@
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.blog_home, name='blog'),
path('<str:slug>/', views.blog_view_post, name='post_detail'),
]

View file

@ -1,11 +0,0 @@
from django.shortcuts import render
from blog.models import Post
def blog_home(request):
return render(request, 'blog/home.html')
def blog_view_post(request, slug):
post = Post.objects.get(slug=slug)
return render(request, 'blog/details.html', {'post': post})

View file

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import SiteSettings, Visit, Maintenance from .models import SiteSettings
@admin.register(SiteSettings) @admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin): class SiteSettingsAdmin(admin.ModelAdmin):
@ -14,7 +14,7 @@ class SiteSettingsAdmin(admin.ModelAdmin):
# Petite astuce visuelle pour l'admin # Petite astuce visuelle pour l'admin
fieldsets = ( fieldsets = (
('Général', { ('Général', {
'fields': ('site_name', 'site_logo', 'receive_emails_active') 'fields': ('site_name', 'site_logo')
}), }),
('Réseaux Sociaux', { ('Réseaux Sociaux', {
'fields': ('facebook_url', 'twitter_url', 'youtube_url'), 'fields': ('facebook_url', 'twitter_url', 'youtube_url'),
@ -23,18 +23,4 @@ class SiteSettingsAdmin(admin.ModelAdmin):
('Contact', { ('Contact', {
'fields': ('contact_email',) 'fields': ('contact_email',)
}), }),
('Blog', {
'fields': ('blog_title', 'blog_description')
}),
) )
@admin.register(Maintenance)
class MaintenanceAdmin(admin.ModelAdmin):
list_display = ("name","start_date", "end_date")
@admin.register(Visit)
class VisitAdmin(admin.ModelAdmin):
list_display = ("date", "visitor_id", "user", "source", "country", "first_seen", "last_seen")
list_filter = ("date", "country", "source")
search_fields = ("visitor_id", "referrer", "utm_source", "utm_medium", "utm_campaign")

View file

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

View file

@ -1,14 +1,5 @@
from django.utils.timesince import timesince from .models import SiteSettings
from .models import SiteSettings, Maintenance
def site_settings(request): def site_settings(request):
# On récupère le premier objet, ou None s'il n'existe pas encore # On récupère le premier objet, ou None s'il n'existe pas encore
return {'settings': SiteSettings.objects.first()} 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

@ -1,121 +0,0 @@
import uuid
from urllib.parse import urlparse
from django.utils import timezone
from .models import Visit
class VisitTrackingMiddleware:
"""Middleware très léger pour enregistrer des statistiques de visites.
- Assigne un cookie visiteur persistant (vid) si absent
- Enregistre/Met à jour une ligne Visit par visiteur et par jour
- Capture la source (UTM/referrer) et le pays si disponible via headers
"""
COOKIE_NAME = 'vid'
COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2 # 2 ans
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
vid = request.COOKIES.get(self.COOKIE_NAME)
if not vid:
vid = uuid.uuid4().hex
request.visitor_id = vid
# Enregistrer la visite (agrégée par jour)
try:
self._track(request, vid)
except Exception:
# On ne casse jamais la requête pour des stats
pass
response = self.get_response(request)
# S'assurer que le cookie est posé
if request.COOKIES.get(self.COOKIE_NAME) != vid:
response.set_cookie(
self.COOKIE_NAME,
vid,
max_age=self.COOKIE_MAX_AGE,
httponly=True,
samesite='Lax',
)
return response
def _track(self, request, vid):
# On ignore l'admin et les assets statiques
path = request.path
if path.startswith('/admin') or path.startswith('/static') or path.startswith('/staticfiles'):
return
now = timezone.now()
date = now.date()
ref = request.META.get('HTTP_REFERER', '')[:512]
utm_source = request.GET.get('utm_source', '')[:100]
utm_medium = request.GET.get('utm_medium', '')[:100]
utm_campaign = request.GET.get('utm_campaign', '')[:150]
# Déterminer source
source = ''
if utm_source:
source = utm_source
elif ref:
try:
netloc = urlparse(ref).netloc
source = netloc
except Exception:
source = ref[:150]
# Déterminer pays via en-têtes si fournis par proxy/CDN
country = (
request.META.get('HTTP_CF_IPCOUNTRY')
or request.META.get('HTTP_X_APPENGINE_COUNTRY')
or request.META.get('HTTP_X_COUNTRY')
or ''
)[:64]
became_user_at = now if request.user.is_authenticated else None
visit, created = Visit.objects.get_or_create(
visitor_id=vid,
date=date,
defaults={
'path': path[:512],
'referrer': ref,
'utm_source': utm_source,
'utm_medium': utm_medium,
'utm_campaign': utm_campaign,
'source': source,
'country': country,
'first_seen': now,
'last_seen': now,
'user': request.user if request.user.is_authenticated else None,
'became_user_at': became_user_at,
}
)
if not created:
# Mise à jour basique
dirty = False
if not visit.source and source:
visit.source = source
dirty = True
if not visit.country and country:
visit.country = country
dirty = True
if request.user.is_authenticated and visit.user_id is None:
visit.user = request.user
dirty = True
# 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'])

View file

@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 19:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='blog_description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='sitesettings',
name='blog_title',
field=models.CharField(default='Mon Blog', max_length=200),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 19:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_sitesettings_blog_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='sitesettings',
name='blog_description',
field=models.TextField(blank=True, default='Je documente la construction de PartirDeZero.com : mes choix techniques, mes bugs résolus et mes conseils pour lancer tes propres projets web. Apprends en regardant faire.'),
),
migrations.AlterField(
model_name='sitesettings',
name='blog_title',
field=models.CharField(default='Blog du développeur', max_length=200),
),
]

View file

@ -1,41 +0,0 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_sitesettings_blog_description_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Visit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visitor_id', models.CharField(db_index=True, max_length=64)),
('date', models.DateField(db_index=True)),
('first_seen', models.DateTimeField(default=django.utils.timezone.now)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('path', models.CharField(blank=True, max_length=512)),
('referrer', models.CharField(blank=True, max_length=512)),
('utm_source', models.CharField(blank=True, max_length=100)),
('utm_medium', models.CharField(blank=True, max_length=100)),
('utm_campaign', models.CharField(blank=True, max_length=150)),
('source', models.CharField(blank=True, help_text='Domaine de provenance ou utm_source', max_length=150)),
('country', models.CharField(blank=True, max_length=64)),
('became_user_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='visits', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-date', '-last_seen'],
},
),
migrations.AlterUniqueTogether(
name='visit',
unique_together={('visitor_id', 'date')},
),
]

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

@ -1,12 +1,9 @@
from django.db import models from django.db import models
from django.conf import settings
from django.utils import timezone
class SiteSettings(models.Model): class SiteSettings(models.Model):
site_name = models.CharField(max_length=200, default="Mon Super Site") site_name = models.CharField(max_length=200, default="Mon Super Site")
site_logo = models.ImageField(upload_to='settings/', blank=True) site_logo = models.ImageField(upload_to='settings/', blank=True)
contact_email = models.EmailField(blank=True) contact_email = models.EmailField(blank=True)
receive_emails_active = models.BooleanField(default=True)
# Réseaux sociaux # Réseaux sociaux
facebook_url = models.URLField(blank=True) facebook_url = models.URLField(blank=True)
@ -16,10 +13,6 @@ class SiteSettings(models.Model):
linkedin_url = models.URLField(blank=True) linkedin_url = models.URLField(blank=True)
github_url = models.URLField(blank=True) github_url = models.URLField(blank=True)
# Blog
blog_title = models.CharField(max_length=200, default="Blog du développeur")
blog_description = models.TextField(blank=True, default="Je documente la construction de PartirDeZero.com : mes choix techniques, mes bugs résolus et mes conseils pour lancer tes propres projets web. Apprends en regardant faire.")
# L'astuce pour qu'il n'y ait qu'un seul réglage # L'astuce pour qu'il n'y ait qu'un seul réglage
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.pk = 1 # On force l'ID à 1. Si tu sauvegardes, ça écrase l'existant. self.pk = 1 # On force l'ID à 1. Si tu sauvegardes, ça écrase l'existant.
@ -34,41 +27,3 @@ class SiteSettings(models.Model):
class Meta: class Meta:
verbose_name = "Réglages du site" verbose_name = "Réglages du site"
verbose_name_plural = "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).
Objectif: fournir des stats de base sans dépendances externes.
"""
visitor_id = models.CharField(max_length=64, db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='visits'
)
date = models.DateField(db_index=True)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now)
path = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=512, blank=True)
utm_source = models.CharField(max_length=100, blank=True)
utm_medium = models.CharField(max_length=100, blank=True)
utm_campaign = models.CharField(max_length=150, blank=True)
source = models.CharField(max_length=150, blank=True, help_text="Domaine de provenance ou utm_source")
country = models.CharField(max_length=64, blank=True)
# Conversion: première fois où un visiteur devient utilisateur authentifié
became_user_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('visitor_id', 'date')
ordering = ['-date', '-last_seen']
def __str__(self):
return f"{self.visitor_id} @ {self.date}"

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.shortcuts import render
from django.core.management import call_command
from django.core.cache import cache
import subprocess
def update_database(request): # Create your views here.
call_command('makemigrations')
call_command('migrate')
message = "La base de données à bien été mise à jour !"
return render(request, 'home.html', {'message': message})
def clear_cache(request):
cache.clear()
message = "Le cache à bien été effacé !"
return render(request, 'home.html', {'message': message})
def regen_static_files(request):
call_command('collectstatic', '--noinput')
message = "Les fichiers statics ont bien été générés !"
return render(request, 'home.html', {'message': message})
def reload_server(request):
pass

View file

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

View file

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

View file

@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 14:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('courses', '0003_comment_parent'),
]
operations = [
migrations.AlterModelOptions(
name='course',
options={'verbose_name': 'Cours', 'verbose_name_plural': 'Cours'},
),
migrations.AlterModelOptions(
name='lesson',
options={'verbose_name': 'Leçon', 'verbose_name_plural': 'Leçons'},
),
]

View file

@ -12,16 +12,9 @@ class Course(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
enable = models.BooleanField(default=True) enable = models.BooleanField(default=True)
class Meta:
verbose_name = "Cours"
verbose_name_plural = "Cours"
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return f"/courses/{self.slug}-{self.id}/"
class Module(models.Model): class Module(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
slug = models.SlugField() slug = models.SlugField()
@ -32,9 +25,6 @@ class Module(models.Model):
enable = models.BooleanField(default=True) enable = models.BooleanField(default=True)
order = models.PositiveIntegerField() order = models.PositiveIntegerField()
def __str__(self):
return self.name
class Lesson(models.Model): class Lesson(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
slug = models.SlugField() slug = models.SlugField()
@ -44,10 +34,6 @@ class Lesson(models.Model):
is_premium = models.BooleanField(default=False) is_premium = models.BooleanField(default=False)
order = models.PositiveIntegerField() order = models.PositiveIntegerField()
class Meta:
verbose_name = "Leçon"
verbose_name_plural = "Leçons"
def clean(self): def clean(self):
# Remplacer les chevrons <?php et ?> par leurs équivalents HTML # Remplacer les chevrons <?php et ?> par leurs équivalents HTML
if self.content: if self.content:
@ -57,9 +43,6 @@ class Lesson(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return f"/courses/{self.module.course.slug}/{self.module.slug}/{self.slug}/"
class Comment(models.Model): class Comment(models.Model):
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments') lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')

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

@ -27,19 +27,6 @@ def _format_inline(text: str) -> str:
lambda m: f"<code class=\"comment-code\">{m.group(1)}</code>", lambda m: f"<code class=\"comment-code\">{m.group(1)}</code>",
text, text,
) )
# H1
text = re.sub(r"^# (.+)$", r"<h1>\1</h1>", text, flags=re.MULTILINE)
# H2
text = re.sub(r"^## (.+)$", r"<h2>\1</h2>", text, flags=re.MULTILINE)
# H3
text = re.sub(r"^### (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
# H4
text = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", text, flags=re.MULTILINE)
# H5
text = re.sub(r"^##### (.+)$", r"<h5>\1</h5>", text, flags=re.MULTILINE)
# H6
text = re.sub(r"^###### (.+)$", r"<h6>\1</h6>", text, flags=re.MULTILINE)
# bold **text** # bold **text**
text = re.sub( text = re.sub(
r"\*\*([^*]+)\*\*", r"\*\*([^*]+)\*\*",

View file

@ -3,7 +3,6 @@ from django.urls import reverse
from django.views import generic from django.views import generic
from django.db.models import Prefetch from django.db.models import Prefetch
from .models import Course, Lesson, Module, Comment from .models import Course, Lesson, Module, Comment
from progression.models import Progression
from .forms import CommentForm from .forms import CommentForm
def list_courses(request): def list_courses(request):
@ -24,23 +23,9 @@ def show(request, course_name, course_id):
.order_by('order') .order_by('order')
) )
# Récupération de la progression de l'utilisateur sur le cours
user_progress = None
completed_lesson_ids = [] # On prépare une liste vide par défaut
if request.user.is_authenticated:
user_progress = Progression.objects.filter(user=request.user, course=course).first()
# 2. S'il y a une progression, on extrait juste les IDs des leçons finies
if user_progress:
# values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds
completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True)
context = { context = {
'course': course, 'course': course,
'lessons': lessons, 'lessons': lessons,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
} }
return render(request, 'courses/show.html', context) return render(request, 'courses/show.html', context)
@ -120,18 +105,6 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
.order_by('created_at') .order_by('created_at')
) )
# Récupération de la progression de l'utilisateur sur le cours
user_progress = None
completed_lesson_ids = [] # On prépare une liste vide par défaut
if request.user.is_authenticated:
user_progress = Progression.objects.filter(user=request.user, course=course).first()
# 2. S'il y a une progression, on extrait juste les IDs des leçons finies
if user_progress:
# values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds
completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True)
context = { context = {
'course': course, 'course': course,
'module': module, 'module': module,
@ -141,7 +114,5 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
'comment_form': form, 'comment_form': form,
'prev_lesson': prev_lesson, 'prev_lesson': prev_lesson,
'next_lesson': next_lesson, 'next_lesson': next_lesson,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
} }
return render(request, 'courses/lesson.html', context) return render(request, 'courses/lesson.html', context)

View file

@ -12,8 +12,6 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path from pathlib import Path
import os import os
import dotenv
from dotenv import load_dotenv from dotenv import load_dotenv
import devart.context_processor import devart.context_processor
@ -48,15 +46,10 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sitemaps',
'core', 'core',
'courses', 'courses',
'users', 'users',
'progression',
'blog',
'discord_integration.apps.DiscordIntegrationConfig',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -68,7 +61,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.VisitTrackingMiddleware',
] ]
ROOT_URLCONF = 'devart.urls' ROOT_URLCONF = 'devart.urls'
@ -86,9 +79,7 @@ TEMPLATES = [
'devart.context_processor.app_version', 'devart.context_processor.app_version',
'core.context_processor.site_settings', 'core.context_processor.site_settings',
'core.context_processor.site_maintenance',
'courses.context_processors.course_list', 'courses.context_processors.course_list',
'blog.context_processor.posts_list',
], ],
}, },
}, },
@ -208,11 +199,3 @@ def get_git_version():
return "Version inconnue (Fichier manquant)" return "Version inconnue (Fichier manquant)"
GIT_VERSION = get_git_version() GIT_VERSION = get_git_version()
EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND')
EMAIL_HOST = dotenv.get_key('.env', 'EMAIL_HOST')
EMAIL_PORT = dotenv.get_key('.env', 'EMAIL_PORT')
EMAIL_USE_TLS = dotenv.get_key('.env', 'EMAIL_USE_TLS') == 'True'
EMAIL_HOST_USER = dotenv.get_key('.env', 'EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = dotenv.get_key('.env', 'EMAIL_HOST_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

View file

@ -1,38 +0,0 @@
from django.contrib import sitemaps
from django.urls import reverse
# --- FEATURES ---
from courses.models import Course
from users.models import Profile
from blog.models import Post
# --- SITEMAP : LES Cours ---
class CourseSitemap(sitemaps.Sitemap):
changefreq = "weekly"
priority = 0.9
def items(self):
return Course.objects.filter(enable=True).order_by('id')
def location(self, item):
# Assure-toi que ton modèle Course a bien une méthode get_absolute_url
return item.get_absolute_url()
# --- SITEMAP : BLOG ---
class BlogSitemap(sitemaps.Sitemap):
changefreq = "weekly"
priority = 0.8
def location(self, item):
return item.get_absolute_url()
# --- SITEMAP : PAGES STATIQUES ---
class StaticViewSitemap(sitemaps.Sitemap):
priority = 0.5
changefreq = "monthly"
def items(self):
return ["home"] # Les noms de tes URLs
def location(self, item):
return ""

View file

@ -18,38 +18,12 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.http import HttpResponse
from devart.sitemap import CourseSitemap, StaticViewSitemap
from django.contrib.sitemaps.views import sitemap
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
]
return HttpResponse("\n".join(lines), content_type="text/plain")
sitemaps_dict = {
'cours': CourseSitemap,
'static': StaticViewSitemap,
}
urlpatterns = [ urlpatterns = [
path('core/', include('core.urls')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', include('home.urls')), path('', include('home.urls')),
path('courses/', include('courses.urls')), path('courses/', include('courses.urls')),
path('users/', include('users.urls')), path('users/', include('users.urls')),
path('progression/', include('progression.urls')),
path('blog/', include('blog.urls')),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'),
path('robots.txt', robots_txt),
] ]
if settings.DEBUG: if settings.DEBUG:

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

@ -5,8 +5,4 @@ app_name = 'home'
urlpatterns = [ urlpatterns = [
path('', views.home, name='home'), path('', views.home, name='home'),
path('premium/<int:course_id>', views.premium, name='premium'), path('premium/<int:course_id>', views.premium, name='premium'),
# Tableau de bord statistiques (réservé superadministrateurs)
path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'),
path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'),
path('dashboard/stats/live-activity/', views.live_activity, name='live_activity'),
] ]

View file

@ -1,58 +1,5 @@
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import user_passes_test from courses.models import Course
from django.utils import timezone
from django.db.models import Count
from core.models import Visit
from django.views.decorators.cache import cache_page
from django.contrib.auth.models import User
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): def home(request):
courses = Course.objects.order_by('-created_at')[:6] courses = Course.objects.order_by('-created_at')[:6]
@ -62,295 +9,3 @@ def premium(request, course_id):
"""Landing page présentant les avantages du Premium.""" """Landing page présentant les avantages du Premium."""
course = get_object_or_404(Course, pk=course_id) course = get_object_or_404(Course, pk=course_id)
return render(request, 'premium.html', {'course': course}) return render(request, 'premium.html', {'course': course})
# 15 minutes de cache sur la page complète (sauf si décochée plus tard pour des blocs en direct)
@user_passes_test(lambda u: u.is_superuser)
@cache_page(60 * 15)
def stats_dashboard(request):
""" Tableau de bord statistiques réservé aux superadministrateurs.
Périodes supportées: 7, 30, 90, 180 jours (GET param 'p'). """
# 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
)
# 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)
# Séries quotidiennes nouveaux utilisateurs
new_users_by_day = (
new_users_qs
.extra(select={'day': "date(date_joined)"})
.values('day')
.annotate(c=Count('id'))
)
# 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)
.values('user').distinct()
)
active_users_count = active_users_qs.count()
# Cours
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)
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
# Leçons
total_lessons = Lesson.objects.count()
# Achèvements de leçons (via table de liaison M2M)
through = Progression.completed_lessons.through
lesson_completions_by_day = (
through.objects.filter(
progression__updated_at__date__gte=start_dt.date(),
progression__updated_at__date__lte=now.date(),
)
.extra(select={'day': "date(progression_updated_at)"}) if 'progression_updated_at' in [f.name for f in through._meta.fields]
else through.objects.extra(select={'day': "date(created_at)"}) # fallback si champs créé n'existe pas
)
# 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)
.extra(select={'day': "date(updated_at)"})
.values('day').annotate(c=Count('id'))
)
# 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)
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
# Revenus/Paiements & Technique (placeholders faute de sources)
revenus_disponibles = False
technique_disponible = False
# Visites / Trafic
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
unique_visitors = period_visits.values('visitor_id').distinct().count()
earlier_visitors_qs = Visit.objects.filter(date__lt=period_start_date).values('visitor_id').distinct()
returning_visitors = period_visits.filter(visitor_id__in=earlier_visitors_qs).values('visitor_id').distinct().count()
converted_visitors = (
period_visits
.filter(became_user_at__isnull=False, became_user_at__date__gte=period_start_date, became_user_at__date__lte=period_end_date)
.values('visitor_id').distinct().count()
)
top_sources_qs = (
period_visits
.values('source')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_countries_qs = (
period_visits
.exclude(country='')
.values('country')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_sources_table = [(row['source'] or 'Direct/Unknown', row['c']) for row in top_sources_qs[:10]]
top_countries_table = [(row['country'], row['c']) for row in top_countries_qs[:10]]
# 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)
days_users, values_new_users = build_series_dict(new_users_by_day)
days_courses, values_new_courses = build_series_dict(new_courses_by_day)
days_posts, values_new_posts = build_series_dict(new_posts_by_day)
days_activity, values_activity = build_series_dict(progress_activity_by_day)
# Tables simples (jour, valeur)
new_users_table = list(zip(days_users, values_new_users))
new_courses_table = list(zip(days_courses, values_new_courses))
context = {
'period_options': period_options,
'p': p,
'start_date': period_start_date,
'end_date': period_end_date,
# KPI
'kpi': {
'total_users': total_users,
'new_users_period': sum(values_new_users),
'active_users_period': active_users_count,
'unique_visitors': unique_visitors,
'returning_visitors': returning_visitors,
'converted_visitors': converted_visitors,
'total_courses': total_courses,
'courses_enabled': total_courses_enabled,
'total_lessons': total_lessons,
'total_posts': total_posts,
},
# Séries pour graphiques
'series': {
'days': days_users, # mêmes intervalles pour tous
'new_users': values_new_users,
'new_courses': values_new_courses,
'new_posts': values_new_posts,
'activity_progress': values_activity,
},
# Disponibilité des sections
'revenus_disponibles': revenus_disponibles,
'technique_disponible': technique_disponible,
# Tables
'new_users_table': new_users_table,
'new_courses_table': new_courses_table,
'top_sources_table': top_sources_table,
'top_countries_table': top_countries_table,
}
# Sérialisation JSON pour Chart.js
context['series_json'] = json.dumps(context['series'])
context['labels_json'] = json.dumps(context['series']['days'])
return render(request, 'home/stats_dashboard.html', context)
@user_passes_test(lambda u: u.is_superuser)
@cache_page(60 * 15)
def stats_charts(request):
"""Page dédiée aux graphiques (réservée superadmins)."""
# Période (utilise les mêmes helpers que le dashboard pour harmonisation)
period_options = [7, 30, 90, 180]
p, _now, _start_dt, period_start_date, period_end_date = _parse_period(
request, default=30, options=period_options
)
# Trafic par jour (visiteurs uniques)
visits_qs = (
Visit.objects
.filter(date__gte=period_start_date, date__lte=period_end_date)
.values('date')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('date')
)
# Conversions par jour (visiteurs devenus utilisateurs)
conversions_qs = (
Visit.objects
.filter(
became_user_at__isnull=False,
became_user_at__date__gte=period_start_date,
became_user_at__date__lte=period_end_date,
)
.extra(select={'day': "date(became_user_at)"})
.values('day')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('day')
)
def build_series_dict(qs, date_key='date', count_key='c'):
counts = {str(item[date_key]): item[count_key] for item in qs}
days = []
values = []
d = period_start_date
while d <= period_end_date:
key = str(d)
days.append(key)
values.append(counts.get(key, 0))
d += timezone.timedelta(days=1)
return days, values
labels, visitors_series = _build_series_for_range(period_start_date, period_end_date, visits_qs, date_key='date')
_, conversions_series = _build_series_for_range(period_start_date, period_end_date, conversions_qs, date_key='day')
# Sources & Pays (sur la période)
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
top_sources_qs = (
period_visits
.values('source')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')[:10]
)
top_countries_qs = (
period_visits
.exclude(country='')
.values('country')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')[:10]
)
sources_labels = [(row['source'] or 'Direct/Unknown') for row in top_sources_qs]
sources_values = [row['c'] for row in top_sources_qs]
countries_labels = [row['country'] for row in top_countries_qs]
countries_values = [row['c'] for row in top_countries_qs]
context = {
'period_options': period_options,
'p': p,
'start_date': period_start_date,
'end_date': period_end_date,
'labels_json': json.dumps(labels),
'visitors_series_json': json.dumps(visitors_series),
'conversions_series_json': json.dumps(conversions_series),
'sources_labels_json': json.dumps(sources_labels),
'sources_values_json': json.dumps(sources_values),
'countries_labels_json': json.dumps(countries_labels),
'countries_values_json': json.dumps(countries_values),
}
return render(request, 'home/stats_charts.html', context)
@user_passes_test(lambda u: u.is_superuser)
def live_activity(request):
"""Retourne en JSON l'activité récente (5 dernières minutes):
visiteurs et utilisateurs et leur page actuelle.
"""
now = timezone.now()
since = now - timezone.timedelta(minutes=5)
qs = (
Visit.objects
.filter(last_seen__gte=since)
.order_by('-last_seen')
)
data = []
for v in qs[:200]:
username = None
is_user = False
if v.user_id:
is_user = True
# safe access if user deleted
try:
username = v.user.username
except Exception:
username = 'Utilisateur'
visitor_label = v.visitor_id[:8]
seconds_ago = int((now - v.last_seen).total_seconds())
data.append({
'visitor': visitor_label,
'is_user': is_user,
'username': username,
'path': v.path,
'last_seen': v.last_seen.isoformat(),
'seconds_ago': seconds_ago,
'date': str(v.date),
'country': v.country,
'source': v.source,
})
return JsonResponse({'now': now.isoformat(), 'items': data})

View file

@ -1,15 +0,0 @@
from django.contrib import admin
from .models import Progression
from courses.models import Course, Lesson
@admin.register(Progression)
class ProgressionAdmin(admin.ModelAdmin):
list_display = ('user', 'course', 'get_percent', 'updated_at')
list_filter = ('course', 'updated_at')
search_fields = ('user__username', 'course__name')
autocomplete_fields = ['course', 'completed_lessons']
def get_percent(self, obj):
return f"{obj.percent_completed}"
get_percent.short_description = 'Progression'

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class ProgressionConfig(AppConfig):
name = 'progression'

View file

@ -1,34 +0,0 @@
# Generated by Django 6.0 on 2025-12-15 14:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('courses', '0003_comment_parent'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Progression',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('completed_lessons', models.ManyToManyField(blank=True, related_name='completed_by', to='courses.lesson')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_progress', to='courses.course')),
('last_viewed_lesson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='last_viewed_by', to='courses.lesson')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Progression du cours',
'unique_together': {('user', 'course')},
},
),
]

View file

@ -1,28 +0,0 @@
from django.db import models
from django.conf import settings
class Progression(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='progress')
course = models.ForeignKey('courses.Course', on_delete=models.CASCADE, related_name='user_progress')
completed_lessons = models.ManyToManyField('courses.Lesson', blank=True, related_name='completed_by')
last_viewed_lesson = models.ForeignKey('courses.Lesson', on_delete=models.SET_NULL, null=True, blank=True, related_name='last_viewed_by')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'course')
verbose_name = "Progression du cours"
def __str__(self):
return f"{self.user} - {self.course.name}"
@property
def percent_completed(self):
from courses.models import Lesson
total_lessons = Lesson.objects.filter(module__course=self.course).count()
if total_lessons == 0:
return 0
completed_lessons = self.completed_lessons.count()
return int((completed_lessons / total_lessons) * 100)

View file

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

View file

@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = 'progression'
urlpatterns = [
# On ne garde QUE la route AJAX ici.
# Les routes 'list', 'detail', etc. doivent rester dans courses/urls.py
path('ajax/toggle-lesson/', views.toggle_lesson_completion, name='toggle_lesson'),
]

View file

@ -1,41 +0,0 @@
from django.shortcuts import render, get_object_or_404
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from progression.models import Progression
from courses.models import Lesson
@login_required
@require_POST
def toggle_lesson_completion(request):
data = json.loads(request.body)
lesson_id = data.get('lesson_id')
lesson = get_object_or_404(Lesson, id=lesson_id)
# On remonte au cours via le module (Lesson -> Module -> Course)
course = lesson.module.course
# On récupère ou crée la progression
progression, created = Progression.objects.get_or_create(
user=request.user,
course=course
)
# La logique du Toggle
if lesson in progression.completed_lessons.all():
progression.completed_lessons.remove(lesson)
is_completed = False
else:
progression.completed_lessons.add(lesson)
is_completed = True
# Mise à jour de la dernière leçon vue
progression.last_viewed_lesson = lesson
progression.save()
return JsonResponse({
'status': 'success',
'is_completed': is_completed,
'new_percent': progression.percent_completed
})

View file

@ -247,43 +247,9 @@ html { box-sizing: border-box; }
gap: 6px; gap: 6px;
padding: 2px 8px; padding: 2px 8px;
margin-left: 8px; margin-left: 8px;
border-radius: 2px; border-radius: 999px;
background: var(--accent); background: var(--accent);
color: var(--text); color: var(--warning-contrast);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
line-height: 1.2;
vertical-align: middle;
text-transform: uppercase;
white-space: nowrap;
}
.current-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 2px;
background: var(--primary);
color: var(--primary-contrast);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
line-height: 1.2;
vertical-align: middle;
text-transform: uppercase;
white-space: nowrap;
}
.completed-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 2px;
background: var(--success);
color: var(--success-contrast);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
letter-spacing: .3px; letter-spacing: .3px;
@ -303,70 +269,6 @@ html { box-sizing: border-box; }
font-weight: 500; font-weight: 500;
} }
/* Progression des cours */
.progress-container {
display: flex;
flex-direction: row;
}
.progress-bar {
display: flex;
border-radius: var(--r-2);
border: 1px solid var(--border);
height: 20px;
background: var(--neutral-900);
}
.progress-text {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--text);
font-size: 12px;
margin-left: 10px;
}
.progress-bar-fill {
background: var(--success);
height: 100%;
border-radius: var(--r-2) 0 0 var(--r-2);
}
.course-completed {
display: flex;
flex-direction: row;
border-radius: var(--r-2);
border: 1px solid var(--border);
margin: var(--space-2) 0;
background: var(--success);
}
.course-completed .container {
display: flex;
flex-direction: column;
padding: var(--space-2);
height: 100%;
vertical-align: middle;
}
.course-completed .container .icon {
color: var(--success-contrast);
font-size: 50px;
}
.course-completed .container .title {
color: var(--success-contrast);
font-size: 28px;
font-weight: 600;
}
.course-completed .container .content {
color: var(--success-contrast);
font-size: 14px;
}
[data-theme='light'] { [data-theme='light'] {
/* Palette: plus nuancé, moins "blanc" */ /* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */ --bg: #eef3f7; /* fond légèrement teinté bleu-gris */
@ -849,100 +751,6 @@ img {
height: auto; height: auto;
} }
/* ======================================
Blog components
====================================== */
/* Accessibilité: élément visuellement masqué mais accessible aux lecteurs d'écran */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Blog layout wrappers */
.blog.blog-home .blog-header {
display: grid;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.blog-title {
margin: 0;
}
.blog-description {
color: var(--text-muted);
font-size: 1.05rem;
}
/* Post list grid */
.post-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-5);
}
.post-card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--r-3);
box-shadow: var(--shadow-1);
padding: var(--space-5);
display: grid;
gap: var(--space-3);
transition: transform var(--transition-1), box-shadow var(--transition-1), border-color var(--transition-1);
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2);
border-color: var(--border-strong);
}
.post-card-title {
margin: 0;
}
.post-card-title a { color: var(--fg); text-decoration: none; }
.post-card-title a:hover { color: var(--link-hover); text-decoration: underline; }
.post-excerpt { color: var(--text); opacity: 0.95; }
.post-actions { margin-top: 2px; }
/* Post meta (date, tags) */
.post-meta { color: var(--text-muted); font-size: 0.95rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.post-meta i { color: var(--muted); margin-right: 6px; }
.post-meta .sep { color: var(--muted); }
/* Post detail */
.post-detail .post-header { margin-bottom: var(--space-5); }
.post-detail .post-title { margin-bottom: var(--space-2); }
/* Prose content: typographic rhythm inside articles */
.prose {
line-height: 1.75;
color: var(--text);
}
.prose :where(p, ul, ol, blockquote, pre, table, img) { margin: 0 0 var(--space-4); }
.prose a { color: var(--link); }
.prose a:hover { color: var(--link-hover); text-decoration: underline; }
.prose blockquote {
margin-left: 0;
padding-left: var(--space-4);
border-left: 3px solid var(--border-strong);
color: var(--text-muted);
font-style: italic;
}
.prose code { font-family: var(--font-mono); background: var(--neutral-300); padding: 0 4px; border-radius: var(--r-1); }
.prose pre {
background: var(--code-bg);
color: var(--code-text);
padding: var(--space-4);
border-radius: var(--r-2);
overflow: auto;
}
/* Make embedded media responsive */ /* Make embedded media responsive */
iframe, video { max-width: 100%; height: auto; } iframe, video { max-width: 100%; height: auto; }
@ -2330,16 +2138,6 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
filter: brightness(1.05); 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 */ /* Tailles */
.btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; } .btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; }
.btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; } .btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; }
@ -2673,21 +2471,3 @@ ul.flash_messages li.success {
background-color: var(--success); background-color: var(--success);
color: var(--success-contrast); 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) {} } 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); let buttonChange = document.getElementById(id);
buttonChange.onclick = function() { hide(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,25 +0,0 @@
{% extends 'layout.html' %}
{% load comment_format %}
{% block title %} | Blog : {{ post.name }}{% endblock %}
{% block og_title %}Blog de Partir De Zéro : {{ post.name }}{% endblock %}
{% block description %}{{ post.content|striptags|truncatewords:20 }}{% endblock %}
{% block og_description %}{{ post.content|striptags|truncatewords:20 }}{% endblock %}
{% block content %}
<section class="blog post-detail">
<header class="post-header">
<h1 class="post-title">{{ post.name }}</h1>
<div class="post-meta">
<span class="post-date"><i class="fa-regular fa-calendar"></i> {{ post.created_at|date:"d F Y" }}</span>
{% if post.tags %}
<span class="sep"></span>
<span class="post-tags"><i class="fa-solid fa-tag"></i> {{ post.tags }}</span>
{% endif %}
</div>
</header>
<article class="post-content prose">
{{ post.content|comment_markdown }}
</article>
</section>
{% endblock %}

View file

@ -1,17 +0,0 @@
{% extends 'layout.html' %}
{% block title %} | Blog{% endblock %}
{% block og_title %}Blog de Partir De Zéro{% endblock %}
{% block description %}{{ settings.blog_description }}{% endblock %}
{% block og_description %}{{ settings.blog_description }}{% endblock %}
{% block content %}
<section class="blog blog-home">
<header class="blog-header">
<h1 class="blog-title">{{ settings.blog_title|default:'Blog' }}</h1>
<p class="blog-description">{{ settings.blog_description }}</p>
</header>
<h2 class="sr-only">Liste des articles</h2>
{% include 'blog/partials/_posts_list.html' %}
</section>
{% endblock %}

View file

@ -1,21 +0,0 @@
{% load comment_format %}
<div class="post-list">
{% for post in posts %}
<article class="post-card">
<h3 class="post-card-title"><a href="{% url 'blog:post_detail' post.slug %}">{{ post.name|truncatewords:6 }}</a></h3>
<div class="post-meta">
<span class="post-date"><i class="fa-regular fa-calendar"></i> {{ post.created_at|date:"d F Y" }}</span>
{% if post.tags %}
<span class="sep"></span>
<span class="post-tags"><i class="fa-solid fa-tag"></i> {{ post.tags }}</span>
{% endif %}
</div>
<p class="post-excerpt">{{ post.description|comment_markdown|truncatewords:26 }}</p>
<div class="post-actions">
<a class="button button-secondary" href="{% url 'blog:post_detail' post.slug %}">Lire l'article →</a>
</div>
</article>
{% empty %}
<p>Aucun article pour le moment.</p>
{% endfor %}
</div>

View file

@ -4,22 +4,3 @@
Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a> Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a>
</p> </p>
<p>{{ course.content }}</p> <p>{{ course.content }}</p>
<strong>Progression pour ce cours</strong>
{% if user_progress.percent_completed == 100 %}
<div class="course-completed">
<div class="container">
<div class="icon"><i class="fa-solid fa-check"></i></div>
</div>
<div class="container">
<div class="title">Félicitation</div>
<div class="content">Tu as terminé(e) ce cours ! Tu peux réellement être fier(e) de toi !!</div>
</div>
</div>
{% else %}
<div class="progress-container">
<div class="progress-bar" style="width: 100%;">
<div id="progress-bar-fill" class="progress-bar-fill" style="width: {{ user_progress.percent_completed }}%; height: 100%; transition: width 0.3s;"></div>
</div>
<div id="progress-text" class="progress-text">{{ user_progress.percent_completed }}%</div>
</div>
{% endif %}

View file

@ -13,8 +13,7 @@
<li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}"> <li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}">
<a class="tocLink {% if item.is_premium and user.profile.is_premium == False %}premium{% endif %}" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}"> <a class="tocLink {% if item.is_premium and user.profile.is_premium == False %}premium{% endif %}" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}">
{{ item.name }} {% if item.is_premium %}<span class="premium-tag">PREMIUM</span>{% endif %} {{ item.name }} {% if item.is_premium %}<span class="premium-tag">PREMIUM</span>{% endif %}
{% if lesson and lesson.id == item.id %}<span class="current-tag">(cours actuel)</span>{% endif %} {% if lesson and lesson.id == item.id %}<span class="tocCurrentTag">(cours actuel)</span>{% endif %}
{% if item.id in completed_lesson_ids %}<span class="completed-tag">Terminé</span>{% endif %}
</a> </a>
{% if lesson and lesson.id == item.id %} {% if lesson and lesson.id == item.id %}
<div class="lessonInline"> <div class="lessonInline">
@ -70,12 +69,6 @@
<div class="content-lesson"> <div class="content-lesson">
<div class="lessonTitle">Ce que l'on voit durant ce cours : </div> <div class="lessonTitle">Ce que l'on voit durant ce cours : </div>
{{ lesson.content|comment_markdown }} {{ lesson.content|comment_markdown }}
{% if lesson.id in completed_lesson_ids %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-success">✅ Terminée</button>
{% else %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-secondary">Marquer comme terminé</button>
{% endif %}
</div> </div>
<h3 id="comments">Commentaires</h3> <h3 id="comments">Commentaires</h3>
<div class="lessonComments"> <div class="lessonComments">
@ -258,64 +251,6 @@
}); });
})(); })();
</script> </script>
<!-- Progression Bar -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('btn-complete');
const progressBar = document.getElementById('progress-bar-fill');
const progressText = document.getElementById('progress-text');
btn.addEventListener('click', function() {
const lessonId = this.getAttribute('data-lesson-id');
// 1. On prépare la requête
fetch("{% url 'progression:toggle_lesson' %}", { // Assure-toi que c'est le bon nom dans urls.py
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') // Fonction indispensable pour Django
},
body: JSON.stringify({
'lesson_id': lessonId
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// 2. On met à jour le visuel de la barre
progressBar.style.width = data.new_percent + '%';
progressText.innerText = data.new_percent + '%';
// 3. On change l'aspect du bouton
if (data.is_completed) {
btn.innerText = "✅ Terminée";
btn.style.backgroundColor = "green";
} else {
btn.innerText = "Marquer comme terminé";
btn.style.backgroundColor = "gray";
}
}
});
});
// --- Fonction Helper pour récupérer le cookie CSRF de Django ---
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
});
</script>
</div> <!-- /.lessonComments --> </div> <!-- /.lessonComments -->
</div> <!-- /.lesson --> </div> <!-- /.lesson -->
</div> <!-- /.lessonInline --> </div> <!-- /.lessonInline -->

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

@ -1,210 +0,0 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}- Tableau de bord{% endblock %}
{% block extra_head %}
{% include "partials/_stats_head.html" %}
{% 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>
{% include "partials/_stats_toolbar.html" %}
<section class="stats-grid">
<div class="card">
<h3>Visiteurs uniques (période)</h3>
<div class="kpi">{{ kpi.unique_visitors }}</div>
</div>
<div class="card">
<h3>Visiteurs revenants (période)</h3>
<div class="kpi">{{ kpi.returning_visitors }}</div>
</div>
<div class="card">
<h3>Conversions en utilisateurs (période)</h3>
<div class="kpi">{{ kpi.converted_visitors }}</div>
</div>
<div class="card">
<h3>Utilisateurs (total)</h3>
<div class="kpi">{{ kpi.total_users }}</div>
</div>
<div class="card">
<h3>Nouveaux utilisateurs (période)</h3>
<div class="kpi">{{ kpi.new_users_period }}</div>
</div>
<div class="card">
<h3>Utilisateurs actifs (période)</h3>
<div class="kpi">{{ kpi.active_users_period }}</div>
</div>
<div class="card">
<h3>Cours (publiés / total)</h3>
<div class="kpi">{{ kpi.courses_enabled }} / {{ kpi.total_courses }}</div>
</div>
<div class="card">
<h3>Leçons (total)</h3>
<div class="kpi">{{ kpi.total_lessons }}</div>
</div>
<div class="card">
<h3>Articles de blog (total)</h3>
<div class="kpi">{{ kpi.total_posts }}</div>
</div>
<div class="card">
<h3>Revenus</h3>
{% if revenus_disponibles %}
<div class="kpi"></div>
{% else %}
<div class="kpi" title="Aucune source de données">N/A</div>
{% endif %}
</div>
<div class="card">
<h3>Technique</h3>
{% if technique_disponible %}
<div class="kpi"></div>
{% else %}
<div class="kpi" title="Aucune source de données">N/A</div>
{% endif %}
</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>
<canvas id="chartMain" height="120"></canvas>
</div>
</section>
<section class="tables">
<div class="card">
<h3>Nouveaux utilisateurs par jour</h3>
<table>
<thead><tr><th>Jour</th><th>Nb</th></tr></thead>
<tbody>
{% for row in new_users_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Nouveaux cours par jour</h3>
<table>
<thead><tr><th>Jour</th><th>Nb</th></tr></thead>
<tbody>
{% for row in new_courses_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="tables">
<div class="card">
<h3>Top sources (visiteurs uniques)</h3>
<table>
<thead><tr><th>Source</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_sources_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Top pays (visiteurs uniques)</h3>
<table>
<thead><tr><th>Pays</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_countries_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<script>
const labels = {{ labels_json|safe }};
const series = {{ series_json|safe }};
const ctx = document.getElementById('chartMain');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{label: 'Nouveaux utilisateurs', data: series.new_users, borderColor: '#4f46e5', backgroundColor: 'rgba(79,70,229,.2)', tension:.2},
{label: 'Nouveaux cours', data: series.new_courses, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,.2)', tension:.2},
{label: 'Nouveaux articles', data: series.new_posts, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,.2)', tension:.2},
{label: 'Activité progression', data: series.activity_progress, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,.2)', tension:.2}
]
},
options: {
responsive: true,
scales: {y: {beginAtZero: true}},
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="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' %}"> <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 %} {% block extra_head %}{% endblock %}
<script src="{% static 'js/functions.js' %}" defer></script> <script src="{% static 'js/functions.js' %}" defer></script>
@ -70,8 +39,8 @@
(function() { (function() {
try { try {
var stored = localStorage.getItem('pdz-theme'); var stored = localStorage.getItem('pdz-theme');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var theme = stored || (prefersLight ? 'dark' : 'light'); var theme = stored || (prefersLight ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') { if (theme === 'light') {
var link = document.getElementById('theme-css'); var link = document.getElementById('theme-css');
@ -84,14 +53,6 @@
<script defer>hljs.highlightAll();</script> <script defer>hljs.highlightAll();</script>
</head> </head>
<body> <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>
{% endif %}
{% block header %} {% block header %}
{% include "partials/_header.html" %} {% include "partials/_header.html" %}
{% endblock %} {% endblock %}
@ -111,6 +72,5 @@
{% block footer %} {% block footer %}
{% include "partials/_footer.html" %} {% include "partials/_footer.html" %}
{% endblock %} {% endblock %}
{% endif %}
</body> </body>
</html> </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">
<footer class="site-footer{% if month == '12' %} pdz-xmas{% endif %}">
<div class="footer-columns"> <div class="footer-columns">
<div class="about"> <div class="about">
<h5>À propos</h5> <h5>À propos</h5>
@ -29,7 +28,6 @@
{% if settings.contact_email %} {% if settings.contact_email %}
<li><a href="mailto:contact@exemple.com"><i class="fa-solid fa-envelope"></i> Email</a></li> <li><a href="mailto:contact@exemple.com"><i class="fa-solid fa-envelope"></i> Email</a></li>
{% endif %} {% endif %}
<li><a href="https://discord.gg/bAkuKHWFqU" target="_blank"><i class="fab fa-discord"></i> Communauté</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -37,8 +35,5 @@
<span>Partir de Zero ©2024 - {% now "Y" %}</span> <span>Partir de Zero ©2024 - {% now "Y" %}</span>
<span>v{{ SITE_VERSION }}</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> <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> </div>
</footer> </footer>

View file

@ -2,12 +2,7 @@
<div class="brand"> <div class="brand">
<div class="brand-title"> <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 %} {% 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 <span class="site-title">Partir de zéro</span>
{% now "n" as month %}
{% if month == '12' %}
<span class="pdz-festive-emoji" aria-hidden="true" title="Joyeuses fêtes">🎄</span>
{% endif %}
</span>
</div> </div>
<span class="subtitle comment">/* Anthony Violet */</span> <span class="subtitle comment">/* Anthony Violet */</span>
</div> </div>
@ -25,8 +20,8 @@
{% endfor %} {% endfor %}
</ul> </ul>
</li> </li>
<!--<li><a href="">Tutoriels</a></li>--> <!--<li><a href="">Tutoriels</a></li>
<li><a href="{% url 'blog:blog' %}">Blog</a></li> <li><a href="">Billets</a></li>-->
</ul> </ul>
<div class="navend"> <div class="navend">
<ul> <ul>
@ -45,10 +40,10 @@
{% endif %} {% endif %}
</ul> </ul>
</li> </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 %} {% if user.is_authenticated and user.is_staff %}
<li><a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a></li> <li>
<li><a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a></li> <a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
</li>
{% endif %} {% endif %}
<li> <li>
{% if user.is_authenticated %} {% 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 %} {% endblock %}
<div class="profile-details"> <div class="profile-details">
<h2>Mes cours</h2> <h2>Mes cours</h2>
<p>Retrouvez ici la liste de tous les cours que vous suivez et votre progression.</p> <p>Retrouvez ici la liste de tous les cours que vous suivez.</p>
<ul>
{% if progress_list %} {% for course in user_courses %}
<ul class="profile-courses"> <li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li>
{% 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 %} {% endfor %}
</ul> </ul>
{% else %}
<p class="muted">Vous ne suivez aucun cours pour le moment.</p>
{% endif %}
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -22,19 +22,16 @@
<div class="profile-card"> <div class="profile-card">
<h3>Mes cours</h3> <h3>Mes cours</h3>
{% with progress_list=latest_progress %} {% with courses=user.course_set.all %}
{% if progress_list %} {% if courses %}
<ul class="profile-courses"> <ul class="profile-courses">
{% for p in progress_list %} {% for course in courses|slice:":6" %}
{% with course=p.course %}
<li> <li>
<a href="{% url 'courses:show' course_id=course.id course_name=course.slug %}"> <a href="{% url 'courses:show' course_id=course.id course_name=course.slug %}">
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini"> <img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
<span>{{ course.name }}</span> <span>{{ course.name }}</span>
</a> </a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li> </li>
{% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="text-right"> <div class="text-right">

View file

@ -27,15 +27,7 @@ class UserRegistrationForm(forms.Form):
password2 = cleaned_data.get("password2") password2 = cleaned_data.get("password2")
if password1 and password2 and password1 != password2: if password1 and password2 and password1 != password2:
raise forms.ValidationError("Les mots de passe ne correspondent pas.") raise forms.ValidationError("Passwords do not match")
return cleaned_data
def clean_username(self):
username = self.cleaned_data.get('username')
if username and User.objects.filter(username__iexact=username).exists():
raise forms.ValidationError("Ce pseudo est déjà pris. Veuillez en choisir un autre.")
return username
class UserLoginForm(forms.Form): class UserLoginForm(forms.Form):
username = forms.CharField( username = forms.CharField(

View file

@ -1,4 +0,0 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
# Générateur de tokens pour l'activation de compte
activation_token = PasswordResetTokenGenerator()

View file

@ -7,8 +7,6 @@ urlpatterns = [
path('', views.register, name='register'), path('', views.register, name='register'),
path('login/', views.login, name='login'), path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'), path('logout/', views.logout, name='logout'),
# Activation de compte par lien tokenisé
path('activate/<uidb64>/<token>/', views.activate, name='activate'),
path('profile/view/<int:user_id>/', views.another_profile, name='another_profile'), path('profile/view/<int:user_id>/', views.another_profile, name='another_profile'),
path('complete-profile/', views.complete_profile, name='complete_profile'), path('complete-profile/', views.complete_profile, name='complete_profile'),
path('profile/', views.profile, name='profile'), path('profile/', views.profile, name='profile'),

View file

@ -3,14 +3,8 @@ from django.contrib import messages
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.models import User from django.contrib.auth.models import User
from courses.models import Course from courses.models import Course
from progression.models import Progression
from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail
from django.urls import reverse
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from .tokens import activation_token
def register(request): def register(request):
# Si l'utilisateur est deja connecté, on le redirige vers la page de profil # Si l'utilisateur est deja connecté, on le redirige vers la page de profil
@ -19,36 +13,13 @@ def register(request):
if request.method == 'POST': if request.method == 'POST':
form = UserRegistrationForm(request.POST) form = UserRegistrationForm(request.POST)
if form.is_valid(): if form.is_valid():
# Crée un utilisateur inactif en attente d'activation par email
user = User.objects.create_user( user = User.objects.create_user(
username=form.cleaned_data['username'], username=form.cleaned_data['username'],
email=form.cleaned_data['email'], email=form.cleaned_data['email'],
password=form.cleaned_data['password1'] password=form.cleaned_data['password1']
) )
user.is_active = False auth_login(request, user)
user.save() return redirect('profile')
# Envoi du lien d'activation par email
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = activation_token.make_token(user)
activation_link = request.build_absolute_uri(
reverse('activate', kwargs={'uidb64': uid, 'token': token})
)
subject = 'Active ton compte sur partirdezero'
message = (
'Bienvenue !\n\n'
'Pour activer ton compte, clique sur le lien suivant (valide pendant une durée limitée) :\n'
f'{activation_link}\n\n'
"Si tu n'es pas à l'origine de cette inscription, ignore ce message."
)
try:
send_mail(subject, message, None, [user.email], fail_silently=True)
except Exception:
# Même si l'envoi échoue, on n'interrompt pas le flux; un admin vérifiera la config email
pass
messages.success(request, "Inscription réussie. Vérifie ta boîte mail pour activer ton compte.")
return redirect('login')
else: else:
form = UserRegistrationForm() form = UserRegistrationForm()
return render(request, 'users/register.html', {'form': form}) return render(request, 'users/register.html', {'form': form})
@ -61,15 +32,8 @@ def login(request):
password = form.cleaned_data['password'] password = form.cleaned_data['password']
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
if user is not None: if user is not None:
if not user.is_active:
messages.error(request, "Votre compte n'est pas encore activé. Consultez l'email d'activation envoyé." )
return render(request, 'users/login.html', {'form': form})
auth_login(request, user) auth_login(request, user)
return redirect('profile') return redirect('profile')
else:
# Identifiants invalides: avertir l'utilisateur
messages.error(request, "Nom d'utilisateur ou mot de passe incorrect.")
return render(request, 'users/login.html', {'form': form})
else: else:
form = UserLoginForm() form = UserLoginForm()
return render(request, 'users/login.html', {'form': form}) return render(request, 'users/login.html', {'form': form})
@ -82,17 +46,7 @@ def logout(request):
def profile(request): def profile(request):
if not hasattr(request.user, 'profile'): if not hasattr(request.user, 'profile'):
return redirect('complete_profile') return redirect('complete_profile')
return render(request, 'users/profile.html')
latest_progress = (
Progression.objects
.filter(user=request.user)
.select_related('course')
.prefetch_related('completed_lessons')
.order_by('-updated_at')[:5]
)
# Affiche les 5 derniers cours regardés avec leur progression
return render(request, 'users/profile.html', {'latest_progress': latest_progress})
@login_required(login_url='login') @login_required(login_url='login')
def complete_profile(request): def complete_profile(request):
@ -141,16 +95,9 @@ def account_update(request):
@login_required(login_url='login') @login_required(login_url='login')
def my_courses(request): def my_courses(request):
# Liste tous les cours suivis par l'utilisateur avec leur progression user_courses = Course.objects.filter(author=request.user.id)
progress_list = ( print(user_courses)
Progression.objects return render(request, 'users/my_courses.html', {'user_courses' : user_courses})
.filter(user=request.user)
.select_related('course')
.prefetch_related('completed_lessons')
.order_by('-updated_at')
)
return render(request, 'users/my_courses.html', {'progress_list': progress_list})
def create_post(request): def create_post(request):
# Implement post creation logic here # Implement post creation logic here
@ -159,21 +106,3 @@ def create_post(request):
def another_profile(request, user_id): def another_profile(request, user_id):
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
return render(request, 'users/another_profile.html', {'user': user}) return render(request, 'users/another_profile.html', {'user': user})
# Activation de compte via lien tokenisé
def activate(request, uidb64, token):
try:
uid = urlsafe_base64_decode(uidb64).decode()
user = User.objects.get(pk=uid)
except Exception:
user = None
if user and activation_token.check_token(user, token):
if not user.is_active:
user.is_active = True
user.save()
messages.success(request, 'Votre compte a été activé. Vous pouvez maintenant vous connecter.')
return redirect('login')
messages.error(request, "Lien d'activation invalide ou expiré. Demandez un nouveau lien ou inscrivezvous à nouveau.")
return redirect('register')