diff --git a/VERSION.txt b/VERSION.txt
new file mode 100644
index 0000000..299b2e2
--- /dev/null
+++ b/VERSION.txt
@@ -0,0 +1 @@
+1.3.5 (1b0ccc5)
\ No newline at end of file
diff --git a/blog/__init__.py b/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/admin.py b/blog/admin.py
new file mode 100644
index 0000000..8eb9366
--- /dev/null
+++ b/blog/admin.py
@@ -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",)}
\ No newline at end of file
diff --git a/blog/apps.py b/blog/apps.py
new file mode 100644
index 0000000..7930587
--- /dev/null
+++ b/blog/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BlogConfig(AppConfig):
+ name = 'blog'
diff --git a/blog/context_processor.py b/blog/context_processor.py
new file mode 100644
index 0000000..a1666a4
--- /dev/null
+++ b/blog/context_processor.py
@@ -0,0 +1,5 @@
+from .models import Post
+
+def posts_list(request):
+ posts = Post.objects.all()
+ return {'posts': posts}
\ No newline at end of file
diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py
new file mode 100644
index 0000000..9c7c01b
--- /dev/null
+++ b/blog/migrations/0001_initial.py
@@ -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',
+ },
+ ),
+ ]
diff --git a/blog/migrations/0002_post_enable.py b/blog/migrations/0002_post_enable.py
new file mode 100644
index 0000000..bb99a9b
--- /dev/null
+++ b/blog/migrations/0002_post_enable.py
@@ -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),
+ ),
+ ]
diff --git a/blog/migrations/0003_post_description.py b/blog/migrations/0003_post_description.py
new file mode 100644
index 0000000..1d12e7e
--- /dev/null
+++ b/blog/migrations/0003_post_description.py
@@ -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'),
+ ),
+ ]
diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/blog/models.py b/blog/models.py
new file mode 100644
index 0000000..387d847
--- /dev/null
+++ b/blog/models.py
@@ -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
\ No newline at end of file
diff --git a/blog/tests.py b/blog/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/blog/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/blog/urls.py b/blog/urls.py
new file mode 100644
index 0000000..ef6ad01
--- /dev/null
+++ b/blog/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+from . import views
+
+app_name = 'blog'
+urlpatterns = [
+ path('', views.blog_home, name='blog'),
+ path('/', views.blog_view_post, name='post_detail'),
+]
\ No newline at end of file
diff --git a/blog/views.py b/blog/views.py
new file mode 100644
index 0000000..3db99b3
--- /dev/null
+++ b/blog/views.py
@@ -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})
\ No newline at end of file
diff --git a/core/admin.py b/core/admin.py
index 7f53918..a9baff2 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import SiteSettings
+from .models import SiteSettings, Visit
@admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin):
@@ -23,4 +23,14 @@ class SiteSettingsAdmin(admin.ModelAdmin):
('Contact', {
'fields': ('contact_email',)
}),
- )
\ No newline at end of file
+ ('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")
diff --git a/core/middleware.py b/core/middleware.py
new file mode 100644
index 0000000..3e76796
--- /dev/null
+++ b/core/middleware.py
@@ -0,0 +1,117 @@
+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
+ visit.last_seen = now
+ dirty = True
+ if dirty:
+ visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen'])
diff --git a/core/migrations/0002_sitesettings_blog_description_and_more.py b/core/migrations/0002_sitesettings_blog_description_and_more.py
new file mode 100644
index 0000000..7d669a8
--- /dev/null
+++ b/core/migrations/0002_sitesettings_blog_description_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/core/migrations/0003_alter_sitesettings_blog_description_and_more.py b/core/migrations/0003_alter_sitesettings_blog_description_and_more.py
new file mode 100644
index 0000000..ccfc1e0
--- /dev/null
+++ b/core/migrations/0003_alter_sitesettings_blog_description_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/core/migrations/0004_visit.py b/core/migrations/0004_visit.py
new file mode 100644
index 0000000..6301e48
--- /dev/null
+++ b/core/migrations/0004_visit.py
@@ -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')},
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index de47391..63d9b18 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,4 +1,6 @@
from django.db import models
+from django.conf import settings
+from django.utils import timezone
class SiteSettings(models.Model):
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)
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
def save(self, *args, **kwargs):
self.pk = 1 # On force l'ID à 1. Si tu sauvegardes, ça écrase l'existant.
@@ -26,4 +32,36 @@ class SiteSettings(models.Model):
class Meta:
verbose_name = "Réglages du site"
- verbose_name_plural = "Réglages du site"
\ No newline at end of file
+ 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}"
\ No newline at end of file
diff --git a/courses/migrations/0004_alter_course_options_alter_lesson_options.py b/courses/migrations/0004_alter_course_options_alter_lesson_options.py
new file mode 100644
index 0000000..ff801fb
--- /dev/null
+++ b/courses/migrations/0004_alter_course_options_alter_lesson_options.py
@@ -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'},
+ ),
+ ]
diff --git a/courses/models.py b/courses/models.py
index 1c5a096..3cc46d1 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -12,9 +12,16 @@ class Course(models.Model):
updated_at = models.DateTimeField(auto_now=True)
enable = models.BooleanField(default=True)
+ class Meta:
+ verbose_name = "Cours"
+ verbose_name_plural = "Cours"
+
def __str__(self):
return self.name
+ def get_absolute_url(self):
+ return f"/courses/{self.slug}-{self.id}/"
+
class Module(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField()
@@ -25,6 +32,9 @@ class Module(models.Model):
enable = models.BooleanField(default=True)
order = models.PositiveIntegerField()
+ def __str__(self):
+ return self.name
+
class Lesson(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField()
@@ -34,6 +44,10 @@ class Lesson(models.Model):
is_premium = models.BooleanField(default=False)
order = models.PositiveIntegerField()
+ class Meta:
+ verbose_name = "Leçon"
+ verbose_name_plural = "Leçons"
+
def clean(self):
# Remplacer les chevrons par leurs équivalents HTML
if self.content:
@@ -43,6 +57,9 @@ class Lesson(models.Model):
def __str__(self):
return self.name
+ def get_absolute_url(self):
+ return f"/courses/{self.module.course.slug}/{self.module.slug}/{self.slug}/"
+
class Comment(models.Model):
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')
diff --git a/courses/templatetags/comment_format.py b/courses/templatetags/comment_format.py
index 2fbfd7e..64ea248 100644
--- a/courses/templatetags/comment_format.py
+++ b/courses/templatetags/comment_format.py
@@ -27,6 +27,19 @@ def _format_inline(text: str) -> str:
lambda m: f"",
text,
)
+ # H1
+ text = re.sub(r"^# (.+)$", r"\1
", text, flags=re.MULTILINE)
+ # H2
+ text = re.sub(r"^## (.+)$", r"\1
", text, flags=re.MULTILINE)
+ # H3
+ text = re.sub(r"^### (.+)$", r"\1
", text, flags=re.MULTILINE)
+ # H4
+ text = re.sub(r"^#### (.+)$", r"\1
", text, flags=re.MULTILINE)
+ # H5
+ text = re.sub(r"^##### (.+)$", r"\1
", text, flags=re.MULTILINE)
+ # H6
+ text = re.sub(r"^###### (.+)$", r"\1
", text, flags=re.MULTILINE)
+
# bold **text**
text = re.sub(
r"\*\*([^*]+)\*\*",
diff --git a/courses/views.py b/courses/views.py
index 8a33bbb..ca9fb22 100644
--- a/courses/views.py
+++ b/courses/views.py
@@ -3,6 +3,7 @@ from django.urls import reverse
from django.views import generic
from django.db.models import Prefetch
from .models import Course, Lesson, Module, Comment
+from progression.models import Progression
from .forms import CommentForm
def list_courses(request):
@@ -23,9 +24,23 @@ def show(request, course_name, course_id):
.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 = {
'course': course,
'lessons': lessons,
+ 'user_progress': user_progress,
+ 'completed_lesson_ids': completed_lesson_ids,
}
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')
)
+ # 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 = {
'course': course,
'module': module,
@@ -114,5 +141,7 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
'comment_form': form,
'prev_lesson': prev_lesson,
'next_lesson': next_lesson,
+ 'user_progress': user_progress,
+ 'completed_lesson_ids': completed_lesson_ids,
}
return render(request, 'courses/lesson.html', context)
\ No newline at end of file
diff --git a/devart/settings.py b/devart/settings.py
index 68e1525..f846daa 100644
--- a/devart/settings.py
+++ b/devart/settings.py
@@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path
import os
+
+import dotenv
from dotenv import load_dotenv
import devart.context_processor
@@ -46,10 +48,13 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'django.contrib.sitemaps',
'core',
'courses',
'users',
+ 'progression',
+ 'blog',
]
MIDDLEWARE = [
@@ -61,7 +66,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
-
+ 'core.middleware.VisitTrackingMiddleware',
]
ROOT_URLCONF = 'devart.urls'
@@ -80,6 +85,7 @@ TEMPLATES = [
'devart.context_processor.app_version',
'core.context_processor.site_settings',
'courses.context_processors.course_list',
+ 'blog.context_processor.posts_list',
],
},
},
@@ -198,4 +204,12 @@ def get_git_version():
else:
return "Version inconnue (Fichier manquant)"
-GIT_VERSION = get_git_version()
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/devart/sitemap.py b/devart/sitemap.py
new file mode 100644
index 0000000..e761d33
--- /dev/null
+++ b/devart/sitemap.py
@@ -0,0 +1,38 @@
+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) # Exemple de filtre
+
+ 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 ""
\ No newline at end of file
diff --git a/devart/urls.py b/devart/urls.py
index 86830eb..17cb4e7 100644
--- a/devart/urls.py
+++ b/devart/urls.py
@@ -18,12 +18,36 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
+from django.http import HttpResponse
+from devart.sitemap import CourseSitemap, StaticViewSitemap
+from django.contrib.sitemaps.views import sitemap
+
+# La vue pour le robots.txt
+def robots_txt(request):
+ lines = [
+ "User-agent: *",
+ "Disallow: /admin/",
+ "Disallow: /users/",
+ "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 = [
path('admin/', admin.site.urls),
path('', include('home.urls')),
path('courses/', include('courses.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:
diff --git a/home/urls.py b/home/urls.py
index a1ea3c1..1dbd960 100644
--- a/home/urls.py
+++ b/home/urls.py
@@ -5,4 +5,7 @@ app_name = 'home'
urlpatterns = [
path('', views.home, name='home'),
path('premium/', 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'),
]
\ No newline at end of file
diff --git a/home/views.py b/home/views.py
index 5bd3838..ebd2cf2 100644
--- a/home/views.py
+++ b/home/views.py
@@ -1,5 +1,14 @@
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
def home(request):
courses = Course.objects.order_by('-created_at')[:6]
@@ -9,3 +18,278 @@ def premium(request, course_id):
"""Landing page présentant les avantages du Premium."""
course = get_object_or_404(Course, pk=course_id)
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]
+ try:
+ p = int(request.GET.get('p', 30))
+ except ValueError:
+ p = 30
+ if p not in period_options:
+ p = 30
+
+ now = timezone.now()
+ start_dt = now - timezone.timedelta(days=p-1) # inclut aujourd'hui
+
+ # Utilisateurs
+ total_users = User.objects.count()
+ new_users_qs = User.objects.filter(date_joined__date__gte=start_dt.date(), date_joined__date__lte=now.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=start_dt.date(), updated_at__date__lte=now.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=start_dt.date(), created_at__date__lte=now.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=start_dt.date(), updated_at__date__lte=now.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=start_dt.date(), created_at__date__lte=now.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_start_date = start_dt.date()
+ period_end_date = now.date()
+ period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
+
+ unique_visitors = period_visits.values('visitor_id').distinct().count()
+
+ 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'):
+ counts = {str(item[date_key]): item[count_key] for item in qs}
+ days = []
+ values = []
+ d = start_dt.date()
+ while d <= now.date():
+ key = str(d)
+ days.append(key)
+ values.append(counts.get(key, 0))
+ d += timezone.timedelta(days=1)
+ return days, values
+
+ days_users, values_new_users = build_series_dict(new_users_by_day)
+ days_courses, values_new_courses = build_series_dict(new_courses_by_day)
+ 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': start_dt.date(),
+ 'end_date': now.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
+ period_options = [7, 30, 90, 180]
+ try:
+ p = int(request.GET.get('p', 30))
+ except ValueError:
+ p = 30
+ if p not in period_options:
+ p = 30
+
+ now = timezone.now()
+ start_dt = now - timezone.timedelta(days=p-1)
+ period_start_date = start_dt.date()
+ period_end_date = now.date()
+
+ # Trafic par jour (visiteurs uniques)
+ visits_qs = (
+ 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_dict(visits_qs, date_key='date')
+ _, conversions_series = build_series_dict(conversions_qs, date_key='day')
+
+ # Sources & Pays (sur la période)
+ period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
+ 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)
diff --git a/progression/__init__.py b/progression/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/progression/admin.py b/progression/admin.py
new file mode 100644
index 0000000..6dd7924
--- /dev/null
+++ b/progression/admin.py
@@ -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'
\ No newline at end of file
diff --git a/progression/apps.py b/progression/apps.py
new file mode 100644
index 0000000..7bf7a21
--- /dev/null
+++ b/progression/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ProgressionConfig(AppConfig):
+ name = 'progression'
diff --git a/progression/migrations/0001_initial.py b/progression/migrations/0001_initial.py
new file mode 100644
index 0000000..f2023ef
--- /dev/null
+++ b/progression/migrations/0001_initial.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/progression/migrations/__init__.py b/progression/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/progression/models.py b/progression/models.py
new file mode 100644
index 0000000..d8002a8
--- /dev/null
+++ b/progression/models.py
@@ -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)
\ No newline at end of file
diff --git a/progression/tests.py b/progression/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/progression/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/progression/urls.py b/progression/urls.py
new file mode 100644
index 0000000..cef076c
--- /dev/null
+++ b/progression/urls.py
@@ -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'),
+]
\ No newline at end of file
diff --git a/progression/views.py b/progression/views.py
new file mode 100644
index 0000000..1cee11c
--- /dev/null
+++ b/progression/views.py
@@ -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
+ })
\ No newline at end of file
diff --git a/static/css/app.css b/static/css/app.css
index 9aa06af..367520c 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -247,9 +247,9 @@ html { box-sizing: border-box; }
gap: 6px;
padding: 2px 8px;
margin-left: 8px;
- border-radius: 999px;
+ border-radius: 2px;
background: var(--accent);
- color: var(--warning-contrast);
+ color: var(--text);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
@@ -259,6 +259,40 @@ html { box-sizing: border-box; }
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-weight: 800;
+ letter-spacing: .3px;
+ line-height: 1.2;
+ vertical-align: middle;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
.courseToc .tocLink.disabled {
color: var(--text-muted);
background: transparent;
@@ -269,6 +303,70 @@ html { box-sizing: border-box; }
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'] {
/* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */
@@ -466,6 +564,11 @@ body {
border-radius: var(--r-2);
}
+/* Hide burger on large screens explicitly */
+@media (min-width: 1025px) {
+ .nav-toggle { display: none !important; }
+}
+
@media (max-width: 1024px) {
.site-nav {
position: sticky;
@@ -710,22 +813,136 @@ section {
font-size: 1.5rem;
}
+/* --- Headings system (H1–H6) --- */
+/* Base: consistent rhythm, weight, and accessibility across themes */
+:where(h1,h2,h3,h4,h5,h6) {
+ font-family: var(--font-sans);
+ font-weight: 700;
+ line-height: 1.2;
+ margin: 0 0 var(--space-4);
+ color: var(--fg);
+ letter-spacing: -0.01em;
+}
+
h1 {
+ font-size: clamp(2rem, 4.5vw, 2.75rem);
color: var(--primary);
- font-size: clamp(1.8rem, 4vw, 2.5rem);
+ border-left: 4px solid var(--accent);
padding-left: var(--space-3);
}
h2 {
+ font-size: clamp(1.5rem, 3.5vw, 2rem);
color: var(--accent);
- font-size: clamp(1.3rem, 3vw, 1.8rem);
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 6px;
+ margin-top: var(--space-6);
}
+h3 { font-size: clamp(1.25rem, 3vw, 1.5rem); color: var(--fg); }
+h4 { font-size: clamp(1.1rem, 2.2vw, 1.25rem); color: var(--fg); }
+h5 { font-size: clamp(1rem, 2vw, 1.1rem); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
+h6 { font-size: 0.95rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
+
img {
max-width: 100%;
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 */
iframe, video { max-width: 100%; height: auto; }
diff --git a/templates/blog/details.html b/templates/blog/details.html
new file mode 100644
index 0000000..0cf8ef9
--- /dev/null
+++ b/templates/blog/details.html
@@ -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 %}
+
+
+
+
+ {{ post.content|comment_markdown }}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/blog/home.html b/templates/blog/home.html
new file mode 100644
index 0000000..b89265d
--- /dev/null
+++ b/templates/blog/home.html
@@ -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 %}
+
+
+
+ Liste des articles
+ {% include 'blog/partials/_posts_list.html' %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/blog/partials/_posts_list.html b/templates/blog/partials/_posts_list.html
new file mode 100644
index 0000000..c1b05e8
--- /dev/null
+++ b/templates/blog/partials/_posts_list.html
@@ -0,0 +1,21 @@
+{% load comment_format %}
+
+ {% for post in posts %}
+
+
+
+ {{ post.created_at|date:"d F Y" }}
+ {% if post.tags %}
+ •
+ {{ post.tags }}
+ {% endif %}
+
+ {{ post.description|comment_markdown|truncatewords:26 }}
+
+
+ {% empty %}
+
Aucun article pour le moment.
+ {% endfor %}
+
diff --git a/templates/courses/partials/_course_header.html b/templates/courses/partials/_course_header.html
index bff069f..33fbd36 100644
--- a/templates/courses/partials/_course_header.html
+++ b/templates/courses/partials/_course_header.html
@@ -4,3 +4,22 @@
Un cours proposé par {{ course.author }}
{{ course.content }}
+Progression pour ce cours
+{% if user_progress.percent_completed == 100 %}
+
+
+
+
Félicitation
+
Tu as terminé(e) ce cours ! Tu peux réellement être fier(e) de toi !!
+
+
+{% else %}
+
+
+
{{ user_progress.percent_completed }}%
+
+{% endif %}
diff --git a/templates/courses/partials/_course_toc.html b/templates/courses/partials/_course_toc.html
index 9039157..1389e55 100644
--- a/templates/courses/partials/_course_toc.html
+++ b/templates/courses/partials/_course_toc.html
@@ -13,7 +13,8 @@
{{ item.name }} {% if item.is_premium %}PREMIUM{% endif %}
- {% if lesson and lesson.id == item.id %}(cours actuel){% endif %}
+ {% if lesson and lesson.id == item.id %}(cours actuel){% endif %}
+ {% if item.id in completed_lesson_ids %}Terminé{% endif %}
{% if lesson and lesson.id == item.id %}
@@ -69,6 +70,12 @@
Ce que l'on voit durant ce cours :
{{ lesson.content|comment_markdown }}
+
+ {% if lesson.id in completed_lesson_ids %}
+
+ {% else %}
+
+ {% endif %}
diff --git a/templates/home/stats_charts.html b/templates/home/stats_charts.html
new file mode 100644
index 0000000..159bdcd
--- /dev/null
+++ b/templates/home/stats_charts.html
@@ -0,0 +1,96 @@
+{% extends "layout.html" %}
+{% load static %}
+
+{% block title %}- Stats · Graphiques{% endblock %}
+
+{% block extra_head %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
Graphiques statistiques
+
+
+
+
+
+
Visiteurs uniques par jour
+
+
+
+
Conversions (visiteurs devenus utilisateurs) par jour
+
+
+
+
+
+
+
Top sources (visiteurs uniques)
+
+
+
+
Top pays (visiteurs uniques)
+
+
+
+
+
← Retour au tableau de bord
+
+
+
+{% endblock %}
diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html
new file mode 100644
index 0000000..3dddab3
--- /dev/null
+++ b/templates/home/stats_dashboard.html
@@ -0,0 +1,177 @@
+{% extends "layout.html" %}
+{% load static %}
+
+{% block title %}- Tableau de bord{% endblock %}
+
+{% block extra_head %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
Tableau de bord statistiques
+
→ Voir la page de graphiques
+
+
+
+
+
+
Visiteurs uniques (période)
+
{{ kpi.unique_visitors }}
+
+
+
Visiteurs revenants (période)
+
{{ kpi.returning_visitors }}
+
+
+
Conversions en utilisateurs (période)
+
{{ kpi.converted_visitors }}
+
+
+
Utilisateurs (total)
+
{{ kpi.total_users }}
+
+
+
Nouveaux utilisateurs (période)
+
{{ kpi.new_users_period }}
+
+
+
Utilisateurs actifs (période)
+
{{ kpi.active_users_period }}
+
+
+
Cours (publiés / total)
+
{{ kpi.courses_enabled }} / {{ kpi.total_courses }}
+
+
+
Leçons (total)
+
{{ kpi.total_lessons }}
+
+
+
Articles de blog (total)
+
{{ kpi.total_posts }}
+
+
+
Revenus
+ {% if revenus_disponibles %}
+
—
+ {% else %}
+
N/A
+ {% endif %}
+
+
+
Technique
+ {% if technique_disponible %}
+
—
+ {% else %}
+
N/A
+ {% endif %}
+
+
+
+
+
+
Évolution quotidienne
+
+
+
+
+
+
+
Nouveaux utilisateurs par jour
+
+ | Jour | Nb |
+
+ {% for row in new_users_table %}
+ | {{ row.0 }} | {{ row.1 }} |
+ {% endfor %}
+
+
+
+
+
Nouveaux cours par jour
+
+ | Jour | Nb |
+
+ {% for row in new_courses_table %}
+ | {{ row.0 }} | {{ row.1 }} |
+ {% endfor %}
+
+
+
+
+
+
+
+
Top sources (visiteurs uniques)
+
+ | Source | Visiteurs |
+
+ {% for row in top_sources_table %}
+ | {{ row.0 }} | {{ row.1 }} |
+ {% empty %}
+ | Aucune donnée |
+ {% endfor %}
+
+
+
+
+
Top pays (visiteurs uniques)
+
+ | Pays | Visiteurs |
+
+ {% for row in top_countries_table %}
+ | {{ row.0 }} | {{ row.1 }} |
+ {% empty %}
+ | Aucune donnée |
+ {% endfor %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index 2c3189d..2e7a521 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -39,8 +39,8 @@
(function() {
try {
var stored = localStorage.getItem('pdz-theme');
- var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
- var theme = stored || (prefersLight ? 'light' : 'dark');
+ var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var theme = stored || (prefersLight ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
var link = document.getElementById('theme-css');
diff --git a/templates/partials/_header.html b/templates/partials/_header.html
index 7b6107d..20db214 100644
--- a/templates/partials/_header.html
+++ b/templates/partials/_header.html
@@ -20,8 +20,8 @@
{% endfor %}
-
+
+ Blog
diff --git a/users/forms.py b/users/forms.py
index 2335d4c..362845e 100644
--- a/users/forms.py
+++ b/users/forms.py
@@ -27,7 +27,15 @@ class UserRegistrationForm(forms.Form):
password2 = cleaned_data.get("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):
username = forms.CharField(
diff --git a/users/tokens.py b/users/tokens.py
new file mode 100644
index 0000000..bc82caf
--- /dev/null
+++ b/users/tokens.py
@@ -0,0 +1,4 @@
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+
+# Générateur de tokens pour l'activation de compte
+activation_token = PasswordResetTokenGenerator()
diff --git a/users/urls.py b/users/urls.py
index cdf7a52..e46826d 100644
--- a/users/urls.py
+++ b/users/urls.py
@@ -7,6 +7,8 @@ urlpatterns = [
path('', views.register, name='register'),
path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'),
+ # Activation de compte par lien tokenisé
+ path('activate///', views.activate, name='activate'),
path('profile/view//', views.another_profile, name='another_profile'),
path('complete-profile/', views.complete_profile, name='complete_profile'),
path('profile/', views.profile, name='profile'),
diff --git a/users/views.py b/users/views.py
index 7b6ce5e..bf3c51d 100644
--- a/users/views.py
+++ b/users/views.py
@@ -5,6 +5,11 @@ from django.contrib.auth.models import User
from courses.models import Course
from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm
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):
# Si l'utilisateur est deja connecté, on le redirige vers la page de profil
@@ -13,13 +18,36 @@ def register(request):
if request.method == 'POST':
form = UserRegistrationForm(request.POST)
if form.is_valid():
+ # Crée un utilisateur inactif en attente d'activation par e‑mail
user = User.objects.create_user(
username=form.cleaned_data['username'],
email=form.cleaned_data['email'],
password=form.cleaned_data['password1']
)
- auth_login(request, user)
- return redirect('profile')
+ user.is_active = False
+ user.save()
+
+ # Envoi du lien d'activation par e‑mail
+ 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 e‑mail
+ pass
+
+ messages.success(request, "Inscription réussie. Vérifie ta boîte mail pour activer ton compte.")
+ return redirect('login')
else:
form = UserRegistrationForm()
return render(request, 'users/register.html', {'form': form})
@@ -32,8 +60,15 @@ def login(request):
password = form.cleaned_data['password']
user = authenticate(request, username=username, password=password)
if user is not None:
+ if not user.is_active:
+ messages.error(request, "Votre compte n'est pas encore activé. Consultez l'e‑mail d'activation envoyé." )
+ return render(request, 'users/login.html', {'form': form})
auth_login(request, user)
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:
form = UserLoginForm()
return render(request, 'users/login.html', {'form': form})
@@ -106,3 +141,21 @@ def create_post(request):
def another_profile(request, user_id):
user = User.objects.get(id=user_id)
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 inscrivez‑vous à nouveau.")
+ return redirect('register')