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 %} +
    + {{ course.title }} +
    + {% 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 %} + + {% endif %} + + + +
    + {% csrf_token %} + +
    + + {{ form.email }} + {% if form.email.errors %} +
    + + {{ form.email.errors|join:" " }} +
    + {% endif %} + {% if user.is_authenticated %} + Email is pre-filled from your account. + {% endif %} +
    + +
    + + {{ form.name }} + {% if form.name.errors %} +
    + + {{ form.name.errors|join:" " }} +
    + {% endif %} +
    + +
    + + {{ form.country }} + {% if form.country.errors %} +
    + + {{ form.country.errors|join:" " }} +
    + {% endif %} +
    + +
    + + {{ form.role }} + {% if form.role.errors %} +
    + + {{ form.role.errors|join:" " }} +
    + {% endif %} +
    + +
    + + {{ form.comment }} + {% if form.comment.errors %} +
    + + {{ form.comment.errors|join:" " }} +
    + {% endif %} +
    + +
    + +
    +
    +
    + + +
    + {% 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

    + + {% endif %} +

    Active courses