Compare commits

...

35 commits

Author SHA1 Message Date
38e4ce2562 Ajout des partials _stats_head.html et _stats_toolbar.html pour harmoniser les styles et outils des pages de statistiques. 2025-12-16 15:16:18 +01:00
18b807bf5a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 15:03:46 +01:00
d20302be0e Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 14:16:24 +01:00
7cf04968eb Ajout de la fonctionnalité d’activité en direct dans le tableau de bord des statistiques et mise à jour des templates, vues, URL, et styles associés. 2025-12-16 14:15:58 +01:00
a7b51e3a82 Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:55:46 +01:00
8f0fad45be Affichage de la progression utilisateur dans les cours suivis et amélioration des templates associés. 2025-12-16 13:55:14 +01:00
81b42b8b4a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:23:58 +01:00
afa673ccd9 Tri des articles par date de création décroissante dans le context_processor. 2025-12-16 13:23:15 +01:00
5fd06f5ae1 Ajout de la feuille de style christmas.css pour les décorations festives et animations de neige, chargées conditionnellement en décembre. 2025-12-16 13:14:15 +01:00
5a241e394b Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:12:00 +01:00
91f7f79546 Ajout des décorations et animations de neige pour les fêtes de fin d'année, chargées conditionnellement en décembre. 2025-12-16 13:10:43 +01:00
e1f8a23f3d Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:53:26 +01:00
1b0ccc54a2 Ajout du template stats_charts.html pour afficher des graphiques statistiques interactifs avec Chart.js. 2025-12-16 10:52:55 +01:00
f9e2df559c Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:48:48 +01:00
7869abf441 Ajout de la vue stats_charts avec graphiques détaillés pour les superadministrateurs, mise à jour des templates et des routes associées, et configuration supplémentaire pour l'email backend. 2025-12-16 10:48:12 +01:00
083af6f9d4 Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:28:55 +01:00
6e8a2bc287 Ajout du suivi des visites : modèle Visit, middleware de tracking, mises à jour des vues et du tableau de bord statistiques. 2025-12-16 10:28:20 +01:00
bec74976ba Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:20:26 +01:00
b3b18bd15a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:20:12 +01:00
fc4939577a Ajout de la gestion de l'activation des comptes utilisateur par e-mail, des validations pour les pseudos existants, et configuration par défaut de l'e-mail backend. 2025-12-16 10:19:47 +01:00
ca0211e841 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 23:03:16 +01:00
ab8307b272 Ajout du template stats_dashboard.html pour afficher le tableau de bord des statistiques, avec styles, graphiques Chart.js, tableaux dynamiques et gestion du contexte. 2025-12-15 23:02:43 +01:00
2c715a3af4 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 22:55:01 +01:00
e44e31bfb9 Ajout du tableau de bord statistiques pour les superadministrateurs, avec cache et graphiques JSON, et mise à jour des routes pour inclure la vue correspondante. 2025-12-15 22:54:27 +01:00
82c2e234e3 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 22:34:39 +01:00
ac8ef6894d Ajout des fonctionnalités de gestion de progression des cours : vue dédiée pour le toggle des leçons, mise à jour des templates pour afficher la progression, intégration des routes Ajax, styles associés, et ajustements des vues et modèles pour gérer les données utilisateur. 2025-12-15 22:34:02 +01:00
609745a723 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 21:30:13 +01:00
4a48425374 Ajout d'un champ description au modèle Post avec migration associée, mise à jour des templates pour utiliser ce champ, et amélioration du formatage des commentaires Markdown avec gestion des titres typographiques. 2025-12-15 21:29:26 +01:00
43af8bd0d8 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 20:59:43 +01:00
c1749068af Ajout des fonctionnalités de blog : modèles, migrations, vues, templates, contexte et styles. 2025-12-15 20:58:25 +01:00
3e44013132 Ajout des applications blog et progression avec modèles, vues, URLs et intégration dans le sitemap et les configurations du projet. 2025-12-15 16:02:34 +01:00
45d2cb66f0 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:50:33 +01:00
2bfab05b49 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:44:27 +01:00
6c7f91c72f Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:29:39 +01:00
84c94e28de Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:27:24 +01:00
58 changed files with 1977 additions and 76 deletions

View file

@ -1 +1 @@
1.0.3 (e79ffee) 1.3.8 (7cf0496)

0
blog/__init__.py Normal file
View file

8
blog/admin.py Normal file
View file

@ -0,0 +1,8 @@
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",)}

5
blog/apps.py Normal file
View file

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

View file

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

View file

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

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

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

18
blog/models.py Normal file
View file

@ -0,0 +1,18 @@
from django.db import models
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

3
blog/tests.py Normal file
View file

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

8
blog/urls.py Normal file
View file

@ -0,0 +1,8 @@
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'),
]

11
blog/views.py Normal file
View file

@ -0,0 +1,11 @@
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 from .models import SiteSettings, Visit
@admin.register(SiteSettings) @admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin): class SiteSettingsAdmin(admin.ModelAdmin):
@ -23,4 +23,14 @@ class SiteSettingsAdmin(admin.ModelAdmin):
('Contact', { ('Contact', {
'fields': ('contact_email',) 'fields': ('contact_email',)
}), }),
('Blog', {
'fields': ('blog_title', 'blog_description')
}),
) )
@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")

121
core/middleware.py Normal file
View file

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

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

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

@ -0,0 +1,41 @@
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,4 +1,6 @@
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")
@ -13,6 +15,10 @@ 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.
@ -27,3 +33,35 @@ 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 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

@ -0,0 +1,21 @@
# 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,6 +12,10 @@ 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
@ -40,6 +44,10 @@ 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:

View file

@ -27,6 +27,19 @@ 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,13 +3,14 @@ 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):
courses = Course.objects.all() courses = Course.objects.all()
return render(request, 'courses/list.html', {'courses': courses}) return render(request, 'courses/list.html', {'courses': courses})
def show(request, course_id): def show(request, course_name, course_id):
# Optimized course fetch with related author and profile (if present) # Optimized course fetch with related author and profile (if present)
course = get_object_or_404( course = get_object_or_404(
Course.objects.select_related('author', 'author__profile'), Course.objects.select_related('author', 'author__profile'),
@ -23,9 +24,23 @@ def show(request, 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)
@ -105,6 +120,18 @@ 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,
@ -114,5 +141,7 @@ 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,6 +12,8 @@ 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
@ -51,6 +53,8 @@ INSTALLED_APPS = [
'core', 'core',
'courses', 'courses',
'users', 'users',
'progression',
'blog',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -62,7 +66,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'
@ -81,6 +85,7 @@ TEMPLATES = [
'devart.context_processor.app_version', 'devart.context_processor.app_version',
'core.context_processor.site_settings', 'core.context_processor.site_settings',
'courses.context_processors.course_list', 'courses.context_processors.course_list',
'blog.context_processor.posts_list',
], ],
}, },
}, },
@ -200,3 +205,11 @@ 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,9 +1,10 @@
from django.contrib import sitemaps from django.contrib import sitemaps
from django.urls import reverse from django.urls import reverse
# --- IMPORTS DEPUIS TES DIFFÉRENTES FEATURES --- # --- FEATURES ---
from courses.models import Course from courses.models import Course
from users.models import Profile from users.models import Profile
from blog.models import Post
# --- SITEMAP : LES Cours --- # --- SITEMAP : LES Cours ---
class CourseSitemap(sitemaps.Sitemap): class CourseSitemap(sitemaps.Sitemap):
@ -11,12 +12,20 @@ class CourseSitemap(sitemaps.Sitemap):
priority = 0.9 priority = 0.9
def items(self): def items(self):
return Course.objects.filter(enable=True) # Exemple de filtre return Course.objects.filter(enable=True).order_by('id')
def location(self, item): def location(self, item):
# Assure-toi que ton modèle Course a bien une méthode get_absolute_url # Assure-toi que ton modèle Course a bien une méthode get_absolute_url
return item.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 --- # --- SITEMAP : PAGES STATIQUES ---
class StaticViewSitemap(sitemaps.Sitemap): class StaticViewSitemap(sitemaps.Sitemap):
priority = 0.5 priority = 0.5
@ -26,4 +35,4 @@ class StaticViewSitemap(sitemaps.Sitemap):
return ["home"] # Les noms de tes URLs return ["home"] # Les noms de tes URLs
def location(self, item): def location(self, item):
return "https://partirdezero.com" return ""

View file

@ -43,6 +43,8 @@ urlpatterns = [
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('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'),
path('robots.txt', robots_txt), path('robots.txt', robots_txt),

View file

@ -5,4 +5,8 @@ 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,5 +1,58 @@
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from courses.models import Course from django.contrib.auth.decorators import user_passes_test
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]
@ -9,3 +62,295 @@ 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})

0
progression/__init__.py Normal file
View file

15
progression/admin.py Normal file
View file

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

5
progression/apps.py Normal file
View file

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

View file

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

28
progression/models.py Normal file
View file

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

3
progression/tests.py Normal file
View file

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

10
progression/urls.py Normal file
View file

@ -0,0 +1,10 @@
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'),
]

41
progression/views.py Normal file
View file

@ -0,0 +1,41 @@
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,9 +247,43 @@ html { box-sizing: border-box; }
gap: 6px; gap: 6px;
padding: 2px 8px; padding: 2px 8px;
margin-left: 8px; margin-left: 8px;
border-radius: 999px; border-radius: 2px;
background: var(--accent); background: var(--accent);
color: var(--warning-contrast); color: var(--text);
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;
@ -269,6 +303,70 @@ 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 */
@ -751,6 +849,100 @@ 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; }
@ -2138,6 +2330,16 @@ 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; }

99
static/css/christmas.css Normal file
View file

@ -0,0 +1,99 @@
/* 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,27 +95,3 @@ 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

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

17
templates/blog/home.html Normal file
View file

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

@ -0,0 +1,21 @@
{% 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,3 +4,22 @@
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,7 +13,8 @@
<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="tocCurrentTag">(cours actuel)</span>{% endif %} {% if lesson and lesson.id == item.id %}<span class="current-tag">(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">
@ -69,6 +70,12 @@
<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">
@ -251,6 +258,64 @@
}); });
})(); })();
</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

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

@ -0,0 +1,210 @@
{% 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,6 +31,37 @@
<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>
@ -39,8 +70,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: light)').matches; var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersLight ? 'light' : 'dark'); var theme = stored || (prefersLight ? 'dark' : 'light');
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');
@ -53,6 +84,11 @@
<script defer>hljs.highlightAll();</script> <script defer>hljs.highlightAll();</script>
</head> </head>
<body> <body>
{% 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 %}

View file

@ -1,4 +1,5 @@
<footer class="site-footer"> {% now "n" as month %}
<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>
@ -35,5 +36,8 @@
<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,7 +2,12 @@
<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> <span class="site-title">Partir de zéro
{% now "n" as month %}
{% if month == '12' %}
<span class="pdz-festive-emoji" aria-hidden="true" title="Joyeuses fêtes">🎄</span>
{% endif %}
</span>
</div> </div>
<span class="subtitle comment">/* Anthony Violet */</span> <span class="subtitle comment">/* Anthony Violet */</span>
</div> </div>
@ -20,8 +25,8 @@
{% endfor %} {% endfor %}
</ul> </ul>
</li> </li>
<!--<li><a href="">Tutoriels</a></li> <!--<li><a href="">Tutoriels</a></li>-->
<li><a href="">Billets</a></li>--> <li><a href="{% url 'blog:blog' %}">Blog</a></li>
</ul> </ul>
<div class="navend"> <div class="navend">
<ul> <ul>
@ -43,6 +48,7 @@
{% if user.is_authenticated and user.is_staff %} {% if user.is_authenticated and user.is_staff %}
<li> <li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a> <a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
<a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a>
</li> </li>
{% endif %} {% endif %}
<li> <li>

View file

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

View file

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

View file

@ -6,12 +6,27 @@
{% 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.</p> <p>Retrouvez ici la liste de tous les cours que vous suivez et votre progression.</p>
<ul>
{% for course in user_courses %} {% if progress_list %}
<li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li> <ul class="profile-courses">
{% for p in progress_list %}
{% with course=p.course %}
<li>
<a href="{% url 'courses:show' course.name|slugify course.id %}">
{% if course.thumbnail %}
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
{% endif %}
<span>{{ course.name }}</span>
</a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li>
{% endwith %}
{% endfor %} {% 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,16 +22,19 @@
<div class="profile-card"> <div class="profile-card">
<h3>Mes cours</h3> <h3>Mes cours</h3>
{% with courses=user.course_set.all %} {% with progress_list=latest_progress %}
{% if courses %} {% if progress_list %}
<ul class="profile-courses"> <ul class="profile-courses">
{% for course in courses|slice:":6" %} {% for p in progress_list %}
{% 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,7 +27,15 @@ 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("Passwords do not match") raise forms.ValidationError("Les mots de passe ne correspondent pas.")
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(

4
users/tokens.py Normal file
View file

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

View file

@ -7,6 +7,8 @@ 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,8 +3,14 @@ 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
@ -13,13 +19,36 @@ 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']
) )
auth_login(request, user) user.is_active = False
return redirect('profile') user.save()
# 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})
@ -32,8 +61,15 @@ 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})
@ -46,7 +82,17 @@ 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):
@ -95,9 +141,16 @@ def account_update(request):
@login_required(login_url='login') @login_required(login_url='login')
def my_courses(request): def my_courses(request):
user_courses = Course.objects.filter(author=request.user.id) # Liste tous les cours suivis par l'utilisateur avec leur progression
print(user_courses) progress_list = (
return render(request, 'users/my_courses.html', {'user_courses' : user_courses}) Progression.objects
.filter(user=request.user)
.select_related('course')
.prefetch_related('completed_lessons')
.order_by('-updated_at')
)
return render(request, 'users/my_courses.html', {'progress_list': progress_list})
def create_post(request): def create_post(request):
# Implement post creation logic here # Implement post creation logic here
@ -106,3 +159,21 @@ 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')