Compare commits
17 Commits
feat/add-m
...
feat/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
af55935651
|
|||
|
55e6a80c08
|
|||
|
f7b0bba3a4
|
|||
|
f0a58c46cb
|
|||
|
3a980a7a20
|
|||
|
a2ad8e7ac9
|
|||
|
a0b4697c61
|
|||
|
983384f62b
|
|||
|
668ddccea5
|
|||
|
6dd826c3bd
|
|||
|
e74c1fb28d
|
|||
|
cb19bc6262
|
|||
|
a918ee73c4
|
|||
|
5913e847bc
|
|||
|
18b21b0892
|
|||
|
efb3799e12
|
|||
|
306d39bd22
|
25
home/migrations/0020_coursepage_repository_url_and_more.py
Normal file
25
home/migrations/0020_coursepage_repository_url_and_more.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
40
home/migrations/0022_blogindexpage_blogpage.py
Normal file
40
home/migrations/0022_blogindexpage_blogpage.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,17 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.db import models
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.utils import timezone
|
||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import TaggedItemBase
|
||||
from wagtail import blocks
|
||||
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.copying import ParentalManyToManyField
|
||||
from wagtail_color_panel.edit_handlers import NativeColorPanel
|
||||
@@ -28,6 +30,10 @@ class HomePage(Page):
|
||||
content_panels = Page.content_panels + ["body"]
|
||||
|
||||
|
||||
class BlogIndexPage(Page):
|
||||
subpage_types = ["home.BlogPage"]
|
||||
|
||||
|
||||
class CourseIndexPage(Page):
|
||||
subpage_types = ["home.CoursePage"]
|
||||
|
||||
@@ -50,6 +56,31 @@ class CourseIndexPage(Page):
|
||||
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):
|
||||
course_image = models.ForeignKey(
|
||||
"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.",
|
||||
)
|
||||
|
||||
repository_url = models.URLField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def _user_has_access(self, user):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
@@ -105,11 +141,11 @@ class CoursePage(Page):
|
||||
return created
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
group_name = f"course_{self.id}_access"
|
||||
group, created = Group.objects.get_or_create(name=group_name)
|
||||
if state := not self.allowed_groups.filter(id=group.id).exists():
|
||||
print(state)
|
||||
self.allowed_groups.add(group)
|
||||
if self.id is not None:
|
||||
group_name = f"course_{self.id}_access"
|
||||
group, created = Group.objects.get_or_create(name=group_name)
|
||||
if not self.allowed_groups.filter(id=group.id).exists():
|
||||
self.allowed_groups.add(group)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -124,6 +160,11 @@ class CoursePage(Page):
|
||||
FieldPanel("description"),
|
||||
FieldPanel("body"),
|
||||
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
|
||||
FieldPanel(
|
||||
"repository_url",
|
||||
read_only=True,
|
||||
heading="Repository URL (auto-generated)",
|
||||
),
|
||||
]
|
||||
parent_page_types = ["home.CourseIndexPage"]
|
||||
subpage_types = ["home.CourseModulePage"]
|
||||
@@ -154,6 +195,15 @@ class CourseModulePage(Page):
|
||||
|
||||
class ModuleLessonPage(Page):
|
||||
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
|
||||
def module(self):
|
||||
@@ -170,7 +220,15 @@ class ModuleLessonPage(Page):
|
||||
return f"{module.full_title} - {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"]
|
||||
|
||||
|
||||
@@ -261,6 +319,8 @@ class EventPage(Page):
|
||||
Generate EventOccurrence objects for this event based on recurrence settings.
|
||||
For endless recurrence, generate up to days_ahead into the future.
|
||||
"""
|
||||
from .event_occurrence import EventOccurrence
|
||||
|
||||
now = timezone.now()
|
||||
if not self.recurrence_enabled:
|
||||
# if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end
|
||||
@@ -310,7 +370,8 @@ class EventPage(Page):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
self.generate_occurrences()
|
||||
if self.live:
|
||||
self.generate_occurrences()
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("tags"),
|
||||
|
||||
164
home/signals.py
164
home/signals.py
@@ -1,39 +1,141 @@
|
||||
import logging as lg
|
||||
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
|
||||
|
||||
from home.models.pages import CoursePage
|
||||
|
||||
@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
|
||||
GITEA_ORG_NAME = "Studio77"
|
||||
|
||||
logger = lg.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=CoursePage)
|
||||
def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwargs):
|
||||
if not instance.live:
|
||||
logger.debug(
|
||||
f"Course {instance.title} is not live, skipping Gitea team creation"
|
||||
)
|
||||
return
|
||||
|
||||
course = instance
|
||||
team_name = f"course-{course.id}"
|
||||
api_url = getattr(settings, "GITEA_URL", None)
|
||||
|
||||
if not api_url:
|
||||
logger.debug("GITEA_URL is not set, skipping Gitea team creation")
|
||||
return
|
||||
|
||||
def team():
|
||||
# check if team already exists
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{api_url}/orgs/{GITEA_ORG_NAME}/teams",
|
||||
timeout=5,
|
||||
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
|
||||
{% block content %}
|
||||
<h2 class="not-prose text-xl mb-4 text-gray-700">
|
||||
<a href="{{ page.course.url }}" class="font-bold">{{ page.course.title }}</a> » {{ page.title }}
|
||||
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
|
||||
» <a href="{{ page.course.url }}" class="font-bold hover:underline">{{ page.course.title }}</a>
|
||||
» {{ page.title }}
|
||||
</h2>
|
||||
|
||||
{{ page.body|richtext }}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
{% block content_class %}prose{% endblock content_class %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="not-prose text-3xl mb-4 text-gray-700 font-bold">
|
||||
{{ page.title }}
|
||||
<h1 class="not-prose text-3xl mb-4 text-gray-700">
|
||||
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
|
||||
» {{ page.title }}
|
||||
</h1>
|
||||
|
||||
{% if page.course_image %}
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
|
||||
{% block content %}
|
||||
<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="{{ page.module.url }}" class="font-bold">{{ page.module.title }}</a>
|
||||
»
|
||||
<span class="text-gray-500">{{ page.title }}</span>
|
||||
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
|
||||
» <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
|
||||
» <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
|
||||
» <span class="text-gray-500">{{ page.title }}</span>
|
||||
</h2>
|
||||
|
||||
{{ page.body|richtext }}
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
import os
|
||||
|
||||
import requests
|
||||
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):
|
||||
first_name = forms.CharField(max_length=60, required=True, label="First Name")
|
||||
last_name = forms.CharField(max_length=60, required=True, label="Last Name")
|
||||
|
||||
def signup(self, request, user):
|
||||
user.first_name = self.cleaned_data["first_name"]
|
||||
user.last_name = self.cleaned_data["last_name"]
|
||||
def signup(self, request: WSGIRequest, user):
|
||||
user.first_name = self.cleaned_data["first_name"].strip().title()
|
||||
user.last_name = self.cleaned_data["last_name"].strip().title()
|
||||
user.save()
|
||||
|
||||
return user
|
||||
# gitea account creation
|
||||
password = request.POST.get("password1")
|
||||
create_gitea_account(user, password)
|
||||
|
||||
@@ -6,6 +6,6 @@ class CustomOAuth2Validator(OAuth2Validator):
|
||||
print("get_additional_claims", request.user)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -241,6 +241,43 @@ STORAGES = {
|
||||
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_SITE_NAME = "kursy"
|
||||
|
||||
Reference in New Issue
Block a user