12 Commits

9 changed files with 261 additions and 49 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

@@ -66,6 +66,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,11 +110,11 @@ class CoursePage(Page):
return created return created
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
group_name = f"course_{self.id}_access" if self.id is not None:
group, created = Group.objects.get_or_create(name=group_name) group_name = f"course_{self.id}_access"
if state := not self.allowed_groups.filter(id=group.id).exists(): group, created = Group.objects.get_or_create(name=group_name)
print(state) if not self.allowed_groups.filter(id=group.id).exists():
self.allowed_groups.add(group) self.allowed_groups.add(group)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -124,6 +129,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"]

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"