diff --git a/courses/admin/course.py b/courses/admin/course.py
index de077d5..90e19b3 100644
--- a/courses/admin/course.py
+++ b/courses/admin/course.py
@@ -9,7 +9,7 @@
from django.contrib import messages
-from courses.models import Course, ReviewCriteria
+from courses.models import Course, ReviewCriteria, CourseRegistration
from courses.scoring import update_leaderboard
@@ -100,4 +100,29 @@ def duplicate_course(modeladmin, request, queryset):
class CourseAdmin(ModelAdmin):
actions = [update_leaderboard_admin, duplicate_course]
inlines = [CriteriaInline]
- list_display = ["title"]
+ list_display = ["title", "state"]
+ list_filter = ["state", "finished"]
+ fieldsets = (
+ ("Basic Information", {
+ "fields": ("slug", "title", "description", "social_media_hashtag")
+ }),
+ ("Course State", {
+ "fields": ("state", "finished", "first_homework_scored")
+ }),
+ ("Landing Page Content", {
+ "fields": ("about_content", "video_url", "hero_image_url", "meta_description", "mailchimp_tag"),
+ "classes": ("collapse",)
+ }),
+ ("Settings", {
+ "fields": ("faq_document_url", "min_projects_to_pass", "project_passing_score", "homework_problems_comments_field")
+ }),
+ )
+
+
+@admin.register(CourseRegistration)
+class CourseRegistrationAdmin(ModelAdmin):
+ list_display = ["name", "email", "course", "country", "role", "registered_at", "mailchimp_subscribed"]
+ list_filter = ["course", "role", "country", "mailchimp_subscribed", "registered_at"]
+ search_fields = ["name", "email", "comment"]
+ readonly_fields = ["registered_at", "mailchimp_subscribed"]
+ date_hierarchy = "registered_at"
diff --git a/courses/constants.py b/courses/constants.py
new file mode 100644
index 0000000..d06c39e
--- /dev/null
+++ b/courses/constants.py
@@ -0,0 +1,235 @@
+# Country and region data
+COUNTRIES = [
+ ("Algeria", "Africa"),
+ ("Angola", "Africa"),
+ ("Benin", "Africa"),
+ ("Botswana", "Africa"),
+ ("Burkina Faso", "Africa"),
+ ("Burundi", "Africa"),
+ ("Cabo Verde", "Africa"),
+ ("Cameroon", "Africa"),
+ ("Central African Republic", "Africa"),
+ ("Chad", "Africa"),
+ ("Comoros", "Africa"),
+ ("Congo", "Africa"),
+ ("Democratic Republic of the Congo", "Africa"),
+ ("Cote d'Ivoire", "Africa"),
+ ("Djibouti", "Africa"),
+ ("Egypt", "Africa"),
+ ("Equatorial Guinea", "Africa"),
+ ("Eritrea", "Africa"),
+ ("Eswatini", "Africa"),
+ ("Ethiopia", "Africa"),
+ ("Gabon", "Africa"),
+ ("Gambia", "Africa"),
+ ("Ghana", "Africa"),
+ ("Guinea", "Africa"),
+ ("Guinea-Bissau", "Africa"),
+ ("Kenya", "Africa"),
+ ("Lesotho", "Africa"),
+ ("Liberia", "Africa"),
+ ("Libya", "Africa"),
+ ("Madagascar", "Africa"),
+ ("Malawi", "Africa"),
+ ("Mali", "Africa"),
+ ("Mauritania", "Africa"),
+ ("Mauritius", "Africa"),
+ ("Morocco", "Africa"),
+ ("Mozambique", "Africa"),
+ ("Namibia", "Africa"),
+ ("Niger", "Africa"),
+ ("Nigeria", "Africa"),
+ ("Rwanda", "Africa"),
+ ("Sao Tome and Principe", "Africa"),
+ ("Senegal", "Africa"),
+ ("Seychelles", "Africa"),
+ ("Sierra Leone", "Africa"),
+ ("Somalia", "Africa"),
+ ("South Africa", "Africa"),
+ ("South Sudan", "Africa"),
+ ("Sudan", "Africa"),
+ ("Tanzania", "Africa"),
+ ("Togo", "Africa"),
+ ("Tunisia", "Africa"),
+ ("Uganda", "Africa"),
+ ("Zambia", "Africa"),
+ ("Zimbabwe", "Africa"),
+ ("Canada", "North America"),
+ ("United States", "North America"),
+ ("United States of America", "North America"),
+ ("Mexico", "North America"),
+ ("Bermuda", "North America"),
+ ("Greenland", "North America"),
+ ("Saint Pierre and Miquelon", "North America"),
+ ("Belize", "North America"),
+ ("Costa Rica", "North America"),
+ ("El Salvador", "North America"),
+ ("Guatemala", "North America"),
+ ("Honduras", "North America"),
+ ("Nicaragua", "North America"),
+ ("Panama", "North America"),
+ ("Cuba", "North America"),
+ ("Dominican Republic", "North America"),
+ ("Haiti", "North America"),
+ ("Jamaica", "North America"),
+ ("Trinidad and Tobago", "North America"),
+ ("Barbados", "North America"),
+ ("Bahamas", "North America"),
+ ("Grenada", "North America"),
+ ("Saint Lucia", "North America"),
+ ("Saint Vincent and the Grenadines", "North America"),
+ ("Dominica", "North America"),
+ ("Antigua and Barbuda", "North America"),
+ ("Saint Kitts and Nevis", "North America"),
+ ("Puerto Rico", "North America"),
+ ("Curacao", "North America"),
+ ("Aruba", "North America"),
+ ("Cayman Islands", "North America"),
+ ("Argentina", "South America"),
+ ("Bolivia", "South America"),
+ ("Brazil", "South America"),
+ ("Chile", "South America"),
+ ("Colombia", "South America"),
+ ("Ecuador", "South America"),
+ ("Guyana", "South America"),
+ ("Paraguay", "South America"),
+ ("Peru", "South America"),
+ ("Suriname", "South America"),
+ ("Uruguay", "South America"),
+ ("Venezuela", "South America"),
+ ("French Guiana", "South America"),
+ ("Falkland Islands", "South America"),
+ ("Afghanistan", "Asia"),
+ ("Armenia", "Asia"),
+ ("Azerbaijan", "Asia"),
+ ("Bahrain", "Asia"),
+ ("Bangladesh", "Asia"),
+ ("Bhutan", "Asia"),
+ ("Brunei", "Asia"),
+ ("Cambodia", "Asia"),
+ ("China", "Asia"),
+ ("Georgia", "Asia"),
+ ("India", "Asia"),
+ ("Indonesia", "Asia"),
+ ("Iran", "Asia"),
+ ("Iraq", "Asia"),
+ ("Israel", "Asia"),
+ ("Japan", "Asia"),
+ ("Jordan", "Asia"),
+ ("Kazakhstan", "Asia"),
+ ("Kuwait", "Asia"),
+ ("Kyrgyzstan", "Asia"),
+ ("Laos", "Asia"),
+ ("Lebanon", "Asia"),
+ ("Malaysia", "Asia"),
+ ("Maldives", "Asia"),
+ ("Mongolia", "Asia"),
+ ("Myanmar", "Asia"),
+ ("Nepal", "Asia"),
+ ("North Korea", "Asia"),
+ ("Oman", "Asia"),
+ ("Pakistan", "Asia"),
+ ("Palestine", "Asia"),
+ ("Philippines", "Asia"),
+ ("Qatar", "Asia"),
+ ("Saudi Arabia", "Asia"),
+ ("Singapore", "Asia"),
+ ("South Korea", "Asia"),
+ ("Sri Lanka", "Asia"),
+ ("Syria", "Asia"),
+ ("Tajikistan", "Asia"),
+ ("Thailand", "Asia"),
+ ("Timor-Leste", "Asia"),
+ ("Turkey", "Asia"),
+ ("Turkmenistan", "Asia"),
+ ("United Arab Emirates", "Asia"),
+ ("Uzbekistan", "Asia"),
+ ("Vietnam", "Asia"),
+ ("Yemen", "Asia"),
+ ("Albania", "Europe"),
+ ("Andorra", "Europe"),
+ ("Austria", "Europe"),
+ ("Belarus", "Europe"),
+ ("Belgium", "Europe"),
+ ("Bosnia and Herzegovina", "Europe"),
+ ("Bulgaria", "Europe"),
+ ("Croatia", "Europe"),
+ ("Cyprus", "Europe"),
+ ("Czechia", "Europe"),
+ ("Denmark", "Europe"),
+ ("Estonia", "Europe"),
+ ("Finland", "Europe"),
+ ("France", "Europe"),
+ ("Germany", "Europe"),
+ ("Greece", "Europe"),
+ ("Hungary", "Europe"),
+ ("Iceland", "Europe"),
+ ("Ireland", "Europe"),
+ ("Italy", "Europe"),
+ ("Kosovo", "Europe"),
+ ("Latvia", "Europe"),
+ ("Liechtenstein", "Europe"),
+ ("Lithuania", "Europe"),
+ ("Luxembourg", "Europe"),
+ ("Malta", "Europe"),
+ ("Moldova", "Europe"),
+ ("Monaco", "Europe"),
+ ("Montenegro", "Europe"),
+ ("Netherlands", "Europe"),
+ ("North Macedonia", "Europe"),
+ ("Norway", "Europe"),
+ ("Poland", "Europe"),
+ ("Portugal", "Europe"),
+ ("Romania", "Europe"),
+ ("Russia", "Europe"),
+ ("San Marino", "Europe"),
+ ("Serbia", "Europe"),
+ ("Slovakia", "Europe"),
+ ("Slovenia", "Europe"),
+ ("Spain", "Europe"),
+ ("Sweden", "Europe"),
+ ("Switzerland", "Europe"),
+ ("Ukraine", "Europe"),
+ ("United Kingdom", "Europe"),
+ ("Vatican City", "Europe"),
+ ("Australia", "Oceania"),
+ ("New Zealand", "Oceania"),
+ ("Fiji", "Oceania"),
+ ("Papua New Guinea", "Oceania"),
+ ("Solomon Islands", "Oceania"),
+ ("Vanuatu", "Oceania"),
+ ("Samoa", "Oceania"),
+ ("Tonga", "Oceania"),
+ ("Kiribati", "Oceania"),
+ ("Tuvalu", "Oceania"),
+ ("Nauru", "Oceania"),
+ ("Micronesia", "Oceania"),
+ ("Palau", "Oceania"),
+ ("Marshall Islands", "Oceania"),
+ ("New Caledonia", "Oceania"),
+]
+
+# Role choices for registration
+ROLE_CHOICES = [
+ ("data_engineer", "Data Engineer"),
+ ("data_scientist", "Data Scientist"),
+ ("data_analyst", "Data Analyst"),
+ ("ml_engineer", "ML Engineer"),
+ ("software_engineer_backend", "Software Engineer (Backend)"),
+ ("software_engineer_other", "Software Engineer (Frontend, Test, etc)"),
+ ("student_stem", "Student (STEM)"),
+ ("student_non_stem", "Student (Non-STEM)"),
+ ("other", "Other"),
+]
+
+# Course state choices
+class CourseState:
+ REGISTRATION = "RE"
+ ACTIVE = "AC"
+ FINISHED = "FI"
+
+ CHOICES = [
+ (REGISTRATION, "Registration"),
+ (ACTIVE, "Active"),
+ (FINISHED, "Finished"),
+ ]
diff --git a/courses/mailchimp.py b/courses/mailchimp.py
new file mode 100644
index 0000000..f5cca00
--- /dev/null
+++ b/courses/mailchimp.py
@@ -0,0 +1,65 @@
+"""
+Mailchimp integration for newsletter subscriptions
+"""
+import os
+import logging
+import hashlib
+import requests
+
+logger = logging.getLogger(__name__)
+
+MAILCHIMP_TOKEN = os.getenv("MAILCHIMP_TOKEN", "")
+MAILCHIMP_LIST_ID = os.getenv("MAILCHIMP_LIST_ID", "")
+
+
+def add_subscriber_to_mailchimp(email: str, tag: str = None) -> bool:
+ """
+ Add or update a subscriber in Mailchimp
+
+ Args:
+ email: The email address to subscribe
+ tag: Optional tag to add to the subscriber
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if not MAILCHIMP_TOKEN or not MAILCHIMP_LIST_ID:
+ logger.warning("Mailchimp not configured - skipping subscription")
+ return False
+
+ try:
+ # Create MD5 hash of lowercase email for subscriber_hash
+ subscriber_hash = hashlib.md5(email.lower().encode()).hexdigest()
+
+ # Prepare the data
+ data = {
+ "email_address": email,
+ "status_if_new": "subscribed",
+ }
+
+ # Add tags if provided
+ if tag:
+ data["tags"] = [tag]
+
+ # Mailchimp API URL
+ mc_url = f"https://us19.api.mailchimp.com/3.0/lists/{MAILCHIMP_LIST_ID}/members/{subscriber_hash}"
+
+ # Make the request
+ response = requests.put(
+ mc_url,
+ auth=("anystring", MAILCHIMP_TOKEN),
+ headers={"Content-Type": "application/json"},
+ json=data,
+ timeout=10,
+ )
+
+ if response.status_code in [200, 201]:
+ logger.info(f"Successfully added {email} to Mailchimp with tag {tag}")
+ return True
+ else:
+ logger.error(f"Mailchimp API error: {response.status_code} - {response.text}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Error adding subscriber to Mailchimp: {e}")
+ return False
diff --git a/courses/migrations/0023_course_about_content_course_hero_image_url_and_more.py b/courses/migrations/0023_course_about_content_course_hero_image_url_and_more.py
new file mode 100644
index 0000000..f3454f9
--- /dev/null
+++ b/courses/migrations/0023_course_about_content_course_hero_image_url_and_more.py
@@ -0,0 +1,63 @@
+# Generated by Django 5.2.4 on 2025-10-03 10:01
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('courses', '0022_projectstatistics'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='course',
+ name='about_content',
+ field=models.TextField(blank=True, help_text='Markdown content for the course landing page'),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='hero_image_url',
+ field=models.URLField(blank=True, help_text='Hero image URL for the course landing page', validators=[django.core.validators.URLValidator()]),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='mailchimp_tag',
+ field=models.CharField(blank=True, help_text='Mailchimp tag for newsletter subscriptions', max_length=100),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='meta_description',
+ field=models.TextField(blank=True, help_text='SEO meta description for the course landing page'),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='state',
+ field=models.CharField(choices=[('RE', 'Registration'), ('AC', 'Active'), ('FI', 'Finished')], default='AC', help_text='Current state of the course (Registration, Active, or Finished)', max_length=2),
+ ),
+ migrations.AddField(
+ model_name='course',
+ name='video_url',
+ field=models.URLField(blank=True, help_text='YouTube URL for course overview video', validators=[django.core.validators.URLValidator()]),
+ ),
+ migrations.CreateModel(
+ name='CourseRegistration',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email', models.EmailField(max_length=254)),
+ ('name', models.CharField(max_length=255)),
+ ('country', models.CharField(max_length=100)),
+ ('region', models.CharField(blank=True, max_length=100)),
+ ('role', models.CharField(choices=[('data_engineer', 'Data Engineer'), ('data_scientist', 'Data Scientist'), ('data_analyst', 'Data Analyst'), ('ml_engineer', 'ML Engineer'), ('software_engineer_backend', 'Software Engineer (Backend)'), ('software_engineer_other', 'Software Engineer (Frontend, Test, etc)'), ('student_stem', 'Student (STEM)'), ('student_non_stem', 'Student (Non-STEM)'), ('other', 'Other')], max_length=50)),
+ ('comment', models.TextField(blank=True)),
+ ('registered_at', models.DateTimeField(auto_now_add=True)),
+ ('mailchimp_subscribed', models.BooleanField(default=False)),
+ ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='courses.course')),
+ ],
+ options={
+ 'unique_together': {('course', 'email')},
+ },
+ ),
+ ]
diff --git a/courses/models/course.py b/courses/models/course.py
index e05eaf4..29e8b93 100644
--- a/courses/models/course.py
+++ b/courses/models/course.py
@@ -4,6 +4,7 @@
from accounts.models import CustomUser
from courses.random_names import generate_random_name
+from courses.constants import CourseState, COUNTRIES, ROLE_CHOICES
User = CustomUser
@@ -36,6 +37,41 @@ class Course(models.Model):
help_text="Whether the course has finished.",
)
+ state = models.CharField(
+ max_length=2,
+ choices=CourseState.CHOICES,
+ default=CourseState.ACTIVE,
+ help_text="Current state of the course (Registration, Active, or Finished)",
+ )
+
+ about_content = models.TextField(
+ blank=True,
+ help_text="Markdown content for the course landing page",
+ )
+
+ video_url = models.URLField(
+ blank=True,
+ validators=[URLValidator()],
+ help_text="YouTube URL for course overview video",
+ )
+
+ hero_image_url = models.URLField(
+ blank=True,
+ validators=[URLValidator()],
+ help_text="Hero image URL for the course landing page",
+ )
+
+ meta_description = models.TextField(
+ blank=True,
+ help_text="SEO meta description for the course landing page",
+ )
+
+ mailchimp_tag = models.CharField(
+ max_length=100,
+ blank=True,
+ help_text="Mailchimp tag for newsletter subscriptions",
+ )
+
faq_document_url = models.URLField(
blank=True,
validators=[URLValidator()],
@@ -135,3 +171,24 @@ def save(self, *args, **kwargs):
def __str__(self):
return f"{self.student} enrolled in {self.course}"
+
+
+class CourseRegistration(models.Model):
+ """Model to track course registrations from the landing page"""
+
+ course = models.ForeignKey(Course, on_delete=models.CASCADE)
+ email = models.EmailField()
+ name = models.CharField(max_length=255)
+ country = models.CharField(max_length=100)
+ region = models.CharField(max_length=100, blank=True)
+ role = models.CharField(max_length=50, choices=ROLE_CHOICES)
+ comment = models.TextField(blank=True)
+
+ registered_at = models.DateTimeField(auto_now_add=True)
+ mailchimp_subscribed = models.BooleanField(default=False)
+
+ class Meta:
+ unique_together = ["course", "email"]
+
+ def __str__(self):
+ return f"{self.name} ({self.email}) registered for {self.course.title}"
diff --git a/courses/templates/courses/course_landing.html b/courses/templates/courses/course_landing.html
new file mode 100644
index 0000000..23ed862
--- /dev/null
+++ b/courses/templates/courses/course_landing.html
@@ -0,0 +1,227 @@
+{% extends 'base.html' %}
+
+{% load static %}
+
+{% block title %}{{ course.title }} - DataTalks.Club{% endblock %}
+
+{% block head_extra %}
+
+
+
+
+
+
+
+{% if course.hero_image_url %}
+
+{% endif %}
+
+
+
+
+
+{% if course.hero_image_url %}
+
+{% endif %}
+{% endblock %}
+
+{% block breadcrumbs %}
+
{{ course.title }}
+{% endblock %}
+
+{% block content %}
+
+
+
+ {% if course.hero_image_url %}
+
+

+
+ {% endif %}
+
+
{{ course.title }}
+
+ {% if course.social_media_hashtag %}
+
{{ course.social_media_hashtag }}
+ {% endif %}
+
+
+
+ {% if youtube_video_id %}
+
+ {% endif %}
+
+
+ {% if about_html %}
+
+ {{ about_html|safe }}
+
+ {% else %}
+
+
{{ course.description }}
+
+ {% endif %}
+
+
+
+
Register for This Course
+
+ {% if success_message %}
+
+ {{ success_message }}
+
+ {% endif %}
+
+
+
+ By registering, you'll be subscribed to the DataTalks.Club newsletter to receive course updates and announcements.
+
+
+
+
+
+
+
+ {% if user.is_authenticated and user.is_superuser %}
+
+ View Course Workspace (Admin)
+
+ {% endif %}
+
+ Back to Courses
+
+
+
+
+
+
+{% endblock %}
diff --git a/courses/templates/courses/course_list.html b/courses/templates/courses/course_list.html
index 7d80517..e45269f 100644
--- a/courses/templates/courses/course_list.html
+++ b/courses/templates/courses/course_list.html
@@ -7,6 +7,15 @@ DataTalks.Club courses
our courses in our webpage https://datatalks.club/.
+ {% if registration_courses %}
+ Registration Open
+
+ {% for course in registration_courses %}
+ - {{ course.title }} - Registration
+ {% endfor %}
+
+ {% endif %}
+
Active courses
{% for course in active_courses %}
diff --git a/courses/tests/test_landing_page.py b/courses/tests/test_landing_page.py
new file mode 100644
index 0000000..3bd86fd
--- /dev/null
+++ b/courses/tests/test_landing_page.py
@@ -0,0 +1,260 @@
+import pytest
+from django.test import TestCase, Client
+from django.urls import reverse
+from django.utils import timezone
+from datetime import timedelta
+
+from courses.models import Course, CourseRegistration, User
+from courses.constants import CourseState
+
+
+class CourseLandingPageTests(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.user = User.objects.create_user(
+ username="test@example.com",
+ email="test@example.com",
+ password="testpass123"
+ )
+
+ # Create a course in registration state
+ self.course_registration = Course.objects.create(
+ slug="test-course-registration",
+ title="Test Course Registration",
+ description="A test course in registration state",
+ state=CourseState.REGISTRATION,
+ about_content="# Welcome\n\nThis is a **test** course.",
+ video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ hero_image_url="https://example.com/hero.jpg",
+ meta_description="Test course meta description",
+ mailchimp_tag="test-course",
+ )
+
+ # Create an active course
+ self.course_active = Course.objects.create(
+ slug="test-course-active",
+ title="Test Course Active",
+ description="A test course in active state",
+ state=CourseState.ACTIVE,
+ )
+
+ # Create a finished course
+ self.course_finished = Course.objects.create(
+ slug="test-course-finished",
+ title="Test Course Finished",
+ description="A test course in finished state",
+ state=CourseState.FINISHED,
+ )
+
+ def test_landing_page_accessible(self):
+ """Test that landing page is accessible"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.course_registration.title)
+
+ def test_landing_page_shows_registration_form(self):
+ """Test that landing page displays registration form"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "Register for This Course")
+ self.assertContains(response, "name=\"email\"")
+ self.assertContains(response, "name=\"name\"")
+ self.assertContains(response, "name=\"country\"")
+ self.assertContains(response, "name=\"role\"")
+
+ def test_landing_page_shows_video_embed(self):
+ """Test that landing page embeds YouTube video"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "youtube.com/embed/dQw4w9WgXcQ")
+
+ def test_landing_page_shows_hero_image(self):
+ """Test that landing page shows hero image"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertContains(response, self.course_registration.hero_image_url)
+
+ def test_landing_page_renders_markdown(self):
+ """Test that landing page renders markdown content"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "Welcome
")
+ self.assertContains(response, "test")
+
+ def test_landing_page_seo_meta_tags(self):
+ """Test that landing page includes SEO meta tags"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+
+ # Check title
+ self.assertContains(response, "Test Course Registration - DataTalks.Club")
+
+ # Check meta description
+ self.assertContains(response, 'name="description"')
+ self.assertContains(response, self.course_registration.meta_description)
+
+ # Check Open Graph tags
+ self.assertContains(response, 'property="og:title"')
+ self.assertContains(response, 'property="og:description"')
+ self.assertContains(response, 'property="og:image"')
+
+ # Check Twitter Card tags
+ self.assertContains(response, 'name="twitter:card"')
+ self.assertContains(response, 'name="twitter:title"')
+
+ def test_registration_form_submission_unauthenticated(self):
+ """Test that unauthenticated users can register"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ data = {
+ "email": "newuser@example.com",
+ "name": "New User",
+ "country": "United States",
+ "role": "data_scientist",
+ "comment": "Looking forward to learning!",
+ }
+ response = self.client.post(url, data)
+
+ # Check registration was created
+ self.assertEqual(CourseRegistration.objects.count(), 1)
+ registration = CourseRegistration.objects.first()
+ self.assertEqual(registration.email, "newuser@example.com")
+ self.assertEqual(registration.name, "New User")
+ self.assertEqual(registration.country, "United States")
+ self.assertEqual(registration.role, "data_scientist")
+ self.assertEqual(registration.region, "North America")
+
+ def test_registration_form_submission_authenticated(self):
+ """Test that authenticated users can register with pre-filled email"""
+ self.client.login(email="test@example.com", password="testpass123")
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ data = {
+ "email": "test@example.com", # Should be pre-filled
+ "name": "Test User",
+ "country": "Canada",
+ "role": "data_engineer",
+ "comment": "",
+ }
+ response = self.client.post(url, data)
+
+ # Check registration was created
+ self.assertEqual(CourseRegistration.objects.count(), 1)
+ registration = CourseRegistration.objects.first()
+ self.assertEqual(registration.email, "test@example.com")
+ self.assertEqual(registration.course, self.course_registration)
+
+ def test_registration_duplicate_prevention(self):
+ """Test that duplicate registrations are prevented"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ data = {
+ "email": "duplicate@example.com",
+ "name": "Duplicate User",
+ "country": "United Kingdom",
+ "role": "ml_engineer",
+ "comment": "",
+ }
+
+ # First registration
+ self.client.post(url, data)
+ self.assertEqual(CourseRegistration.objects.count(), 1)
+
+ # Second registration with same email
+ response = self.client.post(url, data, follow=True)
+ self.assertEqual(CourseRegistration.objects.count(), 1)
+ # Check that a message was displayed
+ messages = list(response.context['messages'])
+ self.assertTrue(any('already registered' in str(m).lower() for m in messages))
+
+ def test_registration_form_validation(self):
+ """Test that form validation works"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ data = {
+ "email": "invalid-email", # Invalid email
+ "name": "", # Required field
+ "country": "", # Required field
+ "role": "", # Required field
+ "comment": "",
+ }
+ response = self.client.post(url, data)
+
+ # No registration should be created
+ self.assertEqual(CourseRegistration.objects.count(), 0)
+ self.assertEqual(response.status_code, 200)
+
+ def test_course_registration_state_redirects_to_landing(self):
+ """Test that accessing course view in REGISTRATION state redirects to landing page"""
+ url = reverse("course", args=[self.course_registration.slug])
+ response = self.client.get(url)
+
+ # Should redirect to landing page
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("/about", response.url)
+
+ def test_course_registration_state_superuser_can_access(self):
+ """Test that superusers can access course view even in REGISTRATION state"""
+ # Create superuser
+ superuser = User.objects.create_superuser(
+ username="admin@example.com",
+ email="admin@example.com",
+ password="adminpass123"
+ )
+ self.client.login(email="admin@example.com", password="adminpass123")
+
+ url = reverse("course", args=[self.course_registration.slug])
+ response = self.client.get(url)
+
+ # Should not redirect - show the course page
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.course_registration.title)
+
+ def test_course_active_state_shows_workspace(self):
+ """Test that active courses show the workspace view"""
+ url = reverse("course", args=[self.course_active.slug])
+ response = self.client.get(url)
+
+ # Should show course workspace, not redirect
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, self.course_active.title)
+
+ def test_course_list_shows_registration_courses(self):
+ """Test that course list page shows registration courses separately"""
+ url = reverse("course_list")
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Registration Open")
+ self.assertContains(response, self.course_registration.title)
+ self.assertContains(response, self.course_active.title)
+ self.assertContains(response, self.course_finished.title)
+
+ def test_newsletter_subscription_notice(self):
+ """Test that landing page shows newsletter subscription notice"""
+ url = reverse("course_landing", args=[self.course_registration.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "DataTalks.Club newsletter")
+
+ def test_youtube_url_parsing_watch_format(self):
+ """Test parsing of youtube.com/watch?v= format"""
+ course = Course.objects.create(
+ slug="test-video-1",
+ title="Test Video 1",
+ description="Test",
+ state=CourseState.REGISTRATION,
+ video_url="https://www.youtube.com/watch?v=ABC123&feature=share"
+ )
+ url = reverse("course_landing", args=[course.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "youtube.com/embed/ABC123")
+
+ def test_youtube_url_parsing_short_format(self):
+ """Test parsing of youtu.be/ format"""
+ course = Course.objects.create(
+ slug="test-video-2",
+ title="Test Video 2",
+ description="Test",
+ state=CourseState.REGISTRATION,
+ video_url="https://youtu.be/XYZ789?t=10"
+ )
+ url = reverse("course_landing", args=[course.slug])
+ response = self.client.get(url)
+ self.assertContains(response, "youtube.com/embed/XYZ789")
diff --git a/courses/urls.py b/courses/urls.py
index 1f1d6f3..7394192 100644
--- a/courses/urls.py
+++ b/courses/urls.py
@@ -8,6 +8,11 @@
urlpatterns = [
path("", course.course_list, name="course_list"),
+ path(
+ "/about",
+ course.course_landing_view,
+ name="course_landing",
+ ),
path(
"/",
course.course_view,
diff --git a/courses/views/course.py b/courses/views/course.py
index b0cf2fd..55c8cd6 100644
--- a/courses/views/course.py
+++ b/courses/views/course.py
@@ -17,6 +17,7 @@
from courses.models import (
Course,
+ CourseRegistration,
Homework,
HomeworkState,
Submission,
@@ -27,7 +28,11 @@
User,
)
-from .forms import EnrollmentForm
+from .forms import EnrollmentForm, CourseRegistrationForm
+from courses.constants import CourseState, COUNTRIES
+from courses.mailchimp import add_subscriber_to_mailchimp
+import markdown
+import threading
logger = logging.getLogger(__name__)
@@ -37,15 +42,25 @@ def course_list(request):
active_courses = []
finished_courses = []
+ registration_courses = []
for course in courses:
- if course.finished:
+ # Use state if available, fallback to finished field for backwards compatibility
+ if hasattr(course, 'state') and course.state:
+ if course.state == CourseState.FINISHED:
+ finished_courses.append(course)
+ elif course.state == CourseState.REGISTRATION:
+ registration_courses.append(course)
+ else: # ACTIVE
+ active_courses.append(course)
+ elif course.finished:
finished_courses.append(course)
else:
active_courses.append(course)
context = {
"active_courses": active_courses,
+ "registration_courses": registration_courses,
"finished_courses": finished_courses,
}
@@ -146,6 +161,12 @@ def update_project_with_additional_info(project: Project) -> None:
def course_view(request: HttpRequest, course_slug: str) -> HttpResponse:
course = get_object_or_404(Course, slug=course_slug)
+
+ # If course is in REGISTRATION state, redirect to landing page
+ # unless user is a superuser
+ if course.state == CourseState.REGISTRATION:
+ if not (request.user.is_authenticated and request.user.is_superuser):
+ return redirect("course_landing", course_slug=course_slug)
user = request.user
homeworks = get_homeworks_for_course(course, user)
@@ -556,3 +577,82 @@ def safe_quartiles(data):
}
return render(request, "courses/dashboard.html", context)
+
+
+def course_landing_view(request: HttpRequest, course_slug: str) -> HttpResponse:
+ """Landing page for course registration"""
+ course = get_object_or_404(Course, slug=course_slug)
+
+ # Convert markdown content to HTML
+ about_html = ""
+ if course.about_content:
+ about_html = markdown.markdown(
+ course.about_content,
+ extensions=["extra", "codehilite"]
+ )
+
+ # Extract YouTube video ID if present
+ youtube_video_id = None
+ if course.video_url:
+ # Support various YouTube URL formats
+ if "youtube.com/watch?v=" in course.video_url:
+ youtube_video_id = course.video_url.split("watch?v=")[1].split("&")[0]
+ elif "youtu.be/" in course.video_url:
+ youtube_video_id = course.video_url.split("youtu.be/")[1].split("?")[0]
+
+ success_message = None
+
+ if request.method == "POST":
+ form = CourseRegistrationForm(request.POST, user=request.user)
+ if form.is_valid():
+ # Save registration
+ registration = form.save(commit=False)
+ registration.course = course
+
+ # Find region from country
+ country = form.cleaned_data["country"]
+ region = next((r for c, r in COUNTRIES if c == country), "")
+ registration.region = region
+
+ # Check if already registered
+ existing = CourseRegistration.objects.filter(
+ course=course,
+ email=registration.email
+ ).first()
+
+ if existing:
+ messages.info(request, "You have already registered for this course!")
+ else:
+ registration.save()
+
+ # Add to Mailchimp in background
+ if course.mailchimp_tag:
+ def subscribe_to_mailchimp():
+ success = add_subscriber_to_mailchimp(
+ registration.email,
+ course.mailchimp_tag
+ )
+ if success:
+ registration.mailchimp_subscribed = True
+ registration.save()
+
+ thread = threading.Thread(target=subscribe_to_mailchimp)
+ thread.daemon = True
+ thread.start()
+
+ success_message = "Thank you for registering! We'll keep you updated."
+ form = CourseRegistrationForm(user=request.user) # Reset form
+ else:
+ messages.error(request, "Please correct the errors below.")
+ else:
+ form = CourseRegistrationForm(user=request.user)
+
+ context = {
+ "course": course,
+ "form": form,
+ "about_html": about_html,
+ "youtube_video_id": youtube_video_id,
+ "success_message": success_message,
+ }
+
+ return render(request, "courses/course_landing.html", context)
diff --git a/courses/views/forms.py b/courses/views/forms.py
index 7ef1498..efe8a0d 100644
--- a/courses/views/forms.py
+++ b/courses/views/forms.py
@@ -1,6 +1,7 @@
from django import forms
-from courses.models import Answer, Enrollment
+from courses.models import Answer, Enrollment, CourseRegistration
+from courses.constants import COUNTRIES, ROLE_CHOICES
class AnswerForm(forms.ModelForm):
@@ -9,6 +10,59 @@ class Meta:
fields = ["answer_text"]
+class CourseRegistrationForm(forms.ModelForm):
+ """Form for course registration on the landing page"""
+
+ country = forms.ChoiceField(
+ choices=[("", "Select your country")] + [(country, country) for country, _ in COUNTRIES],
+ widget=forms.Select(attrs={"class": "form-control"}),
+ required=True,
+ )
+
+ role = forms.ChoiceField(
+ choices=[("", "Select your role")] + ROLE_CHOICES,
+ widget=forms.Select(attrs={"class": "form-control"}),
+ required=True,
+ )
+
+ class Meta:
+ model = CourseRegistration
+ fields = ["email", "name", "country", "role", "comment"]
+ widgets = {
+ "email": forms.EmailInput(
+ attrs={"class": "form-control", "placeholder": "your.email@example.com"}
+ ),
+ "name": forms.TextInput(
+ attrs={"class": "form-control", "placeholder": "Your Full Name"}
+ ),
+ "comment": forms.Textarea(
+ attrs={
+ "class": "form-control",
+ "rows": 3,
+ "placeholder": "Anything you would like to add? (optional)",
+ }
+ ),
+ }
+ labels = {
+ "email": "Email",
+ "name": "Name",
+ "country": "Country",
+ "role": "What best describes you?",
+ "comment": "Comment",
+ }
+
+ def __init__(self, *args, user=None, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # If user is authenticated, pre-fill and lock the email field
+ if user and user.is_authenticated:
+ self.fields["email"].initial = user.email
+ self.fields["email"].widget.attrs["readonly"] = True
+
+ # Make comment optional
+ self.fields["comment"].required = False
+
+
class EnrollmentForm(forms.ModelForm):
class Meta:
model = Enrollment
diff --git a/pyproject.toml b/pyproject.toml
index 1fe5ac7..29031f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
"python-json-logger",
"django-unfold",
"django-loginas",
+ "markdown",
]
[dependency-groups]
diff --git a/templates/base.html b/templates/base.html
index c1ca6dd..da10756 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -15,6 +15,8 @@
+ {% block head_extra %}{% endblock %}
+
diff --git a/uv.lock b/uv.lock
index 19ec77e..e6df27d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.13"
[[package]]
@@ -231,6 +231,7 @@ dependencies = [
{ name = "django-loginas" },
{ name = "django-unfold" },
{ name = "gunicorn" },
+ { name = "markdown" },
{ name = "psycopg2-binary" },
{ name = "pyjwt" },
{ name = "python-json-logger" },
@@ -257,6 +258,7 @@ requires-dist = [
{ name = "django-loginas" },
{ name = "django-unfold" },
{ name = "gunicorn" },
+ { name = "markdown" },
{ name = "psycopg2-binary" },
{ name = "pyjwt" },
{ name = "python-json-logger" },
@@ -857,6 +859,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/6a/ca128561b22b60bd5a0c4ea26649e68c8556b82bc70a0c396eebc977fe86/jupyterlab_widgets-3.0.15-py3-none-any.whl", hash = "sha256:d59023d7d7ef71400d51e6fee9a88867f6e65e10a4201605d2d7f3e8f012a31c", size = 216571, upload-time = "2025-05-05T12:32:29.534Z" },
]
+[[package]]
+name = "markdown"
+version = "3.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.2"