17 Commits

Author SHA1 Message Date
af55935651 chore(migrations/0022): create BlogIndexPage and BlogPage 2026-03-30 10:00:53 +02:00
55e6a80c08 feat(pages.py): add blog pages 2026-03-30 09:59:31 +02:00
f7b0bba3a4 chore(migrations/0021): add create_gitea_repo and gitea_repo_url to ModuleLessonPage 2026-03-30 09:52:56 +02:00
f0a58c46cb fix(pages.py): generate event occurrences only if live 2026-03-30 09:47:46 +02:00
3a980a7a20 feat(pages.py): add per-lesson repo fields 2026-03-30 09:46:53 +02:00
a2ad8e7ac9 feat(module_lesson_page.html): add link to course library 2026-03-23 14:03:38 +01:00
a0b4697c61 feat(course_module_page.html): add link to course library 2026-03-23 14:03:29 +01:00
983384f62b feat(course_page.html): add link to course library in CoursePage 2026-03-23 14:03:10 +01:00
668ddccea5 feat(settings/base.py): add LOGGING config 2026-03-23 14:02:24 +01:00
6dd826c3bd feat(home/signals.py): create gitea team and repo for course on CoursePage save 2026-03-23 14:02:07 +01:00
e74c1fb28d chore(migrations/0020): add repository_url field to CoursePage 2026-03-23 13:46:24 +01:00
cb19bc6262 feat(models/pages.py): add repository_url field to CoursePage 2026-03-23 13:45:19 +01:00
a918ee73c4 fix(models/pages.py): ensure course has ID before creating group 2026-03-23 13:44:33 +01:00
5913e847bc refactor(forms.py): move gitea account creation login to separate function 2026-03-20 14:47:12 +01:00
18b21b0892 feat(forms.py): create gitea account on signup 2026-03-20 14:37:40 +01:00
efb3799e12 feat(forms.py): capitalize first and last name 2026-03-20 14:37:22 +01:00
306d39bd22 feat(oauth_validators.py): use user ID for gitea username 2026-03-20 14:36:27 +01:00
11 changed files with 379 additions and 53 deletions

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-23 11:59
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('home', '0019_coursepage_description'),
]
operations = [
migrations.AddField(
model_name='coursepage',
name='repository_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='coursepage',
name='allowed_groups',
field=modelcluster.fields.ParentalManyToManyField(help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", related_name='course_pages', to='auth.group'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-30 07:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0020_coursepage_repository_url_and_more'),
]
operations = [
migrations.AddField(
model_name='modulelessonpage',
name='create_gitea_repo',
field=models.BooleanField(default=False, help_text='If enabled, a Gitea repository will be automatically created for this module when the module is published.'),
),
migrations.AddField(
model_name='modulelessonpage',
name='gitea_repo_url',
field=models.URLField(blank=True, help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)", null=True),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 6.0.3 on 2026-03-30 07:53
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0021_modulelessonpage_create_gitea_repo_and_more'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='BlogIndexPage',
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',),
),
migrations.CreateModel(
name='BlogPage',
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')),
('author', models.CharField(max_length=255)),
('body', wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {'form_classname': 'title'}), 1: ('wagtail.blocks.RichTextBlock', (), {}), 2: ('wagtail.images.blocks.ImageBlock', [], {})})),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

@@ -1,15 +1,17 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.contrib.auth.models import Group, User
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User
from django.db import models from django.db import models
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.utils import timezone from django.utils import timezone
from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase from taggit.models import TaggedItemBase
from wagtail import blocks
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField from wagtail.fields import RichTextField, StreamField
from wagtail.images.blocks import ImageBlock
from wagtail.models import Page from wagtail.models import Page
from wagtail.models.copying import ParentalManyToManyField from wagtail.models.copying import ParentalManyToManyField
from wagtail_color_panel.edit_handlers import NativeColorPanel from wagtail_color_panel.edit_handlers import NativeColorPanel
@@ -28,6 +30,10 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + ["body"]
class BlogIndexPage(Page):
subpage_types = ["home.BlogPage"]
class CourseIndexPage(Page): class CourseIndexPage(Page):
subpage_types = ["home.CoursePage"] subpage_types = ["home.CoursePage"]
@@ -50,6 +56,31 @@ class CourseIndexPage(Page):
return context return context
class BlogPage(Page):
author = models.CharField(max_length=255)
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
body = StreamField(
[
("heading", blocks.CharBlock(classname="title")),
("paragraph", blocks.RichTextBlock()),
("image", ImageBlock()),
]
)
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("image"),
FieldPanel("body"),
]
parent_page_types = ["home.BlogIndexPage"]
class CoursePage(Page): class CoursePage(Page):
course_image = models.ForeignKey( course_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
@@ -66,6 +97,11 @@ class CoursePage(Page):
help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.",
) )
repository_url = models.URLField(
null=True,
blank=True,
)
def _user_has_access(self, user): def _user_has_access(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@@ -105,10 +141,10 @@ class CoursePage(Page):
return created return created
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.id is not None:
group_name = f"course_{self.id}_access" group_name = f"course_{self.id}_access"
group, created = Group.objects.get_or_create(name=group_name) group, created = Group.objects.get_or_create(name=group_name)
if state := not self.allowed_groups.filter(id=group.id).exists(): if not self.allowed_groups.filter(id=group.id).exists():
print(state)
self.allowed_groups.add(group) self.allowed_groups.add(group)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -124,6 +160,11 @@ class CoursePage(Page):
FieldPanel("description"), FieldPanel("description"),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple), FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
FieldPanel(
"repository_url",
read_only=True,
heading="Repository URL (auto-generated)",
),
] ]
parent_page_types = ["home.CourseIndexPage"] parent_page_types = ["home.CourseIndexPage"]
subpage_types = ["home.CourseModulePage"] subpage_types = ["home.CourseModulePage"]
@@ -154,6 +195,15 @@ class CourseModulePage(Page):
class ModuleLessonPage(Page): class ModuleLessonPage(Page):
body = RichTextField(blank=True) body = RichTextField(blank=True)
create_gitea_repo = models.BooleanField(
default=False,
help_text="If enabled, a Gitea repository will be automatically created for this module when the module is published.",
)
gitea_repo_url = models.URLField(
null=True,
blank=True,
help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)",
)
@property @property
def module(self): def module(self):
@@ -170,7 +220,15 @@ class ModuleLessonPage(Page):
return f"{module.full_title} - {self.title}" return f"{module.full_title} - {self.title}"
return self.title return self.title
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + [
FieldPanel("body"),
FieldPanel("create_gitea_repo"),
FieldPanel(
"gitea_repo_url",
read_only=True,
heading="Gitea Repository URL (auto-generated if 'create_gitea_repo' is enabled)",
),
]
parent_page_types = ["home.CourseModulePage"] parent_page_types = ["home.CourseModulePage"]
@@ -261,6 +319,8 @@ class EventPage(Page):
Generate EventOccurrence objects for this event based on recurrence settings. Generate EventOccurrence objects for this event based on recurrence settings.
For endless recurrence, generate up to days_ahead into the future. For endless recurrence, generate up to days_ahead into the future.
""" """
from .event_occurrence import EventOccurrence
now = timezone.now() now = timezone.now()
if not self.recurrence_enabled: if not self.recurrence_enabled:
# if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end # if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end
@@ -310,6 +370,7 @@ class EventPage(Page):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.live:
self.generate_occurrences() self.generate_occurrences()
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [

View File

@@ -1,39 +1,141 @@
import logging as lg
import os import os
import requests import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from home.models.pages import CoursePage
@receiver(post_save, sender=User) GITEA_ORG_NAME = "Studio77"
def notify_external_service_on_signup(sender, instance, created, **kwargs):
pass logger = lg.getLogger(__name__)
# if created and not instance.is_staff:
# payload = {
# "user_id": instance.id, @receiver(post_save, sender=CoursePage)
# "username": f"KURSY-{instance.id}", def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwargs):
# "email": instance.email, if not instance.live:
# "full_name": f"{instance.first_name} {instance.last_name}".strip(), logger.debug(
# # "must_change_password": True, f"Course {instance.title} is not live, skipping Gitea team creation"
# # "password": instance.password, )
# "visibility": "private", return
# }
# api_url = getattr(settings, "GITEA_URL", None) course = instance
# if api_url: team_name = f"course-{course.id}"
# url = f"{api_url}/admin/users" api_url = getattr(settings, "GITEA_URL", None)
# try:
# response = requests.post( if not api_url:
# url, logger.debug("GITEA_URL is not set, skipping Gitea team creation")
# json=payload, return
# timeout=5,
# headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"}, def team():
# ) # check if team already exists
# response.raise_for_status() try:
# print(f"Successfully created Gitea account for {instance.email}") response = requests.get(
# except Exception as e: f"{api_url}/orgs/{GITEA_ORG_NAME}/teams",
# print( timeout=5,
# f"Failed to create Gitea account for user {instance.email}: {e}\n{response.text}" headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
# ) )
# raise e response.raise_for_status()
teams = response.json()
if any(team["name"] == team_name for team in teams):
logger.info(f"Gitea team {team_name} already exists, skipping creation")
return
except Exception as e:
logger.exception(
f"Failed to check existing Gitea teams: {e}\n{response.text}",
e,
)
return
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/teams"
payload = {
"can_create_org_repo": False,
"description": f"Team for course {course.title}",
"includes_all_repositories": False,
"name": team_name,
"permission": "read",
"units": [
# "repo.actions",
"repo.code",
# "repo.issues",
# "repo.ext_issues",
# "repo.wiki",
# "repo.ext_wiki",
# "repo.pulls",
# "repo.releases",
# "repo.projects",
# "repo.ext_wiki",
],
}
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
logger.info(f"Successfully created Gitea team for course {course.title}")
except Exception as e:
logger.exception(
f"Failed to create Gitea team for course {course.title}: {e}\n{response.text}",
e,
)
def repo():
# check if repository already exists
try:
response = requests.get(
f"{api_url}/orgs/{GITEA_ORG_NAME}/repos",
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
repos = response.json()
if any(repo["name"] == team_name for repo in repos):
logger.debug(
f"Gitea repository {team_name} already exists, skipping creation"
)
return
except Exception as e:
logger.exception(
f"Failed to check existing Gitea repositories: {e}\n{response.text}",
e,
)
return
# create course repository
repo_name = f"course-{course.id}"
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/repos"
payload = {
"auto_init": True,
"default_branch": "main",
"description": f"{course.title}",
"name": repo_name,
"private": True,
}
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
repo_url = response.json().get("url", None)
course.repository_url = repo_url
course.save(update_fields=["repository_url"])
logger.info(
f"Successfully created Gitea repository for course {course.title}"
)
except Exception as e:
logger.exception(
f"Failed to create Gitea repository for course {course.title}: {e}\n{response.text}",
e,
)
team()
repo()

View File

@@ -13,7 +13,9 @@
{% block content %} {% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700"> <h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.course.url }}" class="font-bold">{{ page.course.title }}</a> &raquo; {{ page.title }} <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; <a href="{{ page.course.url }}" class="font-bold hover:underline">{{ page.course.title }}</a>
&raquo; {{ page.title }}
</h2> </h2>
{{ page.body|richtext }} {{ page.body|richtext }}

View File

@@ -12,8 +12,9 @@
{% block content_class %}prose{% endblock content_class %} {% block content_class %}prose{% endblock content_class %}
{% block content %} {% block content %}
<h1 class="not-prose text-3xl mb-4 text-gray-700 font-bold"> <h1 class="not-prose text-3xl mb-4 text-gray-700">
{{ page.title }} <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; {{ page.title }}
</h1> </h1>
{% if page.course_image %} {% if page.course_image %}

View File

@@ -13,11 +13,10 @@
{% block content %} {% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700"> <h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.module.course.url }}" class="font-bold">{{ page.module.course.title }}</a> <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; &raquo; <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
<a href="{{ page.module.url }}" class="font-bold">{{ page.module.title }}</a> &raquo; <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
&raquo; &raquo; <span class="text-gray-500">{{ page.title }}</span>
<span class="text-gray-500">{{ page.title }}</span>
</h2> </h2>
{{ page.body|richtext }} {{ page.body|richtext }}

View File

@@ -1,13 +1,49 @@
import os
import requests
from django import forms from django import forms
from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
def create_gitea_account(user, password):
payload = {
"user_id": user.id,
"username": f"studio77-{user.id}",
"email": user.email,
"full_name": f"{user.first_name} {user.last_name}".strip(),
"password": password,
"must_change_password": False,
"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 {user.email}")
except Exception as e:
print(
f"Failed to create Gitea account for user {user.email}: {e}\n{response.text}"
)
raise e
class SignUpForm(forms.Form): class SignUpForm(forms.Form):
first_name = forms.CharField(max_length=60, required=True, label="First Name") first_name = forms.CharField(max_length=60, required=True, label="First Name")
last_name = forms.CharField(max_length=60, required=True, label="Last Name") last_name = forms.CharField(max_length=60, required=True, label="Last Name")
def signup(self, request, user): def signup(self, request: WSGIRequest, user):
user.first_name = self.cleaned_data["first_name"] user.first_name = self.cleaned_data["first_name"].strip().title()
user.last_name = self.cleaned_data["last_name"] user.last_name = self.cleaned_data["last_name"].strip().title()
user.save() user.save()
return user # gitea account creation
password = request.POST.get("password1")
create_gitea_account(user, password)

View File

@@ -6,6 +6,6 @@ class CustomOAuth2Validator(OAuth2Validator):
print("get_additional_claims", request.user) print("get_additional_claims", request.user)
return { return {
"name": " ".join([request.user.first_name, request.user.last_name]), "name": " ".join([request.user.first_name, request.user.last_name]),
"preferred_username": f"studio77-{request.user.username}", "preferred_username": f"studio77-{request.user.id}",
"email": request.user.email, "email": request.user.email,
} }

View File

@@ -241,6 +241,43 @@ STORAGES = {
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000 DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{asctime} : {levelname} : {filename}:{lineno} : {name} :: {message}",
"style": "{",
},
"simple": {"format": "{asctime} : {levelname} :: {message}", "style": "{"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"home": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
},
}
# Wagtail settings # Wagtail settings
WAGTAIL_SITE_NAME = "kursy" WAGTAIL_SITE_NAME = "kursy"