16 Commits

Author SHA1 Message Date
72fca4228c feat(home/course_page.html): add description to course page template 2026-03-19 17:56:53 +01:00
9f779407af feat(header.html): add link to course index page and move course calendar to left side 2026-03-19 17:56:27 +01:00
f2f594afb6 feat(templates/course_index_page.html): add CourseIndexPage template 2026-03-19 17:55:31 +01:00
95ab896e5f chore(migrations/0019): add description to CoursePage 2026-03-19 17:54:57 +01:00
4f58cb0320 feat(models/pages.py): add description field to CoursePage 2026-03-19 17:54:35 +01:00
294ea9a28b chore(migrations/0018): add CourseIndexPage 2026-03-19 17:54:15 +01:00
e56aff1a5c feat(models/pages.py): add CourseIndexPage 2026-03-19 17:53:53 +01:00
71d4580a82 feat(settings/base.py): add GITEA_URL config 2026-03-19 15:26:58 +01:00
0356374870 feat(settings/base.py): add OAUTH2_PROVIDER config 2026-03-19 15:26:42 +01:00
ffc33d3be4 feat(oauth_validators.py): add CustomOAuth2Validator to supply OIDC scopes 2026-03-19 15:23:07 +01:00
730e041794 feat(kursy/urls.py): add oauth2/ url 2026-03-19 15:22:11 +01:00
e0f3f094ff feat(home/apps.py): register signals 2026-03-19 15:21:26 +01:00
157ee875e1 feat(signals.py): add signals 2026-03-19 15:20:36 +01:00
12de01c2dc chore(settings/base.py): add oauth to INSTALLED_APPS and MIDDLEWARE 2026-03-18 11:47:58 +01:00
718aeb9cf5 build(uv.lock): add depedency on django-oauth-toolkit 2026-03-18 11:47:13 +01:00
9761daf820 build(pyproject.toml): add depedency on django-oauth-toolkit 2026-03-18 11:47:04 +01:00
13 changed files with 241 additions and 4 deletions

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class HomeConfig(AppConfig): class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "home" name = "home"
def ready(self):
import home.signals

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-19 14:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0017_chatmessage'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
]
operations = [
migrations.CreateModel(
name='CourseIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-03-19 15:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0018_courseindexpage'),
]
operations = [
migrations.AddField(
model_name='coursepage',
name='description',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@@ -25,8 +25,29 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + ["body"]
class CourseIndexPage(Page):
subpage_types = ["home.CoursePage"]
def get_context(self, request):
context = super().get_context(request)
all_courses = self.get_children().live()
purchased_courses = set()
other_courses = set()
for course in all_courses:
if course.specific._user_has_access(request.user):
purchased_courses.add(course)
else:
other_courses.add(course)
context["purchased_courses"] = sorted(
purchased_courses, key=lambda c: c.title.lower()
)
context["other_courses"] = sorted(other_courses, key=lambda c: c.title.lower())
return context
class CoursePage(Page): class CoursePage(Page):
body = RichTextField(blank=True)
course_image = models.ForeignKey( course_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
null=True, null=True,
@@ -34,6 +55,8 @@ class CoursePage(Page):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
) )
description = models.CharField(max_length=255, blank=True)
body = RichTextField(blank=True)
allowed_groups = ParentalManyToManyField( allowed_groups = ParentalManyToManyField(
Group, Group,
related_name="course_pages", related_name="course_pages",
@@ -53,9 +76,11 @@ class CoursePage(Page):
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("course_image"), FieldPanel("course_image"),
FieldPanel("description"),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple), FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
] ]
parent_page_types = ["home.CourseIndexPage"]
subpage_types = ["home.CourseModulePage"] subpage_types = ["home.CourseModulePage"]

39
home/signals.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import requests
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=User)
def notify_external_service_on_signup(sender, instance, created, **kwargs):
pass
# if created and not instance.is_staff:
# payload = {
# "user_id": instance.id,
# "username": f"KURSY-{instance.id}",
# "email": instance.email,
# "full_name": f"{instance.first_name} {instance.last_name}".strip(),
# # "must_change_password": True,
# # "password": instance.password,
# "visibility": "private",
# }
# api_url = getattr(settings, "GITEA_URL", None)
# if api_url:
# url = f"{api_url}/admin/users"
# try:
# response = requests.post(
# url,
# json=payload,
# timeout=5,
# headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
# )
# response.raise_for_status()
# print(f"Successfully created Gitea account for {instance.email}")
# except Exception as e:
# print(
# f"Failed to create Gitea account for user {instance.email}: {e}\n{response.text}"
# )
# raise e

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% load static i18n wagtailcore_tags wagtailimages_tags %}
{% block title %}{% trans "Courses" %}{% endblock title %}
{% block body_class %}template-courseindex{% endblock body_class %}
{% block content %}
<h1 class="text-3xl font-bold mb-6 text-center">{% trans "Courses" %}</h1>
<h2 class="text-2xl font-semibold mb-4">{% trans "Purchased Courses" %}</h2>
<div class="flex flex-wrap -mx-4">
{% for course in purchased_courses %}
<div class="w-full md:w-1/2 lg:w-1/3 px-4 mb-8">
<a href="{{ course.url }}" class="block bg-green-50 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{% image course.specific.course_image original %}
<div class="p-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-semibold">{{ course.specific.title }}</h2>
<div class="relative w-8 h-8 rounded-full bg-green-500">
<i class="fi fi-br-lock-open-alt leading-0 absolute left-0 top-1/2 translate-x-1/2 -translate-y-1/2 text-white" title="{% trans "Purchased" %}"></i>
</div>
</div>
<p class="text-gray-600">{{ course.specific.description|truncatewords:20 }}</p>
</div>
</a>
</div>
{% endfor %}
</div>
<h2 class="text-2xl font-semibold mb-4">{% trans "Available Courses" %}</h2>
<div class="flex flex-wrap -mx-4">
{% for course in other_courses %}
<div class="w-full md:w-1/2 lg:w-1/3 px-4 mb-8">
<a href="{{ course.url }}" class="block bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{% image course.specific.course_image original %}
<div class="p-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-semibold">{{ course.specific.title }}</h2>
<div class="relative w-8 h-8 rounded-full bg-gray-200">
<i class="fi fi-br-shopping-basket leading-0 absolute left-0 top-1/2 translate-x-1/2 -translate-y-1/2 text-gray-700" title="{% trans "Not Purchased" %}"></i>
</div>
</div>
<p class="text-gray-600">{{ course.specific.description|truncatewords:20 }}</p>
</div>
</a>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -17,9 +17,13 @@
</h1> </h1>
{% if page.course_image %} {% if page.course_image %}
{% image page.course_image original alt=page.title class="w-full h-auto rounded-lg mb-6" %} {% image page.course_image original alt=page.title class="w-full h-auto rounded-lg mb-4" %}
{% endif %} {% endif %}
<p class="not-prose text-gray-600 mb-6 text-lg">
{{ page.description }}
</p>
{{ page.body|richtext }} {{ page.body|richtext }}
{% if user_has_access %} {% if user_has_access %}

11
kursy/oauth_validators.py Normal file
View File

@@ -0,0 +1,11 @@
from oauth2_provider.oauth2_validators import OAuth2Validator
class CustomOAuth2Validator(OAuth2Validator):
def get_additional_claims(self, request):
print("get_additional_claims", request.user)
return {
"name": " ".join([request.user.first_name, request.user.last_name]),
"preferred_username": f"studio77-{request.user.username}",
"email": request.user.email,
}

View File

@@ -58,6 +58,7 @@ INSTALLED_APPS = [
"allauth.account", "allauth.account",
"allauth.socialaccount", "allauth.socialaccount",
"allauth.socialaccount.providers.github", "allauth.socialaccount.providers.github",
"oauth2_provider",
"tailwind", "tailwind",
"theme", "theme",
"widget_tweaks", "widget_tweaks",
@@ -75,6 +76,7 @@ MIDDLEWARE = [
"wagtail.contrib.redirects.middleware.RedirectMiddleware", "wagtail.contrib.redirects.middleware.RedirectMiddleware",
"allauth.account.middleware.AccountMiddleware", "allauth.account.middleware.AccountMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware",
] ]
ROOT_URLCONF = "kursy.urls" ROOT_URLCONF = "kursy.urls"
@@ -131,6 +133,26 @@ SOCIALACCOUNT_PROVIDERS = {
WSGI_APPLICATION = "kursy.wsgi.application" WSGI_APPLICATION = "kursy.wsgi.application"
OAUTH2_PROVIDER = {
"OIDC_ENABLED": True,
"OIDC_RPID_ENDPOINT": "http://127.0.0.1:8000/oauth2",
"OIDC_ISS_ENDPOINT": "http://127.0.0.1:8000",
"PKCE_REQUIRED": False,
"OAUTH2_VALIDATOR_CLASS": "kursy.oauth_validators.CustomOAuth2Validator",
"SCOPES": {
"openid": "OpenID Connect scope",
"profile": "User profile scope",
"email": "User email scope",
"read": "Read scope",
"write": "Write scope",
},
"OIDC_CLAIM_MAPS": {
"nickname": "preferred_username",
"email": "email",
},
"DEFAULT_SCOPES": ["openid", "profile", "email"],
}
# Database # Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
@@ -252,3 +274,6 @@ WAGTAILDOCS_EXTENSIONS = [
] ]
TAILWIND_APP_NAME = "theme" TAILWIND_APP_NAME = "theme"
# Gitea API
GITEA_URL = "http://localhost:3000/api/v1"

View File

@@ -2,10 +2,13 @@
<header class="bg-blue-900 text-white shadow-md relative"> <header class="bg-blue-900 text-white shadow-md relative">
<div class="container mx-auto flex items-center justify-between py-4 px-6"> <div class="container mx-auto flex items-center justify-between py-4 px-6">
{% wagtail_site as current_site %} {% wagtail_site as current_site %}
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a> <div class="flex items-center gap-4">
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a>
<a href="{% slugurl 'courses' %}" class="hover:underline">{% trans "Courses" %}</a>
<a href="{% url 'calendar' %}" class="hover:underline">{% trans "Course Calendar" %}</a>
</div>
<nav class="flex items-center gap-4"> <nav class="flex items-center gap-4">
<a href="{% url 'calendar' %}" class="hover:underline">{% trans "Course Calendar" %}</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'account_logout' %}" class="hover:underline">{% trans "Logout" %}</a> <a href="{% url 'account_logout' %}" class="hover:underline">{% trans "Logout" %}</a>
{% else %} {% else %}

View File

@@ -20,6 +20,7 @@ urlpatterns = [
path("accounts/profile/", views.profile, name="profile"), path("accounts/profile/", views.profile, name="profile"),
path("accounts/signup/", views.signup, name="signup"), path("accounts/signup/", views.signup, name="signup"),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("", include("home.urls")), path("", include("home.urls")),
path("calendar/", views.calendar, name="calendar"), path("calendar/", views.calendar, name="calendar"),
# TODO: move occurrence related urls to home app # TODO: move occurrence related urls to home app

View File

@@ -8,6 +8,7 @@ dependencies = [
"django-allauth-ui>=1.8.1", "django-allauth-ui>=1.8.1",
"django-allauth[socialaccount]>=65.15.0", "django-allauth[socialaccount]>=65.15.0",
"django-browser-reload>=1.21.0", "django-browser-reload>=1.21.0",
"django-oauth-toolkit>=3.2.0",
"django-tailwind>=4.4.2", "django-tailwind>=4.4.2",
"django-widget-tweaks>=1.5.1", "django-widget-tweaks>=1.5.1",
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",

30
uv.lock generated
View File

@@ -308,6 +308,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/e4/ec99d52aa04e204e938564b603f4591e2e82e236ed59af664fee35179e75/django_modelcluster-6.4.1-py2.py3-none-any.whl", hash = "sha256:ccc190cd9e22c24900ea2410bff64d444d48f43f0f4aedeed0f6cd94e2536698", size = 29315, upload-time = "2025-12-04T12:21:39.911Z" }, { url = "https://files.pythonhosted.org/packages/a5/e4/ec99d52aa04e204e938564b603f4591e2e82e236ed59af664fee35179e75/django_modelcluster-6.4.1-py2.py3-none-any.whl", hash = "sha256:ccc190cd9e22c24900ea2410bff64d444d48f43f0f4aedeed0f6cd94e2536698", size = 29315, upload-time = "2025-12-04T12:21:39.911Z" },
] ]
[[package]]
name = "django-oauth-toolkit"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "jwcrypto" },
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/95/efd83b35c34b86eb2249d2b54c5eaf383c48f3f19034aa6f3807e37471b6/django_oauth_toolkit-3.2.0.tar.gz", hash = "sha256:c36761ae6810083d95a652e9c820046cde0d45a2e2a5574bbe7202656ec20bb6", size = 114211, upload-time = "2026-01-08T22:03:13.311Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/cc/f27a784c0ecd13335abd9ef85ebb80dbc04945f919da5f496f56e3562751/django_oauth_toolkit-3.2.0-py3-none-any.whl", hash = "sha256:bd2cd2719b010231a2f370f927dbcc740454fb1d0dd7e7f4138f36227363dc26", size = 87077, upload-time = "2026-01-08T22:03:12.123Z" },
]
[[package]] [[package]]
name = "django-permissionedforms" name = "django-permissionedforms"
version = "0.1" version = "0.1"
@@ -456,6 +471,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "jwcrypto"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" },
]
[[package]] [[package]]
name = "kursy" name = "kursy"
version = "0.1.0" version = "0.1.0"
@@ -466,6 +494,7 @@ dependencies = [
{ name = "django-allauth", extra = ["socialaccount"] }, { name = "django-allauth", extra = ["socialaccount"] },
{ name = "django-allauth-ui" }, { name = "django-allauth-ui" },
{ name = "django-browser-reload" }, { name = "django-browser-reload" },
{ name = "django-oauth-toolkit" },
{ name = "django-tailwind" }, { name = "django-tailwind" },
{ name = "django-widget-tweaks" }, { name = "django-widget-tweaks" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -482,6 +511,7 @@ requires-dist = [
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.15.0" }, { name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.15.0" },
{ name = "django-allauth-ui", specifier = ">=1.8.1" }, { name = "django-allauth-ui", specifier = ">=1.8.1" },
{ name = "django-browser-reload", specifier = ">=1.21.0" }, { name = "django-browser-reload", specifier = ">=1.21.0" },
{ name = "django-oauth-toolkit", specifier = ">=3.2.0" },
{ name = "django-tailwind", specifier = ">=4.4.2" }, { name = "django-tailwind", specifier = ">=4.4.2" },
{ name = "django-widget-tweaks", specifier = ">=1.5.1" }, { name = "django-widget-tweaks", specifier = ">=1.5.1" },
{ name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", specifier = ">=1.2.2" },